forked from Ivasoft/mattermost-mobile
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -14,7 +14,7 @@ executors:
|
||||
NODE_ENV: production
|
||||
BABEL_ENV: production
|
||||
docker:
|
||||
- image: circleci/android:api-29-node
|
||||
- image: circleci/android:api-30-node
|
||||
working_directory: ~/mattermost-mobile
|
||||
resource_class: <<parameters.resource_class>>
|
||||
|
||||
@@ -24,7 +24,7 @@ executors:
|
||||
NODE_ENV: production
|
||||
BABEL_ENV: production
|
||||
macos:
|
||||
xcode: "12.1.0"
|
||||
xcode: "13.0.0"
|
||||
working_directory: ~/mattermost-mobile
|
||||
shell: /bin/bash --login -o pipefail
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -23,11 +23,10 @@ node_modules/react-native/flow/
|
||||
[options]
|
||||
emoji=true
|
||||
|
||||
esproposal.optional_chaining=enable
|
||||
esproposal.nullish_coalescing=enable
|
||||
|
||||
exact_by_default=true
|
||||
|
||||
format.bracket_spacing=false
|
||||
|
||||
module.file_ext=.js
|
||||
module.file_ext=.json
|
||||
module.file_ext=.ios.js
|
||||
@@ -64,4 +63,4 @@ untyped-import
|
||||
untyped-type-import
|
||||
|
||||
[version]
|
||||
^0.137.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) -->
|
||||
|
||||
58
NOTICE.txt
58
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.
|
||||
@@ -532,6 +567,29 @@ SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
## @types/redux-mock-store
|
||||
|
||||
This product contains '@types/redux-mock-store' by Redux.
|
||||
|
||||
A mock store for testing Redux async action creators and middleware. The mock store will create an array of dispatched actions which serve as an action log for tests.
|
||||
|
||||
* HOMEPAGE:
|
||||
* https://github.com/reduxjs/redux-mock-store
|
||||
|
||||
* LICENSE: MIT
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2017 Arnaud Benard
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## analytics-react-native
|
||||
|
||||
This product contains a modified version of 'analytics-react-native' by Segment.
|
||||
|
||||
10
README.md
10
README.md
@@ -1,14 +1,14 @@
|
||||
# Mattermost Mobile
|
||||
# Mattermost Mobile App
|
||||
[](https://mattermost.com)
|
||||
|
||||
- **Minimum Server versions:** Current ESR version (5.37.0)
|
||||
- **Supported iOS versions:** 11+
|
||||
- **Supported iOS versions:** 12.1+
|
||||
- **Supported Android versions:** 7.0+
|
||||
|
||||
Mattermost is an open source Slack-alternative used by thousands of companies around the world in 14 languages. Learn more at [https://about.mattermost.com](https://about.mattermost.com).
|
||||
|
||||
You can download our apps from the [App Store](https://about.mattermost.com/mattermost-ios-app/) or [Google Play Store](https://about.mattermost.com/mattermost-android-app/), or [build them yourself](https://developers.mattermost.com/contribute/mobile/build-your-own/).
|
||||
[Mattermost](https://mattermost.com) is an open source platform for secure collaboration across the entire software development lifecycle. This repo is for the mobile app that runs on Android and iOS. You can download our apps from the [App Store](https://about.mattermost.com/mattermost-ios-app/) or [Google Play Store](https://about.mattermost.com/mattermost-android-app/), or [build them yourself](https://developers.mattermost.com/contribute/mobile/build-your-own/).
|
||||
|
||||
We plan on releasing monthly updates with new features - check the [changelog](https://github.com/mattermost/mattermost-mobile/blob/master/CHANGELOG.md) for what features are currently supported!
|
||||
New features are released monthly - check the [changelog](https://github.com/mattermost/mattermost-mobile/blob/master/CHANGELOG.md) for currently-supported features!
|
||||
|
||||
**Important:** If you self-compile the Mattermost Mobile apps you also need to deploy your own [Mattermost Push Notification Service](https://github.com/mattermost/mattermost-push-proxy/releases).
|
||||
|
||||
|
||||
@@ -119,21 +119,21 @@ 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
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.mattermost.rnbeta"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60"
|
||||
versionCode 369
|
||||
versionName "1.47.0"
|
||||
versionCode 381
|
||||
versionName "1.48.1"
|
||||
multiDexEnabled = true
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
@@ -165,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 {
|
||||
@@ -230,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'
|
||||
@@ -269,7 +274,7 @@ dependencies {
|
||||
// Run this once to be able to run the application with BUCK
|
||||
// puts all compile dependencies into folder libs for BUCK to use
|
||||
task copyDownloadableDepsToLibs(type: Copy) {
|
||||
from configurations.compile
|
||||
from configurations.implementation
|
||||
into 'libs'
|
||||
}
|
||||
|
||||
|
||||
@@ -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,19 +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);
|
||||
notifications.remove(notificationId);
|
||||
saveNotificationsMap(mContext, notificationsInChannel);
|
||||
clearChannelNotifications(mContext, channelId);
|
||||
clearChannelNotifications(mContext, channelId, rootId, isCRTEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,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);
|
||||
@@ -262,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);
|
||||
|
||||
@@ -57,7 +57,6 @@ private final ReactNativeHost mReactNativeHost =
|
||||
// Packages that cannot be auto linked yet can be added manually here, for example:
|
||||
// packages.add(new MyReactNativePackage());
|
||||
packages.add(new RNNotificationsPackage(MainApplication.this));
|
||||
packages.add(new RNPasteableTextInputPackage());
|
||||
packages.add(
|
||||
new TurboReactPackage() {
|
||||
@Override
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
public interface RNEditTextOnPasteListener {
|
||||
void onPaste(Uri itemUri);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.ActionMode;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
|
||||
public class RNPasteableActionCallback implements ActionMode.Callback {
|
||||
|
||||
private final RNPasteableEditText mEditText;
|
||||
|
||||
RNPasteableActionCallback(RNPasteableEditText editText) {
|
||||
mEditText = editText;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
|
||||
Bundle config = MainApplication.instance.getManagedConfig();
|
||||
if (config != null) {
|
||||
WritableMap result = Arguments.fromBundle(config);
|
||||
String copyPasteProtection = result.getString("copyAndPasteProtection");
|
||||
assert copyPasteProtection != null;
|
||||
if (copyPasteProtection.equals("true")) {
|
||||
disableMenus(menu);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
|
||||
Uri uri = this.getUriInClipboard();
|
||||
if (item.getItemId() == android.R.id.paste && uri != null) {
|
||||
mEditText.getOnPasteListener().onPaste(uri);
|
||||
mode.finish();
|
||||
} else {
|
||||
mEditText.onTextContextMenuItem(item.getItemId());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyActionMode(ActionMode mode) {
|
||||
|
||||
}
|
||||
|
||||
private void disableMenus(Menu menu) {
|
||||
for (int i = 0; i < menu.size(); i++) {
|
||||
MenuItem item = menu.getItem(i);
|
||||
int id = item.getItemId();
|
||||
boolean shouldDisableMenu = (
|
||||
id == android.R.id.paste
|
||||
|| id == android.R.id.copy
|
||||
|| id == android.R.id.cut
|
||||
);
|
||||
item.setEnabled(!shouldDisableMenu);
|
||||
}
|
||||
}
|
||||
|
||||
private Uri getUriInClipboard() {
|
||||
ClipboardManager clipboardManager = (ClipboardManager) mEditText.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
ClipData clipData = clipboardManager.getPrimaryClip();
|
||||
if (clipData == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ClipData.Item item = clipData.getItemAt(0);
|
||||
if (item == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
CharSequence chars = item.getText();
|
||||
if (chars == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String text = chars.toString();
|
||||
if (text.length() > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.getUri();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.facebook.react.views.textinput.ReactEditText;
|
||||
|
||||
public class RNPasteableEditText extends ReactEditText {
|
||||
|
||||
private RNEditTextOnPasteListener mOnPasteListener;
|
||||
|
||||
public RNPasteableEditText(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
public void setOnPasteListener(RNEditTextOnPasteListener listener) {
|
||||
mOnPasteListener = listener;
|
||||
}
|
||||
|
||||
public RNEditTextOnPasteListener getOnPasteListener() {
|
||||
return mOnPasteListener;
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.res.AssetFileDescriptor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.util.Patterns;
|
||||
import android.webkit.MimeTypeMap;
|
||||
import android.webkit.URLUtil;
|
||||
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.ReactContext;
|
||||
import com.facebook.react.bridge.WritableArray;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
import com.facebook.react.uimanager.events.RCTEventEmitter;
|
||||
import com.mattermost.share.RealPathUtil;
|
||||
import com.mattermost.share.ShareModule;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.File;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteListener {
|
||||
|
||||
private final RNPasteableEditText mEditText;
|
||||
|
||||
RNPasteableEditTextOnPasteListener(RNPasteableEditText editText) {
|
||||
mEditText = editText;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPaste(Uri itemUri) {
|
||||
ReactContext reactContext = (ReactContext)mEditText.getContext();
|
||||
String uri = itemUri.toString();
|
||||
|
||||
WritableArray images = null;
|
||||
WritableMap error = null;
|
||||
|
||||
String uriMimeType = reactContext.getContentResolver().getType(itemUri);
|
||||
if (uriMimeType == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Special handle for Google docs
|
||||
if (uri.equals("content://com.google.android.apps.docs.editors.kix.editors.clipboard")) {
|
||||
ClipboardManager clipboardManager = (ClipboardManager) reactContext.getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
ClipData clipData = clipboardManager.getPrimaryClip();
|
||||
if (clipData == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ClipData.Item item = clipData.getItemAt(0);
|
||||
String htmlText = item.getHtmlText();
|
||||
// Find uri from html
|
||||
Matcher matcher = Patterns.WEB_URL.matcher(htmlText);
|
||||
if (matcher.find()) {
|
||||
uri = htmlText.substring(matcher.start(1), matcher.end());
|
||||
}
|
||||
}
|
||||
|
||||
if (uri.startsWith("http")) {
|
||||
Thread pastImageFromUrlThread = new Thread(new RNPasteableImageFromUrl(reactContext, mEditText, uri));
|
||||
pastImageFromUrlThread.start();
|
||||
return;
|
||||
}
|
||||
|
||||
uri = RealPathUtil.getRealPathFromURI(reactContext, itemUri);
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get type
|
||||
String extension = MimeTypeMap.getFileExtensionFromUrl(uri);
|
||||
if (extension == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
|
||||
if (mimeType == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get fileName
|
||||
String fileName = URLUtil.guessFileName(uri, null, mimeType);
|
||||
|
||||
if (uri.contains(ShareModule.CACHE_DIR_NAME) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
uri = moveToImagesCache(uri, fileName);
|
||||
}
|
||||
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get fileSize
|
||||
long fileSize;
|
||||
try {
|
||||
ContentResolver contentResolver = reactContext.getContentResolver();
|
||||
AssetFileDescriptor assetFileDescriptor = contentResolver.openAssetFileDescriptor(itemUri, "r");
|
||||
if (assetFileDescriptor == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
fileSize = assetFileDescriptor.getLength();
|
||||
|
||||
WritableMap image = Arguments.createMap();
|
||||
image.putString("type", mimeType);
|
||||
image.putDouble("fileSize", fileSize);
|
||||
image.putString("fileName", fileName);
|
||||
image.putString("uri", "file://" + uri);
|
||||
|
||||
images = Arguments.createArray();
|
||||
images.pushMap(image);
|
||||
} catch (FileNotFoundException e) {
|
||||
error = Arguments.createMap();
|
||||
error.putString("message", e.getMessage());
|
||||
}
|
||||
|
||||
WritableMap event = Arguments.createMap();
|
||||
event.putArray("data", images);
|
||||
event.putMap("error", error);
|
||||
|
||||
reactContext
|
||||
.getJSModule(RCTEventEmitter.class)
|
||||
.receiveEvent(
|
||||
mEditText.getId(),
|
||||
"onPaste",
|
||||
event
|
||||
);
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||
private String moveToImagesCache(String src, String fileName) {
|
||||
ReactContext ctx = (ReactContext)mEditText.getContext();
|
||||
String cacheFolder = ctx.getCacheDir().getAbsolutePath() + "/Images/";
|
||||
String dest = cacheFolder + fileName;
|
||||
File folder = new File(cacheFolder);
|
||||
|
||||
try {
|
||||
if (!folder.exists()) {
|
||||
boolean created = folder.mkdirs();
|
||||
if (!created) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Files.move(Paths.get(src), Paths.get(dest));
|
||||
} catch (FileAlreadyExistsException fileError) {
|
||||
// Do nothing and return dest path
|
||||
} catch (Exception err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dest;
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import com.facebook.react.bridge.Arguments;
|
||||
import com.facebook.react.bridge.ReactContext;
|
||||
import com.facebook.react.bridge.WritableArray;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
import com.facebook.react.uimanager.events.RCTEventEmitter;
|
||||
import com.facebook.react.views.textinput.ReactEditText;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
|
||||
public class RNPasteableImageFromUrl implements Runnable {
|
||||
|
||||
private final ReactContext mContext;
|
||||
private final String mUri;
|
||||
private final ReactEditText mTarget;
|
||||
|
||||
RNPasteableImageFromUrl(ReactContext context, ReactEditText target, String uri) {
|
||||
mContext = context;
|
||||
mUri = uri;
|
||||
mTarget = target;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
WritableArray images = null;
|
||||
WritableMap error = null;
|
||||
|
||||
try {
|
||||
URL url = new URL(mUri);
|
||||
URLConnection u = url.openConnection();
|
||||
|
||||
// Get type
|
||||
String mimeType = u.getHeaderField("Content-Type");
|
||||
if (!mimeType.startsWith("image")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get fileSize
|
||||
long fileSize = Long.parseLong(u.getHeaderField("Content-Length"));
|
||||
|
||||
// Get fileName
|
||||
String contentDisposition = u.getHeaderField("Content-Disposition");
|
||||
int startIndex = contentDisposition.indexOf("filename=\"") + 10;
|
||||
int endIndex = contentDisposition.length() - 1;
|
||||
String fileName = contentDisposition.substring(startIndex, endIndex);
|
||||
|
||||
WritableMap image = Arguments.createMap();
|
||||
image.putString("type", mimeType);
|
||||
image.putDouble("fileSize", fileSize);
|
||||
image.putString("fileName", fileName);
|
||||
image.putString("uri", mUri);
|
||||
|
||||
images = Arguments.createArray();
|
||||
images.pushMap(image);
|
||||
|
||||
} catch (IOException e) {
|
||||
error = Arguments.createMap();
|
||||
error.putString("message", e.getMessage());
|
||||
}
|
||||
|
||||
WritableMap event = Arguments.createMap();
|
||||
event.putArray("data", images);
|
||||
event.putMap("error", error);
|
||||
|
||||
mContext
|
||||
.getJSModule(RCTEventEmitter.class)
|
||||
.receiveEvent(
|
||||
mTarget.getId(),
|
||||
"onPaste",
|
||||
event
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat;
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat;
|
||||
import android.text.InputType;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputConnection;
|
||||
|
||||
import com.facebook.react.common.MapBuilder;
|
||||
import com.facebook.react.uimanager.ThemedReactContext;
|
||||
import com.facebook.react.views.textinput.ReactEditText;
|
||||
import com.facebook.react.views.textinput.ReactTextInputManager;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
public class RNPasteableTextInputManager extends ReactTextInputManager {
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public String getName() {
|
||||
return "PasteableTextInputAndroid";
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public ReactEditText createViewInstance(ThemedReactContext context) {
|
||||
RNPasteableEditText editText = new RNPasteableEditText(context) {
|
||||
@Override
|
||||
public InputConnection onCreateInputConnection(EditorInfo editorInfo) {
|
||||
final InputConnection ic = super.onCreateInputConnection(editorInfo);
|
||||
EditorInfoCompat.setContentMimeTypes(editorInfo,
|
||||
new String [] {"image/*"});
|
||||
|
||||
|
||||
final InputConnectionCompat.OnCommitContentListener callback =
|
||||
(inputContentInfo, flags, opts) -> {
|
||||
// read and display inputContentInfo asynchronously
|
||||
if ((flags &
|
||||
InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
|
||||
try {
|
||||
inputContentInfo.requestPermission();
|
||||
}
|
||||
catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.getOnPasteListener().onPaste(inputContentInfo.getContentUri());
|
||||
return true;
|
||||
};
|
||||
return InputConnectionCompat.createWrapper(ic, editorInfo, callback);
|
||||
}
|
||||
};
|
||||
int inputType = editText.getInputType();
|
||||
editText.setInputType(inputType & (~InputType.TYPE_TEXT_FLAG_MULTI_LINE));
|
||||
editText.setReturnKeyType("done");
|
||||
editText.setCustomInsertionActionModeCallback(new RNPasteableActionCallback(editText));
|
||||
editText.setCustomSelectionActionModeCallback(new RNPasteableActionCallback(editText));
|
||||
return editText;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addEventEmitters(ThemedReactContext reactContext, ReactEditText editText) {
|
||||
super.addEventEmitters(reactContext, editText);
|
||||
|
||||
RNPasteableEditText pasteableEditText = (RNPasteableEditText)editText;
|
||||
pasteableEditText.setOnPasteListener(new RNPasteableEditTextOnPasteListener(pasteableEditText));
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Map<String, Object> getExportedCustomBubblingEventTypeConstants() {
|
||||
Map<String, Object> map = super.getExportedCustomBubblingEventTypeConstants();
|
||||
assert map != null;
|
||||
map.put(
|
||||
"onPaste",
|
||||
MapBuilder.of(
|
||||
"phasedRegistrationNames",
|
||||
MapBuilder.of("bubbled", "onPaste")));
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.mattermost.rnbeta;
|
||||
|
||||
import com.facebook.react.ReactPackage;
|
||||
import com.facebook.react.bridge.NativeModule;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.uimanager.ViewManager;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
public class RNPasteableTextInputPackage implements ReactPackage {
|
||||
@Nonnull
|
||||
@Override
|
||||
public List<NativeModule> createNativeModules(@Nonnull ReactApplicationContext reactContext) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public List<ViewManager> createViewManagers(@Nonnull ReactApplicationContext reactContext) {
|
||||
return Arrays.asList(
|
||||
new RNPasteableTextInputManager()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
buildscript {
|
||||
ext {
|
||||
buildToolsVersion = "29.0.3"
|
||||
buildToolsVersion = "30.0.2"
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 29
|
||||
targetSdkVersion = 29
|
||||
compileSdkVersion = 30
|
||||
targetSdkVersion = 30
|
||||
supportLibVersion = "28.0.0"
|
||||
kotlinVersion = "1.3.61"
|
||||
kotlinVersion = "1.5.30"
|
||||
firebaseVersion = "21.0.0"
|
||||
RNNKotlinVersion = kotlinVersion
|
||||
ndkVersion = "21.1.6352462"
|
||||
ndkVersion = "21.4.7075529"
|
||||
|
||||
}
|
||||
repositories {
|
||||
@@ -20,7 +20,7 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:4.1.0'
|
||||
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.75.1
|
||||
FLIPPER_VERSION=0.99.0
|
||||
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.9-all.zip
|
||||
|
||||
@@ -457,6 +457,13 @@ export function showOverlay(name, passProps, options = {}) {
|
||||
overlay: {
|
||||
interceptTouchOutside: false,
|
||||
},
|
||||
...Platform.select({
|
||||
android: {
|
||||
statusBar: {
|
||||
drawBehind: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
Navigation.showOverlay({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('Websocket Chanel Events', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
Actions.close()();
|
||||
Actions.close();
|
||||
mockServer.stop();
|
||||
await TestHelper.tearDown();
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('Websocket General Events', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
Actions.close()();
|
||||
Actions.close();
|
||||
mockServer.stop();
|
||||
await TestHelper.tearDown();
|
||||
});
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('Websocket Integration Events', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
Actions.close()();
|
||||
Actions.close();
|
||||
mockServer.stop();
|
||||
await TestHelper.tearDown();
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('Websocket Post Events', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
Actions.close()();
|
||||
Actions.close();
|
||||
mockServer.stop();
|
||||
await TestHelper.tearDown();
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('Websocket Reaction Events', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
Actions.close()();
|
||||
Actions.close();
|
||||
mockServer.stop();
|
||||
await TestHelper.tearDown();
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('Websocket Team Events', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
Actions.close()();
|
||||
Actions.close();
|
||||
mockServer.stop();
|
||||
await TestHelper.tearDown();
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('Websocket User Events', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
Actions.close()();
|
||||
Actions.close();
|
||||
mockServer.stop();
|
||||
await TestHelper.tearDown();
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('Actions.Websocket', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
Actions.close()();
|
||||
Actions.close();
|
||||
mockServer.stop();
|
||||
await TestHelper.tearDown();
|
||||
});
|
||||
@@ -166,7 +166,7 @@ describe('Actions.Websocket doReconnect', () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
Actions.close()();
|
||||
Actions.close();
|
||||
await TestHelper.tearDown();
|
||||
});
|
||||
|
||||
@@ -373,7 +373,7 @@ describe('Actions.Websocket notVisibleUsersActions', () => {
|
||||
const user4 = TestHelper.fakeUserWithId();
|
||||
const user5 = TestHelper.fakeUserWithId();
|
||||
|
||||
it('should do nothing if the known users and the profiles list are the same', async () => {
|
||||
it.skip('should do nothing if the known users and the profiles list are the same', async () => {
|
||||
const profiles = {
|
||||
[me.id]: me,
|
||||
[user.id]: user,
|
||||
@@ -397,7 +397,7 @@ describe('Actions.Websocket notVisibleUsersActions', () => {
|
||||
expect(actions.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should do nothing if there are known users in my memberships but not in the profiles list', async () => {
|
||||
it.skip('should do nothing if there are known users in my memberships but not in the profiles list', async () => {
|
||||
const profiles = {
|
||||
[me.id]: me,
|
||||
[user3.id]: user3,
|
||||
@@ -419,7 +419,7 @@ describe('Actions.Websocket notVisibleUsersActions', () => {
|
||||
expect(actions.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should remove the users if there are unknown users in the profiles list', async () => {
|
||||
it.skip('should remove the users if there are unknown users in the profiles list', async () => {
|
||||
const profiles = {
|
||||
[me.id]: me,
|
||||
[user.id]: user,
|
||||
@@ -502,7 +502,7 @@ describe('Actions.Websocket handleUserTypingEvent', () => {
|
||||
|
||||
const expectedActionsTypes = [
|
||||
WebsocketEvents.TYPING,
|
||||
UserTypes.RECEIVED_STATUSES,
|
||||
'BATCHING_REDUCER.BATCH',
|
||||
];
|
||||
|
||||
await testStore.dispatch(Actions.handleUserTypingEvent(msg));
|
||||
|
||||
@@ -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)},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -104,7 +104,6 @@ const ClientUsers = (superclass: any) => class extends superclass {
|
||||
|
||||
getKnownUsers = async () => {
|
||||
analytics.trackAPI('api_get_known_users');
|
||||
|
||||
return this.doFetch(
|
||||
`${this.getUsersRoute()}/known`,
|
||||
{method: 'get'},
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`FormattedRelativeTime should match snapshot 1`] = `
|
||||
<Text>
|
||||
a few seconds ago
|
||||
</Text>
|
||||
`;
|
||||
@@ -113,7 +113,6 @@ export default class AtMention extends React.PureComponent {
|
||||
|
||||
BottomSheet.showBottomSheetWithOptions({
|
||||
options: [actionText, cancelText],
|
||||
cancelButtonIndex: 1,
|
||||
}, (value) => {
|
||||
if (value !== 1) {
|
||||
this.handleCopyMention();
|
||||
|
||||
@@ -232,7 +232,7 @@ export default class AttachmentButton extends PureComponent {
|
||||
|
||||
if (hasPermission) {
|
||||
try {
|
||||
const res = await DocumentPicker.pick({type: [browseFileTypes]});
|
||||
const res = await DocumentPicker.pickSingle({type: [browseFileTypes]});
|
||||
emmProvider.inBackgroundSince = null;
|
||||
if (Platform.OS === 'android') {
|
||||
// For android we need to retrieve the realPath in case the file being imported is from the cloud
|
||||
|
||||
@@ -66,7 +66,7 @@ const GroupMentionItem = (props) => {
|
||||
>
|
||||
<View style={style.rowPicture}>
|
||||
<CompassIcon
|
||||
name='account-group-outline'
|
||||
name='account-multiple-outline'
|
||||
style={style.rowIcon}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -2341,7 +2341,6 @@ exports[`components/autocomplete/emoji_suggestion should match snapshot 2`] = `
|
||||
"zzz",
|
||||
]
|
||||
}
|
||||
disableVirtualization={false}
|
||||
extraData={
|
||||
Object {
|
||||
"active": true,
|
||||
@@ -4682,19 +4681,13 @@ exports[`components/autocomplete/emoji_suggestion should match snapshot 2`] = `
|
||||
],
|
||||
}
|
||||
}
|
||||
horizontal={false}
|
||||
initialListSize={10}
|
||||
initialNumToRender={10}
|
||||
keyExtractor={[Function]}
|
||||
keyboardShouldPersistTaps="always"
|
||||
maxToRenderPerBatch={10}
|
||||
nestedScrollEnabled={false}
|
||||
numColumns={1}
|
||||
onEndReachedThreshold={2}
|
||||
pageSize={10}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={[Function]}
|
||||
scrollEventThrottle={50}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
@@ -4708,7 +4701,5 @@ exports[`components/autocomplete/emoji_suggestion should match snapshot 2`] = `
|
||||
]
|
||||
}
|
||||
testID="emoji_suggestion.list"
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={21}
|
||||
/>
|
||||
`;
|
||||
|
||||
@@ -13,7 +13,6 @@ exports[`components/autocomplete/slash_suggestion should match snapshot 1`] = `
|
||||
},
|
||||
]
|
||||
}
|
||||
disableVirtualization={false}
|
||||
extraData={
|
||||
Object {
|
||||
"active": true,
|
||||
@@ -29,17 +28,11 @@ exports[`components/autocomplete/slash_suggestion should match snapshot 1`] = `
|
||||
"lastCommandRequest": 1234,
|
||||
}
|
||||
}
|
||||
horizontal={false}
|
||||
initialNumToRender={10}
|
||||
keyExtractor={[Function]}
|
||||
keyboardShouldPersistTaps="always"
|
||||
maxToRenderPerBatch={10}
|
||||
nestedScrollEnabled={false}
|
||||
numColumns={1}
|
||||
onEndReachedThreshold={2}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={[Function]}
|
||||
scrollEventThrottle={50}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
@@ -54,7 +47,5 @@ exports[`components/autocomplete/slash_suggestion should match snapshot 1`] = `
|
||||
]
|
||||
}
|
||||
testID="slash_suggestion.list"
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={21}
|
||||
/>
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
}],
|
||||
},
|
||||
|
||||
@@ -13,18 +13,11 @@ exports[`components/autocomplete/app_slash_suggestion should match snapshot 1`]
|
||||
},
|
||||
]
|
||||
}
|
||||
disableVirtualization={false}
|
||||
horizontal={false}
|
||||
initialNumToRender={10}
|
||||
keyExtractor={[Function]}
|
||||
keyboardShouldPersistTaps="always"
|
||||
maxToRenderPerBatch={10}
|
||||
nestedScrollEnabled={false}
|
||||
numColumns={1}
|
||||
onEndReachedThreshold={2}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={[Function]}
|
||||
scrollEventThrottle={50}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
@@ -39,7 +32,5 @@ exports[`components/autocomplete/app_slash_suggestion should match snapshot 1`]
|
||||
]
|
||||
}
|
||||
testID="app_slash_suggestion.list"
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={21}
|
||||
/>
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
@@ -103,7 +108,7 @@ describe('components/autocomplete/app_slash_suggestion', () => {
|
||||
expect(wrapper.state('dataSource')).toEqual([]);
|
||||
});
|
||||
|
||||
test('should show commands from app sub commands', async (done) => {
|
||||
test('should show commands from app sub commands', (done?: jest.DoneCallback) => {
|
||||
const props: Props = {
|
||||
...baseProps,
|
||||
};
|
||||
@@ -126,7 +131,9 @@ describe('components/autocomplete/app_slash_suggestion', () => {
|
||||
|
||||
setTimeout(() => {
|
||||
expect(wrapper.state('dataSource')).toEqual(expected);
|
||||
done();
|
||||
if (done) {
|
||||
done();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -60,9 +60,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
|
||||
}
|
||||
>
|
||||
<ActivityIndicator
|
||||
animating={true}
|
||||
color="#FFFFFF"
|
||||
hidesWhenStopped={true}
|
||||
size="small"
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -23,6 +23,8 @@ describe('ChannelLoader', () => {
|
||||
|
||||
test('should call setTimeout and setInterval for showIndicator and retryLoad on mount', () => {
|
||||
shallow(<ChannelLoader {...baseProps}/>);
|
||||
const setTimeout = jest.spyOn(global, 'setTimeout');
|
||||
const setInterval = jest.spyOn(global, 'setInterval');
|
||||
expect(setTimeout).not.toHaveBeenCalled();
|
||||
expect(setInterval).not.toHaveBeenCalled();
|
||||
|
||||
@@ -42,6 +44,8 @@ describe('ChannelLoader', () => {
|
||||
retryLoad: jest.fn(),
|
||||
};
|
||||
const wrapper = shallow(<ChannelLoader {...props}/>);
|
||||
const clearTimeout = jest.spyOn(global, 'clearTimeout');
|
||||
const clearInterval = jest.spyOn(global, 'clearInterval');
|
||||
const instance = wrapper.instance();
|
||||
instance.componentWillUnmount();
|
||||
|
||||
|
||||
@@ -20,15 +20,11 @@ exports[`CustomList should match snapshot with FlatList 1`] = `
|
||||
},
|
||||
]
|
||||
}
|
||||
disableVirtualization={false}
|
||||
horizontal={false}
|
||||
initialNumToRender={15}
|
||||
keyExtractor={[Function]}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="always"
|
||||
maxToRenderPerBatch={16}
|
||||
numColumns={1}
|
||||
onEndReachedThreshold={2}
|
||||
onLayout={[Function]}
|
||||
onScroll={[Function]}
|
||||
removeClippedSubviews={true}
|
||||
@@ -41,8 +37,6 @@ exports[`CustomList should match snapshot with FlatList 1`] = `
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={21}
|
||||
/>
|
||||
`;
|
||||
|
||||
@@ -56,16 +50,12 @@ exports[`CustomList should match snapshot with SectionList 1`] = `
|
||||
"flexGrow": 1,
|
||||
}
|
||||
}
|
||||
data={Array []}
|
||||
disableVirtualization={false}
|
||||
extraData={false}
|
||||
horizontal={false}
|
||||
initialNumToRender={15}
|
||||
keyExtractor={[Function]}
|
||||
keyboardDismissMode="on-drag"
|
||||
keyboardShouldPersistTaps="always"
|
||||
maxToRenderPerBatch={16}
|
||||
onEndReachedThreshold={2}
|
||||
onLayout={[Function]}
|
||||
onScroll={[Function]}
|
||||
removeClippedSubviews={true}
|
||||
@@ -89,8 +79,6 @@ exports[`CustomList should match snapshot with SectionList 1`] = `
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={21}
|
||||
/>
|
||||
`;
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/custom_status/clear_button should match snapshot 1`] = `
|
||||
<ForwardRef
|
||||
<TouchableOpacity
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Array [
|
||||
@@ -27,5 +27,5 @@ exports[`components/custom_status/clear_button should match snapshot 1`] = `
|
||||
}
|
||||
}
|
||||
/>
|
||||
</ForwardRef>
|
||||
</TouchableOpacity>
|
||||
`;
|
||||
|
||||
@@ -74,6 +74,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
||||
}
|
||||
>
|
||||
<TextInputWithLocalizedPlaceholder
|
||||
allowFontScaling={true}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
disableFullscreenUI={true}
|
||||
@@ -140,6 +141,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
||||
}
|
||||
>
|
||||
<TextInputWithLocalizedPlaceholder
|
||||
allowFontScaling={true}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
blurOnSubmit={false}
|
||||
@@ -229,6 +231,7 @@ exports[`EditChannelInfo should match snapshot 1`] = `
|
||||
}
|
||||
>
|
||||
<TextInputWithLocalizedPlaceholder
|
||||
allowFontScaling={true}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
blurOnSubmit={false}
|
||||
|
||||
@@ -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}
|
||||
@@ -334,6 +336,7 @@ export default class EditChannelInfo extends PureComponent {
|
||||
</View>
|
||||
<View style={style.inputContainer}>
|
||||
<TextInputWithLocalizedPlaceholder
|
||||
allowFontScaling={true}
|
||||
testID='edit_channel_info.name.input'
|
||||
ref={this.nameInput}
|
||||
value={displayName}
|
||||
@@ -364,6 +367,7 @@ export default class EditChannelInfo extends PureComponent {
|
||||
</View>
|
||||
<View style={style.inputContainer}>
|
||||
<TextInputWithLocalizedPlaceholder
|
||||
allowFontScaling={true}
|
||||
testID='edit_channel_info.purpose.input'
|
||||
ref={this.purposeInput}
|
||||
value={purpose}
|
||||
@@ -407,6 +411,7 @@ export default class EditChannelInfo extends PureComponent {
|
||||
</View>
|
||||
<View style={style.inputContainer}>
|
||||
<TextInputWithLocalizedPlaceholder
|
||||
allowFontScaling={true}
|
||||
testID='edit_channel_info.header.input'
|
||||
ref={this.headerInput}
|
||||
value={header}
|
||||
|
||||
@@ -87,15 +87,10 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
|
||||
>
|
||||
<SectionList
|
||||
ListFooterComponent={[Function]}
|
||||
data={Array []}
|
||||
disableVirtualization={false}
|
||||
getItemLayout={[Function]}
|
||||
horizontal={false}
|
||||
initialNumToRender={50}
|
||||
keyExtractor={[Function]}
|
||||
keyboardDismissMode="interactive"
|
||||
keyboardShouldPersistTaps="always"
|
||||
maxToRenderPerBatch={10}
|
||||
nativeID="emojiPicker"
|
||||
onEndReached={[Function]}
|
||||
onEndReachedThreshold={0}
|
||||
@@ -106,7 +101,6 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
|
||||
removeClippedSubviews={true}
|
||||
renderItem={[Function]}
|
||||
renderSectionHeader={[Function]}
|
||||
scrollEventThrottle={50}
|
||||
sections={
|
||||
Array [
|
||||
Object {
|
||||
@@ -12888,7 +12882,6 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
|
||||
]
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
stickySectionHeadersEnabled={true}
|
||||
style={
|
||||
Array [
|
||||
Object {},
|
||||
@@ -12897,8 +12890,6 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
|
||||
},
|
||||
]
|
||||
}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={21}
|
||||
/>
|
||||
<KeyboardTrackingView
|
||||
normalList={true}
|
||||
@@ -12926,7 +12917,7 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
|
||||
}
|
||||
}
|
||||
>
|
||||
<ForwardRef
|
||||
<TouchableOpacity
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
@@ -12951,8 +12942,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
|
||||
]
|
||||
}
|
||||
/>
|
||||
</ForwardRef>
|
||||
<ForwardRef
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
@@ -12975,8 +12966,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
|
||||
]
|
||||
}
|
||||
/>
|
||||
</ForwardRef>
|
||||
<ForwardRef
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
@@ -12999,8 +12990,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
|
||||
]
|
||||
}
|
||||
/>
|
||||
</ForwardRef>
|
||||
<ForwardRef
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
@@ -13023,8 +13014,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
|
||||
]
|
||||
}
|
||||
/>
|
||||
</ForwardRef>
|
||||
<ForwardRef
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
@@ -13047,8 +13038,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
|
||||
]
|
||||
}
|
||||
/>
|
||||
</ForwardRef>
|
||||
<ForwardRef
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
@@ -13071,8 +13062,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
|
||||
]
|
||||
}
|
||||
/>
|
||||
</ForwardRef>
|
||||
<ForwardRef
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
@@ -13095,8 +13086,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
|
||||
]
|
||||
}
|
||||
/>
|
||||
</ForwardRef>
|
||||
<ForwardRef
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
@@ -13119,8 +13110,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
|
||||
]
|
||||
}
|
||||
/>
|
||||
</ForwardRef>
|
||||
<ForwardRef
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
@@ -13143,8 +13134,8 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
|
||||
]
|
||||
}
|
||||
/>
|
||||
</ForwardRef>
|
||||
<ForwardRef
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
@@ -13167,7 +13158,7 @@ exports[`components/emoji_picker/emoji_picker.ios should match snapshot 1`] = `
|
||||
]
|
||||
}
|
||||
/>
|
||||
</ForwardRef>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardTrackingView>
|
||||
|
||||
@@ -60,17 +60,17 @@ describe('components/emoji_picker/emoji_picker.ios', () => {
|
||||
];
|
||||
|
||||
testCases.forEach((testCase) => {
|
||||
test(`'${testCase.input}' should return '${testCase.output}'`, async () => {
|
||||
test(`'${testCase.input}' should return '${testCase.output}'`, () => {
|
||||
expect(filterEmojiSearchInput(testCase.input)).toEqual(testCase.output);
|
||||
});
|
||||
});
|
||||
|
||||
test('should match snapshot', async () => {
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallowWithIntl(<EmojiPicker {...baseProps}/>);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('searchEmojis should return the right values on fuse', async () => {
|
||||
test('searchEmojis should return the right values on fuse', () => {
|
||||
const input = '1';
|
||||
const output = ['100', '1234', '1st_place_medal', '+1', '-1', 'clock1', 'clock10', 'clock1030', 'clock11', 'clock1130', 'clock12', 'clock1230', 'clock130', 'u7121', 'u7981'];
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('components/emoji_picker/emoji_picker.ios', () => {
|
||||
expect(result).toEqual(output);
|
||||
});
|
||||
|
||||
test('should rebuild emojis emojis when emojis change', async () => {
|
||||
test('should rebuild emojis emojis when emojis change', () => {
|
||||
const wrapper = shallowWithIntl(<EmojiPicker {...baseProps}/>);
|
||||
const instance = wrapper.instance();
|
||||
const renderableEmojis = jest.spyOn(instance, 'renderableEmojis');
|
||||
@@ -92,7 +92,7 @@ describe('components/emoji_picker/emoji_picker.ios', () => {
|
||||
expect(renderableEmojis).toHaveBeenCalledWith(baseProps.emojisBySection, baseProps.deviceWidth);
|
||||
});
|
||||
|
||||
test('should set rebuilt emojis when rebuildEmojis is true and searchBarAnimationComplete is true', async () => {
|
||||
test('should set rebuilt emojis when rebuildEmojis is true and searchBarAnimationComplete is true', () => {
|
||||
const wrapper = shallowWithIntl(<EmojiPicker {...baseProps}/>);
|
||||
const instance = wrapper.instance();
|
||||
instance.setState = jest.fn();
|
||||
@@ -107,7 +107,7 @@ describe('components/emoji_picker/emoji_picker.ios', () => {
|
||||
expect(instance.rebuildEmojis).toBe(false);
|
||||
});
|
||||
|
||||
test('should not set rebuilt emojis when rebuildEmojis is false and searchBarAnimationComplete is true', async () => {
|
||||
test('should not set rebuilt emojis when rebuildEmojis is false and searchBarAnimationComplete is true', () => {
|
||||
const wrapper = shallowWithIntl(<EmojiPicker {...baseProps}/>);
|
||||
const instance = wrapper.instance();
|
||||
instance.setState = jest.fn();
|
||||
@@ -120,7 +120,7 @@ describe('components/emoji_picker/emoji_picker.ios', () => {
|
||||
expect(instance.setState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not set rebuilt emojis when rebuildEmojis is true and searchBarAnimationComplete is false', async () => {
|
||||
test('should not set rebuilt emojis when rebuildEmojis is true and searchBarAnimationComplete is false', () => {
|
||||
const wrapper = shallowWithIntl(<EmojiPicker {...baseProps}/>);
|
||||
const instance = wrapper.instance();
|
||||
instance.setState = jest.fn();
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ exports[`Global Thread Footer Should render for channel view and unfollow the th
|
||||
>
|
||||
2 replies
|
||||
</Text>
|
||||
<ForwardRef
|
||||
<TouchableOpacity
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
@@ -75,7 +75,7 @@ exports[`Global Thread Footer Should render for channel view and unfollow the th
|
||||
>
|
||||
Following
|
||||
</Text>
|
||||
</ForwardRef>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Global Thread Item Should render thread item with unread messages dot 1`] = `
|
||||
<ForwardRef
|
||||
<TouchableHighlight
|
||||
onLongPress={[Function]}
|
||||
onPress={[Function]}
|
||||
testID="thread_item.post1.item"
|
||||
underlayColor="rgba(28,88,217,0.08)"
|
||||
@@ -180,11 +181,12 @@ exports[`Global Thread Item Should render thread item with unread messages dot 1
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ForwardRef>
|
||||
</TouchableHighlight>
|
||||
`;
|
||||
|
||||
exports[`Global Thread Item Should show unread mentions count 1`] = `
|
||||
<ForwardRef
|
||||
<TouchableHighlight
|
||||
onLongPress={[Function]}
|
||||
onPress={[Function]}
|
||||
testID="thread_item.post1.item"
|
||||
underlayColor="rgba(28,88,217,0.08)"
|
||||
@@ -378,5 +380,5 @@ exports[`Global Thread Item Should show unread mentions count 1`] = `
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ForwardRef>
|
||||
</TouchableHighlight>
|
||||
`;
|
||||
|
||||
@@ -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,68 +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",
|
||||
]
|
||||
}
|
||||
disableVirtualization={false}
|
||||
horizontal={false}
|
||||
initialNumToRender={10}
|
||||
keyExtractor={[Function]}
|
||||
maxToRenderPerBatch={10}
|
||||
numColumns={1}
|
||||
onEndReached={[Function]}
|
||||
onEndReachedThreshold={2}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={[Function]}
|
||||
scrollEventThrottle={50}
|
||||
scrollIndicatorInsets={
|
||||
Object {
|
||||
"right": 1,
|
||||
data={
|
||||
Array [
|
||||
"thread1",
|
||||
]
|
||||
}
|
||||
}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={21}
|
||||
/>
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ exports[`Global Thread List Header Should render threads with functional tabs &
|
||||
}
|
||||
>
|
||||
<View>
|
||||
<ForwardRef
|
||||
<TouchableOpacity
|
||||
onPress={[MockFunction]}
|
||||
testID="thread_list.all_threads"
|
||||
>
|
||||
@@ -33,8 +33,8 @@ exports[`Global Thread List Header Should render threads with functional tabs &
|
||||
All Your Threads
|
||||
</Text>
|
||||
</View>
|
||||
</ForwardRef>
|
||||
<ForwardRef
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={[MockFunction]}
|
||||
testID="thread_list.unread_threads"
|
||||
>
|
||||
@@ -62,10 +62,10 @@ exports[`Global Thread List Header Should render threads with functional tabs &
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ForwardRef>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View>
|
||||
<ForwardRef
|
||||
<TouchableOpacity
|
||||
disabled={false}
|
||||
onPress={[MockFunction]}
|
||||
testID="thread_list.mark_all_read"
|
||||
@@ -78,7 +78,7 @@ exports[`Global Thread List Header Should render threads with functional tabs &
|
||||
]
|
||||
}
|
||||
/>
|
||||
</ForwardRef>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// 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} from 'react-native';
|
||||
import {Dimensions, EmitterSubscription} from 'react-native';
|
||||
|
||||
import {DeviceTypes} from '@constants';
|
||||
import mattermostManaged from '@mattermost-managed';
|
||||
@@ -12,6 +12,7 @@ import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
|
||||
// TODO: Use permanentSidebar and splitView hooks instead
|
||||
export default class ImageViewPort extends PureComponent {
|
||||
dimensionsListener: EmitterSubscription | undefined;
|
||||
mounted = false;
|
||||
state = {
|
||||
isSplitView: false,
|
||||
@@ -23,13 +24,13 @@ export default class ImageViewPort extends PureComponent {
|
||||
this.handlePermanentSidebar();
|
||||
this.handleDimensions();
|
||||
EventEmitter.on(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS, this.handlePermanentSidebar);
|
||||
Dimensions.addEventListener('change', this.handleDimensions);
|
||||
this.dimensionsListener = Dimensions.addEventListener('change', this.handleDimensions);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
EventEmitter.off(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS, this.handlePermanentSidebar);
|
||||
Dimensions.removeEventListener('change', this.handleDimensions);
|
||||
this.dimensionsListener?.remove();
|
||||
}
|
||||
|
||||
handleDimensions = () => {
|
||||
|
||||
@@ -78,7 +78,6 @@ export default class MarkdownCodeBlock extends React.PureComponent {
|
||||
const actionText = formatMessage({id: 'mobile.markdown.code.copy_code', defaultMessage: 'Copy Code'});
|
||||
BottomSheet.showBottomSheetWithOptions({
|
||||
options: [actionText, cancelText],
|
||||
cancelButtonIndex: 1,
|
||||
}, (value) => {
|
||||
if (value !== 1) {
|
||||
this.handleCopyCode();
|
||||
|
||||
@@ -151,7 +151,6 @@ export default class MarkdownImage extends ImageViewPort {
|
||||
const actionText = formatMessage({id: 'mobile.markdown.link.copy_url', defaultMessage: 'Copy URL'});
|
||||
BottomSheet.showBottomSheetWithOptions({
|
||||
options: [actionText, cancelText],
|
||||
cancelButtonIndex: 1,
|
||||
}, (value) => {
|
||||
if (value !== 1) {
|
||||
this.handleLinkCopy();
|
||||
|
||||
@@ -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 = () => {
|
||||
@@ -130,7 +137,6 @@ export default class MarkdownLink extends PureComponent {
|
||||
const actionText = formatMessage({id: 'mobile.markdown.link.copy_url', defaultMessage: 'Copy URL'});
|
||||
BottomSheet.showBottomSheetWithOptions({
|
||||
options: [actionText, cancelText],
|
||||
cancelButtonIndex: 1,
|
||||
}, (value) => {
|
||||
if (value !== 1) {
|
||||
this.handleLinkCopy();
|
||||
|
||||
@@ -45,14 +45,14 @@ export default class MarkdownTable extends React.PureComponent {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
Dimensions.addEventListener('change', this.setMaxPreviewColumns);
|
||||
this.dimensionsListener = Dimensions.addEventListener('change', this.setMaxPreviewColumns);
|
||||
|
||||
const window = Dimensions.get('window');
|
||||
this.setMaxPreviewColumns({window});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
Dimensions.removeEventListener('change', this.setMaxPreviewColumns);
|
||||
this.dimensionsListener?.remove();
|
||||
}
|
||||
|
||||
setMaxPreviewColumns = ({window}) => {
|
||||
|
||||
@@ -181,10 +181,10 @@ const NetworkIndicator = ({
|
||||
}
|
||||
});
|
||||
|
||||
AppState.addEventListener('change', handleAppStateChange);
|
||||
const listener = AppState.addEventListener('change', handleAppStateChange);
|
||||
|
||||
return () => {
|
||||
AppState.removeEventListener('change', handleAppStateChange);
|
||||
listener.remove();
|
||||
if (clearNotificationTimeout.current && AppState.currentState !== 'active') {
|
||||
clearTimeout(clearNotificationTimeout.current);
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PasteableTextInput should render pasteable text input 1`] = `
|
||||
<TextInput
|
||||
allowFontScaling={true}
|
||||
onPaste={[Function]}
|
||||
rejectResponderTermination={true}
|
||||
screenId="Channel"
|
||||
underlineColorAndroid="transparent"
|
||||
>
|
||||
My Text
|
||||
</TextInput>
|
||||
`;
|
||||
@@ -1,77 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {NativeEventEmitter, NativeModules, Platform, TextInput} from 'react-native';
|
||||
|
||||
import {PASTE_FILES} from '@constants/post_draft';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
|
||||
const {OnPasteEventManager} = NativeModules;
|
||||
const OnPasteEventEmitter = new NativeEventEmitter(OnPasteEventManager);
|
||||
|
||||
export class PasteableTextInput extends React.PureComponent {
|
||||
static propTypes = {
|
||||
...TextInput.PropTypes,
|
||||
forwardRef: PropTypes.any,
|
||||
screenId: PropTypes.string.isRequired,
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.subscription = OnPasteEventEmitter.addListener('onPaste', this.onPaste);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.subscription) {
|
||||
this.subscription.remove();
|
||||
}
|
||||
}
|
||||
|
||||
getLastSubscriptionKey = () => {
|
||||
const subscriptions = OnPasteEventEmitter._subscriber._subscriptionsForType.onPaste?.filter((sub) => sub); // eslint-disable-line no-underscore-dangle
|
||||
return subscriptions?.length && subscriptions[subscriptions.length - 1].key;
|
||||
}
|
||||
|
||||
onPaste = (event) => {
|
||||
const lastSubscriptionKey = this.getLastSubscriptionKey();
|
||||
if (this.subscription.key !== lastSubscriptionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
let data = null;
|
||||
let error = null;
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
const {nativeEvent} = event;
|
||||
data = nativeEvent.data;
|
||||
error = nativeEvent.error;
|
||||
} else {
|
||||
data = event;
|
||||
}
|
||||
|
||||
EventEmitter.emit(PASTE_FILES, error, data, this.props.screenId);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {testID, forwardRef, ...props} = this.props;
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
testID={testID}
|
||||
{...props}
|
||||
onPaste={this.onPaste}
|
||||
ref={forwardRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const WrappedPasteableTextInput = (props, ref) => (
|
||||
<PasteableTextInput
|
||||
{...props}
|
||||
forwardRef={ref}
|
||||
/>
|
||||
);
|
||||
|
||||
export default React.forwardRef(WrappedPasteableTextInput);
|
||||
@@ -1,62 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {shallow} from 'enzyme';
|
||||
import React from 'react';
|
||||
import {NativeEventEmitter} from 'react-native';
|
||||
|
||||
import {PASTE_FILES} from '@constants/post_draft';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
|
||||
import {PasteableTextInput} from './index';
|
||||
|
||||
const nativeEventEmitter = new NativeEventEmitter();
|
||||
|
||||
describe('PasteableTextInput', () => {
|
||||
const emit = jest.spyOn(EventEmitter, 'emit');
|
||||
|
||||
test('should render pasteable text input', () => {
|
||||
const onPaste = jest.fn();
|
||||
const text = 'My Text';
|
||||
const component = shallow(
|
||||
<PasteableTextInput
|
||||
onPaste={onPaste}
|
||||
screenId='Channel'
|
||||
>{text}</PasteableTextInput>,
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should call onPaste props if native onPaste trigger', () => {
|
||||
const event = {someData: 'data'};
|
||||
const text = 'My Text';
|
||||
shallow(
|
||||
<PasteableTextInput screenId='Channel'>{text}</PasteableTextInput>,
|
||||
);
|
||||
nativeEventEmitter.emit('onPaste', event);
|
||||
expect(emit).toHaveBeenCalledWith(PASTE_FILES, null, event, 'Channel');
|
||||
});
|
||||
|
||||
test('should remove onPaste listener when unmount', () => {
|
||||
const mockRemove = jest.fn();
|
||||
const text = 'My Text';
|
||||
const component = shallow(
|
||||
<PasteableTextInput screenId='Channel'>{text}</PasteableTextInput>,
|
||||
);
|
||||
|
||||
component.instance().subscription.remove = mockRemove;
|
||||
component.instance().componentWillUnmount();
|
||||
expect(mockRemove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should emit PASTE_FILES event only for last subscription', () => {
|
||||
const component1 = shallow(<PasteableTextInput screenId='Channel'/>);
|
||||
const instance1 = component1.instance();
|
||||
const component2 = shallow(<PasteableTextInput screenId='Thread'/>);
|
||||
const instance2 = component2.instance();
|
||||
|
||||
instance1.onPaste();
|
||||
expect(emit).not.toHaveBeenCalled();
|
||||
instance2.onPaste();
|
||||
expect(emit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -68,7 +68,9 @@ exports[`PostDraft Should render the Archived for channelIsArchived 1`] = `
|
||||
<View
|
||||
accessibilityRole="button"
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={true}
|
||||
nativeID="animatedComponent"
|
||||
onClick={[Function]}
|
||||
onResponderGrant={[Function]}
|
||||
onResponderMove={[Function]}
|
||||
@@ -172,7 +174,9 @@ exports[`PostDraft Should render the Archived for deactivatedChannel 1`] = `
|
||||
<View
|
||||
accessibilityRole="button"
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={true}
|
||||
nativeID="animatedComponent"
|
||||
onClick={[Function]}
|
||||
onResponderGrant={[Function]}
|
||||
onResponderMove={[Function]}
|
||||
@@ -213,6 +217,8 @@ exports[`PostDraft Should render the DraftInput 1`] = `
|
||||
inverted={false}
|
||||
>
|
||||
<View
|
||||
collapsable={false}
|
||||
nativeID="animatedComponent"
|
||||
style={
|
||||
Object {
|
||||
"bottom": 0,
|
||||
@@ -278,33 +284,52 @@ exports[`PostDraft Should render the DraftInput 1`] = `
|
||||
}
|
||||
>
|
||||
<View>
|
||||
<TextInput
|
||||
<PasteInput
|
||||
accessible={true}
|
||||
allowFontScaling={true}
|
||||
autoCapitalize="sentences"
|
||||
autoCompleteType="off"
|
||||
blurOnSubmit={false}
|
||||
disableCopyPaste={false}
|
||||
disableFullscreenUI={true}
|
||||
focusable={true}
|
||||
keyboardAppearance="light"
|
||||
keyboardType="default"
|
||||
mostRecentEventCount={0}
|
||||
multiline={true}
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onChangeText={[Function]}
|
||||
onClick={[Function]}
|
||||
onEndEditing={[Function]}
|
||||
onFocus={[Function]}
|
||||
onPaste={[Function]}
|
||||
onPressIn={[Function]}
|
||||
onPressOut={[Function]}
|
||||
onResponderGrant={[Function]}
|
||||
onResponderMove={[Function]}
|
||||
onResponderRelease={[Function]}
|
||||
onResponderTerminate={[Function]}
|
||||
onResponderTerminationRequest={[Function]}
|
||||
onScroll={[Function]}
|
||||
onSelectionChange={[Function]}
|
||||
onStartShouldSetResponder={[Function]}
|
||||
placeholder="Write to "
|
||||
placeholderTextColor="rgba(63,67,80,0.5)"
|
||||
rejectResponderTermination={true}
|
||||
screenId="NavigationScreen1"
|
||||
style={
|
||||
Object {
|
||||
"color": "#3f4350",
|
||||
"fontSize": 15,
|
||||
"lineHeight": 20,
|
||||
"maxHeight": 150,
|
||||
"minHeight": 30,
|
||||
"paddingBottom": 6,
|
||||
"paddingHorizontal": 12,
|
||||
"paddingTop": 6,
|
||||
}
|
||||
Array [
|
||||
Object {
|
||||
"color": "#3f4350",
|
||||
"fontSize": 15,
|
||||
"lineHeight": 20,
|
||||
"maxHeight": 150,
|
||||
"minHeight": 30,
|
||||
"paddingBottom": 6,
|
||||
"paddingHorizontal": 12,
|
||||
"paddingTop": 6,
|
||||
},
|
||||
]
|
||||
}
|
||||
testID="post_draft.post.input"
|
||||
textContentType="none"
|
||||
@@ -319,8 +344,10 @@ exports[`PostDraft Should render the DraftInput 1`] = `
|
||||
}
|
||||
>
|
||||
<View
|
||||
collapsable={false}
|
||||
forwardedRef={[Function]}
|
||||
isInteraction={true}
|
||||
nativeID="animatedComponent"
|
||||
style={
|
||||
Object {
|
||||
"display": "flex",
|
||||
@@ -349,8 +376,10 @@ exports[`PostDraft Should render the DraftInput 1`] = `
|
||||
</RCTScrollView>
|
||||
</View>
|
||||
<View
|
||||
collapsable={false}
|
||||
forwardedRef={[Function]}
|
||||
isInteraction={true}
|
||||
nativeID="animatedComponent"
|
||||
style={
|
||||
Object {
|
||||
"height": 0,
|
||||
@@ -390,8 +419,15 @@ exports[`PostDraft Should render the DraftInput 1`] = `
|
||||
testID="post_draft.quick_actions"
|
||||
>
|
||||
<View
|
||||
accessibilityState={
|
||||
Object {
|
||||
"disabled": false,
|
||||
}
|
||||
}
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={true}
|
||||
nativeID="animatedComponent"
|
||||
onClick={[Function]}
|
||||
onResponderGrant={[Function]}
|
||||
onResponderMove={[Function]}
|
||||
@@ -416,8 +452,15 @@ exports[`PostDraft Should render the DraftInput 1`] = `
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
accessibilityState={
|
||||
Object {
|
||||
"disabled": false,
|
||||
}
|
||||
}
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={true}
|
||||
nativeID="animatedComponent"
|
||||
onClick={[Function]}
|
||||
onResponderGrant={[Function]}
|
||||
onResponderMove={[Function]}
|
||||
@@ -442,8 +485,15 @@ exports[`PostDraft Should render the DraftInput 1`] = `
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
accessibilityState={
|
||||
Object {
|
||||
"disabled": false,
|
||||
}
|
||||
}
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={true}
|
||||
nativeID="animatedComponent"
|
||||
onClick={[Function]}
|
||||
onResponderGrant={[Function]}
|
||||
onResponderMove={[Function]}
|
||||
@@ -463,13 +513,20 @@ 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}
|
||||
nativeID="animatedComponent"
|
||||
onClick={[Function]}
|
||||
onResponderGrant={[Function]}
|
||||
onResponderMove={[Function]}
|
||||
@@ -494,8 +551,15 @@ exports[`PostDraft Should render the DraftInput 1`] = `
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
accessibilityState={
|
||||
Object {
|
||||
"disabled": false,
|
||||
}
|
||||
}
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={true}
|
||||
nativeID="animatedComponent"
|
||||
onClick={[Function]}
|
||||
onResponderGrant={[Function]}
|
||||
onResponderMove={[Function]}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,6 +41,9 @@ export default class PostDraft extends PureComponent {
|
||||
|
||||
componentWillUnmount() {
|
||||
EventEmitter.off(UPDATE_NATIVE_SCROLLVIEW, this.updateNativeScrollView);
|
||||
if (this.resetScrollView) {
|
||||
cancelAnimationFrame(this.resetScrollView);
|
||||
}
|
||||
}
|
||||
|
||||
handleInputQuickAction = (value) => {
|
||||
@@ -51,9 +54,10 @@ export default class PostDraft extends PureComponent {
|
||||
|
||||
updateNativeScrollView = (scrollViewNativeID) => {
|
||||
if (this.keyboardTracker?.current && this.props.scrollViewNativeID === scrollViewNativeID) {
|
||||
const resetScrollView = requestAnimationFrame(() => {
|
||||
this.keyboardTracker.current.resetScrollView(scrollViewNativeID);
|
||||
cancelAnimationFrame(resetScrollView);
|
||||
this.resetScrollView = requestAnimationFrame(() => {
|
||||
this.keyboardTracker.current?.resetScrollView(scrollViewNativeID);
|
||||
cancelAnimationFrame(this.resetScrollView);
|
||||
this.resetScrollView = null;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PostInput should match, full snapshot 1`] = `
|
||||
<ForwardRef(WrappedPasteableTextInput)
|
||||
<ForwardRef
|
||||
allowFontScaling={true}
|
||||
autoCompleteType="off"
|
||||
blurOnSubmit={false}
|
||||
disableCopyPaste={false}
|
||||
disableFullscreenUI={true}
|
||||
keyboardAppearance="light"
|
||||
keyboardType="default"
|
||||
multiline={true}
|
||||
onChangeText={[Function]}
|
||||
onEndEditing={[Function]}
|
||||
onPaste={[Function]}
|
||||
onPressIn={[Function]}
|
||||
onPressOut={[Function]}
|
||||
onSelectionChange={[Function]}
|
||||
placeholder="Write to Test Channel"
|
||||
placeholderTextColor="rgba(63,67,80,0.5)"
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {PureComponent} from 'react';
|
||||
import {intlShape} from 'react-intl';
|
||||
import {Alert, AppState, findNodeHandle, Keyboard, NativeModules, Platform} from 'react-native';
|
||||
import {Alert, AppState, DeviceEventEmitter, findNodeHandle, Keyboard, NativeModules, Platform} from 'react-native';
|
||||
|
||||
import PasteableTextInput from '@components/pasteable_text_input';
|
||||
import {NavigationTypes} from '@constants';
|
||||
import DEVICE from '@constants/device';
|
||||
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from '@constants/post_draft';
|
||||
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT, PASTE_FILES} from '@constants/post_draft';
|
||||
import mattermostManaged from '@mattermost-managed';
|
||||
import PasteableTextInput from '@mattermost/react-native-paste-input';
|
||||
import {debounce} from '@mm-redux/actions/helpers';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import {t} from '@utils/i18n';
|
||||
@@ -55,6 +56,7 @@ export default class PostInput extends PureComponent {
|
||||
this.state = {
|
||||
keyboardType: 'default',
|
||||
longMessageAlertShown: false,
|
||||
disableCopyAndPaste: mattermostManaged.getCachedConfig()?.copyAndPasteProtection === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,10 +64,11 @@ export default class PostInput extends PureComponent {
|
||||
const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT;
|
||||
EventEmitter.on(event, this.handleInsertTextToDraft);
|
||||
EventEmitter.on(NavigationTypes.BLUR_POST_DRAFT, this.blur);
|
||||
AppState.addEventListener('change', this.handleAppStateChange);
|
||||
this.appStateListener = AppState.addEventListener('change', this.handleAppStateChange);
|
||||
this.managedListener = mattermostManaged.addEventListener('managedConfigDidChange', this.onManagedConfigurationChange);
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
Keyboard.addListener('keyboardDidHide', this.handleAndroidKeyboard);
|
||||
this.keyboardListener = Keyboard.addListener('keyboardDidHide', this.handleAndroidKeyboard);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,10 +76,11 @@ export default class PostInput extends PureComponent {
|
||||
const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT;
|
||||
EventEmitter.off(NavigationTypes.BLUR_POST_DRAFT, this.blur);
|
||||
EventEmitter.off(event, this.handleInsertTextToDraft);
|
||||
AppState.removeEventListener('change', this.handleAppStateChange);
|
||||
mattermostManaged.removeEventListener(this.managedListener);
|
||||
this.appStateListener.remove();
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
Keyboard.removeListener('keyboardDidHide', this.handleAndroidKeyboard);
|
||||
this.keyboardListener?.remove();
|
||||
}
|
||||
|
||||
this.changeDraft(this.getValue());
|
||||
@@ -249,11 +253,20 @@ export default class PostInput extends PureComponent {
|
||||
}
|
||||
|
||||
this.value = completed;
|
||||
|
||||
this.input.current.setNativeProps({
|
||||
text: completed,
|
||||
});
|
||||
};
|
||||
|
||||
onManagedConfigurationChange = (config) => {
|
||||
this.setState({disableCopyAndPaste: config.copyAndPasteProtection === 'true'});
|
||||
}
|
||||
|
||||
onPaste = (error, files) => {
|
||||
EventEmitter.emit(PASTE_FILES, error, files, this.props.screenId);
|
||||
}
|
||||
|
||||
resetTextInput = () => {
|
||||
if (this.input.current) {
|
||||
this.input.current.setNativeProps({
|
||||
@@ -272,6 +285,18 @@ export default class PostInput extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
onPressIn = () => {
|
||||
if (Platform.OS === 'ios') {
|
||||
DeviceEventEmitter.emit(NavigationTypes.DRAWER, 'locked-closed');
|
||||
}
|
||||
};
|
||||
|
||||
onPressOut = () => {
|
||||
if (Platform.OS === 'ios') {
|
||||
DeviceEventEmitter.emit(NavigationTypes.DRAWER, 'unlocked');
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {formatMessage} = this.context.intl;
|
||||
const {testID, channelDisplayName, screenId, isLandscape, theme} = this.props;
|
||||
@@ -285,8 +310,10 @@ export default class PostInput extends PureComponent {
|
||||
|
||||
return (
|
||||
<PasteableTextInput
|
||||
allowFontScaling={true}
|
||||
testID={testID}
|
||||
ref={this.input}
|
||||
disableCopyPaste={this.state.disableCopyAndPaste}
|
||||
style={{...style.input, maxHeight}}
|
||||
onChangeText={this.handleTextChange}
|
||||
onSelectionChange={this.handlePostDraftSelectionChanged}
|
||||
@@ -297,11 +324,14 @@ export default class PostInput extends PureComponent {
|
||||
underlineColorAndroid='transparent'
|
||||
keyboardType={this.state.keyboardType}
|
||||
onEndEditing={this.handleEndEditing}
|
||||
onPaste={this.onPaste}
|
||||
disableFullscreenUI={true}
|
||||
textContentType='none'
|
||||
autoCompleteType='off'
|
||||
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
|
||||
screenId={screenId}
|
||||
onPressIn={this.onPressIn}
|
||||
onPressOut={this.onPressOut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
|
||||
exports[`CameraButton should match snapshot 1`] = `
|
||||
<View
|
||||
accessibilityState={
|
||||
Object {
|
||||
"disabled": false,
|
||||
}
|
||||
}
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={true}
|
||||
nativeID="animatedComponent"
|
||||
onClick={[Function]}
|
||||
onResponderGrant={[Function]}
|
||||
onResponderMove={[Function]}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user