forked from Ivasoft/mattermost-mobile
Compare commits
79 Commits
release-1.
...
release-1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ce6c7afb | ||
|
|
7bea097c90 | ||
|
|
0fe72d1c10 | ||
|
|
d3211b66a3 | ||
|
|
39bdb69fdd | ||
|
|
8222390c98 | ||
|
|
456208d223 | ||
|
|
2e71fcc226 | ||
|
|
04d7834024 | ||
|
|
7888b971e1 | ||
|
|
49462bd4a0 | ||
|
|
eb8579e84c | ||
|
|
41caf0d865 | ||
|
|
6a91b6544d | ||
|
|
75628cdf2e | ||
|
|
c8ee3bc722 | ||
|
|
3011992995 | ||
|
|
e23960f27d | ||
|
|
84a292003e | ||
|
|
8287e620d8 | ||
|
|
094a34d05f | ||
|
|
76284a78d5 | ||
|
|
434f4c970e | ||
|
|
b7e9cd296b | ||
|
|
f98897147d | ||
|
|
0989ad9208 | ||
|
|
3ddc34d737 | ||
|
|
de564513c3 | ||
|
|
dd866ffeb8 | ||
|
|
acffc7796f | ||
|
|
61c6f5693b | ||
|
|
fe12190f4b | ||
|
|
2a4027f74a | ||
|
|
c3cb9a5d34 | ||
|
|
7df996dcc6 | ||
|
|
e5142f8524 | ||
|
|
789cf9ab54 | ||
|
|
963d04545e | ||
|
|
c58c23a10e | ||
|
|
0708a4d81b | ||
|
|
d96ecb0fdb | ||
|
|
fff455624e | ||
|
|
f18abe11f6 | ||
|
|
a435c96f2e | ||
|
|
45efbe1c97 | ||
|
|
f8baaf8505 | ||
|
|
ebb00a205c | ||
|
|
c4cb7b0c3e | ||
|
|
729f098019 | ||
|
|
856d8bd05f | ||
|
|
5e0c75d772 | ||
|
|
93dbe4af9e | ||
|
|
16583c2a3b | ||
|
|
42071314d3 | ||
|
|
6586d1b283 | ||
|
|
a3c00f59d6 | ||
|
|
000caf09e9 | ||
|
|
bd74310f29 | ||
|
|
a81b56212e | ||
|
|
1a35250811 | ||
|
|
f1c3538283 | ||
|
|
7dd3fbb75d | ||
|
|
5260e252a4 | ||
|
|
e301c9d5d1 | ||
|
|
0332b52ee2 | ||
|
|
a28a3826fa | ||
|
|
8447d7feea | ||
|
|
523777a207 | ||
|
|
d9e8b3e08e | ||
|
|
b36dbf9b34 | ||
|
|
7e9c574c3f | ||
|
|
3668cb62d3 | ||
|
|
a2ed6ba3bd | ||
|
|
dd36545079 | ||
|
|
edfd743699 | ||
|
|
6dbb537f22 | ||
|
|
aa776e4ae6 | ||
|
|
1287357e7c | ||
|
|
ada6be9b7a |
@@ -57,7 +57,7 @@
|
||||
"newlines-between": "always",
|
||||
"pathGroups": [
|
||||
{
|
||||
"pattern": "@(@react-native-community|@react-native-cookies|@react-navigation|@rudderstack|@sentry|@testing-library|@storybook)/**",
|
||||
"pattern": "@(@react-native-async-storage|@react-native-community|@react-native-cookies|@react-navigation|@rudderstack|@sentry|@testing-library|@storybook)/**",
|
||||
"group": "external",
|
||||
"position": "before"
|
||||
},
|
||||
|
||||
@@ -25,6 +25,8 @@ emoji=true
|
||||
|
||||
exact_by_default=true
|
||||
|
||||
format.bracket_spacing=false
|
||||
|
||||
module.file_ext=.js
|
||||
module.file_ext=.json
|
||||
module.file_ext=.ios.js
|
||||
@@ -61,4 +63,4 @@ untyped-import
|
||||
untyped-type-import
|
||||
|
||||
[version]
|
||||
^0.149.0
|
||||
^0.158.0
|
||||
|
||||
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -26,6 +26,7 @@ Place an '[x]' (no spaces) in all applicable fields. Please remove unrelated fie
|
||||
- [ ] Added or updated unit tests (required for all new features)
|
||||
- [ ] Has UI changes
|
||||
- [ ] Includes text changes and localization file updates
|
||||
- [ ] Have tested against the 5 core themes to ensure consistency between them.
|
||||
|
||||
#### Device Information
|
||||
This PR was tested on: <!-- Device name(s), OS version(s) -->
|
||||
|
||||
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.
|
||||
|
||||
@@ -119,6 +119,11 @@ def jscFlavor = 'org.webkit:android-jsc-intl:+'
|
||||
*/
|
||||
def enableHermes = project.ext.react.get("enableHermes", false);
|
||||
|
||||
/**
|
||||
* Architectures to build native code for in debug.
|
||||
*/
|
||||
def nativeArchitectures = project.getProperties().get("reactNativeDebugArchitectures")
|
||||
|
||||
android {
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
|
||||
@@ -127,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'
|
||||
@@ -160,6 +165,11 @@ android {
|
||||
debug {
|
||||
minifyEnabled enableProguardInReleaseBuilds
|
||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||
if (nativeArchitectures) {
|
||||
ndk {
|
||||
abiFilters nativeArchitectures.split(',')
|
||||
}
|
||||
}
|
||||
}
|
||||
unsigned.initWith(buildTypes.release)
|
||||
unsigned {
|
||||
@@ -225,7 +235,7 @@ dependencies {
|
||||
|
||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
|
||||
debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") {
|
||||
exclude group:'com.facebook.fbjni'
|
||||
exclude group:'com.facebook.fbjni'
|
||||
}
|
||||
debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") {
|
||||
exclude group:'com.facebook.flipper'
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ buildscript {
|
||||
kotlinVersion = "1.5.30"
|
||||
firebaseVersion = "21.0.0"
|
||||
RNNKotlinVersion = kotlinVersion
|
||||
ndkVersion = "20.1.5948944"
|
||||
ndkVersion = "21.4.7075529"
|
||||
|
||||
}
|
||||
repositories {
|
||||
@@ -20,7 +20,7 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:4.2.1'
|
||||
classpath 'com.android.tools.build:gradle:4.2.2'
|
||||
classpath 'com.google.gms:google-services:4.2.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -30,4 +30,4 @@ android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
|
||||
# Version of flipper SDK to use with React Native
|
||||
FLIPPER_VERSION=0.93.0
|
||||
FLIPPER_VERSION=0.99.0
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
// See LICENSE.txt for license information.
|
||||
import {ViewTypes} from '@constants';
|
||||
|
||||
export function updateThreadLastViewedAt(threadId: string, lastViewedAt: number) {
|
||||
return {
|
||||
type: ViewTypes.THREAD_LAST_VIEWED_AT,
|
||||
data: {
|
||||
threadId,
|
||||
lastViewedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const handleViewingGlobalThreadsScreen = () => (
|
||||
{
|
||||
type: ViewTypes.VIEWING_GLOBAL_THREADS_SCREEN,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {fetchAppBindings} from '@mm-redux/actions/apps';
|
||||
import {fetchAppBindings, fetchThreadAppBindings} from '@mm-redux/actions/apps';
|
||||
import {getThreadAppsBindingsChannelId} from '@mm-redux/selectors/entities/apps';
|
||||
import {getCurrentChannelId} from '@mm-redux/selectors/entities/common';
|
||||
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
|
||||
import {ActionResult, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions';
|
||||
@@ -10,9 +11,17 @@ import {appsEnabled} from '@utils/apps';
|
||||
export function handleRefreshAppsBindings() {
|
||||
return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => {
|
||||
const state = getState();
|
||||
if (appsEnabled(state)) {
|
||||
dispatch(fetchAppBindings(getCurrentUserId(state), getCurrentChannelId(state)));
|
||||
if (!appsEnabled(state)) {
|
||||
return {data: true};
|
||||
}
|
||||
|
||||
dispatch(fetchAppBindings(getCurrentUserId(state), getCurrentChannelId(state)));
|
||||
|
||||
const threadChannelID = getThreadAppsBindingsChannelId(state);
|
||||
if (threadChannelID) {
|
||||
dispatch(fetchThreadAppBindings(getCurrentUserId(state), threadChannelID));
|
||||
}
|
||||
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
|
||||
import {updateThreadLastViewedAt} from '@actions/views/threads';
|
||||
import {handleThreadArrived, handleReadChanged, handleAllMarkedRead, handleFollowChanged, getThread as fetchThread} from '@mm-redux/actions/threads';
|
||||
import {getCurrentUserId} from '@mm-redux/selectors/entities/common';
|
||||
import {getSelectedPost} from '@mm-redux/selectors/entities/posts';
|
||||
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
|
||||
import {getThread} from '@mm-redux/selectors/entities/threads';
|
||||
import {ActionResult, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions';
|
||||
import {ActionResult, DispatchFunc, GenericAction, GetStateFunc} from '@mm-redux/types/actions';
|
||||
import {WebSocketMessage} from '@mm-redux/types/websocket';
|
||||
|
||||
export function handleThreadUpdated(msg: WebSocketMessage) {
|
||||
@@ -27,21 +31,31 @@ export function handleThreadReadChanged(msg: WebSocketMessage) {
|
||||
const thread = getThread(state, msg.data.thread_id);
|
||||
|
||||
// Mark only following threads as read.
|
||||
if (thread?.is_following) {
|
||||
dispatch(
|
||||
handleReadChanged(
|
||||
msg.data.thread_id,
|
||||
msg.broadcast.team_id,
|
||||
msg.data.channel_id,
|
||||
{
|
||||
lastViewedAt: msg.data.timestamp,
|
||||
prevUnreadMentions: thread.unread_mentions,
|
||||
newUnreadMentions: msg.data.unread_mentions,
|
||||
prevUnreadReplies: thread.unread_replies,
|
||||
newUnreadReplies: msg.data.unread_replies,
|
||||
},
|
||||
),
|
||||
);
|
||||
if (thread) {
|
||||
const actions: GenericAction[] = [];
|
||||
const selectedPost = getSelectedPost(state);
|
||||
if (selectedPost?.id !== thread.id) {
|
||||
actions.push(updateThreadLastViewedAt(thread.id, msg.data.timestamp));
|
||||
}
|
||||
if (thread.is_following) {
|
||||
actions.push(
|
||||
handleReadChanged(
|
||||
msg.data.thread_id,
|
||||
msg.broadcast.team_id,
|
||||
msg.data.channel_id,
|
||||
{
|
||||
lastViewedAt: msg.data.timestamp,
|
||||
prevUnreadMentions: thread.unread_mentions,
|
||||
newUnreadMentions: msg.data.unread_mentions,
|
||||
prevUnreadReplies: thread.unread_replies,
|
||||
newUnreadReplies: msg.data.unread_replies,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (actions.length) {
|
||||
dispatch(batchActions(actions));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dispatch(handleAllMarkedRead(msg.broadcast.team_id));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -250,8 +250,13 @@ const ClientPosts = (superclass: any) => class extends superclass {
|
||||
searchPostsWithParams = async (teamId: string, params: any) => {
|
||||
analytics.trackAPI('api_posts_search', {team_id: teamId});
|
||||
|
||||
let route = `${this.getPostsRoute()}/search`;
|
||||
if (teamId) {
|
||||
route = `${this.getTeamRoute(teamId)}/posts/search`;
|
||||
}
|
||||
|
||||
return this.doFetch(
|
||||
`${this.getTeamRoute(teamId)}/posts/search`,
|
||||
route,
|
||||
{method: 'post', body: JSON.stringify(params)},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4685,7 +4685,6 @@ exports[`components/autocomplete/emoji_suggestion should match snapshot 2`] = `
|
||||
keyExtractor={[Function]}
|
||||
keyboardShouldPersistTaps="always"
|
||||
nestedScrollEnabled={false}
|
||||
numColumns={1}
|
||||
pageSize={10}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={[Function]}
|
||||
|
||||
@@ -31,7 +31,6 @@ exports[`components/autocomplete/slash_suggestion should match snapshot 1`] = `
|
||||
keyExtractor={[Function]}
|
||||
keyboardShouldPersistTaps="always"
|
||||
nestedScrollEnabled={false}
|
||||
numColumns={1}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={[Function]}
|
||||
style={
|
||||
|
||||
@@ -33,7 +33,11 @@ describe('AppCommandParser', () => {
|
||||
...reduxTestState,
|
||||
entities: {
|
||||
...reduxTestState.entities,
|
||||
apps: {bindings},
|
||||
apps: {
|
||||
bindings,
|
||||
threadBindings: bindings,
|
||||
threadBindingsForms: {},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const testStore = await mockStore(initialState);
|
||||
@@ -415,6 +419,60 @@ describe('AppCommandParser', () => {
|
||||
command: '/jira issue create --project == test',
|
||||
submit: {expectError: 'Multiple `=` signs are not allowed.'},
|
||||
},
|
||||
{
|
||||
title: 'rest field',
|
||||
command: '/jira issue rest hello world',
|
||||
autocomplete: {verify: (parsed: ParsedCommand): void => {
|
||||
expect(parsed.state).toBe(ParseState.Rest);
|
||||
expect(parsed.binding?.label).toBe('rest');
|
||||
expect(parsed.incomplete).toBe('hello world');
|
||||
expect(parsed.values?.summary).toBe(undefined);
|
||||
}},
|
||||
submit: {verify: (parsed: ParsedCommand): void => {
|
||||
expect(parsed.state).toBe(ParseState.Rest);
|
||||
expect(parsed.binding?.label).toBe('rest');
|
||||
expect(parsed.values?.summary).toBe('hello world');
|
||||
}},
|
||||
},
|
||||
{
|
||||
title: 'rest field with other field',
|
||||
command: '/jira issue rest --verbose true hello world',
|
||||
autocomplete: {verify: (parsed: ParsedCommand): void => {
|
||||
expect(parsed.state).toBe(ParseState.Rest);
|
||||
expect(parsed.binding?.label).toBe('rest');
|
||||
expect(parsed.incomplete).toBe('hello world');
|
||||
expect(parsed.values?.summary).toBe(undefined);
|
||||
expect(parsed.values?.verbose).toBe('true');
|
||||
}},
|
||||
submit: {verify: (parsed: ParsedCommand): void => {
|
||||
expect(parsed.state).toBe(ParseState.Rest);
|
||||
expect(parsed.binding?.label).toBe('rest');
|
||||
expect(parsed.values?.summary).toBe('hello world');
|
||||
expect(parsed.values?.verbose).toBe('true');
|
||||
}},
|
||||
},
|
||||
{
|
||||
title: 'rest field as flag with other field',
|
||||
command: '/jira issue rest --summary "hello world" --verbose true',
|
||||
autocomplete: {verify: (parsed: ParsedCommand): void => {
|
||||
expect(parsed.state).toBe(ParseState.EndValue);
|
||||
expect(parsed.binding?.label).toBe('rest');
|
||||
expect(parsed.incomplete).toBe('true');
|
||||
expect(parsed.values?.summary).toBe('hello world');
|
||||
expect(parsed.values?.verbose).toBe(undefined);
|
||||
}},
|
||||
submit: {verify: (parsed: ParsedCommand): void => {
|
||||
expect(parsed.state).toBe(ParseState.EndValue);
|
||||
expect(parsed.binding?.label).toBe('rest');
|
||||
expect(parsed.values?.summary).toBe('hello world');
|
||||
expect(parsed.values?.verbose).toBe('true');
|
||||
}},
|
||||
},
|
||||
{
|
||||
title: 'error: rest after rest field flag',
|
||||
command: '/jira issue rest --summary "hello world" --verbose true hello world',
|
||||
submit: {expectError: 'Unable to identify argument.'},
|
||||
},
|
||||
];
|
||||
|
||||
table.forEach((tc) => {
|
||||
@@ -504,6 +562,13 @@ describe('AppCommandParser', () => {
|
||||
IconData: 'Create icon',
|
||||
Description: 'Create a new Jira issue',
|
||||
},
|
||||
{
|
||||
Suggestion: 'rest',
|
||||
Complete: 'jira issue rest',
|
||||
Hint: 'rest hint',
|
||||
IconData: 'rest icon',
|
||||
Description: 'rest description',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -524,6 +589,14 @@ describe('AppCommandParser', () => {
|
||||
IconData: 'Create icon',
|
||||
Description: 'Create a new Jira issue',
|
||||
},
|
||||
{
|
||||
Suggestion: 'rest',
|
||||
Complete: 'JiRa IsSuE rest',
|
||||
Hint: 'rest hint',
|
||||
IconData: 'rest icon',
|
||||
Description: 'rest description',
|
||||
},
|
||||
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -855,7 +928,7 @@ describe('AppCommandParser', () => {
|
||||
context: {
|
||||
app_id: 'jira',
|
||||
channel_id: 'current_channel_id',
|
||||
location: '/command',
|
||||
location: '/command/jira/issue/create',
|
||||
root_id: 'root_id',
|
||||
team_id: 'team_id',
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
/* eslint-disable max-lines */
|
||||
|
||||
import {
|
||||
AppsTypes,
|
||||
AppCallRequest,
|
||||
AppBinding,
|
||||
AppField,
|
||||
@@ -32,7 +33,6 @@ import {
|
||||
EXECUTE_CURRENT_COMMAND_ITEM_ID,
|
||||
COMMAND_SUGGESTION_ERROR,
|
||||
getExecuteSuggestion,
|
||||
displayError,
|
||||
createCallRequest,
|
||||
selectUserByUsername,
|
||||
getUserByUsername,
|
||||
@@ -45,10 +45,13 @@ import {
|
||||
filterEmptyOptions,
|
||||
autocompleteUsersInChannel,
|
||||
autocompleteChannels,
|
||||
ExtendedAutocompleteSuggestion,
|
||||
getChannelSuggestions,
|
||||
getUserSuggestions,
|
||||
inTextMentionSuggestions,
|
||||
ExtendedAutocompleteSuggestion,
|
||||
getAppCommandForm,
|
||||
getAppRHSCommandForm,
|
||||
makeRHSAppBindingSelector,
|
||||
} from './app_command_parser_dependencies';
|
||||
|
||||
export interface Store {
|
||||
@@ -74,6 +77,7 @@ export enum ParseState {
|
||||
EndQuotedValue = 'EndQuotedValue',
|
||||
EndTickedValue = 'EndTickedValue',
|
||||
Error = 'Error',
|
||||
Rest = 'Rest',
|
||||
}
|
||||
|
||||
interface FormsCache {
|
||||
@@ -85,6 +89,7 @@ interface Intl {
|
||||
}
|
||||
|
||||
const getCommandBindings = makeAppBindingsSelector(AppBindingLocations.COMMAND);
|
||||
const getRHSCommandBindings = makeRHSAppBindingSelector(AppBindingLocations.COMMAND);
|
||||
|
||||
export class ParsedCommand {
|
||||
state = ParseState.Start;
|
||||
@@ -299,12 +304,20 @@ export class ParsedCommand {
|
||||
// Positional parameter.
|
||||
this.position++;
|
||||
// eslint-disable-next-line no-loop-func
|
||||
const field = fields.find((f: AppField) => f.position === this.position);
|
||||
let field = fields.find((f: AppField) => f.position === this.position);
|
||||
if (!field) {
|
||||
return this.asError(this.intl.formatMessage({
|
||||
id: 'apps.error.parser.no_argument_pos_x',
|
||||
defaultMessage: 'Unable to identify argument.',
|
||||
}));
|
||||
field = fields.find((f) => f.position === -1 && f.type === AppFieldTypes.TEXT);
|
||||
if (!field || this.values[field.name]) {
|
||||
return this.asError(this.intl.formatMessage({
|
||||
id: 'apps.error.parser.no_argument_pos_x',
|
||||
defaultMessage: 'Unable to identify argument.',
|
||||
}));
|
||||
}
|
||||
this.incompleteStart = this.i;
|
||||
this.incomplete = '';
|
||||
this.field = field;
|
||||
this.state = ParseState.Rest;
|
||||
break;
|
||||
}
|
||||
this.field = field;
|
||||
this.state = ParseState.StartValue;
|
||||
@@ -314,6 +327,28 @@ export class ParsedCommand {
|
||||
break;
|
||||
}
|
||||
|
||||
case ParseState.Rest: {
|
||||
if (!this.field) {
|
||||
return this.asError(this.intl.formatMessage({
|
||||
id: 'apps.error.parser.missing_field_value',
|
||||
defaultMessage: 'Field value is missing.',
|
||||
}));
|
||||
}
|
||||
|
||||
if (autocompleteMode && c === '') {
|
||||
return this;
|
||||
}
|
||||
|
||||
if (c === '') {
|
||||
this.values[this.field.name] = this.incomplete;
|
||||
return this;
|
||||
}
|
||||
|
||||
this.i++;
|
||||
this.incomplete += c;
|
||||
break;
|
||||
}
|
||||
|
||||
case ParseState.ParameterSeparator: {
|
||||
this.incompleteStart = this.i;
|
||||
switch (c) {
|
||||
@@ -470,7 +505,7 @@ export class ParsedCommand {
|
||||
if (this.incompleteStart === this.i - 1) {
|
||||
return this.asError(this.intl.formatMessage({
|
||||
id: 'apps.error.parser.empty_value',
|
||||
defaultMessage: 'empty values are not allowed',
|
||||
defaultMessage: 'Empty values are not allowed.',
|
||||
}));
|
||||
}
|
||||
this.i++;
|
||||
@@ -510,7 +545,7 @@ export class ParsedCommand {
|
||||
if (this.incompleteStart === this.i - 1) {
|
||||
return this.asError(this.intl.formatMessage({
|
||||
id: 'apps.error.parser.empty_value',
|
||||
defaultMessage: 'empty values are not allowed',
|
||||
defaultMessage: 'Empty values are not allowed.',
|
||||
}));
|
||||
}
|
||||
this.i++;
|
||||
@@ -544,13 +579,13 @@ export class ParsedCommand {
|
||||
(!autocompleteMode && this.incomplete !== 'true' && this.incomplete !== 'false'))) {
|
||||
// reset back where the value started, and treat as a new parameter
|
||||
this.i = this.incompleteStart;
|
||||
this.values![this.field.name] = 'true';
|
||||
this.values[this.field.name] = 'true';
|
||||
this.state = ParseState.StartParameter;
|
||||
} else {
|
||||
if (autocompleteMode && c === '') {
|
||||
return this;
|
||||
}
|
||||
this.values![this.field.name] = this.incomplete;
|
||||
this.values[this.field.name] = this.incomplete;
|
||||
this.incomplete = '';
|
||||
this.incompleteStart = this.i;
|
||||
if (c === '') {
|
||||
@@ -572,8 +607,6 @@ export class AppCommandParser {
|
||||
private rootPostID?: string;
|
||||
private intl: Intl;
|
||||
|
||||
forms: {[location: string]: AppForm} = {};
|
||||
|
||||
constructor(store: Store|null, intl: Intl, channelID: string, teamID = '', rootPostID = '') {
|
||||
this.store = store || getStore() as Store;
|
||||
this.channelID = channelID;
|
||||
@@ -619,57 +652,59 @@ export class AppCommandParser {
|
||||
}
|
||||
|
||||
private async addDefaultAndReadOnlyValues(parsed: ParsedCommand) {
|
||||
await Promise.all(parsed.form?.fields?.map(async (f) => {
|
||||
if (!parsed.form?.fields) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(parsed.form.fields.map(async (f) => {
|
||||
if (!f.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!f.readonly || f.name in parsed.values) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (f.type) {
|
||||
case AppFieldTypes.TEXT:
|
||||
parsed.values[f.name] = f.value as string;
|
||||
break;
|
||||
case AppFieldTypes.BOOL:
|
||||
parsed.values[f.name] = 'true';
|
||||
break;
|
||||
case AppFieldTypes.USER: {
|
||||
const userID = (f.value as AppSelectOption).value;
|
||||
let user = selectUser(this.store.getState(), userID);
|
||||
if (!user) {
|
||||
const dispatchResult = await this.store.dispatch(getUser(userID));
|
||||
if ('error' in dispatchResult) {
|
||||
// Silently fail on default value
|
||||
break;
|
||||
if (f.readonly || !(f.name in parsed.values)) {
|
||||
switch (f.type) {
|
||||
case AppFieldTypes.TEXT:
|
||||
parsed.values[f.name] = f.value as string;
|
||||
break;
|
||||
case AppFieldTypes.BOOL:
|
||||
parsed.values[f.name] = 'true';
|
||||
break;
|
||||
case AppFieldTypes.USER: {
|
||||
const userID = (f.value as AppSelectOption).value;
|
||||
let user = selectUser(this.store.getState(), userID);
|
||||
if (!user) {
|
||||
const dispatchResult = await this.store.dispatch(getUser(userID));
|
||||
if ('error' in dispatchResult) {
|
||||
// Silently fail on default value
|
||||
break;
|
||||
}
|
||||
user = dispatchResult.data;
|
||||
}
|
||||
user = dispatchResult.data;
|
||||
parsed.values[f.name] = user.username;
|
||||
break;
|
||||
}
|
||||
parsed.values[f.name] = user.username;
|
||||
break;
|
||||
}
|
||||
case AppFieldTypes.CHANNEL: {
|
||||
const channelID = (f.value as AppSelectOption).label;
|
||||
let channel = selectChannel(this.store.getState(), channelID);
|
||||
if (!channel) {
|
||||
const dispatchResult = await this.store.dispatch(getChannel(channelID));
|
||||
if ('error' in dispatchResult) {
|
||||
// Silently fail on default value
|
||||
break;
|
||||
case AppFieldTypes.CHANNEL: {
|
||||
const channelID = (f.value as AppSelectOption).label;
|
||||
let channel = selectChannel(this.store.getState(), channelID);
|
||||
if (!channel) {
|
||||
const dispatchResult = await this.store.dispatch(getChannel(channelID));
|
||||
if ('error' in dispatchResult) {
|
||||
// Silently fail on default value
|
||||
break;
|
||||
}
|
||||
channel = dispatchResult.data;
|
||||
}
|
||||
channel = dispatchResult.data;
|
||||
parsed.values[f.name] = channel.name;
|
||||
break;
|
||||
}
|
||||
parsed.values[f.name] = channel.name;
|
||||
break;
|
||||
}
|
||||
case AppFieldTypes.STATIC_SELECT:
|
||||
case AppFieldTypes.DYNAMIC_SELECT:
|
||||
parsed.values[f.name] = (f.value as AppSelectOption).value;
|
||||
break;
|
||||
case AppFieldTypes.MARKDOWN:
|
||||
case AppFieldTypes.STATIC_SELECT:
|
||||
case AppFieldTypes.DYNAMIC_SELECT:
|
||||
parsed.values[f.name] = (f.value as AppSelectOption).value;
|
||||
break;
|
||||
case AppFieldTypes.MARKDOWN:
|
||||
|
||||
// Do nothing
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
}) || []);
|
||||
}
|
||||
@@ -926,8 +961,11 @@ export class AppCommandParser {
|
||||
// getCommandBindings returns the commands in the redux store.
|
||||
// They are grouped by app id since each app has one base command
|
||||
private getCommandBindings = (): AppBinding[] => {
|
||||
const bindings = getCommandBindings(this.store.getState());
|
||||
return bindings;
|
||||
const state = this.store.getState();
|
||||
if (this.rootPostID) {
|
||||
return getRHSCommandBindings(state);
|
||||
}
|
||||
return getCommandBindings(state);
|
||||
}
|
||||
|
||||
// getChannel gets the channel in which the user is typing the command
|
||||
@@ -937,9 +975,6 @@ export class AppCommandParser {
|
||||
}
|
||||
|
||||
public setChannelContext = (channelID: string, teamID = '', rootPostID?: string) => {
|
||||
if (this.channelID !== channelID || this.rootPostID !== rootPostID || this.teamID !== teamID) {
|
||||
this.forms = {};
|
||||
}
|
||||
this.channelID = channelID;
|
||||
this.rootPostID = rootPostID;
|
||||
this.teamID = teamID;
|
||||
@@ -981,7 +1016,7 @@ export class AppCommandParser {
|
||||
}
|
||||
|
||||
context.channel_id = channel.id;
|
||||
context.team_id = this.teamID || channel.team_id || getCurrentTeamId(this.store.getState());
|
||||
context.team_id = channel.team_id || getCurrentTeamId(this.store.getState());
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -989,7 +1024,10 @@ export class AppCommandParser {
|
||||
// fetchForm unconditionaly retrieves the form for the given binding (subcommand)
|
||||
private fetchForm = async (binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => {
|
||||
if (!binding.call) {
|
||||
return undefined;
|
||||
return {error: this.intl.formatMessage({
|
||||
id: 'apps.error.parser.missing_call',
|
||||
defaultMessage: 'Missing binding call.',
|
||||
})};
|
||||
}
|
||||
|
||||
const payload = createCallRequest(
|
||||
@@ -1033,28 +1071,25 @@ export class AppCommandParser {
|
||||
public getForm = async (location: string, binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => {
|
||||
const rootID = this.rootPostID || '';
|
||||
const key = `${this.channelID}-${rootID}-${location}`;
|
||||
const form = this.forms[key];
|
||||
const form = this.rootPostID ? getAppRHSCommandForm(this.store.getState(), key) : getAppCommandForm(this.store.getState(), key);
|
||||
if (form) {
|
||||
return {form};
|
||||
}
|
||||
|
||||
this.forms = {};
|
||||
const fetched = await this.fetchForm(binding);
|
||||
if (fetched?.form) {
|
||||
this.forms[key] = fetched.form;
|
||||
let actionType: string = AppsTypes.RECEIVED_APP_COMMAND_FORM;
|
||||
if (this.rootPostID) {
|
||||
actionType = AppsTypes.RECEIVED_APP_RHS_COMMAND_FORM;
|
||||
}
|
||||
this.store.dispatch({
|
||||
data: {form: fetched.form, location: key},
|
||||
type: actionType,
|
||||
});
|
||||
}
|
||||
return fetched;
|
||||
}
|
||||
|
||||
// displayError shows an error that was caught by the parser
|
||||
private displayError = (err: any): void => {
|
||||
let errStr = err as string;
|
||||
if (err.message) {
|
||||
errStr = err.message;
|
||||
}
|
||||
displayError(this.intl, errStr);
|
||||
}
|
||||
|
||||
// getSuggestionsForSubCommands returns suggestions for a subcommand's name
|
||||
private getCommandSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
|
||||
if (!parsed.binding?.bindings?.length) {
|
||||
@@ -1104,6 +1139,14 @@ export class AppCommandParser {
|
||||
case ParseState.EndTickedValue:
|
||||
case ParseState.TickValue:
|
||||
return this.getValueSuggestions(parsed, '`');
|
||||
case ParseState.Rest: {
|
||||
const execute = getExecuteSuggestion(parsed);
|
||||
const value = await this.getValueSuggestions(parsed);
|
||||
if (execute) {
|
||||
return [execute, ...value];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -1332,11 +1375,11 @@ export class AppCommandParser {
|
||||
});
|
||||
return [{
|
||||
Complete: '',
|
||||
Suggestion: this.intl.formatMessage({
|
||||
Suggestion: '',
|
||||
Hint: this.intl.formatMessage({
|
||||
id: 'apps.suggestion.dynamic.error',
|
||||
defaultMessage: 'Dynamic select error',
|
||||
}),
|
||||
Hint: '',
|
||||
IconData: COMMAND_SUGGESTION_ERROR,
|
||||
Description: errMsg,
|
||||
}];
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {intlShape} from 'react-intl';
|
||||
import {Alert} from 'react-native';
|
||||
|
||||
import {getUserByUsername, getUser, autocompleteUsers} from '@mm-redux/actions/users';
|
||||
import {getCurrentTeamId, getCurrentTeam} from '@mm-redux/selectors/entities/teams';
|
||||
@@ -37,6 +36,8 @@ export type {
|
||||
DoAppCallResult,
|
||||
} from 'types/actions/apps';
|
||||
|
||||
export {AppsTypes} from '@mm-redux/action_types';
|
||||
|
||||
export type {AutocompleteSuggestion};
|
||||
|
||||
export type {
|
||||
@@ -65,7 +66,7 @@ export {
|
||||
COMMAND_SUGGESTION_USER,
|
||||
} from '@mm-redux/constants/apps';
|
||||
|
||||
export {makeAppBindingsSelector} from '@mm-redux/selectors/entities/apps';
|
||||
export {makeAppBindingsSelector, makeRHSAppBindingSelector, getAppCommandForm, getAppRHSCommandForm} from '@mm-redux/selectors/entities/apps';
|
||||
|
||||
export {getPost} from '@mm-redux/selectors/entities/posts';
|
||||
export {getChannel as selectChannel, getCurrentChannel, getChannelByName as selectChannelByName} from '@mm-redux/selectors/entities/channels';
|
||||
@@ -112,14 +113,6 @@ export const getExecuteSuggestion = (_: ParsedCommand): AutocompleteSuggestion |
|
||||
return null;
|
||||
};
|
||||
|
||||
export const displayError = (intl: typeof intlShape, body: string) => {
|
||||
const title = intl.formatMessage({
|
||||
id: 'mobile.general.error.title',
|
||||
defaultMessage: 'Error',
|
||||
});
|
||||
Alert.alert(title, body);
|
||||
};
|
||||
|
||||
export const errorMessage = (intl: typeof intlShape, error: string, _command: string, _position: number): string => { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
return intl.formatMessage({
|
||||
id: 'apps.error.parser',
|
||||
|
||||
@@ -181,6 +181,37 @@ export const createCommand: AppBinding = {
|
||||
} as AppForm,
|
||||
};
|
||||
|
||||
export const restCommand: AppBinding = {
|
||||
app_id: 'jira',
|
||||
label: 'rest',
|
||||
location: 'rest',
|
||||
description: 'rest description',
|
||||
icon: 'rest icon',
|
||||
hint: 'rest hint',
|
||||
form: {
|
||||
call: {
|
||||
path: '/create-issue',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'summary',
|
||||
label: 'summary',
|
||||
description: 'The Jira issue summary',
|
||||
type: AppFieldTypes.TEXT,
|
||||
hint: 'The thing is working great!',
|
||||
position: -1,
|
||||
},
|
||||
{
|
||||
name: 'verbose',
|
||||
label: 'verbose',
|
||||
description: 'display details',
|
||||
type: AppFieldTypes.BOOL,
|
||||
hint: 'yes or no!',
|
||||
},
|
||||
],
|
||||
} as AppForm,
|
||||
};
|
||||
|
||||
export const testBindings: AppBinding[] = [
|
||||
{
|
||||
app_id: '',
|
||||
@@ -204,6 +235,7 @@ export const testBindings: AppBinding[] = [
|
||||
bindings: [
|
||||
viewCommand,
|
||||
createCommand,
|
||||
restCommand,
|
||||
],
|
||||
}],
|
||||
},
|
||||
|
||||
@@ -16,7 +16,6 @@ exports[`components/autocomplete/app_slash_suggestion should match snapshot 1`]
|
||||
keyExtractor={[Function]}
|
||||
keyboardShouldPersistTaps="always"
|
||||
nestedScrollEnabled={false}
|
||||
numColumns={1}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={[Function]}
|
||||
style={
|
||||
|
||||
@@ -27,7 +27,12 @@ const makeStore = async (bindings: AppBinding[]) => {
|
||||
...reduxTestState,
|
||||
entities: {
|
||||
...reduxTestState.entities,
|
||||
apps: {bindings},
|
||||
apps: {
|
||||
bindings,
|
||||
bindingsForms: {},
|
||||
threadBindings: bindings,
|
||||
threadBindingsForms: {},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const testStore = await mockStore(initialState);
|
||||
|
||||
@@ -155,13 +155,15 @@ const SlashSuggestionItem = (props: Props) => {
|
||||
</View>
|
||||
<View style={style.suggestionContainer}>
|
||||
<Text style={style.suggestionName}>{`${suggestionText}`}</Text>
|
||||
<Text
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={1}
|
||||
style={style.suggestionDescription}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
{Boolean(description) &&
|
||||
<Text
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={1}
|
||||
style={style.suggestionDescription}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {PureComponent} from 'react';
|
||||
import {intlShape} from 'react-intl';
|
||||
import {Text, View} from 'react-native';
|
||||
import {Text, View, Platform} from 'react-native';
|
||||
|
||||
import {goToScreen} from '@actions/navigation';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
@@ -12,33 +11,46 @@ import FormattedText from '@components/formatted_text';
|
||||
import Markdown from '@components/markdown';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {ViewTypes} from '@constants';
|
||||
import {ActionResult} from '@mm-redux/types/actions';
|
||||
import {Channel} from '@mm-redux/types/channels';
|
||||
import {DialogOption} from '@mm-redux/types/integrations';
|
||||
import {Theme} from '@mm-redux/types/theme';
|
||||
import {UserProfile} from '@mm-redux/types/users';
|
||||
import {displayUsername} from '@mm-redux/utils/user_utils';
|
||||
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||||
|
||||
export default class AutocompleteSelector extends PureComponent {
|
||||
static propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
setAutocompleteSelector: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
getDynamicOptions: PropTypes.func,
|
||||
label: PropTypes.string,
|
||||
placeholder: PropTypes.string.isRequired,
|
||||
dataSource: PropTypes.string,
|
||||
options: PropTypes.arrayOf(PropTypes.object),
|
||||
selected: PropTypes.object,
|
||||
optional: PropTypes.bool,
|
||||
showRequiredAsterisk: PropTypes.bool,
|
||||
teammateNameDisplay: PropTypes.string,
|
||||
theme: PropTypes.object.isRequired,
|
||||
onSelected: PropTypes.func,
|
||||
helpText: PropTypes.node,
|
||||
errorText: PropTypes.node,
|
||||
roundedBorders: PropTypes.bool,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
type Selection = DialogOption | Channel | UserProfile | DialogOption[] | Channel[] | UserProfile[];
|
||||
|
||||
type Props = {
|
||||
actions: {
|
||||
setAutocompleteSelector: (dataSource: any, onSelect: any, options: any, getDynamicOptions: any) => Promise<ActionResult>;
|
||||
};
|
||||
getDynamicOptions?: (term: string) => Promise<ActionResult>;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
dataSource?: string;
|
||||
options?: DialogOption[];
|
||||
selected?: DialogOption | DialogOption[];
|
||||
optional?: boolean;
|
||||
showRequiredAsterisk?: boolean;
|
||||
teammateNameDisplay?: string;
|
||||
theme: Theme;
|
||||
onSelected?: ((item: DialogOption) => void) | ((item: DialogOption[]) => void);
|
||||
helpText?: string;
|
||||
errorText?: string;
|
||||
roundedBorders?: boolean;
|
||||
disabled?: boolean;
|
||||
isMultiselect?: boolean;
|
||||
}
|
||||
|
||||
type State = {
|
||||
selectedText: string;
|
||||
selected?: DialogOption | DialogOption[];
|
||||
}
|
||||
|
||||
export default class AutocompleteSelector extends PureComponent<Props, State> {
|
||||
static contextTypes = {
|
||||
intl: intlShape,
|
||||
};
|
||||
@@ -49,26 +61,45 @@ export default class AutocompleteSelector extends PureComponent {
|
||||
roundedBorders: true,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
selectedText: null,
|
||||
selectedText: '',
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
if (props.selected && props.selected !== state.selected) {
|
||||
static getDerivedStateFromProps(props: Props, state: State) {
|
||||
if (!props.selected || props.selected === state.selected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!props.isMultiselect) {
|
||||
return {
|
||||
selectedText: props.selected.text,
|
||||
selectedText: (props.selected as DialogOption).text,
|
||||
selected: props.selected,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
const options = props.selected as DialogOption[];
|
||||
let selectedText = '';
|
||||
const selected: DialogOption[] = [];
|
||||
|
||||
options.forEach((option) => {
|
||||
if (selectedText !== '') {
|
||||
selectedText += ', ';
|
||||
}
|
||||
selectedText += option.text;
|
||||
selected.push(option);
|
||||
});
|
||||
|
||||
return {
|
||||
selectedText,
|
||||
selected,
|
||||
};
|
||||
}
|
||||
|
||||
handleSelect = (selected) => {
|
||||
handleSelect = (selected: Selection) => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
@@ -78,34 +109,113 @@ export default class AutocompleteSelector extends PureComponent {
|
||||
teammateNameDisplay,
|
||||
} = this.props;
|
||||
|
||||
let selectedText;
|
||||
let selectedValue;
|
||||
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
|
||||
selectedText = displayUsername(selected, teammateNameDisplay);
|
||||
selectedValue = selected.id;
|
||||
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
|
||||
selectedText = selected.display_name;
|
||||
selectedValue = selected.id;
|
||||
} else {
|
||||
selectedText = selected.text;
|
||||
selectedValue = selected.value;
|
||||
if (!this.props.isMultiselect) {
|
||||
let selectedText: string;
|
||||
let selectedValue: string;
|
||||
switch (dataSource) {
|
||||
case ViewTypes.DATA_SOURCE_USERS: {
|
||||
const typedSelected = selected as UserProfile;
|
||||
selectedText = displayUsername(typedSelected, teammateNameDisplay || '');
|
||||
selectedValue = typedSelected.id;
|
||||
break;
|
||||
}
|
||||
case ViewTypes.DATA_SOURCE_CHANNELS: {
|
||||
const typedSelected = selected as Channel;
|
||||
selectedText = typedSelected.display_name;
|
||||
selectedValue = typedSelected.id;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const typedSelected = selected as DialogOption;
|
||||
selectedText = typedSelected.text;
|
||||
selectedValue = typedSelected.value;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({selectedText});
|
||||
|
||||
if (this.props.onSelected) {
|
||||
(this.props.onSelected as (opt: DialogOption) => void)({text: selectedText, value: selectedValue});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let selectedText = '';
|
||||
const selectedOptions: DialogOption[] = [];
|
||||
switch (dataSource) {
|
||||
case ViewTypes.DATA_SOURCE_USERS: {
|
||||
const typedSelected = selected as UserProfile[];
|
||||
typedSelected.forEach((option) => {
|
||||
if (selectedText !== '') {
|
||||
selectedText += ', ';
|
||||
}
|
||||
const text = displayUsername(option, teammateNameDisplay || '');
|
||||
selectedText += text;
|
||||
selectedOptions.push({text, value: option.id});
|
||||
});
|
||||
break;
|
||||
}
|
||||
case ViewTypes.DATA_SOURCE_CHANNELS: {
|
||||
const typedSelected = selected as Channel[];
|
||||
typedSelected.forEach((option) => {
|
||||
if (selectedText !== '') {
|
||||
selectedText += ', ';
|
||||
}
|
||||
const text = option.display_name;
|
||||
selectedText += text;
|
||||
selectedOptions.push({text, value: option.id});
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const typedSelected = selected as DialogOption[];
|
||||
typedSelected.forEach((option) => {
|
||||
if (selectedText !== '') {
|
||||
selectedText += ', ';
|
||||
}
|
||||
selectedText += option.text;
|
||||
selectedOptions.push(option);
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({selectedText});
|
||||
|
||||
if (this.props.onSelected) {
|
||||
this.props.onSelected({text: selectedText, value: selectedValue});
|
||||
(this.props.onSelected as (opt: DialogOption[]) => void)(selectedOptions);
|
||||
}
|
||||
};
|
||||
|
||||
goToSelectorScreen = preventDoubleTap(() => {
|
||||
goToSelectorScreen = preventDoubleTap(async () => {
|
||||
const closeButton = await CompassIcon.getImageSource(Platform.select({ios: 'arrow-back-ios', default: 'arrow-left'}), 24, this.props.theme.sidebarHeaderTextColor);
|
||||
|
||||
const {formatMessage} = this.context.intl;
|
||||
const {actions, dataSource, options, placeholder, getDynamicOptions} = this.props;
|
||||
const {actions, dataSource, options, placeholder, getDynamicOptions, theme} = this.props;
|
||||
const screen = 'SelectorScreen';
|
||||
const title = placeholder || formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'});
|
||||
const buttonName = formatMessage({id: 'mobile.forms.select.done', defaultMessage: 'Done'});
|
||||
|
||||
actions.setAutocompleteSelector(dataSource, this.handleSelect, options, getDynamicOptions);
|
||||
goToScreen(screen, title);
|
||||
let screenOptions = {};
|
||||
if (this.props.isMultiselect) {
|
||||
screenOptions = {
|
||||
topBar: {
|
||||
leftButtons: [{
|
||||
id: 'close-dialog',
|
||||
icon: closeButton,
|
||||
}],
|
||||
rightButtons: [{
|
||||
id: 'submit-form',
|
||||
showAsAction: 'always',
|
||||
text: buttonName,
|
||||
}],
|
||||
leftButtonColor: theme.sidebarHeaderTextColor,
|
||||
rightButtonColor: theme.sidebarHeaderTextColor,
|
||||
},
|
||||
};
|
||||
}
|
||||
goToScreen(screen, title, {isMultiselect: this.props.isMultiselect, selected: this.state.selected}, screenOptions);
|
||||
});
|
||||
|
||||
render() {
|
||||
@@ -126,6 +236,8 @@ export default class AutocompleteSelector extends PureComponent {
|
||||
const textStyles = getMarkdownTextStyles(theme);
|
||||
const blockStyles = getMarkdownBlockStyles(theme);
|
||||
|
||||
const chevron = Platform.select({ios: 'chevron-right', default: 'chevron-down'});
|
||||
|
||||
let text = placeholder || intl.formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'});
|
||||
let selectedStyle = style.dropdownPlaceholder;
|
||||
|
||||
@@ -215,8 +327,8 @@ export default class AutocompleteSelector extends PureComponent {
|
||||
{text}
|
||||
</Text>
|
||||
<CompassIcon
|
||||
name='chevron-down'
|
||||
color={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||
name={chevron}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.32)}
|
||||
style={style.icon}
|
||||
/>
|
||||
</View>
|
||||
@@ -228,7 +340,7 @@ export default class AutocompleteSelector extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
const input = {
|
||||
borderWidth: 1,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.1),
|
||||
@@ -263,8 +375,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
},
|
||||
icon: {
|
||||
position: 'absolute',
|
||||
top: 13,
|
||||
top: 6,
|
||||
right: 12,
|
||||
fontSize: 28,
|
||||
},
|
||||
labelContainer: {
|
||||
flexDirection: 'row',
|
||||
@@ -2,23 +2,29 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
|
||||
|
||||
import {setAutocompleteSelector} from '@actions/views/post';
|
||||
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
|
||||
import AutocompleteSelector from './autocomplete_selector';
|
||||
|
||||
function mapStateToProps(state) {
|
||||
import type {Action, ActionResult, GenericAction} from '@mm-redux/types/actions';
|
||||
import type {GlobalState} from '@mm-redux/types/store';
|
||||
|
||||
function mapStateToProps(state: GlobalState) {
|
||||
return {
|
||||
teammateNameDisplay: getTeammateNameDisplaySetting(state),
|
||||
theme: getTheme(state),
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
type Actions = {
|
||||
setAutocompleteSelector: (dataSource: any, onSelect: any, options: any, getDynamicOptions: any) => Promise<ActionResult>;
|
||||
}
|
||||
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
actions: bindActionCreators<ActionCreatorsMapObject<Action>, Actions>({
|
||||
setAutocompleteSelector,
|
||||
}, dispatch),
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -25,7 +25,6 @@ exports[`CustomList should match snapshot with FlatList 1`] = `
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="always"
|
||||
maxToRenderPerBatch={16}
|
||||
numColumns={1}
|
||||
onLayout={[Function]}
|
||||
onScroll={[Function]}
|
||||
removeClippedSubviews={true}
|
||||
|
||||
@@ -52,33 +52,35 @@ export default class ChannelListRow extends React.PureComponent {
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomListRow
|
||||
id={this.props.id}
|
||||
onPress={this.props.onPress ? this.onPress : null}
|
||||
enabled={this.props.enabled}
|
||||
selectable={this.props.selectable}
|
||||
selected={this.props.selected}
|
||||
testID={testID}
|
||||
>
|
||||
<View
|
||||
style={style.container}
|
||||
testID={itemTestID}
|
||||
<View style={style.outerContainer}>
|
||||
<CustomListRow
|
||||
id={this.props.id}
|
||||
onPress={this.props.onPress ? this.onPress : null}
|
||||
enabled={this.props.enabled}
|
||||
selectable={this.props.selectable}
|
||||
selected={this.props.selected}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={style.titleContainer}>
|
||||
<CompassIcon
|
||||
name={icon}
|
||||
style={style.icon}
|
||||
/>
|
||||
<Text
|
||||
style={style.displayName}
|
||||
testID={channelDisplayNameTestID}
|
||||
>
|
||||
{this.props.channel.display_name}
|
||||
</Text>
|
||||
<View
|
||||
style={style.container}
|
||||
testID={itemTestID}
|
||||
>
|
||||
<View style={style.titleContainer}>
|
||||
<CompassIcon
|
||||
name={icon}
|
||||
style={style.icon}
|
||||
/>
|
||||
<Text
|
||||
style={style.displayName}
|
||||
testID={channelDisplayNameTestID}
|
||||
>
|
||||
{this.props.channel.display_name}
|
||||
</Text>
|
||||
</View>
|
||||
{purpose}
|
||||
</View>
|
||||
{purpose}
|
||||
</View>
|
||||
</CustomListRow>
|
||||
</CustomListRow>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -101,7 +103,12 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
container: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
},
|
||||
outerContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 15,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
purpose: {
|
||||
marginTop: 7,
|
||||
|
||||
@@ -42,21 +42,23 @@ export default class OptionListRow extends React.PureComponent {
|
||||
const style = getStyleFromTheme(theme);
|
||||
|
||||
return (
|
||||
<CustomListRow
|
||||
id={value}
|
||||
onPress={this.onPress}
|
||||
enabled={enabled}
|
||||
selectable={selectable}
|
||||
selected={selected}
|
||||
>
|
||||
<View style={style.textContainer}>
|
||||
<View>
|
||||
<Text style={style.optionText}>
|
||||
{text}
|
||||
</Text>
|
||||
<View style={style.container}>
|
||||
<CustomListRow
|
||||
id={value}
|
||||
onPress={this.onPress}
|
||||
enabled={enabled}
|
||||
selectable={selectable}
|
||||
selected={selected}
|
||||
>
|
||||
<View style={style.textContainer}>
|
||||
<View>
|
||||
<Text style={style.optionText}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</CustomListRow>
|
||||
</CustomListRow>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ exports[`UserListRow should match snapshot 1`] = `
|
||||
Object {
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"marginHorizontal": 10,
|
||||
"overflow": "hidden",
|
||||
"paddingHorizontal": 15,
|
||||
}
|
||||
}
|
||||
>
|
||||
@@ -142,8 +142,8 @@ exports[`UserListRow should match snapshot for currentUser with (you) populated
|
||||
Object {
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"marginHorizontal": 10,
|
||||
"overflow": "hidden",
|
||||
"paddingHorizontal": 15,
|
||||
}
|
||||
}
|
||||
>
|
||||
@@ -278,8 +278,8 @@ exports[`UserListRow should match snapshot for deactivated user 1`] = `
|
||||
Object {
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"marginHorizontal": 10,
|
||||
"overflow": "hidden",
|
||||
"paddingHorizontal": 15,
|
||||
}
|
||||
}
|
||||
>
|
||||
@@ -427,8 +427,8 @@ exports[`UserListRow should match snapshot for guest user 1`] = `
|
||||
Object {
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"marginHorizontal": 10,
|
||||
"overflow": "hidden",
|
||||
"paddingHorizontal": 15,
|
||||
}
|
||||
}
|
||||
>
|
||||
@@ -563,8 +563,8 @@ exports[`UserListRow should match snapshot for remote user 1`] = `
|
||||
Object {
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"marginHorizontal": 10,
|
||||
"overflow": "hidden",
|
||||
"paddingHorizontal": 15,
|
||||
}
|
||||
}
|
||||
>
|
||||
|
||||
@@ -165,7 +165,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
container: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
marginHorizontal: 10,
|
||||
paddingHorizontal: 15,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
profileContainer: {
|
||||
|
||||
@@ -279,6 +279,7 @@ export default class EditChannelInfo extends PureComponent {
|
||||
onPress={() => {
|
||||
this.onTypeSelect(General.OPEN_CHANNEL);
|
||||
}}
|
||||
testID='edit_channel_info.type.public.action'
|
||||
>
|
||||
<FormattedText
|
||||
style={style.touchableText}
|
||||
@@ -305,6 +306,7 @@ export default class EditChannelInfo extends PureComponent {
|
||||
onPress={() => {
|
||||
this.onTypeSelect(General.PRIVATE_CHANNEL);
|
||||
}}
|
||||
testID='edit_channel_info.type.private.action'
|
||||
>
|
||||
<FormattedText
|
||||
style={style.touchableText}
|
||||
|
||||
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;
|
||||
@@ -5,9 +5,14 @@ import React from 'react';
|
||||
import {injectIntl, intlShape} from 'react-intl';
|
||||
import {Alert, FlatList} from 'react-native';
|
||||
|
||||
import {goToScreen} from '@actions/navigation';
|
||||
import {THREAD} from '@constants/screen';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
|
||||
import ThreadList from './thread_list';
|
||||
|
||||
import type {ActionResult} from '@mm-redux/types/actions';
|
||||
import type {Post} from '@mm-redux/types/posts';
|
||||
import type {Team} from '@mm-redux/types/teams';
|
||||
import type {Theme} from '@mm-redux/types/theme';
|
||||
import type {ThreadsState, UserThread} from '@mm-redux/types/threads';
|
||||
@@ -16,10 +21,12 @@ import type {$ID} from '@mm-redux/types/utilities';
|
||||
|
||||
type Props = {
|
||||
actions: {
|
||||
getPostThread: (postId: string) => void;
|
||||
getThreads: (userId: $ID<UserProfile>, teamId: $ID<Team>, before?: $ID<UserThread>, after?: $ID<UserThread>, perPage?: number, deleted?: boolean, unread?: boolean) => Promise<ActionResult>;
|
||||
handleViewingGlobalThreadsAll: () => void;
|
||||
handleViewingGlobalThreadsUnreads: () => void;
|
||||
markAllThreadsInTeamRead: (userId: $ID<UserProfile>, teamId: $ID<Team>) => void;
|
||||
selectPost: (postId: string) => void;
|
||||
};
|
||||
allThreadIds: $ID<UserThread>[];
|
||||
intl: typeof intlShape;
|
||||
@@ -38,6 +45,7 @@ function GlobalThreadsList({actions, allThreadIds, intl, teamId, theme, threadCo
|
||||
const listRef = React.useRef<FlatList>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState<boolean>(false);
|
||||
|
||||
const scrollToTop = () => {
|
||||
listRef.current?.scrollToOffset({offset: 0});
|
||||
@@ -79,6 +87,16 @@ function GlobalThreadsList({actions, allThreadIds, intl, teamId, theme, threadCo
|
||||
}
|
||||
};
|
||||
|
||||
const onRefresh = async () => {
|
||||
if (!isLoading) {
|
||||
if (!isRefreshing) {
|
||||
setIsRefreshing(true);
|
||||
}
|
||||
await loadThreads('', '', viewingUnreads);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const markAllAsRead = () => {
|
||||
Alert.alert(
|
||||
intl.formatMessage({
|
||||
@@ -108,13 +126,32 @@ function GlobalThreadsList({actions, allThreadIds, intl, teamId, theme, threadCo
|
||||
);
|
||||
};
|
||||
|
||||
const goToThread = React.useCallback((post: Post) => {
|
||||
actions.getPostThread(post.id);
|
||||
actions.selectPost(post.id);
|
||||
const passProps = {
|
||||
channelId: post.channel_id,
|
||||
rootId: post.id,
|
||||
};
|
||||
goToScreen(THREAD, '', passProps);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
EventEmitter.on('goToThread', goToThread);
|
||||
return () => {
|
||||
EventEmitter.off('goToThread', goToThread);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThreadList
|
||||
haveUnreads={haveUnreads}
|
||||
isLoading={isLoading}
|
||||
isRefreshing={isRefreshing}
|
||||
listRef={listRef}
|
||||
loadMoreThreads={loadMoreThreads}
|
||||
markAllAsRead={markAllAsRead}
|
||||
onRefresh={onRefresh}
|
||||
testID={'global_threads'}
|
||||
theme={theme}
|
||||
threadIds={ids}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators, Dispatch} from 'redux';
|
||||
|
||||
import {getPostThread} from '@actions/views/post';
|
||||
import {handleViewingGlobalThreadsAll, handleViewingGlobalThreadsUnreads} from '@actions/views/threads';
|
||||
import {selectPost} from '@mm-redux/actions/posts';
|
||||
import {getThreads, markAllThreadsInTeamRead} from '@mm-redux/actions/threads';
|
||||
import {getCurrentUserId} from '@mm-redux/selectors/entities/common';
|
||||
import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
@@ -32,10 +34,12 @@ function mapStateToProps(state: GlobalState) {
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
getPostThread,
|
||||
getThreads,
|
||||
handleViewingGlobalThreadsAll,
|
||||
handleViewingGlobalThreadsUnreads,
|
||||
markAllThreadsInTeamRead,
|
||||
selectPost,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
exports[`Global Thread Item Should render thread item with unread messages dot 1`] = `
|
||||
<TouchableHighlight
|
||||
onLongPress={[Function]}
|
||||
onPress={[Function]}
|
||||
testID="thread_item.post1.item"
|
||||
underlayColor="rgba(28,88,217,0.08)"
|
||||
@@ -185,6 +186,7 @@ exports[`Global Thread Item Should render thread item with unread messages dot 1
|
||||
|
||||
exports[`Global Thread Item Should show unread mentions count 1`] = `
|
||||
<TouchableHighlight
|
||||
onLongPress={[Function]}
|
||||
onPress={[Function]}
|
||||
testID="thread_item.post1.item"
|
||||
underlayColor="rgba(28,88,217,0.08)"
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators, Dispatch} from 'redux';
|
||||
|
||||
import {getPost, getPostThread} from '@actions/views/post';
|
||||
import {selectPost} from '@mm-redux/actions/posts';
|
||||
import {getPost} from '@actions/views/post';
|
||||
import {getChannel} from '@mm-redux/selectors/entities/channels';
|
||||
import {getPost as getPostSelector} from '@mm-redux/selectors/entities/posts';
|
||||
import {getThread} from '@mm-redux/selectors/entities/threads';
|
||||
@@ -30,8 +29,6 @@ function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
getPost,
|
||||
getPostThread,
|
||||
selectPost,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,13 +5,12 @@ import {shallow} from 'enzyme';
|
||||
import React from 'react';
|
||||
import {Text} from 'react-native';
|
||||
|
||||
import * as navigationActions from '@actions/navigation';
|
||||
import {THREAD} from '@constants/screen';
|
||||
import {Preferences} from '@mm-redux/constants';
|
||||
import {Channel} from '@mm-redux/types/channels';
|
||||
import {Post} from '@mm-redux/types/posts';
|
||||
import {UserThread} from '@mm-redux/types/threads';
|
||||
import {UserProfile} from '@mm-redux/types/users';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import {intl} from '@test/intl-test-helper';
|
||||
|
||||
import {ThreadItem} from './thread_item';
|
||||
@@ -99,7 +98,7 @@ describe('Global Thread Item', () => {
|
||||
});
|
||||
|
||||
test('Should goto threads when pressed on thread item', () => {
|
||||
const goToScreen = jest.spyOn(navigationActions, 'goToScreen');
|
||||
EventEmitter.emit = jest.fn();
|
||||
const wrapper = shallow(
|
||||
<ThreadItem
|
||||
{...baseProps}
|
||||
@@ -108,6 +107,6 @@ describe('Global Thread Item', () => {
|
||||
const threadItem = wrapper.find({testID: `${testIDPrefix}.item`});
|
||||
expect(threadItem.exists()).toBeTruthy();
|
||||
threadItem.simulate('press');
|
||||
expect(goToScreen).toHaveBeenCalledWith(THREAD, expect.anything(), expect.anything());
|
||||
expect(EventEmitter.emit).toHaveBeenCalledWith('goToThread', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,29 +3,28 @@
|
||||
|
||||
import React from 'react';
|
||||
import {injectIntl, intlShape} from 'react-intl';
|
||||
import {View, Text, TouchableHighlight} from 'react-native';
|
||||
import {Keyboard, Text, TouchableHighlight, View} from 'react-native';
|
||||
|
||||
import {goToScreen} from '@actions/navigation';
|
||||
import {showModalOverCurrentContext} from '@actions/navigation';
|
||||
import FriendlyDate from '@components/friendly_date';
|
||||
import RemoveMarkdown from '@components/remove_markdown';
|
||||
import {GLOBAL_THREADS, THREAD} from '@constants/screen';
|
||||
import {GLOBAL_THREADS} from '@constants/screen';
|
||||
import {Posts, Preferences} from '@mm-redux/constants';
|
||||
import {Channel} from '@mm-redux/types/channels';
|
||||
import {Post} from '@mm-redux/types/posts';
|
||||
import {UserThread} from '@mm-redux/types/threads';
|
||||
import {UserProfile} from '@mm-redux/types/users';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import {displayUsername} from '@mm-redux/utils/user_utils';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import ThreadFooter from '../thread_footer';
|
||||
|
||||
import type {Channel} from '@mm-redux/types/channels';
|
||||
import type {Post} from '@mm-redux/types/posts';
|
||||
import type {Theme} from '@mm-redux/types/theme';
|
||||
import type {UserThread} from '@mm-redux/types/threads';
|
||||
import type {UserProfile} from '@mm-redux/types/users';
|
||||
|
||||
export type DispatchProps = {
|
||||
actions: {
|
||||
getPost: (postId: string) => void;
|
||||
getPostThread: (postId: string) => void;
|
||||
selectPost: (postId: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,13 +65,18 @@ function ThreadItem({actions, channel, intl, post, threadId, testID, theme, thre
|
||||
const threadStarterName = displayUsername(threadStarter, Preferences.DISPLAY_PREFER_FULL_NAME);
|
||||
|
||||
const showThread = () => {
|
||||
actions.getPostThread(postItem.id);
|
||||
actions.selectPost(postItem.id);
|
||||
EventEmitter.emit('goToThread', postItem);
|
||||
};
|
||||
|
||||
const showThreadOptions = () => {
|
||||
const screen = 'GlobalThreadOptions';
|
||||
const passProps = {
|
||||
channelId: postItem.channel_id,
|
||||
rootId: postItem.id,
|
||||
rootId: post.id,
|
||||
};
|
||||
goToScreen(THREAD, '', passProps);
|
||||
Keyboard.dismiss();
|
||||
requestAnimationFrame(() => {
|
||||
showModalOverCurrentContext(screen, passProps);
|
||||
});
|
||||
};
|
||||
|
||||
const testIDPrefix = `${testID}.${postItem?.id}`;
|
||||
@@ -134,6 +138,7 @@ function ThreadItem({actions, channel, intl, post, threadId, testID, theme, thre
|
||||
return (
|
||||
<TouchableHighlight
|
||||
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
|
||||
onLongPress={showThreadOptions}
|
||||
onPress={showThread}
|
||||
testID={`${testIDPrefix}.item`}
|
||||
>
|
||||
|
||||
@@ -86,62 +86,98 @@ exports[`Global Thread List Should render threads with functional tabs & mark al
|
||||
viewUnreadThreads={[MockFunction]}
|
||||
viewingUnreads={true}
|
||||
/>
|
||||
<FlatList
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
intl={
|
||||
Object {
|
||||
"defaultFormats": Object {},
|
||||
"defaultLocale": "en",
|
||||
"formatDate": [Function],
|
||||
"formatHTMLMessage": [Function],
|
||||
"formatMessage": [Function],
|
||||
"formatNumber": [Function],
|
||||
"formatPlural": [Function],
|
||||
"formatRelative": [Function],
|
||||
"formatTime": [Function],
|
||||
"formats": Object {},
|
||||
"formatters": Object {
|
||||
"getDateTimeFormat": [Function],
|
||||
"getMessageFormat": [Function],
|
||||
"getNumberFormat": [Function],
|
||||
"getPluralFormat": [Function],
|
||||
"getRelativeFormat": [Function],
|
||||
},
|
||||
"locale": "en",
|
||||
"messages": Object {},
|
||||
"now": [Function],
|
||||
"onError": [Function],
|
||||
"textComponent": "span",
|
||||
"timeZone": null,
|
||||
<PostListRefreshControl
|
||||
enabled={true}
|
||||
isInverted={false}
|
||||
onRefresh={[MockFunction]}
|
||||
refreshing={false}
|
||||
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",
|
||||
}
|
||||
}
|
||||
>
|
||||
<FlatList
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
intl={
|
||||
Object {
|
||||
"defaultFormats": Object {},
|
||||
"defaultLocale": "en",
|
||||
"formatDate": [Function],
|
||||
"formatHTMLMessage": [Function],
|
||||
"formatMessage": [Function],
|
||||
"formatNumber": [Function],
|
||||
"formatPlural": [Function],
|
||||
"formatRelative": [Function],
|
||||
"formatTime": [Function],
|
||||
"formats": Object {},
|
||||
"formatters": Object {
|
||||
"getDateTimeFormat": [Function],
|
||||
"getMessageFormat": [Function],
|
||||
"getNumberFormat": [Function],
|
||||
"getPluralFormat": [Function],
|
||||
"getRelativeFormat": [Function],
|
||||
},
|
||||
"locale": "en",
|
||||
"messages": Object {},
|
||||
"now": [Function],
|
||||
"onError": [Function],
|
||||
"textComponent": "span",
|
||||
"timeZone": null,
|
||||
}
|
||||
}
|
||||
isUnreads={true}
|
||||
/>
|
||||
}
|
||||
ListFooterComponent={null}
|
||||
contentContainerStyle={
|
||||
Object {
|
||||
"flexGrow": 1,
|
||||
}
|
||||
isUnreads={true}
|
||||
/>
|
||||
}
|
||||
ListFooterComponent={null}
|
||||
contentContainerStyle={
|
||||
Object {
|
||||
"flexGrow": 1,
|
||||
}
|
||||
}
|
||||
data={
|
||||
Array [
|
||||
"thread1",
|
||||
]
|
||||
}
|
||||
initialNumToRender={10}
|
||||
keyExtractor={[Function]}
|
||||
numColumns={1}
|
||||
onEndReached={[Function]}
|
||||
onEndReachedThreshold={2}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={[Function]}
|
||||
scrollIndicatorInsets={
|
||||
Object {
|
||||
"right": 1,
|
||||
data={
|
||||
Array [
|
||||
"thread1",
|
||||
]
|
||||
}
|
||||
}
|
||||
/>
|
||||
initialNumToRender={10}
|
||||
keyExtractor={[Function]}
|
||||
onEndReached={[Function]}
|
||||
onEndReachedThreshold={2}
|
||||
onScroll={[Function]}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={[Function]}
|
||||
scrollIndicatorInsets={
|
||||
Object {
|
||||
"right": 1,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PostListRefreshControl>
|
||||
</View>
|
||||
`;
|
||||
|
||||
@@ -25,9 +25,11 @@ describe('Global Thread List', () => {
|
||||
haveUnreads: true,
|
||||
intl,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
listRef: React.useRef<FlatList>(null),
|
||||
loadMoreThreads: jest.fn(),
|
||||
markAllAsRead,
|
||||
onRefresh: jest.fn(),
|
||||
testID,
|
||||
theme: Preferences.THEMES.denim,
|
||||
threadIds: ['thread1'],
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import {injectIntl, intlShape} from 'react-intl';
|
||||
import {FlatList, Platform, View} from 'react-native';
|
||||
import {FlatList, NativeSyntheticEvent, NativeScrollEvent, Platform, View} from 'react-native';
|
||||
|
||||
import EmptyState from '@components/global_threads/empty_state';
|
||||
import ThreadItem from '@components/global_threads/thread_item';
|
||||
import Loading from '@components/loading';
|
||||
import {INITIAL_BATCH_TO_RENDER} from '@components/post_list/post_list_config';
|
||||
import CustomRefreshControl from '@components/post_list/post_list_refresh_control';
|
||||
import {ViewTypes} from '@constants';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
@@ -21,9 +22,11 @@ export type Props = {
|
||||
haveUnreads: boolean;
|
||||
intl: typeof intlShape;
|
||||
isLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
loadMoreThreads: () => Promise<void>;
|
||||
listRef: React.RefObject<FlatList>;
|
||||
markAllAsRead: () => void;
|
||||
onRefresh: () => void;
|
||||
testID: string;
|
||||
theme: Theme;
|
||||
threadIds: $ID<UserThread>[];
|
||||
@@ -32,9 +35,11 @@ export type Props = {
|
||||
viewingUnreads: boolean;
|
||||
};
|
||||
|
||||
function ThreadList({haveUnreads, intl, isLoading, loadMoreThreads, listRef, markAllAsRead, testID, theme, threadIds, viewAllThreads, viewUnreadThreads, viewingUnreads}: Props) {
|
||||
function ThreadList({haveUnreads, intl, isLoading, isRefreshing, loadMoreThreads, listRef, markAllAsRead, onRefresh, testID, theme, threadIds, viewAllThreads, viewUnreadThreads, viewingUnreads}: Props) {
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
const [offsetY, setOffsetY] = React.useState(0);
|
||||
|
||||
const handleEndReached = React.useCallback(() => {
|
||||
loadMoreThreads();
|
||||
}, [loadMoreThreads, viewingUnreads]);
|
||||
@@ -51,6 +56,17 @@ function ThreadList({haveUnreads, intl, isLoading, loadMoreThreads, listRef, mar
|
||||
);
|
||||
}, [theme]);
|
||||
|
||||
const onScroll = React.useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (Platform.OS === 'android') {
|
||||
const {y} = event.nativeEvent.contentOffset;
|
||||
if (y === 0) {
|
||||
setOffsetY(y);
|
||||
} else if (offsetY === 0 && y !== 0) {
|
||||
setOffsetY(y);
|
||||
}
|
||||
}
|
||||
}, [offsetY]);
|
||||
|
||||
const renderHeader = () => {
|
||||
if (!viewingUnreads && !threadIds.length) {
|
||||
return null;
|
||||
@@ -96,21 +112,30 @@ function ThreadList({haveUnreads, intl, isLoading, loadMoreThreads, listRef, mar
|
||||
return (
|
||||
<View style={style.container}>
|
||||
{renderHeader()}
|
||||
<FlatList
|
||||
contentContainerStyle={style.messagesContainer}
|
||||
data={threadIds}
|
||||
keyExtractor={keyExtractor}
|
||||
ListEmptyComponent={renderEmptyList()}
|
||||
ListFooterComponent={renderFooter()}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={2}
|
||||
ref={listRef}
|
||||
renderItem={renderPost}
|
||||
initialNumToRender={INITIAL_BATCH_TO_RENDER}
|
||||
maxToRenderPerBatch={Platform.select({android: 5})}
|
||||
removeClippedSubviews={true}
|
||||
scrollIndicatorInsets={style.listScrollIndicator}
|
||||
/>
|
||||
<CustomRefreshControl
|
||||
enabled={offsetY === 0}
|
||||
isInverted={false}
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
theme={theme}
|
||||
>
|
||||
<FlatList
|
||||
contentContainerStyle={style.messagesContainer}
|
||||
data={threadIds}
|
||||
keyExtractor={keyExtractor}
|
||||
ListEmptyComponent={renderEmptyList()}
|
||||
ListFooterComponent={renderFooter()}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={2}
|
||||
onScroll={onScroll}
|
||||
ref={listRef}
|
||||
renderItem={renderPost}
|
||||
initialNumToRender={INITIAL_BATCH_TO_RENDER}
|
||||
maxToRenderPerBatch={Platform.select({android: 5})}
|
||||
removeClippedSubviews={true}
|
||||
scrollIndicatorInsets={style.listScrollIndicator}
|
||||
/>
|
||||
</CustomRefreshControl>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import AsyncStorage from '@react-native-community/async-storage';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import {PureComponent} from 'react';
|
||||
import {Dimensions, EmitterSubscription} from 'react-native';
|
||||
|
||||
@@ -9,7 +9,7 @@ import {intlShape} from 'react-intl';
|
||||
import {Alert, Text} from 'react-native';
|
||||
import urlParse from 'url-parse';
|
||||
|
||||
import {dismissAllModals, popToRoot} from '@actions/navigation';
|
||||
import {dismissAllModals, popToRoot, showModal} from '@actions/navigation';
|
||||
import Config from '@assets/config';
|
||||
import {DeepLinkTypes} from '@constants';
|
||||
import {getCurrentServerUrl} from '@init/credentials';
|
||||
@@ -59,13 +59,20 @@ export default class MarkdownLink extends PureComponent {
|
||||
const match = matchDeepLink(url, serverURL, siteURL);
|
||||
|
||||
if (match) {
|
||||
if (match.type === DeepLinkTypes.CHANNEL) {
|
||||
switch (match.type) {
|
||||
case DeepLinkTypes.CHANNEL:
|
||||
await handleSelectChannelByName(match.channelName, match.teamName, errorBadChannel, intl);
|
||||
await dismissAllModals();
|
||||
await popToRoot();
|
||||
} else if (match.type === DeepLinkTypes.PERMALINK) {
|
||||
break;
|
||||
case DeepLinkTypes.PERMALINK: {
|
||||
const teamName = match.teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT ? currentTeamName : match.teamName;
|
||||
showPermalink(intl, teamName, match.postId);
|
||||
break;
|
||||
}
|
||||
case DeepLinkTypes.PLUGIN:
|
||||
showModal('PluginInternal', match.id, {link: url});
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
const onError = () => {
|
||||
|
||||
@@ -419,6 +419,11 @@ exports[`PostDraft Should render the DraftInput 1`] = `
|
||||
testID="post_draft.quick_actions"
|
||||
>
|
||||
<View
|
||||
accessibilityState={
|
||||
Object {
|
||||
"disabled": false,
|
||||
}
|
||||
}
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={true}
|
||||
@@ -447,6 +452,11 @@ exports[`PostDraft Should render the DraftInput 1`] = `
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
accessibilityState={
|
||||
Object {
|
||||
"disabled": false,
|
||||
}
|
||||
}
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={true}
|
||||
@@ -475,6 +485,11 @@ exports[`PostDraft Should render the DraftInput 1`] = `
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
accessibilityState={
|
||||
Object {
|
||||
"disabled": false,
|
||||
}
|
||||
}
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={true}
|
||||
@@ -498,11 +513,16 @@ exports[`PostDraft Should render the DraftInput 1`] = `
|
||||
>
|
||||
<Icon
|
||||
color="rgba(63,67,80,0.64)"
|
||||
name="file-document-outline"
|
||||
name="file-text-outline"
|
||||
size={24}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
accessibilityState={
|
||||
Object {
|
||||
"disabled": false,
|
||||
}
|
||||
}
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={true}
|
||||
@@ -531,6 +551,11 @@ exports[`PostDraft Should render the DraftInput 1`] = `
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
accessibilityState={
|
||||
Object {
|
||||
"disabled": false,
|
||||
}
|
||||
}
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={true}
|
||||
|
||||
@@ -329,12 +329,13 @@ export default class DraftInput extends PureComponent {
|
||||
const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions;
|
||||
const notificationsToGroups = enableConfirmNotificationsToChannel && useGroupMentions;
|
||||
const toAllOrChannel = DraftUtils.textContainsAtAllAtChannel(value);
|
||||
const groupMentions = (!toAllOrChannel && notificationsToGroups) ? DraftUtils.groupsMentionedInText(groupsWithAllowReference, value) : [];
|
||||
const toHere = DraftUtils.textContainsAtHere(value);
|
||||
const groupMentions = (!toAllOrChannel && !toHere && notificationsToGroups) ? DraftUtils.groupsMentionedInText(groupsWithAllowReference, value) : [];
|
||||
|
||||
if (value.indexOf('/') === 0) {
|
||||
this.sendCommand(value);
|
||||
} else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && toAllOrChannel) {
|
||||
this.showSendToAllOrChannelAlert(membersCount, value);
|
||||
} else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && (toAllOrChannel || toHere)) {
|
||||
this.showSendToAllOrChannelOrHereAlert(membersCount, value, toHere && !toAllOrChannel);
|
||||
} else if (groupMentions.length > 0) {
|
||||
const {groupMentionsSet, memberNotifyCount, channelTimezoneCount} = DraftUtils.mapGroupMentions(channelMemberCountsByGroup, groupMentions);
|
||||
if (memberNotifyCount > 0) {
|
||||
@@ -364,11 +365,11 @@ export default class DraftInput extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
showSendToAllOrChannelAlert = (membersCount, msg) => {
|
||||
showSendToAllOrChannelOrHereAlert = (membersCount, msg, atHere) => {
|
||||
const {formatMessage} = this.context.intl;
|
||||
const {channelTimezoneCount} = this.state;
|
||||
const {isTimezoneEnabled} = this.props;
|
||||
const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(formatMessage, membersCount, isTimezoneEnabled, channelTimezoneCount);
|
||||
const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(formatMessage, membersCount, isTimezoneEnabled, channelTimezoneCount, atHere);
|
||||
const cancel = () => {
|
||||
this.setInputValue(msg);
|
||||
this.setState({sendingMessage: false});
|
||||
|
||||
@@ -110,6 +110,31 @@ describe('DraftInput', () => {
|
||||
expect(Alert.alert).toHaveBeenCalledWith('Confirm sending notifications to entire channel', expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
test('should send an alert when sending a message with a @here mention', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<DraftInput
|
||||
{...baseProps}
|
||||
ref={ref}
|
||||
/>,
|
||||
);
|
||||
const message = '@here';
|
||||
const instance = wrapper.instance();
|
||||
expect(instance.input).toEqual({current: null});
|
||||
instance.input = {
|
||||
current: {
|
||||
getValue: () => message,
|
||||
setValue: jest.fn(),
|
||||
changeDraft: jest.fn(),
|
||||
resetTextInput: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
instance.handleSendMessage();
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(Alert.alert).toBeCalled();
|
||||
expect(Alert.alert).toHaveBeenCalledWith('Confirm sending notifications to entire channel', expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
test('should send an alert when sending a message with a group mention with group with count more than NOTIFY_ALL', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<DraftInput
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
exports[`CameraButton should match snapshot 1`] = `
|
||||
<View
|
||||
accessibilityState={
|
||||
Object {
|
||||
"disabled": false,
|
||||
}
|
||||
}
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={true}
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
exports[`FileQuickAction should match snapshot 1`] = `
|
||||
<View
|
||||
accessibilityState={
|
||||
Object {
|
||||
"disabled": false,
|
||||
}
|
||||
}
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={true}
|
||||
@@ -25,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>
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
exports[`ImageQuickAction should match snapshot 1`] = `
|
||||
<View
|
||||
accessibilityState={
|
||||
Object {
|
||||
"disabled": false,
|
||||
}
|
||||
}
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={true}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -58,11 +58,15 @@ const ButtonBinding = ({binding, doAppCall, intl, post, postEphemeralCallRespons
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
const onPress = useCallback(preventDoubleTap(async () => {
|
||||
if (!binding.call || pressed.current) {
|
||||
if (pressed.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
pressed.current = true;
|
||||
const call = binding.form?.call || binding.call;
|
||||
|
||||
if (!call) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = createCallContext(
|
||||
binding.app_id,
|
||||
@@ -72,13 +76,20 @@ const ButtonBinding = ({binding, doAppCall, intl, post, postEphemeralCallRespons
|
||||
post.id,
|
||||
);
|
||||
|
||||
const call = createCallRequest(
|
||||
binding.call,
|
||||
const callRequest = createCallRequest(
|
||||
call,
|
||||
context,
|
||||
{post: AppExpandLevels.EXPAND_ALL},
|
||||
);
|
||||
|
||||
const res = await doAppCall(call, AppCallTypes.SUBMIT, intl);
|
||||
if (binding.form) {
|
||||
showAppForm(binding.form, callRequest, theme);
|
||||
return;
|
||||
}
|
||||
|
||||
pressed.current = true;
|
||||
|
||||
const res = await doAppCall(callRequest, AppCallTypes.SUBMIT, intl);
|
||||
pressed.current = false;
|
||||
|
||||
if (res.error) {
|
||||
|
||||
@@ -42,7 +42,9 @@ const MenuBinding = ({binding, doAppCall, intl, post, postEphemeralCallResponseF
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bind.call) {
|
||||
const call = bind.form?.call || bind.call;
|
||||
|
||||
if (!call) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,13 +56,18 @@ const MenuBinding = ({binding, doAppCall, intl, post, postEphemeralCallResponseF
|
||||
post.id,
|
||||
);
|
||||
|
||||
const call = createCallRequest(
|
||||
bind.call,
|
||||
const callRequest = createCallRequest(
|
||||
call,
|
||||
context,
|
||||
{post: AppExpandLevels.EXPAND_ALL},
|
||||
);
|
||||
|
||||
const res = await doAppCall(call, AppCallTypes.SUBMIT, intl);
|
||||
if (bind.form) {
|
||||
showAppForm(bind.form, callRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await doAppCall(callRequest, AppCallTypes.SUBMIT, intl);
|
||||
if (res.error) {
|
||||
const errorResponse = res.error;
|
||||
const errorMessage = errorResponse.error || intl.formatMessage({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -297,10 +297,10 @@ const PostList = ({
|
||||
|
||||
if (match) {
|
||||
if (match.type === DeepLinkTypes.CHANNEL) {
|
||||
handleSelectChannelByName(match.channelName!, match.teamName, errorBadChannel, intl);
|
||||
handleSelectChannelByName(match.channelName!, match.teamName!, errorBadChannel, intl);
|
||||
} else if (match.type === DeepLinkTypes.PERMALINK) {
|
||||
const teamName = match.teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT ? currentTeamName : match.teamName;
|
||||
onPermalinkPress(match.postId!, teamName);
|
||||
onPermalinkPress(match.postId!, teamName!);
|
||||
}
|
||||
} else {
|
||||
badDeepLink(intl);
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {Theme} from '@mm-redux/types/theme';
|
||||
type Props = {
|
||||
children: ReactElement;
|
||||
enabled: boolean;
|
||||
isInverted?: boolean;
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
theme: Theme;
|
||||
@@ -17,11 +18,13 @@ type Props = {
|
||||
const style = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
containerInverse: {
|
||||
scaleY: -1,
|
||||
},
|
||||
});
|
||||
|
||||
const PostListRefreshControl = ({children, enabled, onRefresh, refreshing, theme}: Props) => {
|
||||
const PostListRefreshControl = ({children, enabled, isInverted = true, onRefresh, refreshing, theme}: Props) => {
|
||||
const props = {
|
||||
colors: [theme.onlineIndicator, theme.awayIndicator, theme.dndIndicator],
|
||||
onRefresh,
|
||||
@@ -34,7 +37,7 @@ const PostListRefreshControl = ({children, enabled, onRefresh, refreshing, theme
|
||||
<RefreshControl
|
||||
{...props}
|
||||
enabled={enabled}
|
||||
style={style.container}
|
||||
style={[style.container, isInverted ? style.containerInverse : undefined]}
|
||||
>
|
||||
{children}
|
||||
</RefreshControl>
|
||||
@@ -45,7 +48,7 @@ const PostListRefreshControl = ({children, enabled, onRefresh, refreshing, theme
|
||||
|
||||
return React.cloneElement(
|
||||
children,
|
||||
{refreshControl, inverted: true},
|
||||
{refreshControl, inverted: isInverted},
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -83,6 +83,7 @@ function mapStateToProps(state) {
|
||||
showLegacySidebar,
|
||||
unreadsOnTop,
|
||||
currentChannelId,
|
||||
currentTeamId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export default class List extends PureComponent {
|
||||
showLegacySidebar: PropTypes.bool.isRequired,
|
||||
unreadsOnTop: PropTypes.bool.isRequired,
|
||||
currentChannelId: PropTypes.string,
|
||||
currentTeamId: PropTypes.string,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
@@ -419,6 +420,21 @@ export default class List extends PureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
const categoryId = () => {
|
||||
switch (type) {
|
||||
case CategoryTypes.UNREADS:
|
||||
return CategoryTypes.UNREADS.toLowerCase();
|
||||
case CategoryTypes.FAVORITES:
|
||||
return CategoryTypes.FAVORITES.toLowerCase();
|
||||
case CategoryTypes.CHANNELS:
|
||||
return CategoryTypes.CHANNELS.toLowerCase();
|
||||
case CategoryTypes.DIRECT_MESSAGES:
|
||||
return CategoryTypes.DIRECT_MESSAGES.toLowerCase();
|
||||
default:
|
||||
return name.replace(/ /g, '_').toLowerCase();
|
||||
}
|
||||
};
|
||||
|
||||
const header = (
|
||||
<View style={styles.titleContainer}>
|
||||
{(type !== CategoryTypes.UNREADS && data.length > 0) &&
|
||||
@@ -434,7 +450,7 @@ export default class List extends PureComponent {
|
||||
<View style={styles.separatorContainer}>
|
||||
<Text> </Text>
|
||||
</View>
|
||||
{action && this.renderSectionAction(styles, action, anchor, id)}
|
||||
{action && this.renderSectionAction(styles, action, anchor, categoryId())}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -532,7 +548,7 @@ export default class List extends PureComponent {
|
||||
};
|
||||
|
||||
render() {
|
||||
const {testID, styles, theme, showLegacySidebar, collapsedThreadsEnabled} = this.props;
|
||||
const {testID, styles, theme, showLegacySidebar, collapsedThreadsEnabled, currentTeamId} = this.props;
|
||||
const {sections, categorySections, showIndicator} = this.state;
|
||||
|
||||
const paddingBottom = this.listContentPadding();
|
||||
@@ -545,6 +561,7 @@ export default class List extends PureComponent {
|
||||
<View
|
||||
style={styles.container}
|
||||
onLayout={this.onLayout}
|
||||
key={currentTeamId}
|
||||
>
|
||||
{collapsedThreadsEnabled && (
|
||||
<ThreadsSidebarEntry/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import AsyncStorage from '@react-native-community/async-storage';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import React from 'react';
|
||||
import {intlShape} from 'react-intl';
|
||||
|
||||
@@ -7,4 +7,5 @@ export default {
|
||||
GROUPCHANNEL: 'groupchannel',
|
||||
PERMALINK: 'permalink',
|
||||
OTHER: 'other',
|
||||
PLUGIN: 'plugin',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -105,6 +107,11 @@ const ViewTypes = keyMirror({
|
||||
|
||||
VIEWING_GLOBAL_THREADS_UNREADS: null,
|
||||
VIEWING_GLOBAL_THREADS_ALL: null,
|
||||
|
||||
THREAD_LAST_VIEWED_AT: null,
|
||||
|
||||
JOIN_CALL_BAR_VISIBLE: null,
|
||||
CURRENT_CALL_BAR_VISIBLE: null,
|
||||
});
|
||||
|
||||
const RequiredServer = {
|
||||
@@ -118,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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import AsyncStorage from '@react-native-community/async-storage';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useWindowDimensions} from 'react-native';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import AsyncStorage from '@react-native-community/async-storage';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import * as KeyChain from 'react-native-keychain';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import AsyncStorage from '@react-native-community/async-storage';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import {DeviceTypes} from '@constants';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import AsyncStorage from '@react-native-community/async-storage';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import CookieManager from '@react-native-cookies/cookies';
|
||||
|
||||
import {AppState, Dimensions, Keyboard, Linking, Platform} from 'react-native';
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -105,7 +105,9 @@ Navigation.events().registerAppLaunchedListener(() => {
|
||||
});
|
||||
|
||||
export function componentDidAppearListener({componentId}) {
|
||||
EphemeralStore.addNavigationComponentId(componentId);
|
||||
if (componentId.indexOf('!screen') !== 0) {
|
||||
EphemeralStore.addNavigationComponentId(componentId);
|
||||
}
|
||||
|
||||
switch (componentId) {
|
||||
case 'MainSidebar':
|
||||
|
||||
@@ -52,6 +52,12 @@ describe('componentDidAppearListener', () => {
|
||||
expect(EventEmitter.emit).toHaveBeenCalledTimes(1);
|
||||
expect(EventEmitter.emit).toHaveBeenCalledWith(NavigationTypes.BLUR_POST_DRAFT);
|
||||
});
|
||||
|
||||
it('should not add componentIds starting with "!screen" to the store as they are not screens', () => {
|
||||
const componentId = '!screen';
|
||||
componentDidAppearListener({componentId});
|
||||
expect(EphemeralStore.addNavigationComponentId).not.toHaveBeenCalledWith(componentId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('componentDidDisappearListener', () => {
|
||||
|
||||
@@ -4,4 +4,9 @@ import keyMirror from '@mm-redux/utils/key_mirror';
|
||||
|
||||
export default keyMirror({
|
||||
RECEIVED_APP_BINDINGS: null,
|
||||
RECEIVED_THREAD_APP_BINDINGS: null,
|
||||
CLEAR_APP_BINDINGS: null,
|
||||
CLEAR_THREAD_APP_BINDINGS: null,
|
||||
RECEIVED_APP_COMMAND_FORM: null,
|
||||
RECEIVED_APP_RHS_COMMAND_FORM: null,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,30 @@ export function fetchAppBindings(userID: string, channelID: string): ActionFunc
|
||||
return dispatch(bindClientFunc({
|
||||
clientFunc: () => Client4.getAppsBindings(userID, channelID, teamID),
|
||||
onSuccess: AppsTypes.RECEIVED_APP_BINDINGS,
|
||||
onFailure: AppsTypes.CLEAR_APP_BINDINGS,
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchThreadAppBindings(userID: string, channelID: string): ActionFunc {
|
||||
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
|
||||
const channel = getChannel(getState(), channelID);
|
||||
const teamID = channel?.team_id || '';
|
||||
|
||||
return dispatch(bindClientFunc({
|
||||
clientFunc: async () => {
|
||||
const bindings = await Client4.getAppsBindings(userID, channelID, teamID);
|
||||
return {bindings, channelID};
|
||||
},
|
||||
onSuccess: AppsTypes.RECEIVED_THREAD_APP_BINDINGS,
|
||||
onRequest: AppsTypes.CLEAR_THREAD_APP_BINDINGS,
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
export function clearThreadAppBindings() {
|
||||
return {
|
||||
type: AppsTypes.CLEAR_THREAD_APP_BINDINGS,
|
||||
data: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import {intlShape} from 'react-intl';
|
||||
import {Alert} from 'react-native';
|
||||
|
||||
import {showModal} from '@actions/navigation';
|
||||
import {handleSelectChannel, handleSelectChannelByName, loadChannelsByTeamName} from '@actions/views/channel';
|
||||
import {makeDirectChannel} from '@actions/views/more_dms';
|
||||
import {showPermalink} from '@actions/views/permalink';
|
||||
@@ -146,6 +147,9 @@ export function handleGotoLocation(href: string, intl: typeof intlShape): Action
|
||||
dispatch(makeGroupMessageVisibleIfNecessary(match.id));
|
||||
dispatch(handleSelectChannel(match.id));
|
||||
break;
|
||||
case DeepLinkTypes.PLUGIN:
|
||||
showModal('PluginInternal', match.id, {link: url});
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
const {formatMessage} = intl;
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
// See LICENSE.txt for license information.
|
||||
/* eslint-disable max-lines */
|
||||
|
||||
import {updateThreadLastViewedAt} from '@actions/views/threads';
|
||||
import {Client4} from '@client/rest';
|
||||
import {WebsocketEvents} from '@constants';
|
||||
import {THREAD} from '@constants/screen';
|
||||
import {GLOBAL_THREADS, THREAD} from '@constants/screen';
|
||||
import {analytics} from '@init/analytics';
|
||||
import {PostTypes, ChannelTypes, FileTypes, IntegrationTypes} from '@mm-redux/action_types';
|
||||
import {handleFollowChanged, updateThreadRead} from '@mm-redux/actions/threads';
|
||||
@@ -443,11 +444,14 @@ export function setUnreadPost(userId: string, postId: string, location: string)
|
||||
return {};
|
||||
}
|
||||
const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state);
|
||||
const isUnreadFromThreadScreen = collapsedThreadsEnabled && location === THREAD;
|
||||
if (isUnreadFromThreadScreen) {
|
||||
const isUnreadFromThread = collapsedThreadsEnabled && (location === THREAD || location === GLOBAL_THREADS);
|
||||
if (isUnreadFromThread) {
|
||||
const currentTeamId = getThreadTeamId(state, postId);
|
||||
const threadId = post.root_id || post.id;
|
||||
dispatch(handleFollowChanged(threadId, currentTeamId, true));
|
||||
const actions: GenericAction[] = [];
|
||||
actions.push(handleFollowChanged(threadId, currentTeamId, true));
|
||||
actions.push(updateThreadLastViewedAt(threadId, post.create_at));
|
||||
dispatch(batchActions(actions));
|
||||
await dispatch(updateThreadRead(userId, threadId, post.create_at));
|
||||
return {data: true};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user