forked from Ivasoft/mattermost-mobile
Compare commits
84 Commits
release-1.
...
v1.29.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79c47e3ce9 | ||
|
|
47682ca4ed | ||
|
|
4f36dbc4d0 | ||
|
|
1c0e0e888c | ||
|
|
5300bd9553 | ||
|
|
ebb20591c0 | ||
|
|
22a51acb50 | ||
|
|
732b301f0d | ||
|
|
62adde0ad0 | ||
|
|
345b5662ec | ||
|
|
356a092843 | ||
|
|
fae40137fd | ||
|
|
4d4f50dac7 | ||
|
|
6f53f9be16 | ||
|
|
c009047003 | ||
|
|
446ddf91d4 | ||
|
|
687968bf39 | ||
|
|
4bd719789f | ||
|
|
e059d7ea3e | ||
|
|
6a2a99b0d6 | ||
|
|
b4d3e6c9b1 | ||
|
|
b49e0a1c62 | ||
|
|
a346a989c3 | ||
|
|
1d9135704c | ||
|
|
cf6cfac5e2 | ||
|
|
a398687955 | ||
|
|
73008d1a5b | ||
|
|
a3df718db7 | ||
|
|
f90aba6632 | ||
|
|
71450be466 | ||
|
|
bacbc5a734 | ||
|
|
d28e747688 | ||
|
|
c969bdbbef | ||
|
|
338d627e75 | ||
|
|
942b088583 | ||
|
|
7ef8eb294c | ||
|
|
0ea99445ca | ||
|
|
3383c93df8 | ||
|
|
a575f95797 | ||
|
|
62d873e45d | ||
|
|
cbae026f8e | ||
|
|
99bbc79953 | ||
|
|
6394f89869 | ||
|
|
31e5e0426e | ||
|
|
81292df787 | ||
|
|
882bc6b32b | ||
|
|
5a6b389b5b | ||
|
|
b60b9985d6 | ||
|
|
8e31c5c1b9 | ||
|
|
1e40d31b30 | ||
|
|
fd1b8ce219 | ||
|
|
62c244cd72 | ||
|
|
af715828b6 | ||
|
|
4b016a5272 | ||
|
|
b7970c3a34 | ||
|
|
6806337b23 | ||
|
|
51e6b1e1aa | ||
|
|
dc7f068b15 | ||
|
|
3daa365e44 | ||
|
|
5f0df6eb49 | ||
|
|
ccc9e7c75c | ||
|
|
61c9110d41 | ||
|
|
bf73bf4ecc | ||
|
|
c04d2e6040 | ||
|
|
1e0ead398f | ||
|
|
352a103b48 | ||
|
|
1f3ffee26f | ||
|
|
8be5649ee6 | ||
|
|
1d75287892 | ||
|
|
96e017e9eb | ||
|
|
44c3910ce6 | ||
|
|
23db3b75e2 | ||
|
|
dddcbefefe | ||
|
|
a7dc68b40b | ||
|
|
0b81a9b4e0 | ||
|
|
e05207412f | ||
|
|
3b909101f2 | ||
|
|
96f5ae009d | ||
|
|
a44032f0fb | ||
|
|
9dd5a1c2ed | ||
|
|
0c42c0d976 | ||
|
|
e8398cb880 | ||
|
|
4d83724092 | ||
|
|
8f8d32ff7a |
@@ -1,6 +1,6 @@
|
||||
# Mattermost Mobile
|
||||
|
||||
- **Minimum Server versions:** Current ESR version (5.9)
|
||||
- **Minimum Server versions:** Current ESR version (5.19)
|
||||
- **Supported iOS versions:** 10.3+
|
||||
- **Supported Android versions:** 7.0+
|
||||
|
||||
|
||||
@@ -75,7 +75,8 @@ import com.android.build.OutputFile
|
||||
project.ext.react = [
|
||||
entryFile: "index.js",
|
||||
bundleConfig: "metro.config.js",
|
||||
enableHermes: true,
|
||||
bundleCommand: "ram-bundle",
|
||||
enableHermes: false,
|
||||
]
|
||||
|
||||
apply from: "../../node_modules/react-native/react.gradle"
|
||||
@@ -105,18 +106,8 @@ def enableSeparateBuildPerCPUArchitecture = false
|
||||
*/
|
||||
def enableProguardInReleaseBuilds = false
|
||||
|
||||
/**
|
||||
* The preferred build flavor of JavaScriptCore.
|
||||
*
|
||||
* For example, to use the international variant, you can use:
|
||||
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
|
||||
*
|
||||
* The international variant includes ICU i18n library and necessary data
|
||||
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
|
||||
* give correct results when using with locales other than en-US. Note that
|
||||
* this variant is about 6MiB larger per architecture than default.
|
||||
*/
|
||||
def jscFlavor = 'org.webkit:android-jsc-intl:r241213'
|
||||
// Add v8-android - prebuilt libv8android.so into APK
|
||||
def jscFlavor = 'org.chromium:v8-android:+'
|
||||
|
||||
/**
|
||||
* Whether to enable the Hermes VM.
|
||||
@@ -140,8 +131,8 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
missingDimensionStrategy "RNN.reactNativeVersion", "reactNative60"
|
||||
versionCode 259
|
||||
versionName "1.27.0"
|
||||
versionCode 276
|
||||
versionName "1.29.0"
|
||||
multiDexEnabled = true
|
||||
ndk {
|
||||
abiFilters 'armeabi-v7a','arm64-v8a','x86','x86_64'
|
||||
@@ -200,6 +191,11 @@ android {
|
||||
sourceCompatibility 1.8
|
||||
targetCompatibility 1.8
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
// Make sure libjsc.so does not packed in APK
|
||||
exclude "**/libjsc.so"
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
@@ -232,6 +228,7 @@ dependencies {
|
||||
def hermesPath = "../../node_modules/hermes-engine/android/";
|
||||
debugImplementation files(hermesPath + "hermes-debug.aar")
|
||||
releaseImplementation files(hermesPath + "hermes-release.aar")
|
||||
unsignedImplementation files(hermesPath + "hermes-release.aar")
|
||||
} else {
|
||||
implementation jscFlavor
|
||||
}
|
||||
@@ -267,6 +264,7 @@ dependencies {
|
||||
implementation project(':@sentry_react-native')
|
||||
implementation project(':react-native-android-open-settings')
|
||||
implementation project(':react-native-haptic-feedback')
|
||||
implementation project(':react-native-permissions')
|
||||
|
||||
implementation project(':react-native-fast-image')
|
||||
// For animated GIF support
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/app_name"
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -39,7 +40,7 @@
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="mattermost-beta" />
|
||||
<data android:scheme="mattermost" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
|
||||
|
||||
@@ -33,6 +33,7 @@ import io.sentry.RNSentryModule;
|
||||
import com.dylanvann.fastimage.FastImageViewPackage;
|
||||
import com.levelasquez.androidopensettings.AndroidOpenSettings;
|
||||
import com.mkuczera.RNReactNativeHapticFeedbackModule;
|
||||
import com.reactnativecommunity.rnpermissions.RNPermissionsModule;
|
||||
|
||||
import com.reactnativecommunity.webview.RNCWebViewPackage;
|
||||
import com.brentvatne.react.ReactVideoPackage;
|
||||
@@ -153,6 +154,8 @@ public class MainApplication extends NavigationApplication implements INotificat
|
||||
return new AndroidOpenSettings(reactContext);
|
||||
case "RNReactNativeHapticFeedbackModule":
|
||||
return new RNReactNativeHapticFeedbackModule(reactContext);
|
||||
case "RNPermissions":
|
||||
return new RNPermissionsModule(reactContext);
|
||||
default:
|
||||
throw new IllegalArgumentException("Could not find module " + name);
|
||||
}
|
||||
@@ -187,6 +190,7 @@ public class MainApplication extends NavigationApplication implements INotificat
|
||||
map.put(NetInfoModule.NAME, new ReactModuleInfo(NetInfoModule.NAME, "com.reactnativecommunity.netinfo.NetInfoModule", false, false, false, false, false));
|
||||
map.put("RNAndroidOpenSettings", new ReactModuleInfo("RNAndroidOpenSettings", "com.levelasquez.androidopensettings.AndroidOpenSettings", false, false, false, false, false));
|
||||
map.put("RNReactNativeHapticFeedbackModule", new ReactModuleInfo("RNReactNativeHapticFeedback", "com.mkuczera.RNReactNativeHapticFeedbackModule", false, false, false, false, false));
|
||||
map.put("RNPermissions", new ReactModuleInfo("RNPermissions", "com.reactnativecommunity.rnpermissions.RNPermissionsModule", false, false, false, false, false));
|
||||
return map;
|
||||
}
|
||||
};
|
||||
@@ -208,7 +212,7 @@ public class MainApplication extends NavigationApplication implements INotificat
|
||||
instance = this;
|
||||
|
||||
// Delete any previous temp files created by the app
|
||||
File tempFolder = new File(getApplicationContext().getCacheDir(), "mmShare");
|
||||
File tempFolder = new File(getApplicationContext().getCacheDir(), ShareModule.CACHE_DIR_NAME);
|
||||
RealPathUtil.deleteTempFiles(tempFolder);
|
||||
Log.i("ReactNative", "Cleaning temp cache " + tempFolder.getAbsolutePath());
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
|
||||
String token = map.getString("password");
|
||||
String serverUrl = map.getString("service");
|
||||
|
||||
Log.i("ReactNative", String.format("URL=%s", serverUrl));
|
||||
replyToMessage(serverUrl, token, notificationId, message);
|
||||
}
|
||||
}
|
||||
@@ -88,12 +87,16 @@ public class NotificationReplyBroadcastReceiver extends BroadcastReceiver {
|
||||
final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
|
||||
String json = buildReplyPost(channelId, rootId, message.toString());
|
||||
RequestBody body = RequestBody.create(JSON, json);
|
||||
|
||||
String postsEndpoint = "/api/v4/posts?set_online=false";
|
||||
String url = String.format("%s%s", serverUrl.replaceAll("/$", ""), postsEndpoint);
|
||||
Log.i("ReactNative", String.format("Reply URL=%s", url));
|
||||
Request request = new Request.Builder()
|
||||
.header("Authorization", String.format("Bearer %s", token))
|
||||
.header("Content-Type", "application/json")
|
||||
.url(String.format("%s/api/v4/posts", serverUrl.replaceAll("/$", "")))
|
||||
.post(body)
|
||||
.build();
|
||||
.header("Authorization", String.format("Bearer %s", token))
|
||||
.header("Content-Type", "application/json")
|
||||
.url(url)
|
||||
.post(body)
|
||||
.build();
|
||||
|
||||
client.newCall(request).enqueue(new okhttp3.Callback() {
|
||||
@Override
|
||||
|
||||
@@ -16,8 +16,12 @@ 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.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteListener {
|
||||
@@ -83,6 +87,14 @@ public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteList
|
||||
// Get fileName
|
||||
String fileName = URLUtil.guessFileName(uri, null, mimeType);
|
||||
|
||||
if (uri.contains(ShareModule.CACHE_DIR_NAME)) {
|
||||
uri = moveToImagesCache(uri, fileName);
|
||||
}
|
||||
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get fileSize
|
||||
long fileSize;
|
||||
try {
|
||||
@@ -119,4 +131,24 @@ public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteList
|
||||
event
|
||||
);
|
||||
}
|
||||
|
||||
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()) {
|
||||
folder.mkdirs();
|
||||
}
|
||||
|
||||
Files.move(Paths.get(src), Paths.get(dest));
|
||||
} catch (Exception err) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dest;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ public class RealPathUtil {
|
||||
}
|
||||
|
||||
|
||||
File cacheDir = new File(context.getCacheDir(), "mmShare");
|
||||
File cacheDir = new File(context.getCacheDir(), ShareModule.CACHE_DIR_NAME);
|
||||
if (!cacheDir.exists()) {
|
||||
cacheDir.mkdirs();
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
|
||||
private final OkHttpClient client = new OkHttpClient();
|
||||
public static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
|
||||
private final MainApplication mApplication;
|
||||
public static final String CACHE_DIR_NAME = "mmShare";
|
||||
|
||||
public ShareModule(MainApplication application, ReactApplicationContext reactContext) {
|
||||
super(reactContext);
|
||||
@@ -67,6 +68,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
|
||||
@Override
|
||||
public Map<String, Object> getConstants() {
|
||||
HashMap<String, Object> constants = new HashMap<>(1);
|
||||
constants.put("cacheDirName", CACHE_DIR_NAME);
|
||||
constants.put("isOpened", mApplication.sharedExtensionIsOpened);
|
||||
mApplication.sharedExtensionIsOpened = false;
|
||||
return constants;
|
||||
@@ -133,7 +135,7 @@ public class ShareModule extends ReactContextBaseJavaModule {
|
||||
Activity currentActivity = getCurrentActivity();
|
||||
|
||||
if (currentActivity != null) {
|
||||
this.tempFolder = new File(currentActivity.getCacheDir(), "mmShare");
|
||||
this.tempFolder = new File(currentActivity.getCacheDir(), CACHE_DIR_NAME);
|
||||
Intent intent = currentActivity.getIntent();
|
||||
action = intent.getAction();
|
||||
type = intent.getType();
|
||||
|
||||
@@ -44,11 +44,17 @@ allprojects {
|
||||
jcenter()
|
||||
maven {
|
||||
// All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
|
||||
url "$rootDir/../node_modules/react-native/android"
|
||||
// url "$rootDir/../node_modules/react-native/android"
|
||||
|
||||
// Replace AAR from original RN with AAR from react-native-v8
|
||||
url("$rootDir/../node_modules/react-native-v8/dist")
|
||||
}
|
||||
maven {
|
||||
// Local Maven repo containing AARs with JSC library built for Android
|
||||
url "$rootDir/../node_modules/jsc-android/dist"
|
||||
// url "$rootDir/../node_modules/jsc-android/dist"
|
||||
|
||||
// prebuilt libv8android.so
|
||||
url("$rootDir/../node_modules/v8-android/dist")
|
||||
}
|
||||
maven {
|
||||
url "https://jitpack.io"
|
||||
|
||||
@@ -3,6 +3,8 @@ include ':@sentry_react-native'
|
||||
project(':@sentry_react-native').projectDir = new File(rootProject.projectDir, '../node_modules/@sentry/react-native/android')
|
||||
include ':react-native-android-open-settings'
|
||||
project(':react-native-android-open-settings').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-android-open-settings/android')
|
||||
include ':react-native-permissions'
|
||||
project(':react-native-permissions').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-permissions/android')
|
||||
include ':react-native-fast-image'
|
||||
project(':react-native-fast-image').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fast-image/android')
|
||||
include ':react-native-haptic-feedback'
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Platform} from 'react-native';
|
||||
import {Keyboard, Platform} from 'react-native';
|
||||
import {Navigation} from 'react-native-navigation';
|
||||
|
||||
import merge from 'deepmerge';
|
||||
|
||||
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
@@ -20,38 +19,60 @@ function getThemeFromState() {
|
||||
export function resetToChannel(passProps = {}) {
|
||||
const theme = getThemeFromState();
|
||||
|
||||
Navigation.setRoot({
|
||||
root: {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
name: 'Channel',
|
||||
passProps,
|
||||
options: {
|
||||
layout: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
visible: false,
|
||||
height: 0,
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
},
|
||||
},
|
||||
const stack = {
|
||||
children: [{
|
||||
component: {
|
||||
id: 'Channel',
|
||||
name: 'Channel',
|
||||
passProps,
|
||||
options: {
|
||||
layout: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
},
|
||||
topBar: {
|
||||
visible: false,
|
||||
height: 0,
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
backButton: {
|
||||
visible: false,
|
||||
},
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
let platformStack = {stack};
|
||||
if (Platform.OS === 'android') {
|
||||
platformStack = {
|
||||
sideMenu: {
|
||||
left: {
|
||||
component: {
|
||||
id: 'MainSidebar',
|
||||
name: 'MainSidebar',
|
||||
},
|
||||
},
|
||||
center: {
|
||||
stack,
|
||||
},
|
||||
right: {
|
||||
component: {
|
||||
id: 'SettingsSidebar',
|
||||
name: 'SettingsSidebar',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Navigation.setRoot({
|
||||
root: {
|
||||
...platformStack,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -64,6 +85,7 @@ export function resetToSelectServer(allowOtherServers) {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: 'SelectServer',
|
||||
name: 'SelectServer',
|
||||
passProps: {
|
||||
allowOtherServers,
|
||||
@@ -121,6 +143,7 @@ export function resetToTeams(name, title, passProps = {}, options = {}) {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
@@ -138,6 +161,10 @@ export function goToScreen(name, title, passProps = {}, options = {}) {
|
||||
layout: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
sideMenu: {
|
||||
left: {enabled: false},
|
||||
right: {enabled: false},
|
||||
},
|
||||
topBar: {
|
||||
animate: true,
|
||||
visible: true,
|
||||
@@ -157,6 +184,7 @@ export function goToScreen(name, title, passProps = {}, options = {}) {
|
||||
|
||||
Navigation.push(componentId, {
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
@@ -216,6 +244,7 @@ export function showModal(name, title, passProps = {}, options = {}) {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
@@ -295,23 +324,6 @@ export async function dismissAllModals(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export function peek(name, passProps = {}, options = {}) {
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
const defaultOptions = {
|
||||
preview: {
|
||||
commit: false,
|
||||
},
|
||||
};
|
||||
|
||||
Navigation.push(componentId, {
|
||||
component: {
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function setButtons(componentId, buttons = {leftButtons: [], rightButtons: []}) {
|
||||
const options = {
|
||||
topBar: {
|
||||
@@ -350,3 +362,77 @@ export async function dismissOverlay(componentId) {
|
||||
// this componentId to dismiss. We'll do nothing in this case.
|
||||
}
|
||||
}
|
||||
|
||||
export function openMainSideMenu() {
|
||||
if (Platform.OS === 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(componentId, {
|
||||
sideMenu: {
|
||||
left: {visible: true},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function closeMainSideMenu() {
|
||||
if (Platform.OS === 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(componentId, {
|
||||
sideMenu: {
|
||||
left: {visible: false},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function enableMainSideMenu(enabled, visible = true) {
|
||||
if (Platform.OS === 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
|
||||
Navigation.mergeOptions(componentId, {
|
||||
sideMenu: {
|
||||
left: {enabled, visible},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function openSettingsSideMenu() {
|
||||
if (Platform.OS === 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(componentId, {
|
||||
sideMenu: {
|
||||
right: {visible: true},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function closeSettingsSideMenu() {
|
||||
if (Platform.OS === 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentId = EphemeralStore.getNavigationTopComponentId();
|
||||
|
||||
Keyboard.dismiss();
|
||||
Navigation.mergeOptions(componentId, {
|
||||
sideMenu: {
|
||||
right: {visible: false},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,11 +37,12 @@ describe('app/actions/navigation', () => {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: 'Channel',
|
||||
name: 'Channel',
|
||||
passProps,
|
||||
options: {
|
||||
layout: {
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
statusBar: {
|
||||
visible: true,
|
||||
@@ -50,15 +51,11 @@ describe('app/actions/navigation', () => {
|
||||
visible: false,
|
||||
height: 0,
|
||||
backButton: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
title: '',
|
||||
visible: false,
|
||||
},
|
||||
background: {
|
||||
color: theme.sidebarHeaderBg,
|
||||
},
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -80,6 +77,7 @@ describe('app/actions/navigation', () => {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: 'SelectServer',
|
||||
name: 'SelectServer',
|
||||
passProps: {
|
||||
allowOtherServers,
|
||||
@@ -141,6 +139,7 @@ describe('app/actions/navigation', () => {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
@@ -161,6 +160,10 @@ describe('app/actions/navigation', () => {
|
||||
layout: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
sideMenu: {
|
||||
left: {enabled: false},
|
||||
right: {enabled: false},
|
||||
},
|
||||
topBar: {
|
||||
animate: true,
|
||||
visible: true,
|
||||
@@ -180,6 +183,7 @@ describe('app/actions/navigation', () => {
|
||||
|
||||
const expectedLayout = {
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
@@ -241,6 +245,7 @@ describe('app/actions/navigation', () => {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
@@ -317,6 +322,7 @@ describe('app/actions/navigation', () => {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: name,
|
||||
name,
|
||||
passProps,
|
||||
options: merge(showModalOptions, defaultOptions),
|
||||
@@ -372,6 +378,7 @@ describe('app/actions/navigation', () => {
|
||||
stack: {
|
||||
children: [{
|
||||
component: {
|
||||
id: showSearchModalName,
|
||||
name: showSearchModalName,
|
||||
passProps: showSearchModalPassProps,
|
||||
options: merge(defaultOptions, showSearchModalOptions),
|
||||
@@ -398,27 +405,6 @@ describe('app/actions/navigation', () => {
|
||||
expect(dismissAllModals).toHaveBeenCalledWith(options);
|
||||
});
|
||||
|
||||
test('peek should call Navigation.push', async () => {
|
||||
const push = jest.spyOn(Navigation, 'push');
|
||||
|
||||
const defaultOptions = {
|
||||
preview: {
|
||||
commit: false,
|
||||
},
|
||||
};
|
||||
|
||||
const expectedLayout = {
|
||||
component: {
|
||||
name,
|
||||
passProps,
|
||||
options: merge(defaultOptions, options),
|
||||
},
|
||||
};
|
||||
|
||||
await NavigationActions.peek(name, passProps, options);
|
||||
expect(push).toHaveBeenCalledWith(topComponentId, expectedLayout);
|
||||
});
|
||||
|
||||
test('mergeNavigationOptions should call Navigation.mergeOptions', () => {
|
||||
const mergeOptions = jest.spyOn(Navigation, 'mergeOptions');
|
||||
|
||||
|
||||
@@ -5,15 +5,13 @@ import {batchActions} from 'redux-batched-actions';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
import {UserTypes} from 'mattermost-redux/action_types';
|
||||
import {UserTypes, ChannelTypes} from 'mattermost-redux/action_types';
|
||||
import {
|
||||
fetchMyChannelsAndMembers,
|
||||
getChannelByNameAndTeamName,
|
||||
markChannelAsRead,
|
||||
markChannelAsViewed,
|
||||
leaveChannel as serviceLeaveChannel,
|
||||
selectChannel,
|
||||
getChannelStats,
|
||||
} from 'mattermost-redux/actions/channels';
|
||||
import {
|
||||
getPosts,
|
||||
@@ -23,21 +21,22 @@ import {
|
||||
} from 'mattermost-redux/actions/posts';
|
||||
import {getFilesForPost} from 'mattermost-redux/actions/files';
|
||||
import {savePreferences} from 'mattermost-redux/actions/preferences';
|
||||
import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles';
|
||||
import {getTeamMembersByIds, selectTeam} from 'mattermost-redux/actions/teams';
|
||||
import {getProfilesInChannel} from 'mattermost-redux/actions/users';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {General, Preferences} from 'mattermost-redux/constants';
|
||||
import {getPostIdsInChannel} from 'mattermost-redux/selectors/entities/posts';
|
||||
import {
|
||||
getChannel,
|
||||
getCurrentChannelId,
|
||||
getMyChannelMember,
|
||||
getRedirectChannelNameForTeam,
|
||||
getChannelsNameMapInTeam,
|
||||
isManuallyUnread,
|
||||
} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getCurrentTeamId, getTeamByName} from 'mattermost-redux/selectors/entities/teams';
|
||||
|
||||
import telemetry from 'app/telemetry';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getMyPreferences} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getCurrentUserId, getUserIdsInChannels, getUsers} from 'mattermost-redux/selectors/entities/users';
|
||||
import {getTeamByName} from 'mattermost-redux/selectors/entities/teams';
|
||||
|
||||
import {
|
||||
getChannelByName,
|
||||
@@ -52,22 +51,25 @@ import {getLastCreateAt} from 'mattermost-redux/utils/post_utils';
|
||||
import {getPreferencesByCategory} from 'mattermost-redux/utils/preference_utils';
|
||||
|
||||
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from 'app/constants/post_textbox';
|
||||
import {isDirectChannelVisible, isGroupChannelVisible} from 'app/utils/channels';
|
||||
import {getChannelReachable} from 'app/selectors/channel';
|
||||
import telemetry from 'app/telemetry';
|
||||
import {isDirectChannelVisible, isGroupChannelVisible, isDirectMessageVisible, isGroupMessageVisible, isDirectChannelAutoClosed} from 'app/utils/channels';
|
||||
import {buildPreference} from 'app/utils/preferences';
|
||||
|
||||
const MAX_POST_TRIES = 3;
|
||||
import {forceLogoutIfNecessary} from './user';
|
||||
|
||||
export function loadChannelsIfNecessary(teamId) {
|
||||
return async (dispatch) => {
|
||||
await dispatch(fetchMyChannelsAndMembers(teamId));
|
||||
};
|
||||
}
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
export function loadChannelsByTeamName(teamName) {
|
||||
export function loadChannelsByTeamName(teamName, errorHandler) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {currentTeamId} = state.entities.teams;
|
||||
const team = getTeamByName(state, teamName);
|
||||
|
||||
if (!team && errorHandler) {
|
||||
errorHandler();
|
||||
}
|
||||
|
||||
if (team && team.id !== currentTeamId) {
|
||||
await dispatch(fetchMyChannelsAndMembers(team.id));
|
||||
}
|
||||
@@ -184,22 +186,10 @@ export function loadPostsIfNecessaryWithRetry(channelId) {
|
||||
const time = Date.now();
|
||||
|
||||
let loadMorePostsVisible = true;
|
||||
let received;
|
||||
let postAction;
|
||||
if (!postsIds || postsIds.length < ViewTypes.POST_VISIBILITY_CHUNK_SIZE) {
|
||||
// Get the first page of posts if it appears we haven't gotten it yet, like the webapp
|
||||
received = await retryGetPostsAction(getPosts(channelId), dispatch, getState);
|
||||
|
||||
if (received?.order) {
|
||||
const count = received.order.length;
|
||||
loadMorePostsVisible = count >= ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
|
||||
actions.push({
|
||||
type: ViewTypes.SET_INITIAL_POST_COUNT,
|
||||
data: {
|
||||
channelId,
|
||||
count,
|
||||
},
|
||||
});
|
||||
}
|
||||
postAction = getPosts(channelId);
|
||||
} else {
|
||||
const lastConnectAt = state.websocket?.lastConnectAt || 0;
|
||||
const lastGetPosts = state.views.channel.lastGetPosts[channelId];
|
||||
@@ -215,27 +205,22 @@ export function loadPostsIfNecessaryWithRetry(channelId) {
|
||||
since = getLastCreateAt(postsForChannel);
|
||||
}
|
||||
|
||||
received = await retryGetPostsAction(getPostsSince(channelId, since), dispatch, getState);
|
||||
|
||||
if (received?.order) {
|
||||
const count = received.order.length;
|
||||
loadMorePostsVisible = postsIds.length + count >= ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
|
||||
actions.push({
|
||||
type: ViewTypes.SET_INITIAL_POST_COUNT,
|
||||
data: {
|
||||
channelId,
|
||||
count: postsIds.length + count,
|
||||
},
|
||||
});
|
||||
}
|
||||
postAction = getPostsSince(channelId, since);
|
||||
}
|
||||
|
||||
const received = await retryGetPostsAction(postAction, dispatch, getState);
|
||||
|
||||
if (received) {
|
||||
actions.push({
|
||||
type: ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME,
|
||||
channelId,
|
||||
time,
|
||||
});
|
||||
|
||||
if (received?.order) {
|
||||
const count = received.order.length;
|
||||
loadMorePostsVisible = count >= ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
actions.push(setLoadMorePostsVisible(loadMorePostsVisible));
|
||||
@@ -243,8 +228,8 @@ export function loadPostsIfNecessaryWithRetry(channelId) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function retryGetPostsAction(action, dispatch, getState, maxTries = MAX_POST_TRIES) {
|
||||
for (let i = 0; i < maxTries; i++) {
|
||||
export async function retryGetPostsAction(action, dispatch, getState, maxTries = MAX_RETRIES) {
|
||||
for (let i = 0; i <= maxTries; i++) {
|
||||
const {data} = await dispatch(action); // eslint-disable-line no-await-in-loop
|
||||
|
||||
if (data) {
|
||||
@@ -331,7 +316,6 @@ export function selectPenultimateChannel(teamId) {
|
||||
lastChannel.delete_at === 0 &&
|
||||
(lastChannel.team_id === teamId || isDMVisible || isGMVisible)
|
||||
) {
|
||||
dispatch(setChannelLoading(true));
|
||||
dispatch(handleSelectChannel(lastChannelId));
|
||||
return;
|
||||
}
|
||||
@@ -346,7 +330,6 @@ export function selectDefaultChannel(teamId) {
|
||||
|
||||
const channelsInTeam = getChannelsNameMapInTeam(state, teamId);
|
||||
const channel = getChannelByNameSelector(channelsInTeam, getRedirectChannelNameForTeam(state, teamId));
|
||||
|
||||
let channelId;
|
||||
if (channel) {
|
||||
channelId = channel.id;
|
||||
@@ -364,43 +347,36 @@ export function selectDefaultChannel(teamId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function handleSelectChannel(channelId, fromPushNotification = false) {
|
||||
export function handleSelectChannel(channelId) {
|
||||
return async (dispatch, getState) => {
|
||||
const dt = Date.now();
|
||||
const state = getState();
|
||||
const channel = getChannel(state, channelId);
|
||||
const currentTeamId = getCurrentTeamId(state);
|
||||
const currentChannelId = getCurrentChannelId(state);
|
||||
const sameChannel = channelId === currentChannelId;
|
||||
const member = getMyChannelMember(state, channelId);
|
||||
const {channels, currentChannelId, myMembers} = state.entities.channels;
|
||||
const {currentTeamId} = state.entities.teams;
|
||||
const channel = channels[channelId];
|
||||
const member = myMembers[channelId];
|
||||
|
||||
dispatch(setLoadMorePostsVisible(true));
|
||||
dispatch(loadPostsIfNecessaryWithRetry(channelId));
|
||||
|
||||
// If the app is open from push notification, we already fetched the posts.
|
||||
if (!fromPushNotification) {
|
||||
dispatch(loadPostsIfNecessaryWithRetry(channelId));
|
||||
if (channel && currentChannelId !== channelId) {
|
||||
dispatch({
|
||||
type: ChannelTypes.SELECT_CHANNEL,
|
||||
data: channelId,
|
||||
extra: {
|
||||
channel,
|
||||
member,
|
||||
teamId: channel.team_id || currentTeamId,
|
||||
},
|
||||
});
|
||||
|
||||
dispatch(markChannelViewedAndRead(channelId, currentChannelId));
|
||||
}
|
||||
|
||||
let previousChannelId;
|
||||
if (!fromPushNotification && !sameChannel) {
|
||||
previousChannelId = currentChannelId;
|
||||
}
|
||||
|
||||
const actions = [
|
||||
selectChannel(channelId),
|
||||
getChannelStats(channelId),
|
||||
setChannelDisplayName(channel.display_name),
|
||||
setInitialPostVisibility(channelId),
|
||||
setChannelLoading(false),
|
||||
setLastChannelForTeam(currentTeamId, channelId),
|
||||
selectChannelWithMember(channelId, channel, member),
|
||||
];
|
||||
|
||||
dispatch(batchActions(actions));
|
||||
dispatch(markChannelViewedAndRead(channelId, previousChannelId));
|
||||
console.log('channel switch to', channel?.display_name, (Date.now() - dt), 'ms'); //eslint-disable-line
|
||||
};
|
||||
}
|
||||
|
||||
export function handleSelectChannelByName(channelName, teamName) {
|
||||
export function handleSelectChannelByName(channelName, teamName, errorHandler) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {teams: currentTeams, currentTeamId} = state.entities.teams;
|
||||
@@ -409,7 +385,13 @@ export function handleSelectChannelByName(channelName, teamName) {
|
||||
const response = await dispatch(getChannelByNameAndTeamName(teamName || currentTeamName, channelName));
|
||||
const {error, data: channel} = response;
|
||||
const currentChannelId = getCurrentChannelId(state);
|
||||
const reachable = getChannelReachable(state, channelName, teamName);
|
||||
|
||||
if (!reachable && errorHandler) {
|
||||
errorHandler();
|
||||
}
|
||||
|
||||
// Fallback to API response error, if any.
|
||||
if (error) {
|
||||
return {error};
|
||||
}
|
||||
@@ -596,8 +578,7 @@ export function setChannelDisplayName(displayName) {
|
||||
export function increasePostVisibility(channelId, postId) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {loadingPosts, postVisibility} = state.views.channel;
|
||||
const currentPostVisibility = postVisibility[channelId] || 0;
|
||||
const {loadingPosts} = state.views.channel;
|
||||
|
||||
if (loadingPosts[channelId]) {
|
||||
return true;
|
||||
@@ -608,20 +589,6 @@ export function increasePostVisibility(channelId, postId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if we already have the posts that we want to show
|
||||
const loadedPostCount = state.views.channel.postCountInChannel[channelId] || 0;
|
||||
const desiredPostVisibility = currentPostVisibility + ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
|
||||
|
||||
if (loadedPostCount >= desiredPostVisibility) {
|
||||
// We already have the posts, so we just need to show them
|
||||
dispatch(batchActions([
|
||||
doIncreasePostVisibility(channelId),
|
||||
setLoadMorePostsVisible(true),
|
||||
]));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
telemetry.reset();
|
||||
telemetry.start(['posts:loading']);
|
||||
|
||||
@@ -646,18 +613,6 @@ export function increasePostVisibility(channelId, postId) {
|
||||
const count = result.order.length;
|
||||
hasMorePost = count >= pageSize;
|
||||
|
||||
actions.push({
|
||||
type: ViewTypes.INCREASE_POST_COUNT,
|
||||
data: {
|
||||
channelId,
|
||||
count,
|
||||
},
|
||||
});
|
||||
|
||||
// make sure to increment the posts visibility
|
||||
// only if we got results
|
||||
actions.push(doIncreasePostVisibility(channelId));
|
||||
|
||||
actions.push(setLoadMorePostsVisible(hasMorePost));
|
||||
}
|
||||
|
||||
@@ -669,24 +624,6 @@ export function increasePostVisibility(channelId, postId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function increasePostVisibilityByOne(channelId) {
|
||||
return (dispatch) => {
|
||||
dispatch({
|
||||
type: ViewTypes.INCREASE_POST_VISIBILITY,
|
||||
data: channelId,
|
||||
amount: 1,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function doIncreasePostVisibility(channelId) {
|
||||
return {
|
||||
type: ViewTypes.INCREASE_POST_VISIBILITY,
|
||||
data: channelId,
|
||||
amount: ViewTypes.POST_VISIBILITY_CHUNK_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
function setLoadMorePostsVisible(visible) {
|
||||
return {
|
||||
type: ViewTypes.SET_LOAD_MORE_POSTS_VISIBLE,
|
||||
@@ -694,26 +631,203 @@ function setLoadMorePostsVisible(visible) {
|
||||
};
|
||||
}
|
||||
|
||||
function setInitialPostVisibility(channelId) {
|
||||
return {
|
||||
type: ViewTypes.SET_INITIAL_POST_VISIBILITY,
|
||||
data: channelId,
|
||||
export function loadChannelsForTeam(teamId) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
|
||||
if (currentUserId) {
|
||||
const data = {sync: true, teamId};
|
||||
for (let i = 0; i <= MAX_RETRIES; i++) {
|
||||
try {
|
||||
console.log('Fetching channels attempt', teamId, (i + 1)); //eslint-disable-line no-console
|
||||
const [channels, channelMembers] = await Promise.all([ //eslint-disable-line no-await-in-loop
|
||||
Client4.getMyChannels(teamId, true),
|
||||
Client4.getMyChannelMembers(teamId),
|
||||
]);
|
||||
|
||||
data.channels = channels;
|
||||
data.channelMembers = channelMembers;
|
||||
break;
|
||||
} catch (err) {
|
||||
const result = await dispatch(forceLogoutIfNecessary(err)); //eslint-disable-line no-await-in-loop
|
||||
if (result || i === MAX_RETRIES) {
|
||||
const hasChannelsLoaded = state.entities.channels.channelsInTeam[teamId]?.size > 0;
|
||||
return {error: hasChannelsLoaded ? null : err};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.channels) {
|
||||
const roles = new Set();
|
||||
const members = data.channelMembers;
|
||||
for (const member of members) {
|
||||
for (const role of member.roles.split(' ')) {
|
||||
roles.add(role);
|
||||
}
|
||||
}
|
||||
|
||||
if (roles.size > 0) {
|
||||
dispatch(loadRolesIfNeeded(roles));
|
||||
}
|
||||
|
||||
// Fetch needed profiles from channel creators and direct channels
|
||||
dispatch(loadSidebarDirectMessagesProfiles(data));
|
||||
|
||||
dispatch({
|
||||
type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
return {data};
|
||||
}
|
||||
|
||||
return {error: 'Cannot fetch channels without a current user'};
|
||||
};
|
||||
}
|
||||
|
||||
function setLastChannelForTeam(teamId, channelId) {
|
||||
return {
|
||||
type: ViewTypes.SET_LAST_CHANNEL_FOR_TEAM,
|
||||
teamId,
|
||||
channelId,
|
||||
export function loadSidebarDirectMessagesProfiles(data) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {channels, channelMembers} = data;
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const usersInChannel = getUserIdsInChannels(state);
|
||||
const directChannels = Object.values(channels).filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL);
|
||||
const prefs = [];
|
||||
const promises = []; //only fetch profiles that we don't have and the Direct channel should be visible
|
||||
|
||||
// Prepare preferences and start fetching profiles to batch them
|
||||
directChannels.forEach((c) => {
|
||||
const profilesInChannel = Array.from(usersInChannel[c.id] || []).filter((u) => u.id !== currentUserId);
|
||||
switch (c.type) {
|
||||
case General.DM_CHANNEL: {
|
||||
const dm = fetchDirectMessageProfileIfNeeded(state, c, channelMembers, profilesInChannel, prefs);
|
||||
|
||||
if (dm) {
|
||||
promises.push(dispatch(dm));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case General.GM_CHANNEL: {
|
||||
const gm = fetchGroupMessageProfilesIfNeeded(state, c, channelMembers, profilesInChannel, prefs);
|
||||
|
||||
if (gm) {
|
||||
promises.push(dispatch(gm));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Save preferences if there are any changes
|
||||
if (prefs.length) {
|
||||
dispatch(savePreferences(currentUserId, prefs));
|
||||
}
|
||||
|
||||
getProfilesFromPromises(dispatch, promises, directChannels);
|
||||
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
function selectChannelWithMember(channelId, channel, member) {
|
||||
return {
|
||||
type: ViewTypes.SELECT_CHANNEL_WITH_MEMBER,
|
||||
data: channelId,
|
||||
channel,
|
||||
member,
|
||||
export function getUsersInChannel(channelId) {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const state = getState();
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const profiles = await Client4.getProfilesInChannel(channelId);
|
||||
|
||||
// When fetching profiles in channels we exclude our own user
|
||||
const users = profiles.filter((p) => p.id !== currentUserId);
|
||||
const data = {
|
||||
channelId,
|
||||
users,
|
||||
};
|
||||
|
||||
return {data};
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function getProfilesFromPromises(dispatch, promiseArray, directChannels) {
|
||||
// Get the profiles returned by the promises and retry those that failed
|
||||
let promises = promiseArray;
|
||||
for (let i = 0; i <= MAX_RETRIES; i++) {
|
||||
if (!promises.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await Promise.all(promises); //eslint-disable-line no-await-in-loop
|
||||
const failed = [];
|
||||
|
||||
result.forEach((p, index) => {
|
||||
if (p.error) {
|
||||
failed.push(directChannels[index].id);
|
||||
}
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: UserTypes.RECEIVED_BATCHED_PROFILES_IN_CHANNEL,
|
||||
data: result,
|
||||
});
|
||||
|
||||
if (failed.length) {
|
||||
promises = failed.map((id) => dispatch(getUsersInChannel(id))); //eslint-disable-line no-loop-func
|
||||
continue;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchDirectMessageProfileIfNeeded(state, channel, channelMembers, profilesInChannel, newPreferences) {
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const preferences = getMyPreferences(state);
|
||||
const users = getUsers(state);
|
||||
const config = getConfig(state);
|
||||
const currentChannelId = getCurrentChannelId(state);
|
||||
const otherUserId = getUserIdFromChannelName(currentUserId, channel.name);
|
||||
const otherUser = users[otherUserId];
|
||||
const dmVisible = isDirectMessageVisible(preferences, channel.id);
|
||||
const dmAutoClosed = isDirectChannelAutoClosed(config, preferences, channel.id, channel.last_post_at, otherUser?.delete_at, currentChannelId); //eslint-disable-line camelcase
|
||||
const dmIsUnread = channelMembers[channel.id]?.mention_count > 0; //eslint-disable-line camelcase
|
||||
const dmFetchProfile = dmIsUnread || (dmVisible && !dmAutoClosed);
|
||||
|
||||
// when then DM is hidden but has new messages
|
||||
if ((!dmVisible || dmAutoClosed) && dmIsUnread) {
|
||||
newPreferences.push(buildPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, currentUserId, otherUserId));
|
||||
newPreferences.push(buildPreference(Preferences.CATEGORY_CHANNEL_OPEN_TIME, currentUserId, channel.id, Date.now().toString()));
|
||||
}
|
||||
|
||||
if (dmFetchProfile && !profilesInChannel.includes(otherUserId) && otherUserId !== currentUserId) {
|
||||
return getUsersInChannel(channel.id);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function fetchGroupMessageProfilesIfNeeded(state, channel, channelMembers, profilesInChannel, newPreferences) {
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const preferences = getMyPreferences(state);
|
||||
const config = getConfig(state);
|
||||
const gmVisible = isGroupMessageVisible(preferences, channel.id);
|
||||
const gmAutoClosed = isDirectChannelAutoClosed(config, preferences, channel.id, channel.last_post_at);
|
||||
const channelMember = channelMembers[channel.id];
|
||||
const gmIsUnread = channelMember?.mention_count > 0 || channelMember?.msg_count < channel.total_msg_count; //eslint-disable-line camelcase
|
||||
const gmFetchProfile = gmIsUnread || (gmVisible && !gmAutoClosed);
|
||||
|
||||
// when then GM is hidden but has new messages
|
||||
if ((!gmVisible || gmAutoClosed) && gmIsUnread) {
|
||||
newPreferences.push(buildPreference(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, currentUserId, channel.id));
|
||||
newPreferences.push(buildPreference(Preferences.CATEGORY_CHANNEL_OPEN_TIME, currentUserId, channel.id, Date.now().toString()));
|
||||
}
|
||||
|
||||
if (gmFetchProfile && !profilesInChannel.length) {
|
||||
return getUsersInChannel(channel.id);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import initialState from 'app/initial_state';
|
||||
import {ViewTypes} from 'app/constants';
|
||||
import {ChannelTypes} from 'mattermost-redux/action_types';
|
||||
import testHelper from 'test/test_helper';
|
||||
|
||||
import * as ChannelActions from 'app/actions/views/channel';
|
||||
@@ -116,14 +116,21 @@ describe('Actions.Views.Channel', () => {
|
||||
},
|
||||
channels: {
|
||||
currentChannelId,
|
||||
channels: {
|
||||
'channel-id': {id: 'channel-id', display_name: 'Test Channel'},
|
||||
'channel-id-2': {id: 'channel-id-2', display_name: 'Test Channel'},
|
||||
},
|
||||
myMembers: {
|
||||
'channel-id': {channel_id: 'channel-id', user_id: currentUserId, mention_count: 0, msg_count: 0},
|
||||
'channel-id-2': {channel_id: 'channel-id-2', user_id: currentUserId, mention_count: 0, msg_count: 0},
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
currentTeamId,
|
||||
teams: {
|
||||
currentTeamId,
|
||||
currentTeams: {
|
||||
[currentTeamId]: {
|
||||
name: currentTeamName,
|
||||
},
|
||||
[currentTeamId]: {
|
||||
id: currentTeamId,
|
||||
name: currentTeamName,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -135,6 +142,9 @@ describe('Actions.Views.Channel', () => {
|
||||
channelSelectors.getCurrentChannelId = jest.fn(() => currentChannelId);
|
||||
channelSelectors.getMyChannelMember = jest.fn(() => ({data: {member: {}}}));
|
||||
|
||||
const appChannelSelectors = require('app/selectors/channel');
|
||||
appChannelSelectors.getChannelReachable = jest.fn(() => true);
|
||||
|
||||
test('handleSelectChannelByName success', async () => {
|
||||
store = mockStore(storeObj);
|
||||
|
||||
@@ -144,14 +154,13 @@ describe('Actions.Views.Channel', () => {
|
||||
const receivedChannel = storeActions.some((action) => action.type === MOCK_RECEIVE_CHANNEL_TYPE);
|
||||
expect(receivedChannel).toBe(true);
|
||||
|
||||
const storeBatchActions = storeActions.filter(({type}) => type === 'BATCHING_REDUCER.BATCH');
|
||||
const selectedChannel = storeBatchActions[0].payload.some((action) => action.type === MOCK_SELECT_CHANNEL_TYPE);
|
||||
const selectedChannel = storeActions.some(({type}) => type === MOCK_RECEIVE_CHANNEL_TYPE);
|
||||
expect(selectedChannel).toBe(true);
|
||||
});
|
||||
|
||||
test('handleSelectChannelByName failure from null currentTeamName', async () => {
|
||||
const failStoreObj = {...storeObj};
|
||||
failStoreObj.entities.teams.teams.currentTeamId = 'not-in-current-teams';
|
||||
failStoreObj.entities.teams.currentTeamId = 'not-in-current-teams';
|
||||
store = mockStore(failStoreObj);
|
||||
|
||||
await store.dispatch(handleSelectChannelByName(currentChannelName, null));
|
||||
@@ -165,6 +174,7 @@ describe('Actions.Views.Channel', () => {
|
||||
});
|
||||
|
||||
test('handleSelectChannelByName failure from no permission to channel', async () => {
|
||||
store = mockStore({...storeObj});
|
||||
actions.getChannelByNameAndTeamName = jest.fn(() => {
|
||||
return {
|
||||
type: 'MOCK_ERROR',
|
||||
@@ -181,6 +191,18 @@ describe('Actions.Views.Channel', () => {
|
||||
expect(receivedChannel).toBe(false);
|
||||
});
|
||||
|
||||
test('handleSelectChannelByName failure from unreachable channel', async () => {
|
||||
appChannelSelectors.getChannelReachable = jest.fn(() => false);
|
||||
|
||||
store = mockStore(storeObj);
|
||||
|
||||
await store.dispatch(handleSelectChannelByName(currentChannelName, currentTeamName));
|
||||
|
||||
const storeActions = store.getActions();
|
||||
const receivedChannel = storeActions.some((action) => action.type === MOCK_RECEIVE_CHANNEL_TYPE);
|
||||
expect(receivedChannel).toBe(false);
|
||||
});
|
||||
|
||||
test('loadPostsIfNecessaryWithRetry for the first time', async () => {
|
||||
store = mockStore(storeObj);
|
||||
|
||||
@@ -262,35 +284,44 @@ describe('Actions.Views.Channel', () => {
|
||||
});
|
||||
|
||||
const handleSelectChannelCases = [
|
||||
[currentChannelId, true],
|
||||
[currentChannelId, false],
|
||||
[`not-${currentChannelId}`, true],
|
||||
[`not-${currentChannelId}`, false],
|
||||
[currentChannelId],
|
||||
[`${currentChannelId}-2`],
|
||||
[`not-${currentChannelId}`],
|
||||
[`not-${currentChannelId}-2`],
|
||||
];
|
||||
test.each(handleSelectChannelCases)('handleSelectChannel dispatches selectChannelWithMember', async (channelId, fromPushNotification) => {
|
||||
store = mockStore({...storeObj});
|
||||
test.each(handleSelectChannelCases)('handleSelectChannel dispatches selectChannelWithMember', async (channelId) => {
|
||||
const testObj = {...storeObj};
|
||||
testObj.entities.teams.currentTeamId = currentTeamId;
|
||||
store = mockStore(testObj);
|
||||
|
||||
await store.dispatch(handleSelectChannel(channelId, fromPushNotification));
|
||||
await store.dispatch(handleSelectChannel(channelId));
|
||||
const storeActions = store.getActions();
|
||||
const storeBatchActions = storeActions.find(({type}) => type === 'BATCHING_REDUCER.BATCH');
|
||||
const selectChannelWithMember = storeBatchActions.payload.find(({type}) => type === ViewTypes.SELECT_CHANNEL_WITH_MEMBER);
|
||||
const selectChannelWithMember = storeActions.find(({type}) => type === ChannelTypes.SELECT_CHANNEL);
|
||||
const viewedAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_VIEWED);
|
||||
const readAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_READ);
|
||||
|
||||
const expectedSelectChannelWithMember = {
|
||||
type: ViewTypes.SELECT_CHANNEL_WITH_MEMBER,
|
||||
type: ChannelTypes.SELECT_CHANNEL,
|
||||
data: channelId,
|
||||
channel: {
|
||||
data: channelId,
|
||||
},
|
||||
member: {
|
||||
data: {
|
||||
member: {},
|
||||
extra: {
|
||||
channel: {
|
||||
id: channelId,
|
||||
display_name: 'Test Channel',
|
||||
},
|
||||
member: {
|
||||
channel_id: channelId,
|
||||
user_id: currentUserId,
|
||||
mention_count: 0,
|
||||
msg_count: 0,
|
||||
},
|
||||
teamId: currentTeamId,
|
||||
},
|
||||
|
||||
};
|
||||
expect(selectChannelWithMember).toStrictEqual(expectedSelectChannelWithMember);
|
||||
if (channelId.includes('not') || channelId === currentChannelId) {
|
||||
expect(selectChannelWithMember).toBe(undefined);
|
||||
} else {
|
||||
expect(selectChannelWithMember).toStrictEqual(expectedSelectChannelWithMember);
|
||||
}
|
||||
expect(viewedAction).not.toBe(null);
|
||||
expect(readAction).not.toBe(null);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {setAppCredentials} from 'app/init/credentials';
|
||||
import PushNotifications from 'app/push_notifications';
|
||||
import {getDeviceTimezoneAsync} from 'app/utils/timezone';
|
||||
import {setCSRFFromCookie} from 'app/utils/security';
|
||||
import {loadConfigAndLicense} from 'app/actions/views/root';
|
||||
|
||||
export function handleLoginIdChanged(loginId) {
|
||||
return async (dispatch, getState) => {
|
||||
@@ -38,6 +39,8 @@ export function handlePasswordChanged(password) {
|
||||
|
||||
export function handleSuccessfulLogin() {
|
||||
return async (dispatch, getState) => {
|
||||
await dispatch(loadConfigAndLicense());
|
||||
|
||||
const state = getState();
|
||||
const config = getConfig(state);
|
||||
const license = getLicense(state);
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import * as GeneralActions from 'mattermost-redux/actions/general';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
import {
|
||||
handleLoginIdChanged,
|
||||
handlePasswordChanged,
|
||||
handleSuccessfulLogin,
|
||||
} from 'app/actions/views/login';
|
||||
|
||||
jest.mock('app/init/credentials', () => ({
|
||||
@@ -36,7 +39,16 @@ describe('Actions.Views.Login', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore({});
|
||||
store = mockStore({
|
||||
entities: {
|
||||
users: {
|
||||
currentUserId: 'current-user-id',
|
||||
},
|
||||
general: {
|
||||
config: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('handleLoginIdChanged', () => {
|
||||
@@ -60,4 +72,13 @@ describe('Actions.Views.Login', () => {
|
||||
store.dispatch(handlePasswordChanged(password));
|
||||
expect(store.getActions()).toEqual([action]);
|
||||
});
|
||||
|
||||
test('handleSuccessfulLogin gets config and license ', async () => {
|
||||
const getClientConfig = jest.spyOn(GeneralActions, 'getClientConfig');
|
||||
const getLicenseConfig = jest.spyOn(GeneralActions, 'getLicenseConfig');
|
||||
|
||||
await store.dispatch(handleSuccessfulLogin());
|
||||
expect(getClientConfig).toHaveBeenCalled();
|
||||
expect(getLicenseConfig).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {GeneralTypes} from 'mattermost-redux/action_types';
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
|
||||
import {ChannelTypes, GeneralTypes, TeamTypes} from 'mattermost-redux/action_types';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
import {fetchMyChannelsAndMembers} from 'mattermost-redux/actions/channels';
|
||||
import {getClientConfig, getDataRetentionPolicy, getLicenseConfig} from 'mattermost-redux/actions/general';
|
||||
import {receivedNewPost} from 'mattermost-redux/actions/posts';
|
||||
import {getMyTeams, getMyTeamMembers, selectTeam} from 'mattermost-redux/actions/teams';
|
||||
import {getMyTeams, getMyTeamMembers} from 'mattermost-redux/actions/teams';
|
||||
|
||||
import {ViewTypes} from 'app/constants';
|
||||
import EphemeralStore from 'app/store/ephemeral_store';
|
||||
import {recordTime} from 'app/utils/segment';
|
||||
|
||||
import {handleSelectChannel} from 'app/actions/views/channel';
|
||||
import {markChannelViewedAndRead} from './channel';
|
||||
|
||||
export function startDataCleanup() {
|
||||
return async (dispatch, getState) => {
|
||||
@@ -37,7 +40,7 @@ export function loadConfigAndLicense() {
|
||||
if (currentUserId) {
|
||||
if (config.DataRetentionEnableMessageDeletion && config.DataRetentionEnableMessageDeletion === 'true' &&
|
||||
license.IsLicensed === 'true' && license.DataRetention === 'true') {
|
||||
getDataRetentionPolicy()(dispatch, getState);
|
||||
dispatch(getDataRetentionPolicy());
|
||||
} else {
|
||||
dispatch({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}});
|
||||
}
|
||||
@@ -79,12 +82,46 @@ export function loadFromPushNotification(notification) {
|
||||
await Promise.all(loading);
|
||||
}
|
||||
|
||||
dispatch(handleSelectTeamAndChannel(teamId, channelId));
|
||||
};
|
||||
}
|
||||
|
||||
export function handleSelectTeamAndChannel(teamId, channelId) {
|
||||
return async (dispatch, getState) => {
|
||||
const dt = Date.now();
|
||||
const state = getState();
|
||||
const {channels, currentChannelId, myMembers} = state.entities.channels;
|
||||
const {currentTeamId} = state.entities.teams;
|
||||
const channel = channels[channelId];
|
||||
const member = myMembers[channelId];
|
||||
const actions = [];
|
||||
|
||||
// when the notification is from a team other than the current team
|
||||
if (teamId !== currentTeamId) {
|
||||
dispatch(selectTeam({id: teamId}));
|
||||
actions.push({type: TeamTypes.SELECT_TEAM, data: teamId});
|
||||
}
|
||||
|
||||
dispatch(handleSelectChannel(channelId, true));
|
||||
if (channel && currentChannelId !== channelId) {
|
||||
actions.push({
|
||||
type: ChannelTypes.SELECT_CHANNEL,
|
||||
data: channelId,
|
||||
extra: {
|
||||
channel,
|
||||
member,
|
||||
teamId: channel.team_id || currentTeamId,
|
||||
},
|
||||
});
|
||||
|
||||
dispatch(markChannelViewedAndRead(channelId));
|
||||
}
|
||||
|
||||
if (actions.length) {
|
||||
dispatch(batchActions(actions));
|
||||
}
|
||||
|
||||
EphemeralStore.setStartFromNotification(false);
|
||||
|
||||
console.log('channel switch from push notification to', channel?.display_name, (Date.now() - dt), 'ms'); //eslint-disable-line
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {TeamTypes} from 'mattermost-redux/action_types';
|
||||
import {batchActions} from 'redux-batched-actions';
|
||||
|
||||
import {ChannelTypes, TeamTypes} from 'mattermost-redux/action_types';
|
||||
import {getMyTeams} from 'mattermost-redux/actions/teams';
|
||||
import {RequestStatus} from 'mattermost-redux/constants';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
@@ -18,7 +20,10 @@ export function handleTeamChange(teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({type: TeamTypes.SELECT_TEAM, data: teamId});
|
||||
dispatch(batchActions([
|
||||
{type: TeamTypes.SELECT_TEAM, data: teamId},
|
||||
{type: ChannelTypes.SELECT_CHANNEL, data: '', extra: {}},
|
||||
]));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,185 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {UserTypes} from 'mattermost-redux/action_types';
|
||||
import {GeneralTypes, UserTypes} from 'mattermost-redux/action_types';
|
||||
import {getDataRetentionPolicy} from 'mattermost-redux/actions/general';
|
||||
import * as HelperActions from 'mattermost-redux/actions/helpers';
|
||||
import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles';
|
||||
import {autoUpdateTimezone} from 'mattermost-redux/actions/timezone';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {isTimezoneEnabled} from 'mattermost-redux/selectors/entities/timezone';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {setAppCredentials} from 'app/init/credentials';
|
||||
import {setCSRFFromCookie} from 'app/utils/security';
|
||||
import {getDeviceTimezoneAsync} from 'app/utils/timezone';
|
||||
|
||||
const HTTP_UNAUTHORIZED = 401;
|
||||
|
||||
export function completeLogin(user, deviceToken) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const config = getConfig(state);
|
||||
const license = getLicense(state);
|
||||
const token = Client4.getToken();
|
||||
const url = Client4.getUrl();
|
||||
|
||||
setCSRFFromCookie(url);
|
||||
setAppCredentials(deviceToken, user.id, token, url);
|
||||
|
||||
// Set timezone
|
||||
const enableTimezone = isTimezoneEnabled(state);
|
||||
if (enableTimezone) {
|
||||
const timezone = await getDeviceTimezoneAsync();
|
||||
dispatch(autoUpdateTimezone(timezone));
|
||||
}
|
||||
|
||||
// Data retention
|
||||
if (config.DataRetentionEnableMessageDeletion && config.DataRetentionEnableMessageDeletion === 'true' &&
|
||||
license.IsLicensed === 'true' && license.DataRetention === 'true') {
|
||||
dispatch(getDataRetentionPolicy());
|
||||
} else {
|
||||
dispatch({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadMe(user, deviceToken) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const data = {user};
|
||||
const deviceId = state.entities?.general?.deviceToken;
|
||||
|
||||
try {
|
||||
if (deviceId && !deviceToken) {
|
||||
await Client4.attachDevice(deviceId);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
data.user = await Client4.getMe();
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch(forceLogoutIfNecessary(error));
|
||||
return {error};
|
||||
}
|
||||
|
||||
try {
|
||||
Client4.setUserId(data.user.id);
|
||||
Client4.setUserRoles(data.user.roles);
|
||||
|
||||
// Execute all other requests in parallel
|
||||
const teamsRequest = Client4.getMyTeams();
|
||||
const teamMembersRequest = Client4.getMyTeamMembers();
|
||||
const teamUnreadRequest = Client4.getMyTeamUnreads();
|
||||
const preferencesRequest = Client4.getMyPreferences();
|
||||
const configRequest = Client4.getClientConfigOld();
|
||||
|
||||
const [teams, teamMembers, teamUnreads, preferences, config] = await Promise.all([
|
||||
teamsRequest,
|
||||
teamMembersRequest,
|
||||
teamUnreadRequest,
|
||||
preferencesRequest,
|
||||
configRequest,
|
||||
]);
|
||||
|
||||
data.teams = teams;
|
||||
data.teamMembers = teamMembers;
|
||||
data.teamUnreads = teamUnreads;
|
||||
data.preferences = preferences;
|
||||
data.config = config;
|
||||
data.url = Client4.getUrl();
|
||||
|
||||
dispatch({
|
||||
type: UserTypes.LOGIN,
|
||||
data,
|
||||
});
|
||||
|
||||
const roles = new Set();
|
||||
for (const role of data.user.roles.split(' ')) {
|
||||
roles.add(role);
|
||||
}
|
||||
for (const teamMember of teamMembers) {
|
||||
for (const role of teamMember.roles.split(' ')) {
|
||||
roles.add(role);
|
||||
}
|
||||
}
|
||||
if (roles.size > 0) {
|
||||
dispatch(loadRolesIfNeeded(roles));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('login error', error.stack); // eslint-disable-line no-console
|
||||
return {error};
|
||||
}
|
||||
|
||||
return {data};
|
||||
};
|
||||
}
|
||||
|
||||
export function login(loginId, password, mfaToken, ldapOnly = false) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const deviceToken = state.entities?.general?.deviceToken;
|
||||
let user;
|
||||
|
||||
try {
|
||||
user = await Client4.login(loginId, password, mfaToken, deviceToken, ldapOnly);
|
||||
} catch (error) {
|
||||
return {error};
|
||||
}
|
||||
|
||||
const result = await dispatch(loadMe(user));
|
||||
|
||||
if (!result.error) {
|
||||
dispatch(completeLogin(user, deviceToken));
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export function ssoLogin(token) {
|
||||
return async (dispatch) => {
|
||||
Client4.setToken(token);
|
||||
const result = await dispatch(loadMe());
|
||||
|
||||
if (!result.error) {
|
||||
dispatch(completeLogin(result.data.user));
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export function logout(skipServerLogout = false) {
|
||||
return async (dispatch) => {
|
||||
if (!skipServerLogout) {
|
||||
try {
|
||||
Client4.logout();
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({type: UserTypes.LOGOUT_SUCCESS});
|
||||
};
|
||||
}
|
||||
|
||||
export function forceLogoutIfNecessary(error) {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
|
||||
if (currentUserId && error.status_code === HTTP_UNAUTHORIZED && error.url && !error.url.includes('/login')) {
|
||||
dispatch(logout(true));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
export function setCurrentUserStatusOffline() {
|
||||
return (dispatch, getState) => {
|
||||
const currentUserId = getCurrentUserId(getState());
|
||||
@@ -18,3 +193,5 @@ export function setCurrentUserStatusOffline() {
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
HelperActions.forceLogoutIfNecessary = forceLogoutIfNecessary;
|
||||
@@ -5,8 +5,7 @@ exports[`SendButton should change theme backgroundColor to 0.3 opacity 1`] = `
|
||||
style={
|
||||
Object {
|
||||
"justifyContent": "flex-end",
|
||||
"paddingHorizontal": 5,
|
||||
"paddingVertical": 2,
|
||||
"paddingRight": 8,
|
||||
}
|
||||
}
|
||||
>
|
||||
@@ -17,10 +16,9 @@ exports[`SendButton should change theme backgroundColor to 0.3 opacity 1`] = `
|
||||
"alignItems": "center",
|
||||
"backgroundColor": "#166de0",
|
||||
"borderRadius": 4,
|
||||
"height": 28,
|
||||
"height": 32,
|
||||
"justifyContent": "center",
|
||||
"paddingLeft": 3,
|
||||
"width": 72,
|
||||
"width": 80,
|
||||
},
|
||||
Object {
|
||||
"backgroundColor": "rgba(22,109,224,0.3)",
|
||||
@@ -29,9 +27,9 @@ exports[`SendButton should change theme backgroundColor to 0.3 opacity 1`] = `
|
||||
}
|
||||
>
|
||||
<PaperPlane
|
||||
color="#ffffff"
|
||||
height={13}
|
||||
width={15}
|
||||
color="rgba(255,255,255,0.5)"
|
||||
height={16}
|
||||
width={19}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -43,8 +41,7 @@ exports[`SendButton should match snapshot 1`] = `
|
||||
style={
|
||||
Object {
|
||||
"justifyContent": "flex-end",
|
||||
"paddingHorizontal": 5,
|
||||
"paddingVertical": 2,
|
||||
"paddingRight": 8,
|
||||
}
|
||||
}
|
||||
type="opacity"
|
||||
@@ -55,17 +52,16 @@ exports[`SendButton should match snapshot 1`] = `
|
||||
"alignItems": "center",
|
||||
"backgroundColor": "#166de0",
|
||||
"borderRadius": 4,
|
||||
"height": 28,
|
||||
"height": 32,
|
||||
"justifyContent": "center",
|
||||
"paddingLeft": 3,
|
||||
"width": 72,
|
||||
"width": 80,
|
||||
}
|
||||
}
|
||||
>
|
||||
<PaperPlane
|
||||
color="#ffffff"
|
||||
height={13}
|
||||
width={15}
|
||||
height={16}
|
||||
width={19}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithFeedbackIOS>
|
||||
@@ -77,8 +73,7 @@ exports[`SendButton should render theme backgroundColor 1`] = `
|
||||
style={
|
||||
Object {
|
||||
"justifyContent": "flex-end",
|
||||
"paddingHorizontal": 5,
|
||||
"paddingVertical": 2,
|
||||
"paddingRight": 8,
|
||||
}
|
||||
}
|
||||
type="opacity"
|
||||
@@ -89,17 +84,16 @@ exports[`SendButton should render theme backgroundColor 1`] = `
|
||||
"alignItems": "center",
|
||||
"backgroundColor": "#166de0",
|
||||
"borderRadius": 4,
|
||||
"height": 28,
|
||||
"height": 32,
|
||||
"justifyContent": "center",
|
||||
"paddingLeft": 3,
|
||||
"width": 72,
|
||||
"width": 80,
|
||||
}
|
||||
}
|
||||
>
|
||||
<PaperPlane
|
||||
color="#ffffff"
|
||||
height={13}
|
||||
width={15}
|
||||
height={16}
|
||||
width={19}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithFeedbackIOS>
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('AnnouncementBanner', () => {
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<AnnouncementBanner {...baseProps}/>
|
||||
<AnnouncementBanner {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('AtMention', () => {
|
||||
|
||||
test('should match snapshot, no highlight', () => {
|
||||
const wrapper = shallow(
|
||||
<AtMention {...baseProps}/>
|
||||
<AtMention {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
@@ -25,7 +25,7 @@ describe('AtMention', () => {
|
||||
|
||||
test('should match snapshot, with highlight', () => {
|
||||
const wrapper = shallow(
|
||||
<AtMention {...baseProps}/>
|
||||
<AtMention {...baseProps}/>,
|
||||
);
|
||||
|
||||
wrapper.setState({user: {username: 'John.Smith'}});
|
||||
@@ -34,7 +34,7 @@ describe('AtMention', () => {
|
||||
|
||||
test('should match snapshot, without highlight', () => {
|
||||
const wrapper = shallow(
|
||||
<AtMention {...baseProps}/>
|
||||
<AtMention {...baseProps}/>,
|
||||
);
|
||||
|
||||
wrapper.setState({user: {username: 'Victor.Welch'}});
|
||||
|
||||
@@ -21,7 +21,7 @@ import Permissions from 'react-native-permissions';
|
||||
import {lookupMimeType} from 'mattermost-redux/utils/file_utils';
|
||||
|
||||
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
|
||||
import {PermissionTypes} from 'app/constants';
|
||||
import emmProvider from 'app/init/emm_provider';
|
||||
import {changeOpacity} from 'app/utils/theme';
|
||||
import {t} from 'app/utils/i18n';
|
||||
import {showModalOverCurrentContext} from 'app/actions/navigation';
|
||||
@@ -178,6 +178,7 @@ export default class AttachmentButton extends PureComponent {
|
||||
|
||||
if (hasCameraPermission) {
|
||||
ImagePicker.launchCamera(options, (response) => {
|
||||
emmProvider.inBackgroundSince = null;
|
||||
if (response.error || response.didCancel) {
|
||||
return;
|
||||
}
|
||||
@@ -208,10 +209,11 @@ export default class AttachmentButton extends PureComponent {
|
||||
options.mediaType = 'mixed';
|
||||
}
|
||||
|
||||
const hasPhotoPermission = await this.hasPhotoPermission('photo');
|
||||
const hasPhotoPermission = await this.hasPhotoPermission('photo', 'photo');
|
||||
|
||||
if (hasPhotoPermission) {
|
||||
ImagePicker.launchImageLibrary(options, (response) => {
|
||||
emmProvider.inBackgroundSince = null;
|
||||
if (response.error || response.didCancel) {
|
||||
return;
|
||||
}
|
||||
@@ -244,6 +246,7 @@ export default class AttachmentButton extends PureComponent {
|
||||
};
|
||||
|
||||
ImagePicker.launchImageLibrary(options, (response) => {
|
||||
emmProvider.inBackgroundSince = null;
|
||||
if (response.error || response.didCancel) {
|
||||
return;
|
||||
}
|
||||
@@ -259,6 +262,7 @@ export default class AttachmentButton extends PureComponent {
|
||||
if (hasPermission) {
|
||||
try {
|
||||
const res = await DocumentPicker.pick({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
|
||||
const newUri = await ShareExtension.getFilePath(res.uri);
|
||||
@@ -283,28 +287,21 @@ export default class AttachmentButton extends PureComponent {
|
||||
if (Platform.OS === 'ios') {
|
||||
const {formatMessage} = this.context.intl;
|
||||
let permissionRequest;
|
||||
const targetSource = source || 'photo';
|
||||
const targetSource = source === 'camera' ? Permissions.PERMISSIONS.IOS.CAMERA : Permissions.PERMISSIONS.IOS.PHOTO_LIBRARY;
|
||||
const hasPermissionToStorage = await Permissions.check(targetSource);
|
||||
|
||||
switch (hasPermissionToStorage) {
|
||||
case PermissionTypes.UNDETERMINED:
|
||||
case Permissions.RESULTS.DENIED:
|
||||
permissionRequest = await Permissions.request(targetSource);
|
||||
if (permissionRequest !== PermissionTypes.AUTHORIZED) {
|
||||
if (permissionRequest !== Permissions.RESULTS.GRANTED) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case PermissionTypes.DENIED: {
|
||||
const canOpenSettings = await Permissions.canOpenSettings();
|
||||
let grantOption = null;
|
||||
if (canOpenSettings) {
|
||||
grantOption = {
|
||||
text: formatMessage({
|
||||
id: 'mobile.permission_denied_retry',
|
||||
defaultMessage: 'Settings',
|
||||
}),
|
||||
onPress: () => Permissions.openSettings(),
|
||||
};
|
||||
}
|
||||
case Permissions.RESULTS.BLOCKED: {
|
||||
const grantOption = {
|
||||
text: formatMessage({id: 'mobile.permission_denied_retry', defaultMessage: 'Settings'}),
|
||||
onPress: () => Permissions.openSettings(),
|
||||
};
|
||||
|
||||
const {title, text} = this.getPermissionDeniedMessage(source, mediaType);
|
||||
|
||||
@@ -319,7 +316,7 @@ export default class AttachmentButton extends PureComponent {
|
||||
defaultMessage: 'Don\'t Allow',
|
||||
}),
|
||||
},
|
||||
]
|
||||
],
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -332,18 +329,19 @@ export default class AttachmentButton extends PureComponent {
|
||||
hasStoragePermission = async () => {
|
||||
if (Platform.OS === 'android') {
|
||||
const {formatMessage} = this.context.intl;
|
||||
const storagePermission = Permissions.PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE;
|
||||
let permissionRequest;
|
||||
const hasPermissionToStorage = await Permissions.check('storage');
|
||||
const hasPermissionToStorage = await Permissions.check(storagePermission);
|
||||
|
||||
switch (hasPermissionToStorage) {
|
||||
case PermissionTypes.UNDETERMINED:
|
||||
permissionRequest = await Permissions.request('storage');
|
||||
if (permissionRequest !== PermissionTypes.AUTHORIZED) {
|
||||
case Permissions.RESULTS.DENIED:
|
||||
permissionRequest = await Permissions.request(storagePermission);
|
||||
if (permissionRequest !== Permissions.RESULTS.GRANTED) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case PermissionTypes.DENIED: {
|
||||
const {title, text} = this.getPermissionDeniedMessage('storage');
|
||||
case Permissions.RESULTS.BLOCKED: {
|
||||
const {title, text} = this.getPermissionDeniedMessage(storagePermission);
|
||||
|
||||
Alert.alert(
|
||||
title,
|
||||
@@ -362,7 +360,7 @@ export default class AttachmentButton extends PureComponent {
|
||||
}),
|
||||
onPress: () => AndroidOpenSettings.appDetailsSettings(),
|
||||
},
|
||||
]
|
||||
],
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import Permissions from 'react-native-permissions';
|
||||
import {Alert} from 'react-native';
|
||||
|
||||
import Preferences from 'mattermost-redux/constants/preferences';
|
||||
|
||||
import {VALID_MIME_TYPES} from 'app/screens/edit_profile/edit_profile';
|
||||
import {PermissionTypes} from 'app/constants';
|
||||
|
||||
import AttachmentButton from './index';
|
||||
|
||||
@@ -28,9 +28,7 @@ describe('AttachmentButton', () => {
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<AttachmentButton {...baseProps}/>
|
||||
);
|
||||
const wrapper = shallow(<AttachmentButton {...baseProps}/>);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
@@ -42,9 +40,7 @@ describe('AttachmentButton', () => {
|
||||
onShowUnsupportedMimeTypeWarning: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<AttachmentButton {...props}/>
|
||||
);
|
||||
const wrapper = shallow(<AttachmentButton {...props}/>);
|
||||
|
||||
const file = {
|
||||
type: 'image/gif',
|
||||
@@ -63,9 +59,7 @@ describe('AttachmentButton', () => {
|
||||
onShowUnsupportedMimeTypeWarning: jest.fn(),
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<AttachmentButton {...props}/>
|
||||
);
|
||||
const wrapper = shallow(<AttachmentButton {...props}/>);
|
||||
|
||||
const file = {
|
||||
fileSize: 10,
|
||||
@@ -79,11 +73,24 @@ describe('AttachmentButton', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should show permission denied alert if permission is denied in iOS', async () => {
|
||||
expect.assertions(1);
|
||||
test('should return permission false if permission is denied in iOS', async () => {
|
||||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.DENIED);
|
||||
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
|
||||
|
||||
jest.spyOn(Permissions, 'check').mockReturnValue(PermissionTypes.DENIED);
|
||||
jest.spyOn(Permissions, 'canOpenSettings').mockReturnValue(true);
|
||||
const wrapper = shallow(
|
||||
<AttachmentButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPhotoPermission = await wrapper.instance().hasPhotoPermission('camera');
|
||||
expect(Permissions.check).toHaveBeenCalled();
|
||||
expect(Permissions.request).toHaveBeenCalled();
|
||||
expect(Alert.alert).not.toHaveBeenCalled();
|
||||
expect(hasPhotoPermission).toBe(false);
|
||||
});
|
||||
|
||||
test('should show permission denied alert and return permission false if permission is blocked in iOS', async () => {
|
||||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.BLOCKED);
|
||||
jest.spyOn(Alert, 'alert').mockReturnValue(true);
|
||||
|
||||
const wrapper = shallow(
|
||||
@@ -91,7 +98,10 @@ describe('AttachmentButton', () => {
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
await wrapper.instance().hasPhotoPermission('camera');
|
||||
expect(Alert.alert).toBeCalled();
|
||||
const hasPhotoPermission = await wrapper.instance().hasPhotoPermission('camera');
|
||||
expect(Permissions.check).toHaveBeenCalled();
|
||||
expect(Permissions.request).not.toHaveBeenCalled();
|
||||
expect(Alert.alert).toHaveBeenCalled();
|
||||
expect(hasPhotoPermission).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -94,13 +94,18 @@ export default class EmojiSuggestion extends PureComponent {
|
||||
|
||||
const results = await fuse.search(matchTerm.toLowerCase());
|
||||
const data = results.map((index) => emojis[index]);
|
||||
this.setEmojiData(data);
|
||||
this.setEmojiData(data, matchTerm);
|
||||
};
|
||||
|
||||
setEmojiData = (data) => {
|
||||
setEmojiData = (data, matchTerm = null) => {
|
||||
let sorter = compareEmojis;
|
||||
if (matchTerm) {
|
||||
sorter = (a, b) => compareEmojis(a, b, matchTerm);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
active: data.length > 0,
|
||||
dataSource: data.sort(compareEmojis),
|
||||
dataSource: data.sort(sorter),
|
||||
});
|
||||
|
||||
this.props.onResultCountChange(data.length);
|
||||
|
||||
@@ -24,7 +24,7 @@ const getEmojisByName = createSelector(
|
||||
}
|
||||
|
||||
return Array.from(emoticons);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function mapStateToProps(state) {
|
||||
|
||||
@@ -23,13 +23,12 @@ const mobileCommandsSelector = createSelector(
|
||||
getAutocompleteCommandsList,
|
||||
(commands) => {
|
||||
return commands.filter((command) => !COMMANDS_TO_HIDE_ON_MOBILE.includes(command.trigger));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
commands: mobileCommandsSelector(state),
|
||||
commandsRequest: state.requests.integrations.getAutocompleteCommands,
|
||||
currentTeamId: getCurrentTeamId(state),
|
||||
theme: getTheme(state),
|
||||
isLandscape: isLandscape(state),
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
FlatList,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
|
||||
import {RequestStatus} from 'mattermost-redux/constants';
|
||||
|
||||
import AutocompleteDivider from 'app/components/autocomplete/autocomplete_divider';
|
||||
import {makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
|
||||
@@ -18,14 +16,13 @@ import SlashSuggestionItem from './slash_suggestion_item';
|
||||
const SLASH_REGEX = /(^\/)([a-zA-Z-]*)$/;
|
||||
const TIME_BEFORE_NEXT_COMMAND_REQUEST = 1000 * 60 * 5;
|
||||
|
||||
export default class SlashSuggestion extends Component {
|
||||
export default class SlashSuggestion extends PureComponent {
|
||||
static propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
getAutocompleteCommands: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
currentTeamId: PropTypes.string.isRequired,
|
||||
commands: PropTypes.array,
|
||||
commandsRequest: PropTypes.object.isRequired,
|
||||
isSearch: PropTypes.bool,
|
||||
maxListHeight: PropTypes.number,
|
||||
theme: PropTypes.object.isRequired,
|
||||
@@ -56,7 +53,6 @@ export default class SlashSuggestion extends Component {
|
||||
const {currentTeamId} = this.props;
|
||||
const {
|
||||
commands: nextCommands,
|
||||
commandsRequest: nextCommandsRequest,
|
||||
currentTeamId: nextTeamId,
|
||||
value: nextValue,
|
||||
} = nextProps;
|
||||
@@ -81,7 +77,7 @@ export default class SlashSuggestion extends Component {
|
||||
|
||||
const dataIsStale = Date.now() - this.state.lastCommandRequest > TIME_BEFORE_NEXT_COMMAND_REQUEST;
|
||||
|
||||
if ((!nextCommands.length || dataIsStale) && nextCommandsRequest.status !== RequestStatus.STARTED) {
|
||||
if ((!nextCommands.length || dataIsStale)) {
|
||||
this.props.actions.getAutocompleteCommands(nextProps.currentTeamId);
|
||||
this.setState({
|
||||
lastCommandRequest: Date.now(),
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('Badge', () => {
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<Badge {...baseProps}/>
|
||||
<Badge {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.instance().renderText()).toMatchSnapshot();
|
||||
|
||||
@@ -351,7 +351,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
marginBottom: 12,
|
||||
},
|
||||
container: {
|
||||
marginTop: 60,
|
||||
marginTop: 10,
|
||||
marginHorizontal: 12,
|
||||
marginBottom: 12,
|
||||
overflow: 'hidden',
|
||||
|
||||
@@ -24,7 +24,7 @@ function makeMapStateToProps() {
|
||||
(currentUserId, profilesInChannel) => {
|
||||
const currentChannelMembers = profilesInChannel || [];
|
||||
return currentChannelMembers.filter((m) => m.id !== currentUserId);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return function mapStateToProps(state, ownProps) {
|
||||
|
||||
@@ -24,7 +24,7 @@ function makeGetChannelNamesMap() {
|
||||
}
|
||||
|
||||
return channelsNameMap;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ export default class ChannelLoader extends PureComponent {
|
||||
|
||||
stopLoadingAnimation = () => {
|
||||
Animated.timing(
|
||||
this.state.barsOpacity
|
||||
this.state.barsOpacity,
|
||||
).stop();
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ export default class ClientUpgradeListener extends PureComponent {
|
||||
intl.formatMessage({
|
||||
id: 'mobile.client_upgrade.download_error.message',
|
||||
defaultMessage: 'An error occurred while trying to open the download link.',
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return false;
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/* eslint-disable no-confusing-arrow */
|
||||
export const ConditionalWrapper = ({conditional, wrapper, children}) => conditional ? wrapper(children) : children;
|
||||
@@ -29,7 +29,7 @@ describe('CustomList', () => {
|
||||
|
||||
test('should match snapshot with FlatList', () => {
|
||||
const wrapper = shallow(
|
||||
<CustomList {...baseProps}/>
|
||||
<CustomList {...baseProps}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.find('FlatList')).toHaveLength(1);
|
||||
@@ -42,7 +42,7 @@ describe('CustomList', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<CustomList {...props}/>
|
||||
<CustomList {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.find('SectionList')).toHaveLength(1);
|
||||
@@ -50,7 +50,7 @@ describe('CustomList', () => {
|
||||
|
||||
test('should match snapshot, renderSectionHeader', () => {
|
||||
const wrapper = shallow(
|
||||
<CustomList {...baseProps}/>
|
||||
<CustomList {...baseProps}/>,
|
||||
);
|
||||
const section = {
|
||||
id: 'section_id',
|
||||
@@ -61,7 +61,7 @@ describe('CustomList', () => {
|
||||
test('should call props.renderItem on renderItem', () => {
|
||||
const props = {...baseProps};
|
||||
const wrapper = shallow(
|
||||
<CustomList {...props}/>
|
||||
<CustomList {...props}/>,
|
||||
);
|
||||
wrapper.instance().renderItem({item: {id: 'item_id', selected: true}, index: 0, section: null});
|
||||
expect(props.renderItem).toHaveBeenCalledTimes(1);
|
||||
@@ -69,7 +69,7 @@ describe('CustomList', () => {
|
||||
|
||||
test('should match snapshot, renderSeparator', () => {
|
||||
const wrapper = shallow(
|
||||
<CustomList {...baseProps}/>
|
||||
<CustomList {...baseProps}/>,
|
||||
);
|
||||
expect(wrapper.instance().renderSeparator()).toMatchSnapshot();
|
||||
});
|
||||
@@ -77,7 +77,7 @@ describe('CustomList', () => {
|
||||
test('should match snapshot, renderFooter', () => {
|
||||
const props = {...baseProps};
|
||||
const wrapper = shallow(
|
||||
<CustomList {...props}/>
|
||||
<CustomList {...props}/>,
|
||||
);
|
||||
|
||||
// should return null
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('EditChannelInfo', () => {
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<EditChannelInfo {...baseProps}/>
|
||||
<EditChannelInfo {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
@@ -45,7 +45,7 @@ describe('EditChannelInfo', () => {
|
||||
|
||||
test('should have called onHeaderChangeText on text change from Autocomplete', () => {
|
||||
const wrapper = shallow(
|
||||
<EditChannelInfo {...baseProps}/>
|
||||
<EditChannelInfo {...baseProps}/>,
|
||||
);
|
||||
|
||||
const instance = wrapper.instance();
|
||||
@@ -67,7 +67,7 @@ describe('EditChannelInfo', () => {
|
||||
|
||||
test('should call scrollHeaderToTop', () => {
|
||||
const wrapper = shallow(
|
||||
<EditChannelInfo {...baseProps}/>
|
||||
<EditChannelInfo {...baseProps}/>,
|
||||
);
|
||||
|
||||
const instance = wrapper.instance();
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Image,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
import FastImage from 'react-native-fast-image';
|
||||
|
||||
import CustomPropTypes from 'app/constants/custom_prop_types';
|
||||
import ImageCacheManager from 'app/utils/image_cache_manager';
|
||||
|
||||
export default class Emoji extends React.PureComponent {
|
||||
static propTypes = {
|
||||
@@ -50,56 +48,15 @@ export default class Emoji extends React.PureComponent {
|
||||
isCustomEmoji: false,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
imageUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const {displayTextOnly, emojiName, imageUrl} = this.props;
|
||||
this.mounted = true;
|
||||
if (!displayTextOnly && imageUrl) {
|
||||
ImageCacheManager.cache(`emoji-${emojiName}`, imageUrl, this.setImageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const {displayTextOnly, emojiName, imageUrl} = nextProps;
|
||||
if (emojiName !== this.props.emojiName && this.mounted) {
|
||||
this.setState({
|
||||
imageUrl: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (!displayTextOnly && imageUrl &&
|
||||
imageUrl !== this.props.imageUrl) {
|
||||
ImageCacheManager.cache(`emoji-${emojiName}`, imageUrl, this.setImageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
setImageUrl = (imageUrl) => {
|
||||
if (this.mounted) {
|
||||
this.setState({
|
||||
imageUrl,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
literal,
|
||||
textStyle,
|
||||
displayTextOnly,
|
||||
customEmojiStyle,
|
||||
displayTextOnly,
|
||||
imageUrl,
|
||||
literal,
|
||||
unicode,
|
||||
textStyle,
|
||||
} = this.props;
|
||||
const {imageUrl} = this.state;
|
||||
|
||||
let size = this.props.size;
|
||||
let fontSize = size;
|
||||
@@ -118,28 +75,23 @@ export default class Emoji extends React.PureComponent {
|
||||
|
||||
// Android can't change the size of an image after its first render, so
|
||||
// force a new image to be rendered when the size changes
|
||||
const key = Platform.OS === 'android' ? (height + '-' + width) : null;
|
||||
const key = Platform.OS === 'android' ? (`${imageUrl}-${height}-${width}`) : null;
|
||||
|
||||
if (this.props.unicode && !this.props.imageUrl) {
|
||||
const codeArray = this.props.unicode.split('-');
|
||||
if (unicode && !imageUrl) {
|
||||
const codeArray = unicode.split('-');
|
||||
const code = codeArray.reduce((acc, c) => {
|
||||
return acc + String.fromCodePoint(parseInt(c, 16));
|
||||
}, '');
|
||||
|
||||
return (
|
||||
<Text style={[this.props.textStyle, {fontSize: size}]}>
|
||||
<Text style={[textStyle, {fontSize: size}]}>
|
||||
{code}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
return (
|
||||
<Image
|
||||
key={key}
|
||||
style={{width, height}}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -148,6 +100,7 @@ export default class Emoji extends React.PureComponent {
|
||||
style={[customEmojiStyle, {width, height}]}
|
||||
source={{uri: imageUrl}}
|
||||
onError={this.onError}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
makeStyleSheetFromTheme,
|
||||
changeOpacity,
|
||||
} from 'app/utils/theme';
|
||||
import {compareEmojis} from 'app/utils/emoji_utils';
|
||||
import {paddingHorizontal as padding} from 'app/components/safe_area_view/iphone_x_spacing';
|
||||
import EmojiPickerRow from './emoji_picker_row';
|
||||
|
||||
@@ -211,7 +212,9 @@ export default class EmojiPicker extends PureComponent {
|
||||
}
|
||||
|
||||
const results = fuse.search(searchTermLowerCase);
|
||||
const data = results.map((index) => emojis[index]);
|
||||
const sorter = (a, b) => compareEmojis(a, b, searchTerm);
|
||||
const data = results.map((index) => emojis[index]).sort(sorter);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ const getEmojisBySection = createSelector(
|
||||
}
|
||||
|
||||
return emoticons;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const getEmojisByName = createSelector(
|
||||
@@ -137,7 +137,7 @@ const getEmojisByName = createSelector(
|
||||
}
|
||||
|
||||
return Array.from(emoticons);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function mapStateToProps(state) {
|
||||
|
||||
@@ -31,7 +31,7 @@ export default class Fade extends PureComponent {
|
||||
toValue: prevProps.visible ? 0 : 1,
|
||||
duration: this.props.duration || FADE_DURATION,
|
||||
useNativeDriver: true,
|
||||
}
|
||||
},
|
||||
).start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('Fade', () => {
|
||||
{...props}
|
||||
>
|
||||
<Text>{dummyText}</Text>
|
||||
</Fade>
|
||||
</Fade>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('FileAttachment', () => {
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<FileAttachment {...baseProps}/>
|
||||
<FileAttachment {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
|
||||
@@ -146,7 +146,7 @@ export default class FileAttachmentDocument extends PureComponent {
|
||||
this.setState({downloading: true});
|
||||
this.downloadTask = RNFetchBlob.config(options).fetch('GET', getFileUrl(data.id));
|
||||
this.downloadTask.progress((received, total) => {
|
||||
const progress = (received / total) * 100;
|
||||
const progress = Math.round((received / total) * 100);
|
||||
if (this.mounted) {
|
||||
this.setState({progress});
|
||||
}
|
||||
@@ -216,7 +216,9 @@ export default class FileAttachmentDocument extends PureComponent {
|
||||
};
|
||||
|
||||
onDonePreviewingFile = () => {
|
||||
this.setState({preview: false});
|
||||
if (this.mounted) {
|
||||
this.setState({preview: false});
|
||||
}
|
||||
this.setStatusBarColor();
|
||||
};
|
||||
|
||||
@@ -256,13 +258,15 @@ export default class FileAttachmentDocument extends PureComponent {
|
||||
id: 'mobile.server_upgrade.button',
|
||||
defaultMessage: 'OK',
|
||||
}),
|
||||
}]
|
||||
}],
|
||||
);
|
||||
this.onDonePreviewingFile();
|
||||
RNFetchBlob.fs.unlink(path);
|
||||
}
|
||||
|
||||
this.setState({downloading: false, progress: 0});
|
||||
if (this.mounted) {
|
||||
this.setState({downloading: false, progress: 0});
|
||||
}
|
||||
});
|
||||
|
||||
// Android does not trigger the event for DoneButtonEvent
|
||||
@@ -281,7 +285,11 @@ export default class FileAttachmentDocument extends PureComponent {
|
||||
didCancel: true,
|
||||
}, () => {
|
||||
// need to wait a bit for the progress circle UI to update to the give progress
|
||||
setTimeout(() => this.setState({downloading: false}), 2000);
|
||||
setTimeout(() => {
|
||||
if (this.mounted) {
|
||||
this.setState({downloading: false});
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -303,7 +311,7 @@ export default class FileAttachmentDocument extends PureComponent {
|
||||
id: 'mobile.server_upgrade.button',
|
||||
defaultMessage: 'OK',
|
||||
}),
|
||||
}]
|
||||
}],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -324,7 +332,7 @@ export default class FileAttachmentDocument extends PureComponent {
|
||||
id: 'mobile.server_upgrade.button',
|
||||
defaultMessage: 'OK',
|
||||
}),
|
||||
}]
|
||||
}],
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,13 +8,12 @@ import {
|
||||
View,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import ProgressiveImage from 'app/components/progressive_image';
|
||||
import {isGif} from 'app/utils/file';
|
||||
import {emptyFunction} from 'app/utils/general';
|
||||
import ImageCacheManager from 'app/utils/image_cache_manager';
|
||||
import {changeOpacity} from 'app/utils/theme';
|
||||
|
||||
import thumb from 'assets/images/thumb.png';
|
||||
@@ -58,11 +57,14 @@ export default class FileAttachmentImage extends PureComponent {
|
||||
|
||||
const {file} = props;
|
||||
if (file && file.id) {
|
||||
ImageCacheManager.cache(file.name, Client4.getFileThumbnailUrl(file.id), emptyFunction);
|
||||
const headers = {Authorization: `Bearer ${Client4.getToken()}`};
|
||||
const preloadImages = [{uri: Client4.getFileThumbnailUrl(file.id), headers}];
|
||||
|
||||
if (isGif(file)) {
|
||||
ImageCacheManager.cache(file.name, Client4.getFileUrl(file.id), emptyFunction);
|
||||
preloadImages.push({uri: Client4.getFileUrl(file.id), headers});
|
||||
}
|
||||
|
||||
FastImage.preload(preloadImages);
|
||||
}
|
||||
|
||||
this.state = {
|
||||
@@ -186,13 +188,8 @@ const style = StyleSheet.create({
|
||||
smallImageOverlay: {
|
||||
...StyleSheet.absoluteFill,
|
||||
justifyContent: 'center',
|
||||
borderRadius: 4,
|
||||
},
|
||||
loaderContainer: {
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
},
|
||||
singleSmallImageWrapper: {
|
||||
height: SMALL_IMAGE_MAX_HEIGHT,
|
||||
|
||||
@@ -13,10 +13,8 @@ import {TABLET_WIDTH} from 'app/components/sidebars/drawer_layout';
|
||||
import {DeviceTypes} from 'app/constants';
|
||||
import mattermostManaged from 'app/mattermost_managed';
|
||||
import {isDocument, isGif, isVideo} from 'app/utils/file';
|
||||
import ImageCacheManager from 'app/utils/image_cache_manager';
|
||||
import {previewImageAtIndex} from 'app/utils/images';
|
||||
import {preventDoubleTap} from 'app/utils/tap';
|
||||
import {emptyFunction} from 'app/utils/general';
|
||||
|
||||
import FileAttachment from './file_attachment';
|
||||
|
||||
@@ -123,9 +121,9 @@ export default class FileAttachmentList extends PureComponent {
|
||||
if (file.localPath) {
|
||||
uri = file.localPath;
|
||||
} else if (isGif(file)) {
|
||||
uri = await ImageCacheManager.cache(file.name, Client4.getFileUrl(file.id), emptyFunction); // eslint-disable-line no-await-in-loop
|
||||
uri = Client4.getFileUrl(file.id);
|
||||
} else {
|
||||
uri = await ImageCacheManager.cache(file.name, Client4.getFilePreviewUrl(file.id), emptyFunction); // eslint-disable-line no-await-in-loop
|
||||
uri = Client4.getFilePreviewUrl(file.id);
|
||||
}
|
||||
|
||||
results.push({
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Platform, StyleSheet, Text, View} from 'react-native';
|
||||
import {Text, View} from 'react-native';
|
||||
import RNFetchBlob from 'rn-fetch-blob';
|
||||
import {AnimatedCircularProgress} from 'react-native-circular-progress';
|
||||
|
||||
@@ -17,6 +17,7 @@ import mattermostBucket from 'app/mattermost_bucket';
|
||||
import {buildFileUploadData, encodeHeaderURIStringToUTF8} from 'app/utils/file';
|
||||
import {emptyFunction} from 'app/utils/general';
|
||||
import ImageCacheManager from 'app/utils/image_cache_manager';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
|
||||
|
||||
export default class FileUploadItem extends PureComponent {
|
||||
static propTypes = {
|
||||
@@ -163,15 +164,14 @@ export default class FileUploadItem extends PureComponent {
|
||||
};
|
||||
|
||||
renderProgress = (fill) => {
|
||||
const styles = getStyleSheet(this.props.theme);
|
||||
const realFill = Number(fill.toFixed(0));
|
||||
|
||||
return (
|
||||
<View style={styles.progressContent}>
|
||||
<View style={styles.progressCirclePercentage}>
|
||||
<Text style={styles.progressText}>
|
||||
{`${realFill}%`}
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.progressText}>
|
||||
{`${realFill}%`}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -184,23 +184,28 @@ export default class FileUploadItem extends PureComponent {
|
||||
theme,
|
||||
} = this.props;
|
||||
const {progress} = this.state;
|
||||
const styles = getStyleSheet(theme);
|
||||
let filePreviewComponent;
|
||||
|
||||
if (this.isImageType()) {
|
||||
filePreviewComponent = (
|
||||
<FileAttachmentImage
|
||||
file={file}
|
||||
theme={theme}
|
||||
/>
|
||||
<View style={styles.filePreview}>
|
||||
<FileAttachmentImage
|
||||
file={file}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
filePreviewComponent = (
|
||||
<FileAttachmentIcon
|
||||
file={file}
|
||||
theme={theme}
|
||||
wrapperHeight={100}
|
||||
wrapperWidth={100}
|
||||
/>
|
||||
<View style={styles.filePreview}>
|
||||
<FileAttachmentIcon
|
||||
file={file}
|
||||
theme={theme}
|
||||
wrapperHeight={53}
|
||||
wrapperWidth={53}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -209,7 +214,7 @@ export default class FileUploadItem extends PureComponent {
|
||||
key={file.clientId}
|
||||
style={styles.preview}
|
||||
>
|
||||
<View style={styles.previewShadow}>
|
||||
<View style={styles.previewContainer}>
|
||||
{filePreviewComponent}
|
||||
{file.failed &&
|
||||
<FileUploadRetry
|
||||
@@ -220,13 +225,12 @@ export default class FileUploadItem extends PureComponent {
|
||||
{file.loading && !file.failed &&
|
||||
<View style={styles.progressCircleContent}>
|
||||
<AnimatedCircularProgress
|
||||
size={100}
|
||||
size={36}
|
||||
fill={progress}
|
||||
width={4}
|
||||
width={2}
|
||||
backgroundColor='rgba(255, 255, 255, 0.5)'
|
||||
tintColor='white'
|
||||
rotation={0}
|
||||
style={styles.progressCircle}
|
||||
>
|
||||
{this.renderProgress}
|
||||
</AnimatedCircularProgress>
|
||||
@@ -245,59 +249,35 @@ export default class FileUploadItem extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
|
||||
preview: {
|
||||
justifyContent: 'flex-end',
|
||||
height: 115,
|
||||
width: 115,
|
||||
paddingTop: 5,
|
||||
marginLeft: 12,
|
||||
},
|
||||
previewShadow: {
|
||||
height: 100,
|
||||
width: 100,
|
||||
elevation: 10,
|
||||
borderRadius: 5,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
backgroundColor: '#fff',
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 4,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
progressCircle: {
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
previewContainer: {
|
||||
height: 56,
|
||||
width: 56,
|
||||
borderRadius: 4,
|
||||
},
|
||||
progressCircleContent: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
height: 100,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
height: 56,
|
||||
width: 56,
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
width: 100,
|
||||
},
|
||||
progressCirclePercentage: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
progressContent: {
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
borderRadius: 4,
|
||||
},
|
||||
progressText: {
|
||||
color: 'white',
|
||||
fontSize: 18,
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
filePreview: {
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.15),
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
width: 56,
|
||||
height: 56,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import {Preferences} from 'mattermost-redux/constants';
|
||||
import ImageCacheManager from 'app/utils/image_cache_manager';
|
||||
import FileUploadItem from './file_upload_item';
|
||||
|
||||
@@ -18,7 +19,7 @@ describe('FileUploadItem', () => {
|
||||
file: {
|
||||
loading: false,
|
||||
},
|
||||
theme: {},
|
||||
theme: Preferences.THEMES.default,
|
||||
};
|
||||
|
||||
describe('downloadAndUploadFile', () => {
|
||||
|
||||
@@ -4,19 +4,25 @@
|
||||
import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Platform,
|
||||
InteractionManager,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import {makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
import * as Animatable from 'react-native-animatable';
|
||||
|
||||
import EventEmitter from 'mattermost-redux/utils/event_emitter';
|
||||
|
||||
import FormattedText from 'app/components/formatted_text';
|
||||
import {makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
|
||||
import FileUploadItem from './file_upload_item';
|
||||
|
||||
const showFiles = {opacity: 1, height: 68};
|
||||
const hideFiles = {opacity: 0, height: 0};
|
||||
const hideError = {height: 0};
|
||||
|
||||
export default class FileUploadPreview extends PureComponent {
|
||||
static propTypes = {
|
||||
channelId: PropTypes.string.isRequired,
|
||||
@@ -36,9 +42,17 @@ export default class FileUploadPreview extends PureComponent {
|
||||
showFileMaxWarning: false,
|
||||
};
|
||||
|
||||
errorRef = React.createRef();
|
||||
errorContainerRef = React.createRef();
|
||||
containerRef = React.createRef();
|
||||
|
||||
componentDidMount() {
|
||||
EventEmitter.on('fileMaxWarning', this.handleFileMaxWarning);
|
||||
EventEmitter.on('fileSizeWarning', this.handleFileSizeWarning);
|
||||
|
||||
if (this.props.files.length) {
|
||||
InteractionManager.runAfterInteractions(this.showOrHideContainer);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -46,6 +60,12 @@ export default class FileUploadPreview extends PureComponent {
|
||||
EventEmitter.off('fileSizeWarning', this.handleFileSizeWarning);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.containerRef.current && this.props.files.length !== prevProps.files.length) {
|
||||
InteractionManager.runAfterInteractions(this.showOrHideContainer);
|
||||
}
|
||||
}
|
||||
|
||||
buildFilePreviews = () => {
|
||||
return this.props.files.map((file) => {
|
||||
return (
|
||||
@@ -60,35 +80,77 @@ export default class FileUploadPreview extends PureComponent {
|
||||
});
|
||||
};
|
||||
|
||||
clearErrorsFromState = (delay) => {
|
||||
setTimeout(() => {
|
||||
this.setState({
|
||||
showFileMaxWarning: false,
|
||||
fileSizeWarning: null,
|
||||
});
|
||||
}, delay || 0);
|
||||
}
|
||||
|
||||
handleFileMaxWarning = () => {
|
||||
this.setState({showFileMaxWarning: true});
|
||||
setTimeout(() => {
|
||||
this.setState({showFileMaxWarning: false});
|
||||
}, 3000);
|
||||
if (this.errorRef.current) {
|
||||
this.makeErrorVisible(true, 20);
|
||||
setTimeout(() => {
|
||||
this.makeErrorVisible(false, 20);
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
handleFileSizeWarning = (message) => {
|
||||
this.setState({fileSizeWarning: message});
|
||||
if (this.errorRef.current) {
|
||||
if (message) {
|
||||
this.setState({fileSizeWarning: message.replace(': ', ':\n')});
|
||||
this.makeErrorVisible(true, 40);
|
||||
} else {
|
||||
this.makeErrorVisible(false, 20);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
makeErrorVisible = (visible, height) => {
|
||||
if (this.errorContainerRef.current) {
|
||||
if (visible) {
|
||||
this.errorContainerRef.current.transition(hideError, {height}, 200, 'ease-out');
|
||||
} else {
|
||||
this.errorContainerRef.current.transition({height}, hideError, 200, 'ease-in');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showOrHideContainer = () => {
|
||||
const {
|
||||
channelIsLoading,
|
||||
filesUploadingForCurrentChannel,
|
||||
files,
|
||||
} = this.props;
|
||||
const {fileSizeWarning, showFileMaxWarning} = this.state;
|
||||
const style = getStyleSheet(this.props.theme);
|
||||
if (
|
||||
!fileSizeWarning && !showFileMaxWarning &&
|
||||
(channelIsLoading || (!files.length && !filesUploadingForCurrentChannel))
|
||||
) {
|
||||
return null;
|
||||
|
||||
if ((channelIsLoading || (!files.length && !filesUploadingForCurrentChannel))) {
|
||||
this.containerRef.current.transition(showFiles, hideFiles, 150, 'ease-out');
|
||||
this.shown = false;
|
||||
} else if (files.length && !this.shown) {
|
||||
this.containerRef.current.transition(hideFiles, showFiles, 350, 'ease-in');
|
||||
this.shown = true;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {fileSizeWarning, showFileMaxWarning} = this.state;
|
||||
const {theme, files} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
const fileContainerStyle = {
|
||||
paddingBottom: files.length ? 5 : 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={style.previewContainer}>
|
||||
<View style={style.fileContainer}>
|
||||
<Animatable.View
|
||||
style={[style.fileContainer, fileContainerStyle]}
|
||||
ref={this.containerRef}
|
||||
isInteraction={true}
|
||||
>
|
||||
<ScrollView
|
||||
horizontal={true}
|
||||
style={style.scrollView}
|
||||
@@ -97,21 +159,32 @@ export default class FileUploadPreview extends PureComponent {
|
||||
>
|
||||
{this.buildFilePreviews()}
|
||||
</ScrollView>
|
||||
</View>
|
||||
<View style={style.errorContainer}>
|
||||
{showFileMaxWarning && (
|
||||
<FormattedText
|
||||
style={style.warning}
|
||||
id='mobile.file_upload.max_warning'
|
||||
defaultMessage='Uploads limited to 5 files maximum.'
|
||||
/>
|
||||
)}
|
||||
{Boolean(fileSizeWarning) &&
|
||||
<Text style={style.warning}>
|
||||
{fileSizeWarning}
|
||||
</Text>
|
||||
}
|
||||
</View>
|
||||
</Animatable.View>
|
||||
<Animatable.View
|
||||
ref={this.errorContainerRef}
|
||||
style={style.errorContainer}
|
||||
isInteraction={true}
|
||||
>
|
||||
<Animatable.View
|
||||
ref={this.errorRef}
|
||||
isInteraction={true}
|
||||
style={style.errorTextContainer}
|
||||
useNativeDriver={true}
|
||||
>
|
||||
{showFileMaxWarning && (
|
||||
<FormattedText
|
||||
style={style.warning}
|
||||
id='mobile.file_upload.max_warning'
|
||||
defaultMessage='Uploads limited to 5 files maximum.'
|
||||
/>
|
||||
)}
|
||||
{Boolean(fileSizeWarning) &&
|
||||
<Text style={style.warning}>
|
||||
{fileSizeWarning}
|
||||
</Text>
|
||||
}
|
||||
</Animatable.View>
|
||||
</Animatable.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -119,32 +192,37 @@ export default class FileUploadPreview extends PureComponent {
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
fileContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
errorContainer: {
|
||||
height: 18,
|
||||
},
|
||||
previewContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
fileContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
height: 0,
|
||||
},
|
||||
errorContainer: {
|
||||
height: 0,
|
||||
},
|
||||
errorTextContainer: {
|
||||
marginTop: Platform.select({
|
||||
ios: 4,
|
||||
android: 2,
|
||||
}),
|
||||
marginHorizontal: 12,
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
marginBottom: 10,
|
||||
},
|
||||
scrollViewContent: {
|
||||
alignItems: 'flex-end',
|
||||
marginLeft: 14,
|
||||
paddingRight: 12,
|
||||
},
|
||||
warning: {
|
||||
color: theme.errorTextColor,
|
||||
marginLeft: 14,
|
||||
marginBottom: Platform.select({
|
||||
android: 14,
|
||||
ios: 0,
|
||||
}),
|
||||
flex: 1,
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Platform} from 'react-native';
|
||||
import {View, Platform} from 'react-native';
|
||||
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
import {makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
|
||||
|
||||
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
|
||||
|
||||
@@ -25,19 +25,22 @@ export default class FileUploadRemove extends PureComponent {
|
||||
};
|
||||
|
||||
render() {
|
||||
const style = getStyleSheet(this.props.theme);
|
||||
const {theme} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
style={style.removeButtonWrapper}
|
||||
style={style.tappableContainer}
|
||||
onPress={this.handleOnPress}
|
||||
type={'opacity'}
|
||||
>
|
||||
<Icon
|
||||
name='close-circle'
|
||||
color={this.props.theme.centerChannelColor}
|
||||
size={20}
|
||||
style={style.removeButtonIcon}
|
||||
/>
|
||||
<View style={style.removeButton}>
|
||||
<Icon
|
||||
name='close-circle'
|
||||
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||
size={21}
|
||||
style={style.removeIcon}
|
||||
/>
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
@@ -45,25 +48,29 @@ export default class FileUploadRemove extends PureComponent {
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
removeButtonIcon: Platform.select({
|
||||
ios: {
|
||||
marginTop: 2,
|
||||
},
|
||||
}),
|
||||
removeButtonWrapper: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
tappableContainer: {
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
elevation: 11,
|
||||
top: 7,
|
||||
right: 7,
|
||||
width: 24,
|
||||
height: 24,
|
||||
top: -5,
|
||||
right: -10,
|
||||
width: 32,
|
||||
height: 32,
|
||||
},
|
||||
removeButton: {
|
||||
borderRadius: 12,
|
||||
alignSelf: 'center',
|
||||
marginTop: Platform.select({
|
||||
ios: 5.4,
|
||||
android: 4.75,
|
||||
}),
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
borderWidth: 2,
|
||||
borderColor: theme.centerChannelBg,
|
||||
},
|
||||
removeIcon: {
|
||||
position: 'relative',
|
||||
top: Platform.select({
|
||||
ios: 1,
|
||||
android: 0,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ export default class FileUploadRetry extends PureComponent {
|
||||
>
|
||||
<Icon
|
||||
name='md-refresh'
|
||||
size={50}
|
||||
size={25}
|
||||
color='#fff'
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
@@ -45,5 +45,6 @@ const style = StyleSheet.create({
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 4,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,19 +4,17 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Text} from 'react-native';
|
||||
import {injectIntl, intlShape} from 'react-intl';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import CustomPropTypes from 'app/constants/custom_prop_types';
|
||||
|
||||
class FormattedTime extends React.PureComponent {
|
||||
export default class FormattedTime extends React.PureComponent {
|
||||
static propTypes = {
|
||||
value: PropTypes.any.isRequired,
|
||||
timeZone: PropTypes.string,
|
||||
children: PropTypes.func,
|
||||
hour12: PropTypes.bool,
|
||||
style: CustomPropTypes.Style,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
getFormattedTime = () => {
|
||||
@@ -24,23 +22,14 @@ class FormattedTime extends React.PureComponent {
|
||||
value,
|
||||
timeZone,
|
||||
hour12,
|
||||
intl,
|
||||
} = this.props;
|
||||
|
||||
const timezoneProps = timeZone ? {timeZone} : {};
|
||||
const options = {
|
||||
...timezoneProps,
|
||||
hour12,
|
||||
};
|
||||
const formattedTime = intl.formatTime(value, options);
|
||||
|
||||
// `formatTime` returns unformatted date string on error like in the case of (react-intl) unsupported timezone.
|
||||
// Therefore, use react-intl by default or moment-timezone for unsupported timezone.
|
||||
if (formattedTime !== String(new Date(value))) {
|
||||
return formattedTime;
|
||||
let format = 'H:mm';
|
||||
if (hour12) {
|
||||
const localeFormat = moment.localeData().longDateFormat('LT');
|
||||
format = localeFormat?.includes('A') ? localeFormat : 'h:mm A';
|
||||
}
|
||||
|
||||
const format = hour12 ? 'hh:mm A' : 'HH:mm';
|
||||
if (timeZone) {
|
||||
return moment.tz(value, timeZone).format(format);
|
||||
}
|
||||
@@ -59,5 +48,3 @@ class FormattedTime extends React.PureComponent {
|
||||
return <Text style={style}>{formattedTime}</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(FormattedTime);
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
import React from 'react';
|
||||
import {render} from '@testing-library/react-native';
|
||||
import {IntlProvider} from 'react-intl';
|
||||
import IntlPolyfill from 'intl';
|
||||
import 'intl/locale-data/jsonp/es';
|
||||
import 'intl/locale-data/jsonp/ko';
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import FormattedTime from './formatted_time';
|
||||
|
||||
@@ -17,13 +15,11 @@ describe('FormattedTime', () => {
|
||||
hour12: true,
|
||||
};
|
||||
|
||||
setupTest();
|
||||
|
||||
it('should render correctly', () => {
|
||||
console.error = jest.fn();
|
||||
|
||||
let wrapper = renderWithIntl(
|
||||
<FormattedTime {...baseProps}/>
|
||||
<FormattedTime {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.baseElement).toMatchSnapshot();
|
||||
@@ -33,20 +29,22 @@ describe('FormattedTime', () => {
|
||||
<FormattedTime
|
||||
{...baseProps}
|
||||
hour12={false}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getByText('19:02')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should support localization', () => {
|
||||
moment.locale('es');
|
||||
let wrapper = renderWithIntl(
|
||||
<FormattedTime {...baseProps}/>,
|
||||
'es',
|
||||
);
|
||||
|
||||
expect(wrapper.getByText('7:02 p. m.')).toBeTruthy();
|
||||
expect(wrapper.getByText('7:02 PM')).toBeTruthy();
|
||||
|
||||
moment.locale('ko');
|
||||
wrapper = renderWithIntl(
|
||||
<FormattedTime {...baseProps}/>,
|
||||
'ko',
|
||||
@@ -66,6 +64,7 @@ describe('FormattedTime', () => {
|
||||
});
|
||||
|
||||
it('should fallback to default short format for unsupported locale of react-intl ', () => {
|
||||
moment.locale('es');
|
||||
let wrapper = renderWithIntl(
|
||||
<FormattedTime
|
||||
{...baseProps}
|
||||
@@ -74,24 +73,21 @@ describe('FormattedTime', () => {
|
||||
'es',
|
||||
);
|
||||
|
||||
expect(wrapper.getByText('08:47 AM')).toBeTruthy();
|
||||
expect(wrapper.getByText('8:47 AM')).toBeTruthy();
|
||||
|
||||
wrapper = renderWithIntl(
|
||||
<FormattedTime
|
||||
{...baseProps}
|
||||
timeZone='NZ-CHAT'
|
||||
hour12={false}
|
||||
/>
|
||||
/>,
|
||||
'es',
|
||||
);
|
||||
|
||||
expect(wrapper.getByText('08:47')).toBeTruthy();
|
||||
expect(wrapper.getByText('8:47')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
function renderWithIntl(component, locale = 'en') {
|
||||
return render(<IntlProvider locale={locale}>{component}</IntlProvider>);
|
||||
}
|
||||
|
||||
function setupTest() {
|
||||
global.Intl = IntlPolyfill;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import React from 'react';
|
||||
import {intlShape} from 'react-intl';
|
||||
import {
|
||||
Clipboard,
|
||||
Keyboard,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
@@ -57,7 +58,7 @@ export default class MarkdownCodeBlock extends React.PureComponent {
|
||||
},
|
||||
{
|
||||
language: languageDisplayName,
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
title = intl.formatMessage({
|
||||
@@ -66,7 +67,10 @@ export default class MarkdownCodeBlock extends React.PureComponent {
|
||||
});
|
||||
}
|
||||
|
||||
goToScreen(screen, title, passProps);
|
||||
Keyboard.dismiss();
|
||||
requestAnimationFrame(() => {
|
||||
goToScreen(screen, title, passProps);
|
||||
});
|
||||
});
|
||||
|
||||
handleLongPress = async () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('MarkdownEmoji', () => {
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<MarkdownEmoji {...baseProps}/>
|
||||
<MarkdownEmoji {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
|
||||
@@ -21,7 +21,6 @@ import CustomPropTypes from 'app/constants/custom_prop_types';
|
||||
import EphemeralStore from 'app/store/ephemeral_store';
|
||||
import mattermostManaged from 'app/mattermost_managed';
|
||||
import BottomSheet from 'app/utils/bottom_sheet';
|
||||
import ImageCacheManager from 'app/utils/image_cache_manager';
|
||||
import {previewImageAtIndex, calculateDimensions, isGifTooLarge} from 'app/utils/images';
|
||||
import {normalizeProtocol} from 'app/utils/url';
|
||||
|
||||
@@ -32,7 +31,7 @@ const ANDROID_MAX_WIDTH = 4096;
|
||||
const VIEWPORT_IMAGE_OFFSET = 66;
|
||||
const VIEWPORT_IMAGE_REPLY_OFFSET = 13;
|
||||
|
||||
export default class MarkdownImage extends React.Component {
|
||||
export default class MarkdownImage extends React.PureComponent {
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
deviceHeight: PropTypes.number.isRequired,
|
||||
@@ -65,7 +64,7 @@ export default class MarkdownImage extends React.Component {
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
|
||||
ImageCacheManager.cache(null, this.getSource(), this.setImageUrl);
|
||||
this.setImageUrl(this.getSource());
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props) {
|
||||
@@ -84,7 +83,7 @@ export default class MarkdownImage extends React.Component {
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.source !== prevProps.source) {
|
||||
// getSource also depends on serverURL, but that shouldn't change while this is mounted
|
||||
ImageCacheManager.cache(null, this.getSource(), this.setImageUrl);
|
||||
this.setImageUrl(this.getSource());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ import {DeepLinkTypes} from 'app/constants';
|
||||
import {getCurrentServerUrl} from 'app/init/credentials';
|
||||
import mattermostManaged from 'app/mattermost_managed';
|
||||
import BottomSheet from 'app/utils/bottom_sheet';
|
||||
import {alertErrorWithFallback} from 'app/utils/general';
|
||||
import {t} from 'app/utils/i18n';
|
||||
import {preventDoubleTap} from 'app/utils/tap';
|
||||
import {matchDeepLink, normalizeProtocol} from 'app/utils/url';
|
||||
import {alertErrorWithFallback} from 'app/utils/general';
|
||||
import {t} from 'app/utils/i18n';
|
||||
|
||||
import Config from 'assets/config';
|
||||
|
||||
@@ -58,16 +58,7 @@ export default class MarkdownLink extends PureComponent {
|
||||
|
||||
if (match) {
|
||||
if (match.type === DeepLinkTypes.CHANNEL) {
|
||||
const error = await this.props.actions.handleSelectChannelByName(match.channelName, match.teamName);
|
||||
|
||||
if (error) {
|
||||
const linkFailedMessage = {
|
||||
id: t('mobile.server_link.private_channel.error'),
|
||||
defaultMessage: 'You are not a member of this private channel.',
|
||||
};
|
||||
|
||||
alertErrorWithFallback(this.context.intl, {}, linkFailedMessage);
|
||||
}
|
||||
this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, this.errorBadChannel);
|
||||
} else if (match.type === DeepLinkTypes.PERMALINK) {
|
||||
onPermalinkPress(match.postId, match.teamName);
|
||||
}
|
||||
@@ -92,6 +83,16 @@ export default class MarkdownLink extends PureComponent {
|
||||
}
|
||||
});
|
||||
|
||||
errorBadChannel = () => {
|
||||
const {intl} = this.context;
|
||||
const message = {
|
||||
id: t('mobile.server_link.unreachable_channel.error'),
|
||||
defaultMessage: 'This link belongs to a deleted channel or to a channel to which you do not have access.',
|
||||
};
|
||||
|
||||
alertErrorWithFallback(intl, {}, message);
|
||||
};
|
||||
|
||||
parseLinkLiteral = (literal) => {
|
||||
let nextLiteral = literal;
|
||||
|
||||
|
||||
@@ -30,13 +30,14 @@ exports[`MarkdownTable should match snapshot 1`] = `
|
||||
"width": "100%",
|
||||
},
|
||||
Object {
|
||||
"width": 480,
|
||||
"width": 672,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<
|
||||
className="row"
|
||||
isFirstRow={true}
|
||||
>
|
||||
<
|
||||
className="col"
|
||||
@@ -53,6 +54,12 @@ exports[`MarkdownTable should match snapshot 1`] = `
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
</>
|
||||
<
|
||||
className="row"
|
||||
@@ -72,6 +79,12 @@ exports[`MarkdownTable should match snapshot 1`] = `
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
</>
|
||||
<
|
||||
className="row"
|
||||
@@ -91,6 +104,12 @@ exports[`MarkdownTable should match snapshot 1`] = `
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
</>
|
||||
<
|
||||
className="row"
|
||||
@@ -110,6 +129,62 @@ exports[`MarkdownTable should match snapshot 1`] = `
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
</>
|
||||
<
|
||||
className="row"
|
||||
>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
</>
|
||||
<
|
||||
className="row"
|
||||
>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
</>
|
||||
<
|
||||
className="row"
|
||||
@@ -130,6 +205,12 @@ exports[`MarkdownTable should match snapshot 1`] = `
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
<
|
||||
className="col"
|
||||
/>
|
||||
</>
|
||||
</View>
|
||||
</ScrollView>
|
||||
@@ -195,7 +276,7 @@ exports[`MarkdownTable should match snapshot 1`] = `
|
||||
"paddingTop": 8,
|
||||
},
|
||||
Object {
|
||||
"width": 480,
|
||||
"width": 672,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -40,14 +40,16 @@ export default class MarkdownTable extends React.PureComponent {
|
||||
this.state = {
|
||||
containerWidth: 0,
|
||||
contentHeight: 0,
|
||||
contentWidth: 0,
|
||||
maxPreviewColumns: MAX_PREVIEW_COLUMNS,
|
||||
cellWidth: 0,
|
||||
rowsSliced: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
Dimensions.addEventListener('change', this.setMaxPreviewColumns);
|
||||
|
||||
const window = Dimensions.get('window');
|
||||
this.setMaxPreviewColumns({window});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -60,13 +62,10 @@ export default class MarkdownTable extends React.PureComponent {
|
||||
}
|
||||
|
||||
getTableWidth = (isFullView = false) => {
|
||||
let columns = this.props.numColumns;
|
||||
const maxPreviewColumns = this.state.maxPreviewColumns || MAX_PREVIEW_COLUMNS;
|
||||
const columns = Math.min(this.props.numColumns, maxPreviewColumns);
|
||||
|
||||
if (columns > MAX_PREVIEW_COLUMNS) {
|
||||
columns = MAX_PREVIEW_COLUMNS;
|
||||
}
|
||||
|
||||
return isFullView || columns === 1 ? columns * CELL_MAX_WIDTH : columns * CELL_MIN_WIDTH;
|
||||
return (isFullView || columns === 1) ? columns * CELL_MAX_WIDTH : columns * CELL_MIN_WIDTH;
|
||||
};
|
||||
|
||||
handlePress = preventDoubleTap(() => {
|
||||
@@ -98,30 +97,7 @@ export default class MarkdownTable extends React.PureComponent {
|
||||
};
|
||||
|
||||
renderPreviewRows = (isFullView = false) => {
|
||||
const {maxPreviewColumns} = this.state;
|
||||
const tableStyle = this.getTableStyle(isFullView);
|
||||
|
||||
// Add an extra prop to the last row of the table so that it knows not to render a bottom border
|
||||
// since the container should be rendering that
|
||||
const rows = React.Children.toArray(this.props.children).slice(0, maxPreviewColumns).map((row) => {
|
||||
const children = React.Children.toArray(row.props.children).slice(0, maxPreviewColumns);
|
||||
return {
|
||||
...row,
|
||||
props: {
|
||||
...row.props,
|
||||
children,
|
||||
},
|
||||
};
|
||||
});
|
||||
rows[rows.length - 1] = React.cloneElement(rows[rows.length - 1], {
|
||||
isLastRow: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={tableStyle}>
|
||||
{rows}
|
||||
</View>
|
||||
);
|
||||
return this.renderRows(isFullView, true);
|
||||
}
|
||||
|
||||
shouldRenderAsFlex = (isFullView = false) => {
|
||||
@@ -165,12 +141,33 @@ export default class MarkdownTable extends React.PureComponent {
|
||||
return tableStyle;
|
||||
}
|
||||
|
||||
renderRows = (isFullView = false) => {
|
||||
renderRows = (isFullView = false, isPreview = false) => {
|
||||
const tableStyle = this.getTableStyle(isFullView);
|
||||
|
||||
let rows = React.Children.toArray(this.props.children);
|
||||
if (isPreview) {
|
||||
const {maxPreviewColumns} = this.state;
|
||||
const prevRowLength = rows.length;
|
||||
const prevColLength = React.Children.toArray(rows[0].props.children).length;
|
||||
|
||||
rows = rows.slice(0, maxPreviewColumns).map((row) => {
|
||||
const children = React.Children.toArray(row.props.children).slice(0, maxPreviewColumns);
|
||||
return {
|
||||
...row,
|
||||
props: {
|
||||
...row.props,
|
||||
children,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const rowsSliced = prevRowLength > rows.length;
|
||||
const colsSliced = prevColLength > React.Children.toArray(rows[0].props.children).length;
|
||||
this.setState({rowsSliced, colsSliced});
|
||||
}
|
||||
|
||||
// Add an extra prop to the last row of the table so that it knows not to render a bottom border
|
||||
// since the container should be rendering that
|
||||
const rows = React.Children.toArray(this.props.children);
|
||||
rows[rows.length - 1] = React.cloneElement(rows[rows.length - 1], {
|
||||
isLastRow: true,
|
||||
});
|
||||
@@ -188,43 +185,53 @@ export default class MarkdownTable extends React.PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
const style = getStyleSheet(this.props.theme);
|
||||
let moreRight = null;
|
||||
const {containerWidth, contentHeight} = this.state;
|
||||
const {theme} = this.props;
|
||||
const style = getStyleSheet(theme);
|
||||
const tableWidth = this.getTableWidth();
|
||||
const renderAsFlex = this.shouldRenderAsFlex();
|
||||
|
||||
let leftOffset;
|
||||
if (renderAsFlex || tableWidth > this.state.containerWidth) {
|
||||
leftOffset = this.state.containerWidth - 20;
|
||||
if (renderAsFlex || tableWidth > containerWidth) {
|
||||
leftOffset = containerWidth - 20;
|
||||
} else {
|
||||
leftOffset = tableWidth - 20;
|
||||
}
|
||||
let expandButtonOffset = leftOffset;
|
||||
if (Platform.OS === 'android') {
|
||||
expandButtonOffset -= 10;
|
||||
}
|
||||
|
||||
// Renders when table width exceeds the container, or if the columns exceed maximum allowed for previews
|
||||
if ((this.state.containerWidth && tableWidth > this.state.containerWidth && !renderAsFlex) ||
|
||||
// Renders when the columns were sliced, or the table width exceeds the container,
|
||||
// or if the columns exceed maximum allowed for previews
|
||||
let moreRight = null;
|
||||
if (this.state.colsSliced ||
|
||||
(containerWidth && tableWidth > containerWidth && !renderAsFlex) ||
|
||||
(this.props.numColumns > MAX_PREVIEW_COLUMNS)) {
|
||||
moreRight = (
|
||||
<LinearGradient
|
||||
colors={[
|
||||
changeOpacity(this.props.theme.centerChannelColor, 0.0),
|
||||
changeOpacity(this.props.theme.centerChannelColor, 0.1),
|
||||
changeOpacity(theme.centerChannelColor, 0.0),
|
||||
changeOpacity(theme.centerChannelColor, 0.1),
|
||||
]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 0}}
|
||||
style={[style.moreRight, {height: this.state.contentHeight, left: leftOffset}]}
|
||||
style={[style.moreRight, {height: contentHeight, left: leftOffset}]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let moreBelow = null;
|
||||
if (this.state.contentHeight > MAX_HEIGHT) {
|
||||
if (this.state.rowsSliced) {
|
||||
const width = renderAsFlex ? '100%' : Math.min(tableWidth, containerWidth);
|
||||
|
||||
moreBelow = (
|
||||
<LinearGradient
|
||||
colors={[
|
||||
changeOpacity(this.props.theme.centerChannelColor, 0.0),
|
||||
changeOpacity(this.props.theme.centerChannelColor, 0.1),
|
||||
changeOpacity(theme.centerChannelColor, 0.0),
|
||||
changeOpacity(theme.centerChannelColor, 0.1),
|
||||
]}
|
||||
style={[style.moreBelow, renderAsFlex ? style.fullWidth : {width: tableWidth}]}
|
||||
style={[style.moreBelow, {width}]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -233,7 +240,7 @@ export default class MarkdownTable extends React.PureComponent {
|
||||
<TouchableWithFeedback
|
||||
type={'opacity'}
|
||||
onPress={this.handlePress}
|
||||
style={[style.expandButton, {left: leftOffset}]}
|
||||
style={[style.expandButton, {left: expandButtonOffset}]}
|
||||
>
|
||||
<View style={[style.iconContainer, {width: this.getTableWidth()}]}>
|
||||
<View style={style.iconButton}>
|
||||
@@ -314,9 +321,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
displayFlex: {
|
||||
flex: 1,
|
||||
},
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
table: {
|
||||
width: '100%',
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
@@ -326,12 +330,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
|
||||
paddingRight: 10,
|
||||
},
|
||||
moreBelow: {
|
||||
bottom: 30,
|
||||
bottom: 34,
|
||||
height: 20,
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
moreRight: {
|
||||
maxHeight: MAX_HEIGHT,
|
||||
|
||||
@@ -32,15 +32,27 @@ describe('MarkdownTable', () => {
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<MarkdownTable {...baseProps}/>
|
||||
<MarkdownTable {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should call setMaxPreviewColumns on mount', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<MarkdownTable {...baseProps}/>,
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
const setMaxPreviewColumns = jest.spyOn(instance, 'setMaxPreviewColumns');
|
||||
|
||||
instance.componentDidMount();
|
||||
expect(setMaxPreviewColumns).toHaveBeenCalled();
|
||||
expect(instance.state.maxPreviewColumns).toBeDefined();
|
||||
});
|
||||
|
||||
test('should slice rows and columns', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<MarkdownTable {...baseProps}/>
|
||||
<MarkdownTable {...baseProps}/>,
|
||||
);
|
||||
|
||||
const {maxPreviewColumns} = wrapper.state();
|
||||
@@ -52,4 +64,34 @@ describe('MarkdownTable', () => {
|
||||
expect(wrapper.find('.row')).toHaveLength(newMaxPreviewColumns);
|
||||
expect(wrapper.find('.col')).toHaveLength(Math.pow(newMaxPreviewColumns, 2));
|
||||
});
|
||||
|
||||
test('should add the isFirstRow prop to the first row', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<MarkdownTable {...baseProps}/>,
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
const fullRows = instance.renderRows();
|
||||
const previewRows = instance.renderPreviewRows();
|
||||
|
||||
[fullRows, previewRows].forEach((rows) => {
|
||||
const firstRows = rows.props.children.filter((child) => child.props.isFirstRow);
|
||||
expect(firstRows.length).toEqual(1);
|
||||
expect(firstRows[0]).toEqual(rows.props.children[0]);
|
||||
});
|
||||
});
|
||||
|
||||
test('should add the isLastRow prop to the last row', () => {
|
||||
const wrapper = shallowWithIntl(
|
||||
<MarkdownTable {...baseProps}/>,
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
const fullRows = instance.renderRows();
|
||||
const previewRows = instance.renderPreviewRows();
|
||||
|
||||
[fullRows, previewRows].forEach((rows) => {
|
||||
const lastRows = rows.props.children.filter((child) => child.props.isLastRow);
|
||||
expect(lastRows.length).toEqual(1);
|
||||
expect(lastRows[0]).toEqual(rows.props.children[rows.props.children.length - 1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function ActionButtonText({message, style}) {
|
||||
literal={match[0]}
|
||||
emojiName={match[1]}
|
||||
textStyle={style}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
text = text.substring(match[0].length);
|
||||
continue;
|
||||
@@ -48,7 +48,7 @@ export default function ActionButtonText({message, style}) {
|
||||
literal={match[0]}
|
||||
emojiName={emoticonName}
|
||||
textStyle={style}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
text = text.substring(match[0].length);
|
||||
continue;
|
||||
@@ -65,7 +65,7 @@ export default function ActionButtonText({message, style}) {
|
||||
style={style}
|
||||
>
|
||||
{match[0]}
|
||||
</Text>
|
||||
</Text>,
|
||||
);
|
||||
text = text.substring(match[0].length);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export default class AttachmentActions extends PureComponent {
|
||||
options={action.options}
|
||||
postId={postId}
|
||||
disabled={action.disabled}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
case 'button':
|
||||
@@ -55,7 +55,7 @@ export default class AttachmentActions extends PureComponent {
|
||||
name={action.name}
|
||||
postId={postId}
|
||||
disabled={action.disabled}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export default class AttachmentFields extends PureComponent {
|
||||
style={style.field}
|
||||
>
|
||||
{fieldInfos}
|
||||
</View>
|
||||
</View>,
|
||||
);
|
||||
fieldInfos = [];
|
||||
rowPos = 0;
|
||||
@@ -89,7 +89,7 @@ export default class AttachmentFields extends PureComponent {
|
||||
onPermalinkPress={onPermalinkPress}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>,
|
||||
);
|
||||
|
||||
rowPos += 1;
|
||||
@@ -103,7 +103,7 @@ export default class AttachmentFields extends PureComponent {
|
||||
style={style.table}
|
||||
>
|
||||
{fieldInfos}
|
||||
</View>
|
||||
</View>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {Image, View} from 'react-native';
|
||||
import ProgressiveImage from 'app/components/progressive_image';
|
||||
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
|
||||
import {isGifTooLarge, previewImageAtIndex, calculateDimensions} from 'app/utils/images';
|
||||
import ImageCacheManager from 'app/utils/image_cache_manager';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
|
||||
const VIEWPORT_IMAGE_OFFSET = 100;
|
||||
@@ -42,13 +41,13 @@ export default class AttachmentImage extends PureComponent {
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
ImageCacheManager.cache(null, imageUrl, this.setImageUrl);
|
||||
this.setImageUrl(imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.imageUrl && (prevProps.imageUrl !== this.props.imageUrl)) {
|
||||
ImageCacheManager.cache(null, this.props.imageUrl, this.setImageUrl);
|
||||
this.setImageUrl(this.props.imageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import ImageCacheManager from 'app/utils/image_cache_manager';
|
||||
import {Image} from 'react-native';
|
||||
import {shallow} from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
const originalCacheFn = ImageCacheManager.cache;
|
||||
const originalGetSizeFn = Image.getSize;
|
||||
|
||||
import Preferences from 'mattermost-redux/constants/preferences';
|
||||
@@ -24,57 +22,35 @@ describe('AttachmentImage', () => {
|
||||
|
||||
afterEach(() => {
|
||||
Image.getSize = originalGetSizeFn;
|
||||
ImageCacheManager.cache = originalCacheFn;
|
||||
});
|
||||
|
||||
test('it matches snapshot', () => {
|
||||
const cacheFn = jest.fn((_, url, callback) => {
|
||||
callback(url);
|
||||
});
|
||||
ImageCacheManager.cache = cacheFn;
|
||||
|
||||
const wrapper = shallow(<AttachmentImage {...baseProps}/>);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('it sets state based on props', () => {
|
||||
const cacheFn = jest.fn((_, url, callback) => {
|
||||
callback(url);
|
||||
});
|
||||
ImageCacheManager.cache = cacheFn;
|
||||
|
||||
const wrapper = shallow(<AttachmentImage {...baseProps}/>);
|
||||
|
||||
const state = wrapper.state();
|
||||
expect(state.hasImage).toBe(true);
|
||||
expect(state.imageUri).toBe('https://images.com/image.png');
|
||||
expect(state.originalWidth).toBe(32);
|
||||
expect(cacheFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('it does not render image if no imageUrl is provided', () => {
|
||||
const cacheFn = jest.fn((_, url, callback) => {
|
||||
callback(url);
|
||||
});
|
||||
ImageCacheManager.cache = cacheFn;
|
||||
|
||||
const props = {...baseProps, imageUrl: null, imageMetadata: null};
|
||||
const wrapper = shallow(<AttachmentImage {...props}/>);
|
||||
|
||||
const state = wrapper.state();
|
||||
expect(state.hasImage).toBe(false);
|
||||
expect(state.imageUri).toBe(null);
|
||||
expect(cacheFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('it calls Image.getSize if metadata is not present', () => {
|
||||
const cacheFn = jest.fn((_, url, callback) => {
|
||||
callback(url);
|
||||
});
|
||||
const getSizeFn = jest.fn((_, callback) => {
|
||||
callback(64, 64);
|
||||
});
|
||||
ImageCacheManager.cache = cacheFn;
|
||||
Image.getSize = getSizeFn;
|
||||
|
||||
const props = {...baseProps, imageMetadata: null};
|
||||
@@ -84,16 +60,10 @@ describe('AttachmentImage', () => {
|
||||
expect(state.hasImage).toBe(true);
|
||||
expect(state.imageUri).toBe('https://images.com/image.png');
|
||||
expect(state.originalWidth).toBe(64);
|
||||
expect(cacheFn).toHaveBeenCalled();
|
||||
expect(getSizeFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('it updates image when imageUrl prop changes', () => {
|
||||
const cacheFn = jest.fn((_, url, callback) => {
|
||||
callback(url);
|
||||
});
|
||||
ImageCacheManager.cache = cacheFn;
|
||||
|
||||
const wrapper = shallow(<AttachmentImage {...baseProps}/>);
|
||||
|
||||
wrapper.setProps({
|
||||
@@ -108,15 +78,9 @@ describe('AttachmentImage', () => {
|
||||
expect(state.hasImage).toBe(true);
|
||||
expect(state.imageUri).toBe('https://someothersite.com/picture.png');
|
||||
expect(state.originalWidth).toBe(96);
|
||||
expect(cacheFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('it does not update image when an unrelated prop changes', () => {
|
||||
const cacheFn = jest.fn((_, url, callback) => {
|
||||
callback(url);
|
||||
});
|
||||
ImageCacheManager.cache = cacheFn;
|
||||
|
||||
const wrapper = shallow(<AttachmentImage {...baseProps}/>);
|
||||
|
||||
wrapper.setProps({
|
||||
@@ -127,6 +91,5 @@ describe('AttachmentImage', () => {
|
||||
expect(state.hasImage).toBe(true);
|
||||
expect(state.imageUri).toBe('https://images.com/image.png');
|
||||
expect(state.originalWidth).toBe(32);
|
||||
expect(cacheFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ export default class AttachmentText extends PureComponent {
|
||||
metadata: PropTypes.object,
|
||||
onPermalinkPress: PropTypes.func,
|
||||
textStyles: PropTypes.object.isRequired,
|
||||
theme: PropTypes.object,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
||||
@@ -68,8 +69,9 @@ export default class AttachmentText extends PureComponent {
|
||||
hasThumbnail,
|
||||
metadata,
|
||||
onPermalinkPress,
|
||||
value,
|
||||
textStyles,
|
||||
theme,
|
||||
value,
|
||||
} = this.props;
|
||||
const {collapsed, isLongText, maxHeight} = this.state;
|
||||
|
||||
@@ -103,6 +105,7 @@ export default class AttachmentText extends PureComponent {
|
||||
<ShowMoreButton
|
||||
onPress={this.toggleCollapseState}
|
||||
showMore={collapsed}
|
||||
theme={theme}
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
|
||||
@@ -55,7 +55,7 @@ export default class MessageAttachments extends PureComponent {
|
||||
postId={postId}
|
||||
theme={theme}
|
||||
textStyles={textStyles}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ export default class MessageAttachment extends PureComponent {
|
||||
onPermalinkPress={onPermalinkPress}
|
||||
textStyles={textStyles}
|
||||
value={attachment.text}
|
||||
theme={theme}
|
||||
/>
|
||||
<AttachmentFields
|
||||
baseTextStyle={baseTextStyle}
|
||||
|
||||
@@ -90,7 +90,7 @@ export default class NetworkIndicator extends PureComponent {
|
||||
|
||||
// Attempt to connect when this component mounts
|
||||
// if the websocket is already connected it does not try and connect again
|
||||
this.connect();
|
||||
this.connect(true);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@@ -175,14 +175,14 @@ export default class NetworkIndicator extends PureComponent {
|
||||
this.backgroundColor, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
}
|
||||
},
|
||||
),
|
||||
Animated.timing(
|
||||
this.top, {
|
||||
toValue: (this.getNavBarHeight() - HEIGHT),
|
||||
duration: 300,
|
||||
delay: 500,
|
||||
}
|
||||
},
|
||||
),
|
||||
]).start(() => {
|
||||
this.backgroundColor.setValue(0);
|
||||
@@ -317,7 +317,7 @@ export default class NetworkIndicator extends PureComponent {
|
||||
}),
|
||||
onPress: actions.logout,
|
||||
}],
|
||||
{cancelable: false}
|
||||
{cancelable: false},
|
||||
);
|
||||
closeWebSocket(true);
|
||||
});
|
||||
@@ -340,7 +340,7 @@ export default class NetworkIndicator extends PureComponent {
|
||||
this.top, {
|
||||
toValue: this.getNavBarHeight(),
|
||||
duration: 300,
|
||||
}
|
||||
},
|
||||
).start(() => {
|
||||
this.props.actions.setCurrentUserStatusOffline();
|
||||
});
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PasteableTextInput should render pasteable text input 1`] = `
|
||||
<ConditionalWrapper
|
||||
conditional={true}
|
||||
wrapper={[Function]}
|
||||
<TextInput
|
||||
allowFontScaling={true}
|
||||
onPaste={[MockFunction]}
|
||||
rejectResponderTermination={true}
|
||||
underlineColorAndroid="transparent"
|
||||
>
|
||||
<TextInput
|
||||
allowFontScaling={true}
|
||||
onContentSizeChange={[Function]}
|
||||
onPaste={[MockFunction]}
|
||||
rejectResponderTermination={true}
|
||||
underlineColorAndroid="transparent"
|
||||
>
|
||||
My Text
|
||||
</TextInput>
|
||||
</ConditionalWrapper>
|
||||
My Text
|
||||
</TextInput>
|
||||
`;
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('CustomTextInput', () => {
|
||||
const onPaste = jest.fn();
|
||||
const text = 'My Text';
|
||||
const component = shallow(
|
||||
<CustomTextInput onPaste={onPaste}>{text}</CustomTextInput>
|
||||
<CustomTextInput onPaste={onPaste}>{text}</CustomTextInput>,
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Animated, Easing, NativeEventEmitter, NativeModules, Platform, TextInput} from 'react-native';
|
||||
import {TextInput, NativeEventEmitter, NativeModules} from 'react-native';
|
||||
import CustomTextInput from './custom_text_input';
|
||||
import {ConditionalWrapper} from 'app/components/conditionalWrapper';
|
||||
import {ViewTypes} from 'app/constants';
|
||||
|
||||
const {OnPasteEventManager} = NativeModules;
|
||||
const OnPasteEventEmitter = new NativeEventEmitter(OnPasteEventManager);
|
||||
@@ -18,10 +16,6 @@ export class PasteableTextInput extends React.PureComponent {
|
||||
forwardRef: PropTypes.any,
|
||||
}
|
||||
|
||||
state = {
|
||||
inputHeight: new Animated.Value(33),
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.subscription = OnPasteEventEmitter.addListener('onPaste', this.onPaste);
|
||||
}
|
||||
@@ -37,41 +31,14 @@ export class PasteableTextInput extends React.PureComponent {
|
||||
return onPaste?.(null, event);
|
||||
}
|
||||
|
||||
animateHeight = (event) => {
|
||||
if (Platform.OS === 'ios') {
|
||||
const {height} = event.nativeEvent.contentSize;
|
||||
const {style} = this.props;
|
||||
const {inputHeight} = this.state;
|
||||
const newHeight = Math.min(style.maxHeight, height + ViewTypes.INPUT_VERTICAL_PADDING);
|
||||
const transitionSpeed = height === ViewTypes.INPUT_LINE_HEIGHT ? 500 : 1;
|
||||
|
||||
Animated.timing(inputHeight, {
|
||||
toValue: newHeight,
|
||||
duration: transitionSpeed,
|
||||
easing: Easing.inOut(Easing.sin),
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
wrapperLayout = (children) => {
|
||||
const {inputHeight} = this.state;
|
||||
return <Animated.View style={{flex: 1, height: inputHeight}}>{children}</Animated.View>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {forwardRef, ...props} = this.props;
|
||||
|
||||
return (
|
||||
<ConditionalWrapper
|
||||
conditional={Platform.OS === 'ios'}
|
||||
wrapper={this.wrapperLayout}
|
||||
>
|
||||
<CustomTextInput
|
||||
{...props}
|
||||
ref={forwardRef}
|
||||
onContentSizeChange={this.animateHeight}
|
||||
/>
|
||||
</ConditionalWrapper>
|
||||
<CustomTextInput
|
||||
{...props}
|
||||
ref={forwardRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('PasteableTextInput', () => {
|
||||
const onPaste = jest.fn();
|
||||
const text = 'My Text';
|
||||
const component = shallow(
|
||||
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>
|
||||
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>,
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
@@ -23,7 +23,7 @@ describe('PasteableTextInput', () => {
|
||||
const event = {someData: 'data'};
|
||||
const text = 'My Text';
|
||||
shallow(
|
||||
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>
|
||||
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>,
|
||||
);
|
||||
nativeEventEmitter.emit('onPaste', event);
|
||||
expect(onPaste).toHaveBeenCalledWith(null, event);
|
||||
@@ -34,7 +34,7 @@ describe('PasteableTextInput', () => {
|
||||
const onPaste = jest.fn();
|
||||
const text = 'My Text';
|
||||
const component = shallow(
|
||||
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>
|
||||
<PasteableTextInput onPaste={onPaste}>{text}</PasteableTextInput>,
|
||||
);
|
||||
|
||||
component.instance().subscription.remove = mockRemove;
|
||||
|
||||
@@ -320,7 +320,7 @@ export default class Post extends PureComponent {
|
||||
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
|
||||
cancelTouchOnPanning={true}
|
||||
>
|
||||
<React.Fragment>
|
||||
<>
|
||||
<PostPreHeader
|
||||
isConsecutive={mergeMessage}
|
||||
isFlagged={isFlagged}
|
||||
@@ -355,7 +355,7 @@ export default class Post extends PureComponent {
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</React.Fragment>
|
||||
</>
|
||||
</TouchableWithFeedback>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -71,7 +71,7 @@ export default class PostAddChannelMember extends React.PureComponent {
|
||||
{
|
||||
username: currentUser.username,
|
||||
addedUsername: usernames[index],
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
actions.sendAddToChannelEphemeralPost(currentUser, usernames[index], message, post.channel_id, post.root_id);
|
||||
|
||||
@@ -259,7 +259,7 @@ exports[`PostAttachmentOpenGraph should match state and snapshot, on renderImage
|
||||
"marginTop": 5,
|
||||
},
|
||||
Object {
|
||||
"height": 112.56666666666666,
|
||||
"height": 69.83261802575107,
|
||||
"width": 307,
|
||||
},
|
||||
]
|
||||
@@ -277,7 +277,7 @@ exports[`PostAttachmentOpenGraph should match state and snapshot, on renderImage
|
||||
"borderRadius": 3,
|
||||
},
|
||||
Object {
|
||||
"height": 112.56666666666666,
|
||||
"height": 69.83261802575107,
|
||||
"width": 307,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -14,7 +14,6 @@ import {TABLET_WIDTH} from 'app/components/sidebars/drawer_layout';
|
||||
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
|
||||
import {DeviceTypes} from 'app/constants';
|
||||
|
||||
import ImageCacheManager from 'app/utils/image_cache_manager';
|
||||
import {previewImageAtIndex, calculateDimensions} from 'app/utils/images';
|
||||
import {getNearestPoint} from 'app/utils/opengraph';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
|
||||
@@ -47,6 +46,10 @@ export default class PostAttachmentOpenGraph extends PureComponent {
|
||||
this.mounted = true;
|
||||
|
||||
this.fetchData(this.props.link, this.props.openGraphData);
|
||||
|
||||
if (this.state.openGraphImageUrl) {
|
||||
this.getImageSize(this.state.openGraphImageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
@@ -56,7 +59,9 @@ export default class PostAttachmentOpenGraph extends PureComponent {
|
||||
}
|
||||
|
||||
if (this.props.openGraphData !== nextProps.openGraphData) {
|
||||
this.setState(this.getBestImageUrl(nextProps.openGraphData));
|
||||
this.setState(this.getBestImageUrl(nextProps.openGraphData), () => {
|
||||
this.getImageSize(this.state.openGraphImageUrl);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,10 +115,6 @@ export default class PostAttachmentOpenGraph extends PureComponent {
|
||||
dimensions = calculateDimensions(ogImage.height, ogImage.width, this.getViewPostWidth());
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
ImageCacheManager.cache(this.getFilename(imageUrl), imageUrl, this.getImageSize);
|
||||
}
|
||||
|
||||
return {
|
||||
hasImage: true,
|
||||
...dimensions,
|
||||
@@ -143,7 +144,7 @@ export default class PostAttachmentOpenGraph extends PureComponent {
|
||||
}
|
||||
|
||||
if (!ogImage) {
|
||||
ogImage = openGraphData.images.find((i) => i.url === openGraphImageUrl || i.secure_url === openGraphImageUrl);
|
||||
ogImage = openGraphData?.images?.find((i) => i.url === openGraphImageUrl || i.secure_url === openGraphImageUrl);
|
||||
}
|
||||
|
||||
// Fallback when the ogImage does not have dimensions but there is a metaImage defined
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('PostAttachmentOpenGraph', () => {
|
||||
|
||||
test('should match snapshot, without image and description', () => {
|
||||
const wrapper = shallow(
|
||||
<PostAttachmentOpenGraph {...baseProps}/>
|
||||
<PostAttachmentOpenGraph {...baseProps}/>,
|
||||
);
|
||||
|
||||
// should return null
|
||||
@@ -58,7 +58,7 @@ describe('PostAttachmentOpenGraph', () => {
|
||||
<PostAttachmentOpenGraph
|
||||
{...baseProps}
|
||||
openGraphData={newOpenGraphData}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
@@ -68,14 +68,14 @@ describe('PostAttachmentOpenGraph', () => {
|
||||
<PostAttachmentOpenGraph
|
||||
{...baseProps}
|
||||
openGraphData={{}}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should match state and snapshot, on renderImage', () => {
|
||||
const wrapper = shallow(
|
||||
<PostAttachmentOpenGraph {...baseProps}/>
|
||||
<PostAttachmentOpenGraph {...baseProps}/>,
|
||||
);
|
||||
|
||||
// should return null
|
||||
@@ -99,7 +99,7 @@ describe('PostAttachmentOpenGraph', () => {
|
||||
<PostAttachmentOpenGraph
|
||||
{...baseProps}
|
||||
openGraphData={openGraphData}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
|
||||
// should return null
|
||||
@@ -112,7 +112,7 @@ describe('PostAttachmentOpenGraph', () => {
|
||||
|
||||
test('should match result on getFilename', () => {
|
||||
const wrapper = shallow(
|
||||
<PostAttachmentOpenGraph {...baseProps}/>
|
||||
<PostAttachmentOpenGraph {...baseProps}/>,
|
||||
);
|
||||
|
||||
const testCases = [
|
||||
|
||||
@@ -431,6 +431,7 @@ export default class PostBody extends PureComponent {
|
||||
<ShowMoreButton
|
||||
highlight={highlight}
|
||||
onPress={this.openLongPost}
|
||||
theme={theme}
|
||||
/>
|
||||
}
|
||||
{this.renderPostAdditionalContent(blockStyles, messageStyle, textStyles)}
|
||||
|
||||
@@ -92,7 +92,6 @@ describe('PostBody', () => {
|
||||
event.nativeEvent.layout.height = wrapper.state('maxHeight') - 1;
|
||||
instance.measurePost(event);
|
||||
expect(wrapper.state('isLongPost')).toEqual(false);
|
||||
|
||||
event.nativeEvent.layout.height = wrapper.state('maxHeight') + 1;
|
||||
instance.measurePost(event);
|
||||
expect(wrapper.state('isLongPost')).toEqual(true);
|
||||
|
||||
@@ -23,7 +23,6 @@ import TouchableWithFeedback from 'app/components/touchable_with_feedback';
|
||||
|
||||
import {DeviceTypes} from 'app/constants';
|
||||
import CustomPropTypes from 'app/constants/custom_prop_types';
|
||||
import ImageCacheManager from 'app/utils/image_cache_manager';
|
||||
import {previewImageAtIndex, calculateDimensions} from 'app/utils/images';
|
||||
import {getYouTubeVideoId, isImageLink, isYoutubeLink} from 'app/utils/url';
|
||||
|
||||
@@ -126,7 +125,6 @@ export default class PostBodyAdditionalContent extends PureComponent {
|
||||
} else if (isYoutubeLink(link)) {
|
||||
const videoId = getYouTubeVideoId(link);
|
||||
imageUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
|
||||
ImageCacheManager.cache(null, `https://i.ytimg.com/vi/${videoId}/default.jpg`, () => true);
|
||||
} else {
|
||||
const {data} = await this.props.actions.getRedirectLocation(link);
|
||||
|
||||
@@ -141,7 +139,6 @@ export default class PostBodyAdditionalContent extends PureComponent {
|
||||
} else if (isYoutubeLink(shortenedLink)) {
|
||||
const videoId = getYouTubeVideoId(shortenedLink);
|
||||
imageUrl = `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
|
||||
ImageCacheManager.cache(null, `https://i.ytimg.com/vi/${videoId}/default.jpg`, () => true);
|
||||
}
|
||||
if (this.mounted) {
|
||||
this.setState({shortenedLink});
|
||||
@@ -150,7 +147,7 @@ export default class PostBodyAdditionalContent extends PureComponent {
|
||||
}
|
||||
|
||||
if (imageUrl) {
|
||||
ImageCacheManager.cache(null, imageUrl, this.getImageSize);
|
||||
this.getImageSize(imageUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -51,7 +51,7 @@ exports[`PostHeader should match snapshot when just a base post 1`] = `
|
||||
John Smith
|
||||
</Text>
|
||||
</TouchableWithFeedbackIOS>
|
||||
<InjectIntl(FormattedTime)
|
||||
<FormattedTime
|
||||
hour12={true}
|
||||
style={
|
||||
Object {
|
||||
@@ -123,7 +123,7 @@ exports[`PostHeader should match snapshot when just a base post in landscape mod
|
||||
John Smith
|
||||
</Text>
|
||||
</TouchableWithFeedbackIOS>
|
||||
<InjectIntl(FormattedTime)
|
||||
<FormattedTime
|
||||
hour12={true}
|
||||
style={
|
||||
Object {
|
||||
@@ -232,7 +232,7 @@ exports[`PostHeader should match snapshot when post is autoresponder 1`] = `
|
||||
}
|
||||
}
|
||||
/>
|
||||
<InjectIntl(FormattedTime)
|
||||
<FormattedTime
|
||||
hour12={true}
|
||||
style={
|
||||
Object {
|
||||
@@ -297,7 +297,7 @@ exports[`PostHeader should match snapshot when post is from system message 1`] =
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<InjectIntl(FormattedTime)
|
||||
<FormattedTime
|
||||
hour12={true}
|
||||
style={
|
||||
Object {
|
||||
@@ -367,7 +367,7 @@ exports[`PostHeader should match snapshot when post is same thread, so dont disp
|
||||
John Smith
|
||||
</Text>
|
||||
</TouchableWithFeedbackIOS>
|
||||
<InjectIntl(FormattedTime)
|
||||
<FormattedTime
|
||||
hour12={true}
|
||||
style={
|
||||
Object {
|
||||
@@ -494,7 +494,7 @@ exports[`PostHeader should match snapshot when post isBot and shouldRenderReplyB
|
||||
}
|
||||
}
|
||||
/>
|
||||
<InjectIntl(FormattedTime)
|
||||
<FormattedTime
|
||||
hour12={true}
|
||||
style={
|
||||
Object {
|
||||
@@ -646,7 +646,7 @@ exports[`PostHeader should match snapshot when post isBot and shouldRenderReplyB
|
||||
}
|
||||
}
|
||||
/>
|
||||
<InjectIntl(FormattedTime)
|
||||
<FormattedTime
|
||||
hour12={true}
|
||||
style={
|
||||
Object {
|
||||
@@ -758,7 +758,7 @@ exports[`PostHeader should match snapshot when post renders Commented On for new
|
||||
John Smith
|
||||
</Text>
|
||||
</TouchableWithFeedbackIOS>
|
||||
<InjectIntl(FormattedTime)
|
||||
<FormattedTime
|
||||
hour12={true}
|
||||
style={
|
||||
Object {
|
||||
@@ -845,7 +845,7 @@ exports[`PostHeader should match snapshot when post should display reply button
|
||||
John Smith
|
||||
</Text>
|
||||
</TouchableWithFeedbackIOS>
|
||||
<InjectIntl(FormattedTime)
|
||||
<FormattedTime
|
||||
hour12={true}
|
||||
style={
|
||||
Object {
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('PostHeader', () => {
|
||||
|
||||
test('should match snapshot when just a base post', () => {
|
||||
const wrapper = shallow(
|
||||
<PostHeader {...baseProps}/>
|
||||
<PostHeader {...baseProps}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.find('#ReplyIcon').exists()).toEqual(false);
|
||||
@@ -55,7 +55,7 @@ describe('PostHeader', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostHeader {...props}/>
|
||||
<PostHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
@@ -67,7 +67,7 @@ describe('PostHeader', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostHeader {...props}/>
|
||||
<PostHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
@@ -79,7 +79,7 @@ describe('PostHeader', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostHeader {...props}/>
|
||||
<PostHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.find('#ReplyIcon').exists()).toEqual(false);
|
||||
@@ -92,7 +92,7 @@ describe('PostHeader', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostHeader {...props}/>
|
||||
<PostHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.find('#ReplyIcon').exists()).toEqual(false);
|
||||
@@ -108,7 +108,7 @@ describe('PostHeader', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostHeader {...props}/>
|
||||
<PostHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
@@ -123,7 +123,7 @@ describe('PostHeader', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostHeader {...props}/>
|
||||
<PostHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
@@ -135,7 +135,7 @@ describe('PostHeader', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostHeader {...props}/>
|
||||
<PostHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.find('#ReplyIcon').exists()).toEqual(false);
|
||||
@@ -150,7 +150,7 @@ describe('PostHeader', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostHeader {...props}/>
|
||||
<PostHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('PostPreHeader', () => {
|
||||
|
||||
test('should match snapshot when not flagged or pinned post', () => {
|
||||
const wrapper = shallow(
|
||||
<PostPreHeader {...baseProps}/>
|
||||
<PostPreHeader {...baseProps}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.type()).toBeNull();
|
||||
@@ -35,7 +35,7 @@ describe('PostPreHeader', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostPreHeader {...props}/>
|
||||
<PostPreHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.type()).toBeNull();
|
||||
@@ -49,7 +49,7 @@ describe('PostPreHeader', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostPreHeader {...props}/>
|
||||
<PostPreHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.type()).toBeNull();
|
||||
@@ -62,7 +62,7 @@ describe('PostPreHeader', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostPreHeader {...props}/>
|
||||
<PostPreHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.find('#flagIcon').exists()).toEqual(true);
|
||||
@@ -77,7 +77,7 @@ describe('PostPreHeader', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostPreHeader {...props}/>
|
||||
<PostPreHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.find('#flagIcon').exists()).toEqual(false);
|
||||
@@ -93,7 +93,7 @@ describe('PostPreHeader', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostPreHeader {...props}/>
|
||||
<PostPreHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.find('#flagIcon').exists()).toEqual(true);
|
||||
@@ -110,7 +110,7 @@ describe('PostPreHeader', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostPreHeader {...props}/>
|
||||
<PostPreHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.find('#flagIcon').exists()).toEqual(true);
|
||||
@@ -127,7 +127,7 @@ describe('PostPreHeader', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostPreHeader {...props}/>
|
||||
<PostPreHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.find('#flagIcon').exists()).toEqual(false);
|
||||
@@ -145,7 +145,7 @@ describe('PostPreHeader', () => {
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostPreHeader {...props}/>
|
||||
<PostPreHeader {...props}/>,
|
||||
);
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
expect(wrapper.type()).toBeNull();
|
||||
|
||||
@@ -20,10 +20,11 @@ exports[`PostList setting channel deep link 1`] = `
|
||||
"channel-id",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
horizontal={false}
|
||||
initialNumToRender={15}
|
||||
initialNumToRender={7}
|
||||
inverted={true}
|
||||
keyExtractor={[Function]}
|
||||
keyboardDismissMode="interactive"
|
||||
@@ -34,12 +35,13 @@ exports[`PostList setting channel deep link 1`] = `
|
||||
"minIndexForVisible": 0,
|
||||
}
|
||||
}
|
||||
maxToRenderPerBatch={16}
|
||||
maxToRenderPerBatch={10}
|
||||
numColumns={1}
|
||||
onContentSizeChange={[Function]}
|
||||
onEndReachedThreshold={2}
|
||||
onLayout={[Function]}
|
||||
onScroll={[Function]}
|
||||
onScrollBeginDrag={[Function]}
|
||||
onScrollToIndexFailed={[Function]}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
@@ -56,8 +58,13 @@ exports[`PostList setting channel deep link 1`] = `
|
||||
removeClippedSubviews={true}
|
||||
renderItem={[Function]}
|
||||
scrollEventThrottle={60}
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={21}
|
||||
windowSize={50}
|
||||
/>
|
||||
`;
|
||||
|
||||
@@ -81,10 +88,11 @@ exports[`PostList setting permalink deep link 1`] = `
|
||||
"channel-id",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
horizontal={false}
|
||||
initialNumToRender={15}
|
||||
initialNumToRender={7}
|
||||
inverted={true}
|
||||
keyExtractor={[Function]}
|
||||
keyboardDismissMode="interactive"
|
||||
@@ -95,12 +103,13 @@ exports[`PostList setting permalink deep link 1`] = `
|
||||
"minIndexForVisible": 0,
|
||||
}
|
||||
}
|
||||
maxToRenderPerBatch={16}
|
||||
maxToRenderPerBatch={10}
|
||||
numColumns={1}
|
||||
onContentSizeChange={[Function]}
|
||||
onEndReachedThreshold={2}
|
||||
onLayout={[Function]}
|
||||
onScroll={[Function]}
|
||||
onScrollBeginDrag={[Function]}
|
||||
onScrollToIndexFailed={[Function]}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
@@ -117,8 +126,13 @@ exports[`PostList setting permalink deep link 1`] = `
|
||||
removeClippedSubviews={true}
|
||||
renderItem={[Function]}
|
||||
scrollEventThrottle={60}
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={21}
|
||||
windowSize={50}
|
||||
/>
|
||||
`;
|
||||
|
||||
@@ -142,10 +156,11 @@ exports[`PostList should match snapshot 1`] = `
|
||||
"channel-id",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
horizontal={false}
|
||||
initialNumToRender={15}
|
||||
initialNumToRender={7}
|
||||
inverted={true}
|
||||
keyExtractor={[Function]}
|
||||
keyboardDismissMode="interactive"
|
||||
@@ -156,12 +171,13 @@ exports[`PostList should match snapshot 1`] = `
|
||||
"minIndexForVisible": 0,
|
||||
}
|
||||
}
|
||||
maxToRenderPerBatch={16}
|
||||
maxToRenderPerBatch={10}
|
||||
numColumns={1}
|
||||
onContentSizeChange={[Function]}
|
||||
onEndReachedThreshold={2}
|
||||
onLayout={[Function]}
|
||||
onScroll={[Function]}
|
||||
onScrollBeginDrag={[Function]}
|
||||
onScrollToIndexFailed={[Function]}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
@@ -178,7 +194,12 @@ exports[`PostList should match snapshot 1`] = `
|
||||
removeClippedSubviews={true}
|
||||
renderItem={[Function]}
|
||||
scrollEventThrottle={60}
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={21}
|
||||
windowSize={50}
|
||||
/>
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import React, {memo} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
View,
|
||||
@@ -71,4 +71,4 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
};
|
||||
});
|
||||
|
||||
export default NewMessagesDivider;
|
||||
export default memo(NewMessagesDivider);
|
||||
|
||||
@@ -18,11 +18,14 @@ import {changeOpacity} from 'app/utils/theme';
|
||||
import {matchDeepLink} from 'app/utils/url';
|
||||
import telemetry from 'app/telemetry';
|
||||
import {showModalOverCurrentContext} from 'app/actions/navigation';
|
||||
import {alertErrorWithFallback} from 'app/utils/general';
|
||||
import {t} from 'app/utils/i18n';
|
||||
|
||||
import DateHeader from './date_header';
|
||||
import NewMessagesDivider from './new_messages_divider';
|
||||
|
||||
const INITIAL_BATCH_TO_RENDER = 15;
|
||||
const INITIAL_BATCH_TO_RENDER = 7;
|
||||
const LOADING_POSTS_HEIGHT = 53;
|
||||
const SCROLL_UP_MULTIPLIER = 3.5;
|
||||
const SCROLL_POSITION_CONFIG = {
|
||||
|
||||
@@ -53,6 +56,7 @@ export default class PostList extends PureComponent {
|
||||
isSearchResult: PropTypes.bool,
|
||||
lastPostIndex: PropTypes.number.isRequired,
|
||||
lastViewedAt: PropTypes.number, // Used by container // eslint-disable-line no-unused-prop-types
|
||||
loadMorePostsVisible: PropTypes.bool,
|
||||
onLoadMoreUp: PropTypes.func,
|
||||
onHashtagPress: PropTypes.func,
|
||||
onPermalinkPress: PropTypes.func,
|
||||
@@ -89,6 +93,7 @@ export default class PostList extends PureComponent {
|
||||
this.hasDoneInitialScroll = false;
|
||||
this.contentOffsetY = 0;
|
||||
this.shouldScrollToBottom = false;
|
||||
this.cancelScrollToIndex = false;
|
||||
this.makeExtraData = makeExtraData();
|
||||
this.flatListRef = React.createRef();
|
||||
|
||||
@@ -127,7 +132,7 @@ export default class PostList extends PureComponent {
|
||||
this.shouldScrollToBottom = false;
|
||||
}
|
||||
|
||||
if (!this.hasDoneInitialScroll && this.props.initialIndex > 0 && this.state.contentHeight) {
|
||||
if (!this.hasDoneInitialScroll && this.props.initialIndex > 0 && this.state.contentHeight > LOADING_POSTS_HEIGHT) {
|
||||
this.scrollToInitialIndexIfNeeded(this.props.initialIndex);
|
||||
}
|
||||
|
||||
@@ -136,7 +141,7 @@ export default class PostList extends PureComponent {
|
||||
this.props.postIds.length &&
|
||||
this.state.contentHeight &&
|
||||
this.state.contentHeight < this.state.postListHeight &&
|
||||
this.props.extraData
|
||||
!this.props.extraData
|
||||
) {
|
||||
this.loadToFillContent();
|
||||
}
|
||||
@@ -145,13 +150,20 @@ export default class PostList extends PureComponent {
|
||||
componentWillUnmount() {
|
||||
EventEmitter.off('scroll-to-bottom', this.handleSetScrollToBottom);
|
||||
|
||||
if (this.animationFrameIndexFailed) {
|
||||
cancelAnimationFrame(this.animationFrameIndexFailed);
|
||||
}
|
||||
this.resetPostList();
|
||||
}
|
||||
|
||||
if (this.animationFrameInitialIndex) {
|
||||
cancelAnimationFrame(this.animationFrameInitialIndex);
|
||||
}
|
||||
flatListScrollToIndex = (index) => {
|
||||
this.animationFrameInitialIndex = requestAnimationFrame(() => {
|
||||
if (!this.cancelScrollToIndex) {
|
||||
this.flatListRef.current.scrollToIndex({
|
||||
animated: false,
|
||||
index,
|
||||
viewOffset: 0,
|
||||
viewPosition: 1, // 0 is at bottom
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getItemCount = () => {
|
||||
@@ -165,12 +177,14 @@ export default class PostList extends PureComponent {
|
||||
};
|
||||
|
||||
handleContentSizeChange = (contentWidth, contentHeight) => {
|
||||
this.setState({contentHeight}, () => {
|
||||
if (this.state.postListHeight && contentHeight < this.state.postListHeight && this.props.extraData) {
|
||||
// We still have less than 1 screen of posts loaded with more to get, so load more
|
||||
this.props.onLoadMoreUp();
|
||||
}
|
||||
});
|
||||
if (this.state.contentHeight !== contentHeight) {
|
||||
this.setState({contentHeight}, () => {
|
||||
if (this.state.postListHeight && contentHeight < this.state.postListHeight && !this.props.extraData && contentHeight > LOADING_POSTS_HEIGHT) {
|
||||
// We still have less than 1 screen of posts loaded with more to get, so load more
|
||||
this.props.onLoadMoreUp();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleDeepLink = (url) => {
|
||||
@@ -180,7 +194,7 @@ export default class PostList extends PureComponent {
|
||||
|
||||
if (match) {
|
||||
if (match.type === DeepLinkTypes.CHANNEL) {
|
||||
this.props.actions.handleSelectChannelByName(match.channelName, match.teamName);
|
||||
this.props.actions.handleSelectChannelByName(match.channelName, match.teamName, this.errorBadChannel);
|
||||
} else if (match.type === DeepLinkTypes.PERMALINK) {
|
||||
this.handlePermalinkPress(match.postId, match.teamName);
|
||||
}
|
||||
@@ -201,7 +215,29 @@ export default class PostList extends PureComponent {
|
||||
|
||||
handleLayout = (event) => {
|
||||
const {height} = event.nativeEvent.layout;
|
||||
this.setState({postListHeight: height});
|
||||
if (this.state.postListHeight !== height) {
|
||||
this.setState({postListHeight: height});
|
||||
}
|
||||
};
|
||||
|
||||
errorBadTeam = () => {
|
||||
const {intl} = this.context;
|
||||
const message = {
|
||||
id: t('mobile.server_link.unreachable_team.error'),
|
||||
defaultMessage: 'This link belongs to a deleted team or to a team to which you do not have access.',
|
||||
};
|
||||
|
||||
alertErrorWithFallback(intl, {}, message);
|
||||
};
|
||||
|
||||
errorBadChannel = () => {
|
||||
const {intl} = this.context;
|
||||
const message = {
|
||||
id: t('mobile.server_link.unreachable_channel.error'),
|
||||
defaultMessage: 'This link belongs to a deleted channel or to a channel to which you do not have access.',
|
||||
};
|
||||
|
||||
alertErrorWithFallback(intl, {}, message);
|
||||
};
|
||||
|
||||
handlePermalinkPress = (postId, teamName) => {
|
||||
@@ -211,7 +247,7 @@ export default class PostList extends PureComponent {
|
||||
if (onPermalinkPress) {
|
||||
onPermalinkPress(postId, true);
|
||||
} else {
|
||||
actions.loadChannelsByTeamName(teamName);
|
||||
actions.loadChannelsByTeamName(teamName, this.errorBadTeam);
|
||||
this.showPermalinkView(postId);
|
||||
}
|
||||
};
|
||||
@@ -250,6 +286,10 @@ export default class PostList extends PureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
handleScrollBeginDrag = () => {
|
||||
this.cancelScrollToIndex = true;
|
||||
}
|
||||
|
||||
handleScrollToIndexFailed = () => {
|
||||
this.animationFrameIndexFailed = requestAnimationFrame(() => {
|
||||
if (this.props.initialIndex > 0 && this.state.contentHeight > 0) {
|
||||
@@ -269,7 +309,7 @@ export default class PostList extends PureComponent {
|
||||
};
|
||||
|
||||
loadToFillContent = () => {
|
||||
setTimeout(() => {
|
||||
this.fillContentTimer = setTimeout(() => {
|
||||
this.handleContentSizeChange(0, this.state.contentHeight);
|
||||
});
|
||||
};
|
||||
@@ -358,26 +398,41 @@ export default class PostList extends PureComponent {
|
||||
};
|
||||
|
||||
scrollToBottom = () => {
|
||||
setTimeout(() => {
|
||||
this.scrollToBottomTimer = setTimeout(() => {
|
||||
if (this.flatListRef.current) {
|
||||
this.flatListRef.current.scrollToOffset({offset: 0, animated: true});
|
||||
}
|
||||
}, 250);
|
||||
};
|
||||
|
||||
flatListScrollToIndex = (index) => {
|
||||
this.flatListRef.current.scrollToIndex({
|
||||
animated: false,
|
||||
index,
|
||||
viewOffset: 0,
|
||||
viewPosition: 1, // 0 is at bottom
|
||||
});
|
||||
}
|
||||
|
||||
resetPostList = () => {
|
||||
this.contentOffsetY = 0;
|
||||
this.hasDoneInitialScroll = false;
|
||||
this.setState({contentHeight: 0});
|
||||
this.cancelScrollToIndex = false;
|
||||
|
||||
if (this.animationFrameIndexFailed) {
|
||||
cancelAnimationFrame(this.animationFrameIndexFailed);
|
||||
}
|
||||
|
||||
if (this.animationFrameInitialIndex) {
|
||||
cancelAnimationFrame(this.animationFrameInitialIndex);
|
||||
}
|
||||
|
||||
if (this.fillContentTimer) {
|
||||
clearTimeout(this.fillContentTimer);
|
||||
}
|
||||
|
||||
if (this.scrollToBottomTimer) {
|
||||
clearTimeout(this.scrollToBottomTimer);
|
||||
}
|
||||
|
||||
if (this.scrollToInitialTimer) {
|
||||
clearTimeout(this.scrollToInitialTimer);
|
||||
}
|
||||
|
||||
if (this.state.contentHeight !== 0) {
|
||||
this.setState({contentHeight: 0});
|
||||
}
|
||||
}
|
||||
|
||||
scrollToIndex = (index) => {
|
||||
@@ -394,14 +449,14 @@ export default class PostList extends PureComponent {
|
||||
this.hasDoneInitialScroll = true;
|
||||
this.scrollToIndex(index);
|
||||
} else if (count < 3) {
|
||||
setTimeout(() => {
|
||||
this.scrollToInitialTimer = setTimeout(() => {
|
||||
this.scrollToInitialIndexIfNeeded(index, count + 1);
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
showPermalinkView = (postId) => {
|
||||
showPermalinkView = (postId, error = '') => {
|
||||
const {actions} = this.props;
|
||||
|
||||
actions.selectFocusedPostId(postId);
|
||||
@@ -411,6 +466,7 @@ export default class PostList extends PureComponent {
|
||||
const passProps = {
|
||||
isPermalink: true,
|
||||
onClose: this.handleClosePermalink,
|
||||
error,
|
||||
};
|
||||
const options = {
|
||||
layout: {
|
||||
@@ -426,7 +482,9 @@ export default class PostList extends PureComponent {
|
||||
render() {
|
||||
const {
|
||||
channelId,
|
||||
extraData,
|
||||
highlightPostId,
|
||||
loadMorePostsVisible,
|
||||
postIds,
|
||||
refreshing,
|
||||
scrollViewNativeID,
|
||||
@@ -447,9 +505,10 @@ export default class PostList extends PureComponent {
|
||||
<FlatList
|
||||
key={`recyclerFor-${channelId}-${hasPostsKey}`}
|
||||
ref={this.flatListRef}
|
||||
style={{flex: 1}}
|
||||
contentContainerStyle={styles.postListContent}
|
||||
data={postIds}
|
||||
extraData={this.makeExtraData(channelId, highlightPostId, this.props.extraData)}
|
||||
extraData={this.makeExtraData(channelId, highlightPostId, extraData, loadMorePostsVisible)}
|
||||
initialNumToRender={INITIAL_BATCH_TO_RENDER}
|
||||
inverted={true}
|
||||
keyboardDismissMode={'interactive'}
|
||||
@@ -457,16 +516,17 @@ export default class PostList extends PureComponent {
|
||||
keyExtractor={this.keyExtractor}
|
||||
ListFooterComponent={this.props.renderFooter}
|
||||
maintainVisibleContentPosition={SCROLL_POSITION_CONFIG}
|
||||
maxToRenderPerBatch={INITIAL_BATCH_TO_RENDER + 1}
|
||||
onContentSizeChange={this.handleContentSizeChange}
|
||||
onLayout={this.handleLayout}
|
||||
onScroll={this.handleScroll}
|
||||
onScrollBeginDrag={this.handleScrollBeginDrag}
|
||||
onScrollToIndexFailed={this.handleScrollToIndexFailed}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={this.renderItem}
|
||||
scrollEventThrottle={60}
|
||||
refreshControl={refreshControl}
|
||||
nativeID={scrollViewNativeID}
|
||||
windowSize={50}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ jest.mock('react-intl');
|
||||
|
||||
describe('PostList', () => {
|
||||
const serverURL = 'https://server-url.fake';
|
||||
const deeplinkRoot = 'mattermost-beta://server-url.fake';
|
||||
const deeplinkRoot = 'mattermost://server-url.fake';
|
||||
|
||||
const baseProps = {
|
||||
actions: {
|
||||
@@ -40,7 +40,7 @@ describe('PostList', () => {
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<PostList {...baseProps}/>
|
||||
<PostList {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
@@ -49,7 +49,7 @@ describe('PostList', () => {
|
||||
test('setting permalink deep link', () => {
|
||||
const showModalOverCurrentContext = jest.spyOn(NavigationActions, 'showModalOverCurrentContext');
|
||||
const wrapper = shallow(
|
||||
<PostList {...baseProps}/>
|
||||
<PostList {...baseProps}/>,
|
||||
);
|
||||
|
||||
wrapper.setProps({deepLinkURL: deepLinks.permalink});
|
||||
@@ -61,7 +61,7 @@ describe('PostList', () => {
|
||||
|
||||
test('setting channel deep link', () => {
|
||||
const wrapper = shallow(
|
||||
<PostList {...baseProps}/>
|
||||
<PostList {...baseProps}/>,
|
||||
);
|
||||
|
||||
wrapper.setProps({deepLinkURL: deepLinks.channel});
|
||||
@@ -74,7 +74,7 @@ describe('PostList', () => {
|
||||
jest.spyOn(global, 'requestAnimationFrame').mockImplementation((cb) => cb());
|
||||
|
||||
const wrapper = shallow(
|
||||
<PostList {...baseProps}/>
|
||||
<PostList {...baseProps}/>,
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
const flatListScrollToIndex = jest.spyOn(instance, 'flatListScrollToIndex');
|
||||
@@ -103,13 +103,13 @@ describe('PostList', () => {
|
||||
|
||||
test('should load more posts if available space on the screen', () => {
|
||||
const wrapper = shallow(
|
||||
<PostList {...baseProps}/>
|
||||
<PostList {...baseProps}/>,
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
instance.loadToFillContent = jest.fn();
|
||||
|
||||
wrapper.setProps({
|
||||
extraData: true,
|
||||
extraData: false,
|
||||
});
|
||||
expect(instance.loadToFillContent).toHaveBeenCalledTimes(0);
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('PostList', () => {
|
||||
expect(instance.loadToFillContent).toHaveBeenCalledTimes(1);
|
||||
|
||||
wrapper.setProps({
|
||||
extraData: false,
|
||||
extraData: true,
|
||||
});
|
||||
|
||||
expect(instance.loadToFillContent).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -14,7 +14,7 @@ exports[`PostTextBox should match, full snapshot 1`] = `
|
||||
"borderTopWidth": 1,
|
||||
"flexDirection": "row",
|
||||
"justifyContent": "center",
|
||||
"paddingVertical": 4,
|
||||
"paddingBottom": 2,
|
||||
},
|
||||
null,
|
||||
]
|
||||
@@ -24,6 +24,7 @@ exports[`PostTextBox should match, full snapshot 1`] = `
|
||||
contentContainerStyle={
|
||||
Object {
|
||||
"alignItems": "stretch",
|
||||
"paddingTop": 7,
|
||||
}
|
||||
}
|
||||
disableScrollViewPanResponder={true}
|
||||
@@ -36,11 +37,8 @@ exports[`PostTextBox should match, full snapshot 1`] = `
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"backgroundColor": "#ffffff",
|
||||
"flex": 1,
|
||||
"flexDirection": "column",
|
||||
"marginLeft": 10,
|
||||
"marginRight": 10,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -53,6 +51,7 @@ exports[`PostTextBox should match, full snapshot 1`] = `
|
||||
keyboardType="default"
|
||||
multiline={true}
|
||||
onChangeText={[Function]}
|
||||
onContentSizeChange={null}
|
||||
onEndEditing={[Function]}
|
||||
onPaste={[Function]}
|
||||
onSelectionChange={[Function]}
|
||||
@@ -61,100 +60,250 @@ exports[`PostTextBox should match, full snapshot 1`] = `
|
||||
style={
|
||||
Object {
|
||||
"color": "#3d3c40",
|
||||
"fontSize": 14,
|
||||
"fontSize": 15,
|
||||
"lineHeight": 20,
|
||||
"maxHeight": 150,
|
||||
"paddingBottom": 8,
|
||||
"paddingLeft": 12,
|
||||
"paddingRight": 12,
|
||||
"paddingTop": 8,
|
||||
"minHeight": 30,
|
||||
"paddingBottom": 6,
|
||||
"paddingHorizontal": 12,
|
||||
"paddingTop": 6,
|
||||
}
|
||||
}
|
||||
underlineColorAndroid="transparent"
|
||||
value=""
|
||||
/>
|
||||
<Connect(FileUploadPreview)
|
||||
files={Array []}
|
||||
rootId=""
|
||||
/>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"display": "flex",
|
||||
"flexDirection": "row",
|
||||
"justifyContent": "space-between",
|
||||
}
|
||||
}
|
||||
>
|
||||
<React.Fragment>
|
||||
<Connect(FileUploadPreview)
|
||||
files={Array []}
|
||||
rootId=""
|
||||
/>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"display": "flex",
|
||||
"flexDirection": "row",
|
||||
"justifyContent": "space-between",
|
||||
"paddingBottom": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.2}
|
||||
disabled={false}
|
||||
onPress={[Function]}
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"paddingLeft": 10,
|
||||
"paddingRight": 10,
|
||||
"display": "flex",
|
||||
"flexDirection": "row",
|
||||
"height": 44,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
allowFontScaling={false}
|
||||
color="#3d3c40"
|
||||
name="at"
|
||||
size={20}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.2}
|
||||
disabled={false}
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"paddingLeft": 10,
|
||||
"paddingRight": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Image
|
||||
source={
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.2}
|
||||
disabled={false}
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"testUri": "../../../dist/assets/images/icons/slash-forward-box.png",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"padding": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
allowFontScaling={false}
|
||||
color="rgba(61,60,64,0.64)"
|
||||
name="at"
|
||||
size={24}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.2}
|
||||
disabled={false}
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"padding": 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Image
|
||||
source={
|
||||
Object {
|
||||
"height": 20,
|
||||
"opacity": 1,
|
||||
"tintColor": "#3d3c40",
|
||||
"width": 20,
|
||||
},
|
||||
]
|
||||
"testUri": "../../../dist/assets/images/icons/slash-forward-box.png",
|
||||
}
|
||||
}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"height": 24,
|
||||
"opacity": 1,
|
||||
"tintColor": "rgba(61,60,64,0.64)",
|
||||
"width": 24,
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<FileUploadButton
|
||||
blurTextBox={[Function]}
|
||||
browseFileTypes="public.item"
|
||||
buttonContainerStyle={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"padding": 10,
|
||||
}
|
||||
}
|
||||
canBrowseFiles={true}
|
||||
canBrowsePhotoLibrary={true}
|
||||
canBrowseVideoLibrary={true}
|
||||
canTakePhoto={true}
|
||||
canTakeVideo={true}
|
||||
extraOptions={null}
|
||||
fileCount={0}
|
||||
maxFileCount={5}
|
||||
maxFileSize={1024}
|
||||
onShowFileMaxWarning={[Function]}
|
||||
onShowFileSizeWarning={[Function]}
|
||||
theme={
|
||||
Object {
|
||||
"awayIndicator": "#ffbc42",
|
||||
"buttonBg": "#166de0",
|
||||
"buttonColor": "#ffffff",
|
||||
"centerChannelBg": "#ffffff",
|
||||
"centerChannelColor": "#3d3c40",
|
||||
"codeTheme": "github",
|
||||
"dndIndicator": "#f74343",
|
||||
"errorTextColor": "#fd5960",
|
||||
"linkColor": "#2389d7",
|
||||
"mentionBg": "#ffffff",
|
||||
"mentionBj": "#ffffff",
|
||||
"mentionColor": "#145dbf",
|
||||
"mentionHighlightBg": "#ffe577",
|
||||
"mentionHighlightLink": "#166de0",
|
||||
"newMessageSeparator": "#ff8800",
|
||||
"onlineIndicator": "#06d6a0",
|
||||
"sidebarBg": "#145dbf",
|
||||
"sidebarHeaderBg": "#1153ab",
|
||||
"sidebarHeaderTextColor": "#ffffff",
|
||||
"sidebarText": "#ffffff",
|
||||
"sidebarTextActiveBorder": "#579eff",
|
||||
"sidebarTextActiveColor": "#ffffff",
|
||||
"sidebarTextHoverBg": "#4578bf",
|
||||
"sidebarUnreadText": "#ffffff",
|
||||
"type": "Mattermost",
|
||||
}
|
||||
}
|
||||
uploadFiles={[Function]}
|
||||
validMimeTypes={Array []}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<FileUploadButton
|
||||
blurTextBox={[Function]}
|
||||
browseFileTypes="public.item"
|
||||
canBrowseFiles={true}
|
||||
canBrowsePhotoLibrary={true}
|
||||
canBrowseVideoLibrary={true}
|
||||
canTakePhoto={true}
|
||||
canTakeVideo={true}
|
||||
extraOptions={null}
|
||||
fileCount={0}
|
||||
maxFileCount={5}
|
||||
maxFileSize={1024}
|
||||
onShowFileMaxWarning={[Function]}
|
||||
onShowFileSizeWarning={[Function]}
|
||||
<ImageUploadButton
|
||||
blurTextBox={[Function]}
|
||||
browseFileTypes="public.item"
|
||||
buttonContainerStyle={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"padding": 10,
|
||||
}
|
||||
}
|
||||
canBrowseFiles={true}
|
||||
canBrowsePhotoLibrary={true}
|
||||
canBrowseVideoLibrary={true}
|
||||
canTakePhoto={true}
|
||||
canTakeVideo={true}
|
||||
extraOptions={null}
|
||||
fileCount={0}
|
||||
maxFileCount={5}
|
||||
maxFileSize={1024}
|
||||
onShowFileMaxWarning={[Function]}
|
||||
onShowFileSizeWarning={[Function]}
|
||||
theme={
|
||||
Object {
|
||||
"awayIndicator": "#ffbc42",
|
||||
"buttonBg": "#166de0",
|
||||
"buttonColor": "#ffffff",
|
||||
"centerChannelBg": "#ffffff",
|
||||
"centerChannelColor": "#3d3c40",
|
||||
"codeTheme": "github",
|
||||
"dndIndicator": "#f74343",
|
||||
"errorTextColor": "#fd5960",
|
||||
"linkColor": "#2389d7",
|
||||
"mentionBg": "#ffffff",
|
||||
"mentionBj": "#ffffff",
|
||||
"mentionColor": "#145dbf",
|
||||
"mentionHighlightBg": "#ffe577",
|
||||
"mentionHighlightLink": "#166de0",
|
||||
"newMessageSeparator": "#ff8800",
|
||||
"onlineIndicator": "#06d6a0",
|
||||
"sidebarBg": "#145dbf",
|
||||
"sidebarHeaderBg": "#1153ab",
|
||||
"sidebarHeaderTextColor": "#ffffff",
|
||||
"sidebarText": "#ffffff",
|
||||
"sidebarTextActiveBorder": "#579eff",
|
||||
"sidebarTextActiveColor": "#ffffff",
|
||||
"sidebarTextHoverBg": "#4578bf",
|
||||
"sidebarUnreadText": "#ffffff",
|
||||
"type": "Mattermost",
|
||||
}
|
||||
}
|
||||
uploadFiles={[Function]}
|
||||
validMimeTypes={Array []}
|
||||
/>
|
||||
<AttachmentButton
|
||||
blurTextBox={[Function]}
|
||||
buttonContainerStyle={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"padding": 10,
|
||||
}
|
||||
}
|
||||
canTakePhoto={true}
|
||||
canTakeVideo={true}
|
||||
fileCount={0}
|
||||
maxFileCount={5}
|
||||
maxFileSize={1024}
|
||||
onShowFileMaxWarning={[Function]}
|
||||
onShowFileSizeWarning={[Function]}
|
||||
theme={
|
||||
Object {
|
||||
"awayIndicator": "#ffbc42",
|
||||
"buttonBg": "#166de0",
|
||||
"buttonColor": "#ffffff",
|
||||
"centerChannelBg": "#ffffff",
|
||||
"centerChannelColor": "#3d3c40",
|
||||
"codeTheme": "github",
|
||||
"dndIndicator": "#f74343",
|
||||
"errorTextColor": "#fd5960",
|
||||
"linkColor": "#2389d7",
|
||||
"mentionBg": "#ffffff",
|
||||
"mentionBj": "#ffffff",
|
||||
"mentionColor": "#145dbf",
|
||||
"mentionHighlightBg": "#ffe577",
|
||||
"mentionHighlightLink": "#166de0",
|
||||
"newMessageSeparator": "#ff8800",
|
||||
"onlineIndicator": "#06d6a0",
|
||||
"sidebarBg": "#145dbf",
|
||||
"sidebarHeaderBg": "#1153ab",
|
||||
"sidebarHeaderTextColor": "#ffffff",
|
||||
"sidebarText": "#ffffff",
|
||||
"sidebarTextActiveBorder": "#579eff",
|
||||
"sidebarTextActiveColor": "#ffffff",
|
||||
"sidebarTextHoverBg": "#4578bf",
|
||||
"sidebarUnreadText": "#ffffff",
|
||||
"type": "Mattermost",
|
||||
}
|
||||
}
|
||||
uploadFiles={[Function]}
|
||||
validMimeTypes={Array []}
|
||||
/>
|
||||
</View>
|
||||
<SendButton
|
||||
disabled={true}
|
||||
handleSendMessage={[Function]}
|
||||
theme={
|
||||
Object {
|
||||
"awayIndicator": "#ffbc42",
|
||||
@@ -184,131 +333,9 @@ exports[`PostTextBox should match, full snapshot 1`] = `
|
||||
"type": "Mattermost",
|
||||
}
|
||||
}
|
||||
uploadFiles={[Function]}
|
||||
validMimeTypes={Array []}
|
||||
/>
|
||||
<ImageUploadButton
|
||||
blurTextBox={[Function]}
|
||||
browseFileTypes="public.item"
|
||||
canBrowseFiles={true}
|
||||
canBrowsePhotoLibrary={true}
|
||||
canBrowseVideoLibrary={true}
|
||||
canTakePhoto={true}
|
||||
canTakeVideo={true}
|
||||
extraOptions={null}
|
||||
fileCount={0}
|
||||
maxFileCount={5}
|
||||
maxFileSize={1024}
|
||||
onShowFileMaxWarning={[Function]}
|
||||
onShowFileSizeWarning={[Function]}
|
||||
theme={
|
||||
Object {
|
||||
"awayIndicator": "#ffbc42",
|
||||
"buttonBg": "#166de0",
|
||||
"buttonColor": "#ffffff",
|
||||
"centerChannelBg": "#ffffff",
|
||||
"centerChannelColor": "#3d3c40",
|
||||
"codeTheme": "github",
|
||||
"dndIndicator": "#f74343",
|
||||
"errorTextColor": "#fd5960",
|
||||
"linkColor": "#2389d7",
|
||||
"mentionBg": "#ffffff",
|
||||
"mentionBj": "#ffffff",
|
||||
"mentionColor": "#145dbf",
|
||||
"mentionHighlightBg": "#ffe577",
|
||||
"mentionHighlightLink": "#166de0",
|
||||
"newMessageSeparator": "#ff8800",
|
||||
"onlineIndicator": "#06d6a0",
|
||||
"sidebarBg": "#145dbf",
|
||||
"sidebarHeaderBg": "#1153ab",
|
||||
"sidebarHeaderTextColor": "#ffffff",
|
||||
"sidebarText": "#ffffff",
|
||||
"sidebarTextActiveBorder": "#579eff",
|
||||
"sidebarTextActiveColor": "#ffffff",
|
||||
"sidebarTextHoverBg": "#4578bf",
|
||||
"sidebarUnreadText": "#ffffff",
|
||||
"type": "Mattermost",
|
||||
}
|
||||
}
|
||||
uploadFiles={[Function]}
|
||||
validMimeTypes={Array []}
|
||||
/>
|
||||
<AttachmentButton
|
||||
blurTextBox={[Function]}
|
||||
canTakePhoto={true}
|
||||
canTakeVideo={true}
|
||||
fileCount={0}
|
||||
maxFileCount={5}
|
||||
maxFileSize={1024}
|
||||
onShowFileMaxWarning={[Function]}
|
||||
onShowFileSizeWarning={[Function]}
|
||||
theme={
|
||||
Object {
|
||||
"awayIndicator": "#ffbc42",
|
||||
"buttonBg": "#166de0",
|
||||
"buttonColor": "#ffffff",
|
||||
"centerChannelBg": "#ffffff",
|
||||
"centerChannelColor": "#3d3c40",
|
||||
"codeTheme": "github",
|
||||
"dndIndicator": "#f74343",
|
||||
"errorTextColor": "#fd5960",
|
||||
"linkColor": "#2389d7",
|
||||
"mentionBg": "#ffffff",
|
||||
"mentionBj": "#ffffff",
|
||||
"mentionColor": "#145dbf",
|
||||
"mentionHighlightBg": "#ffe577",
|
||||
"mentionHighlightLink": "#166de0",
|
||||
"newMessageSeparator": "#ff8800",
|
||||
"onlineIndicator": "#06d6a0",
|
||||
"sidebarBg": "#145dbf",
|
||||
"sidebarHeaderBg": "#1153ab",
|
||||
"sidebarHeaderTextColor": "#ffffff",
|
||||
"sidebarText": "#ffffff",
|
||||
"sidebarTextActiveBorder": "#579eff",
|
||||
"sidebarTextActiveColor": "#ffffff",
|
||||
"sidebarTextHoverBg": "#4578bf",
|
||||
"sidebarUnreadText": "#ffffff",
|
||||
"type": "Mattermost",
|
||||
}
|
||||
}
|
||||
uploadFiles={[Function]}
|
||||
validMimeTypes={Array []}
|
||||
/>
|
||||
</View>
|
||||
<SendButton
|
||||
disabled={true}
|
||||
handleSendMessage={[Function]}
|
||||
theme={
|
||||
Object {
|
||||
"awayIndicator": "#ffbc42",
|
||||
"buttonBg": "#166de0",
|
||||
"buttonColor": "#ffffff",
|
||||
"centerChannelBg": "#ffffff",
|
||||
"centerChannelColor": "#3d3c40",
|
||||
"codeTheme": "github",
|
||||
"dndIndicator": "#f74343",
|
||||
"errorTextColor": "#fd5960",
|
||||
"linkColor": "#2389d7",
|
||||
"mentionBg": "#ffffff",
|
||||
"mentionBj": "#ffffff",
|
||||
"mentionColor": "#145dbf",
|
||||
"mentionHighlightBg": "#ffe577",
|
||||
"mentionHighlightLink": "#166de0",
|
||||
"newMessageSeparator": "#ff8800",
|
||||
"onlineIndicator": "#06d6a0",
|
||||
"sidebarBg": "#145dbf",
|
||||
"sidebarHeaderBg": "#1153ab",
|
||||
"sidebarHeaderTextColor": "#ffffff",
|
||||
"sidebarText": "#ffffff",
|
||||
"sidebarTextActiveBorder": "#579eff",
|
||||
"sidebarTextActiveColor": "#ffffff",
|
||||
"sidebarTextHoverBg": "#4578bf",
|
||||
"sidebarUnreadText": "#ffffff",
|
||||
"type": "Mattermost",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</React.Fragment>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</React.Fragment>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`CameraButton should match snapshot 1`] = `
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={Object {}}
|
||||
type="opacity"
|
||||
>
|
||||
<Icon
|
||||
allowFontScaling={false}
|
||||
color="rgba(61,60,64,0.64)"
|
||||
name="camera-outline"
|
||||
size={24}
|
||||
/>
|
||||
</TouchableWithFeedbackIOS>
|
||||
`;
|
||||
@@ -0,0 +1,16 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`FileUploadButton should match snapshot 1`] = `
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={Object {}}
|
||||
type="opacity"
|
||||
>
|
||||
<Icon
|
||||
allowFontScaling={false}
|
||||
color="rgba(61,60,64,0.64)"
|
||||
name="file-document-outline"
|
||||
size={24}
|
||||
/>
|
||||
</TouchableWithFeedbackIOS>
|
||||
`;
|
||||
@@ -0,0 +1,16 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ImageUploadButton should match snapshot 1`] = `
|
||||
<TouchableWithFeedbackIOS
|
||||
onPress={[Function]}
|
||||
style={Object {}}
|
||||
type="opacity"
|
||||
>
|
||||
<Icon
|
||||
allowFontScaling={false}
|
||||
color="rgba(61,60,64,0.64)"
|
||||
name="image-outline"
|
||||
size={24}
|
||||
/>
|
||||
</TouchableWithFeedbackIOS>
|
||||
`;
|
||||
@@ -6,31 +6,26 @@ import {intlShape} from 'react-intl';
|
||||
import {
|
||||
Alert,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import RNFetchBlob from 'rn-fetch-blob';
|
||||
import DeviceInfo from 'react-native-device-info';
|
||||
import {ICON_SIZE} from 'app/constants/post_textbox';
|
||||
|
||||
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
import ImagePicker from 'react-native-image-picker';
|
||||
import Permissions from 'react-native-permissions';
|
||||
|
||||
import {lookupMimeType} from 'mattermost-redux/utils/file_utils';
|
||||
import {changeOpacity} from 'app/utils/theme';
|
||||
|
||||
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
|
||||
import {PermissionTypes} from 'app/constants';
|
||||
|
||||
export default class AttachmentButton extends PureComponent {
|
||||
static propTypes = {
|
||||
validMimeTypes: PropTypes.array,
|
||||
fileCount: PropTypes.number,
|
||||
maxFileCount: PropTypes.number.isRequired,
|
||||
maxFileSize: PropTypes.number.isRequired,
|
||||
onShowFileMaxWarning: PropTypes.func,
|
||||
onShowFileSizeWarning: PropTypes.func,
|
||||
onShowUnsupportedMimeTypeWarning: PropTypes.func,
|
||||
theme: PropTypes.object.isRequired,
|
||||
uploadFiles: PropTypes.func.isRequired,
|
||||
buttonContainerStyle: PropTypes.object,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@@ -102,106 +97,70 @@ export default class AttachmentButton extends PureComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadFiles([response]);
|
||||
this.props.uploadFiles([response]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
hasCameraPermission = async () => {
|
||||
if (Platform.OS === 'ios') {
|
||||
const {formatMessage} = this.context.intl;
|
||||
let permissionRequest;
|
||||
const targetSource = 'camera';
|
||||
const hasPermissionToStorage = await Permissions.check(targetSource);
|
||||
const {formatMessage} = this.context.intl;
|
||||
const targetSource = Platform.OS === 'ios' ?
|
||||
Permissions.PERMISSIONS.IOS.CAMERA :
|
||||
Permissions.PERMISSIONS.ANDROID.CAMERA;
|
||||
const hasPermission = await Permissions.check(targetSource);
|
||||
|
||||
switch (hasPermissionToStorage) {
|
||||
case PermissionTypes.UNDETERMINED:
|
||||
permissionRequest = await Permissions.request(targetSource);
|
||||
if (permissionRequest !== PermissionTypes.AUTHORIZED) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case PermissionTypes.DENIED: {
|
||||
const canOpenSettings = await Permissions.canOpenSettings();
|
||||
let grantOption = null;
|
||||
if (canOpenSettings) {
|
||||
grantOption = {
|
||||
switch (hasPermission) {
|
||||
case Permissions.RESULTS.DENIED:
|
||||
case Permissions.RESULTS.UNAVAILABLE: {
|
||||
const permissionRequest = await Permissions.request(targetSource);
|
||||
|
||||
return permissionRequest === Permissions.RESULTS.GRANTED;
|
||||
}
|
||||
case Permissions.RESULTS.BLOCKED: {
|
||||
const grantOption = {
|
||||
text: formatMessage({
|
||||
id: 'mobile.permission_denied_retry',
|
||||
defaultMessage: 'Settings',
|
||||
}),
|
||||
onPress: () => Permissions.openSettings(),
|
||||
};
|
||||
|
||||
const {title, text} = this.getPermissionDeniedMessage();
|
||||
|
||||
Alert.alert(
|
||||
title,
|
||||
text,
|
||||
[
|
||||
grantOption,
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'mobile.permission_denied_retry',
|
||||
defaultMessage: 'Settings',
|
||||
id: 'mobile.permission_denied_dismiss',
|
||||
defaultMessage: 'Don\'t Allow',
|
||||
}),
|
||||
onPress: () => Permissions.openSettings(),
|
||||
};
|
||||
}
|
||||
|
||||
const {title, text} = this.getPermissionDeniedMessage();
|
||||
|
||||
Alert.alert(
|
||||
title,
|
||||
text,
|
||||
[
|
||||
grantOption,
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'mobile.permission_denied_dismiss',
|
||||
defaultMessage: 'Don\'t Allow',
|
||||
}),
|
||||
},
|
||||
],
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
uploadFiles = async (files) => {
|
||||
const file = files[0];
|
||||
if (!file.fileSize | !file.fileName) {
|
||||
const path = (file.path || file.uri).replace('file://', '');
|
||||
const fileInfo = await RNFetchBlob.fs.stat(path);
|
||||
file.fileSize = fileInfo.size;
|
||||
file.fileName = fileInfo.filename;
|
||||
}
|
||||
|
||||
if (!file.type) {
|
||||
file.type = lookupMimeType(file.fileName);
|
||||
}
|
||||
|
||||
const {validMimeTypes} = this.props;
|
||||
if (validMimeTypes.length && !validMimeTypes.includes(file.type)) {
|
||||
this.props.onShowUnsupportedMimeTypeWarning();
|
||||
} else if (file.fileSize > this.props.maxFileSize) {
|
||||
this.props.onShowFileSizeWarning(file.fileName);
|
||||
} else {
|
||||
this.props.uploadFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {theme} = this.props;
|
||||
|
||||
const {theme, buttonContainerStyle} = this.props;
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={this.attachFileFromCamera}
|
||||
style={style.buttonContainer}
|
||||
style={buttonContainerStyle}
|
||||
type={'opacity'}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
color={theme.centerChannelColor}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||
name='camera-outline'
|
||||
size={20}
|
||||
size={ICON_SIZE}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const style = StyleSheet.create({
|
||||
buttonContainer: {
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
},
|
||||
});
|
||||
103
app/components/post_textbox/components/camera_button.test.js
Normal file
103
app/components/post_textbox/components/camera_button.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Alert, Platform} from 'react-native';
|
||||
import {shallow} from 'enzyme';
|
||||
import Permissions from 'react-native-permissions';
|
||||
|
||||
import Preferences from 'mattermost-redux/constants/preferences';
|
||||
|
||||
import CameraButton from './camera_button';
|
||||
|
||||
jest.mock('react-intl');
|
||||
jest.mock('react-native-image-picker', () => ({
|
||||
launchCamera: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('CameraButton', () => {
|
||||
const formatMessage = jest.fn();
|
||||
const baseProps = {
|
||||
fileCount: 0,
|
||||
maxFileCount: 5,
|
||||
onShowFileMaxWarning: jest.fn(),
|
||||
theme: Preferences.THEMES.default,
|
||||
uploadFiles: jest.fn(),
|
||||
buttonContainerStyle: {},
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(<CameraButton {...baseProps}/>);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should return permission false if permission is denied in iOS', async () => {
|
||||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.UNAVAILABLE);
|
||||
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
|
||||
|
||||
const wrapper = shallow(
|
||||
<CameraButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPermission = await wrapper.instance().hasCameraPermission();
|
||||
expect(Permissions.check).toHaveBeenCalled();
|
||||
expect(Permissions.request).toHaveBeenCalled();
|
||||
expect(Alert.alert).not.toHaveBeenCalled();
|
||||
expect(hasPermission).toBe(false);
|
||||
});
|
||||
|
||||
test('should show permission denied alert and return permission false if permission is blocked in iOS', async () => {
|
||||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.BLOCKED);
|
||||
jest.spyOn(Alert, 'alert').mockReturnValue(true);
|
||||
|
||||
const wrapper = shallow(
|
||||
<CameraButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPermission = await wrapper.instance().hasCameraPermission();
|
||||
expect(Permissions.check).toHaveBeenCalled();
|
||||
expect(Permissions.request).not.toHaveBeenCalled();
|
||||
expect(Alert.alert).toHaveBeenCalled();
|
||||
expect(hasPermission).toBe(false);
|
||||
});
|
||||
|
||||
test('hasCameraPermission returns true when permission has been granted', async () => {
|
||||
const platformPermissions = [{
|
||||
platform: 'ios',
|
||||
permission: Permissions.PERMISSIONS.IOS.CAMERA,
|
||||
}, {
|
||||
platform: 'android',
|
||||
permission: Permissions.PERMISSIONS.ANDROID.CAMERA,
|
||||
}];
|
||||
|
||||
for (let i = 0; i < platformPermissions.length; i++) {
|
||||
const {platform, permission} = platformPermissions[i];
|
||||
Platform.OS = platform;
|
||||
|
||||
const check = jest.spyOn(Permissions, 'check');
|
||||
const request = jest.spyOn(Permissions, 'request');
|
||||
request.mockReturnValue(Permissions.RESULTS.GRANTED);
|
||||
|
||||
const wrapper = shallow(
|
||||
<CameraButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
|
||||
check.mockReturnValueOnce(Permissions.RESULTS.DENIED);
|
||||
let hasPermission = await instance.hasCameraPermission(); // eslint-disable-line no-await-in-loop
|
||||
expect(check).toHaveBeenCalledWith(permission);
|
||||
expect(request).toHaveBeenCalled();
|
||||
expect(hasPermission).toBe(true);
|
||||
|
||||
check.mockReturnValueOnce(Permissions.RESULTS.UNAVAILABLE);
|
||||
hasPermission = await instance.hasCameraPermission(); // eslint-disable-line no-await-in-loop
|
||||
expect(check).toHaveBeenCalledWith(permission);
|
||||
expect(request).toHaveBeenCalled();
|
||||
expect(hasPermission).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,20 +7,18 @@ import {
|
||||
Alert,
|
||||
NativeModules,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import RNFetchBlob from 'rn-fetch-blob';
|
||||
import DeviceInfo from 'react-native-device-info';
|
||||
import {ICON_SIZE} from 'app/constants/post_textbox';
|
||||
import AndroidOpenSettings from 'react-native-android-open-settings';
|
||||
|
||||
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
import DocumentPicker from 'react-native-document-picker';
|
||||
import Permissions from 'react-native-permissions';
|
||||
|
||||
import {lookupMimeType} from 'mattermost-redux/utils/file_utils';
|
||||
import {changeOpacity} from 'app/utils/theme';
|
||||
|
||||
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
|
||||
import {PermissionTypes} from 'app/constants';
|
||||
|
||||
const ShareExtension = NativeModules.MattermostShare;
|
||||
|
||||
@@ -28,15 +26,12 @@ export default class FileUploadButton extends PureComponent {
|
||||
static propTypes = {
|
||||
blurTextBox: PropTypes.func.isRequired,
|
||||
browseFileTypes: PropTypes.string,
|
||||
validMimeTypes: PropTypes.array,
|
||||
fileCount: PropTypes.number,
|
||||
maxFileCount: PropTypes.number.isRequired,
|
||||
maxFileSize: PropTypes.number.isRequired,
|
||||
onShowFileMaxWarning: PropTypes.func,
|
||||
onShowFileSizeWarning: PropTypes.func,
|
||||
onShowUnsupportedMimeTypeWarning: PropTypes.func,
|
||||
theme: PropTypes.object.isRequired,
|
||||
uploadFiles: PropTypes.func.isRequired,
|
||||
buttonContainerStyle: PropTypes.object,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@@ -90,7 +85,7 @@ export default class FileUploadButton extends PureComponent {
|
||||
// Decode file uri to get the actual path
|
||||
res.uri = decodeURIComponent(res.uri);
|
||||
|
||||
this.uploadFiles([res]);
|
||||
this.props.uploadFiles([res]);
|
||||
} catch (error) {
|
||||
// Do nothing
|
||||
}
|
||||
@@ -100,17 +95,17 @@ export default class FileUploadButton extends PureComponent {
|
||||
hasStoragePermission = async () => {
|
||||
if (Platform.OS === 'android') {
|
||||
const {formatMessage} = this.context.intl;
|
||||
let permissionRequest;
|
||||
const hasPermissionToStorage = await Permissions.check('storage');
|
||||
const storagePermission = Permissions.PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE;
|
||||
const hasPermissionToStorage = await Permissions.check(storagePermission);
|
||||
|
||||
switch (hasPermissionToStorage) {
|
||||
case PermissionTypes.UNDETERMINED:
|
||||
permissionRequest = await Permissions.request('storage');
|
||||
if (permissionRequest !== PermissionTypes.AUTHORIZED) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case PermissionTypes.DENIED: {
|
||||
case Permissions.RESULTS.DENIED:
|
||||
case Permissions.RESULTS.UNAVAILABLE: {
|
||||
const permissionRequest = await Permissions.request(storagePermission);
|
||||
|
||||
return permissionRequest === Permissions.RESULTS.GRANTED;
|
||||
}
|
||||
case Permissions.RESULTS.BLOCKED: {
|
||||
const {title, text} = this.getPermissionDeniedMessage();
|
||||
|
||||
Alert.alert(
|
||||
@@ -130,7 +125,7 @@ export default class FileUploadButton extends PureComponent {
|
||||
}),
|
||||
onPress: () => AndroidOpenSettings.appDetailsSettings(),
|
||||
},
|
||||
]
|
||||
],
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -140,29 +135,6 @@ export default class FileUploadButton extends PureComponent {
|
||||
return true;
|
||||
};
|
||||
|
||||
uploadFiles = async (files) => {
|
||||
const file = files[0];
|
||||
if (!file.fileSize | !file.fileName) {
|
||||
const path = (file.path || file.uri).replace('file://', '');
|
||||
const fileInfo = await RNFetchBlob.fs.stat(path);
|
||||
file.fileSize = fileInfo.size;
|
||||
file.fileName = fileInfo.filename;
|
||||
}
|
||||
|
||||
if (!file.type) {
|
||||
file.type = lookupMimeType(file.fileName);
|
||||
}
|
||||
|
||||
const {validMimeTypes} = this.props;
|
||||
if (validMimeTypes.length && !validMimeTypes.includes(file.type)) {
|
||||
this.props.onShowUnsupportedMimeTypeWarning();
|
||||
} else if (file.fileSize > this.props.maxFileSize) {
|
||||
this.props.onShowFileSizeWarning(file.fileName);
|
||||
} else {
|
||||
this.props.uploadFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
handleButtonPress = () => {
|
||||
const {
|
||||
fileCount,
|
||||
@@ -179,26 +151,19 @@ export default class FileUploadButton extends PureComponent {
|
||||
};
|
||||
|
||||
render() {
|
||||
const {theme} = this.props;
|
||||
const {theme, buttonContainerStyle} = this.props;
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={this.handleButtonPress}
|
||||
style={style.buttonContainer}
|
||||
style={buttonContainerStyle}
|
||||
type={'opacity'}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
color={theme.centerChannelColor}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||
name='file-document-outline'
|
||||
size={20}
|
||||
size={ICON_SIZE}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const style = StyleSheet.create({
|
||||
buttonContainer: {
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Alert, Platform} from 'react-native';
|
||||
import {shallow} from 'enzyme';
|
||||
import Permissions from 'react-native-permissions';
|
||||
|
||||
import Preferences from 'mattermost-redux/constants/preferences';
|
||||
|
||||
import FileUploadButton from './file_upload_button';
|
||||
|
||||
jest.mock('react-intl');
|
||||
jest.mock('react-native-image-picker', () => ({
|
||||
launchCamera: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('FileUploadButton', () => {
|
||||
const formatMessage = jest.fn();
|
||||
const baseProps = {
|
||||
blurTextBox: jest.fn(),
|
||||
browseFileTypes: '*',
|
||||
fileCount: 0,
|
||||
maxFileCount: 5,
|
||||
onShowFileMaxWarning: jest.fn(),
|
||||
theme: Preferences.THEMES.default,
|
||||
uploadFiles: jest.fn(),
|
||||
buttonContainerStyle: {},
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
Platform.OS = 'android';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Platform.OS = 'ios';
|
||||
});
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(<FileUploadButton {...baseProps}/>);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should return permission false if permission is denied in Android', async () => {
|
||||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.UNAVAILABLE);
|
||||
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
|
||||
|
||||
const wrapper = shallow(
|
||||
<FileUploadButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPermission = await wrapper.instance().hasStoragePermission();
|
||||
expect(Permissions.check).toHaveBeenCalled();
|
||||
expect(Permissions.request).toHaveBeenCalled();
|
||||
expect(Alert.alert).not.toHaveBeenCalled();
|
||||
expect(hasPermission).toBe(false);
|
||||
});
|
||||
|
||||
test('should show permission denied alert and return permission false if permission is blocked in Android', async () => {
|
||||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.BLOCKED);
|
||||
jest.spyOn(Alert, 'alert').mockReturnValue(true);
|
||||
|
||||
const wrapper = shallow(
|
||||
<FileUploadButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPermission = await wrapper.instance().hasStoragePermission();
|
||||
expect(Permissions.check).toHaveBeenCalled();
|
||||
expect(Permissions.request).not.toHaveBeenCalled();
|
||||
expect(Alert.alert).toHaveBeenCalled();
|
||||
expect(hasPermission).toBe(false);
|
||||
});
|
||||
|
||||
test('hasStoragePermission returns true when permission has been granted', async () => {
|
||||
const wrapper = shallow(
|
||||
<FileUploadButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
const check = jest.spyOn(Permissions, 'check');
|
||||
const request = jest.spyOn(Permissions, 'request');
|
||||
|
||||
// On iOS storage permissions are not checked
|
||||
Platform.OS = 'ios';
|
||||
let hasPermission = await instance.hasStoragePermission();
|
||||
expect(check).not.toHaveBeenCalled();
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(hasPermission).toBe(true);
|
||||
|
||||
Platform.OS = 'android';
|
||||
request.mockReturnValue(Permissions.RESULTS.GRANTED);
|
||||
const permission = Permissions.PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE;
|
||||
|
||||
check.mockReturnValueOnce(Permissions.RESULTS.DENIED);
|
||||
hasPermission = await instance.hasStoragePermission();
|
||||
expect(check).toHaveBeenCalledWith(permission);
|
||||
expect(request).toHaveBeenCalled();
|
||||
expect(hasPermission).toBe(true);
|
||||
|
||||
check.mockReturnValueOnce(Permissions.RESULTS.UNAVAILABLE);
|
||||
hasPermission = await instance.hasStoragePermission();
|
||||
expect(check).toHaveBeenCalledWith(permission);
|
||||
expect(request).toHaveBeenCalled();
|
||||
expect(hasPermission).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -6,32 +6,27 @@ import {intlShape} from 'react-intl';
|
||||
import {
|
||||
Alert,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import RNFetchBlob from 'rn-fetch-blob';
|
||||
import DeviceInfo from 'react-native-device-info';
|
||||
import {ICON_SIZE} from 'app/constants/post_textbox';
|
||||
|
||||
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
import ImagePicker from 'react-native-image-picker';
|
||||
import Permissions from 'react-native-permissions';
|
||||
|
||||
import {lookupMimeType} from 'mattermost-redux/utils/file_utils';
|
||||
import {changeOpacity} from 'app/utils/theme';
|
||||
|
||||
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
|
||||
import {PermissionTypes} from 'app/constants';
|
||||
|
||||
export default class ImageUploadButton extends PureComponent {
|
||||
static propTypes = {
|
||||
blurTextBox: PropTypes.func.isRequired,
|
||||
validMimeTypes: PropTypes.array,
|
||||
fileCount: PropTypes.number,
|
||||
maxFileCount: PropTypes.number.isRequired,
|
||||
maxFileSize: PropTypes.number.isRequired,
|
||||
onShowFileMaxWarning: PropTypes.func,
|
||||
onShowFileSizeWarning: PropTypes.func,
|
||||
onShowUnsupportedMimeTypeWarning: PropTypes.func,
|
||||
theme: PropTypes.object.isRequired,
|
||||
uploadFiles: PropTypes.func.isRequired,
|
||||
buttonContainerStyle: PropTypes.object,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@@ -104,84 +99,56 @@ export default class ImageUploadButton extends PureComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadFiles([response]);
|
||||
this.props.uploadFiles([response]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
hasPhotoPermission = async () => {
|
||||
if (Platform.OS === 'ios') {
|
||||
const {formatMessage} = this.context.intl;
|
||||
let permissionRequest;
|
||||
const targetSource = 'photo';
|
||||
const hasPermissionToStorage = await Permissions.check(targetSource);
|
||||
const {formatMessage} = this.context.intl;
|
||||
const targetSource = Platform.OS === 'ios' ?
|
||||
Permissions.PERMISSIONS.IOS.PHOTO_LIBRARY :
|
||||
Permissions.PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE;
|
||||
const hasPermission = await Permissions.check(targetSource);
|
||||
|
||||
switch (hasPermissionToStorage) {
|
||||
case PermissionTypes.UNDETERMINED:
|
||||
permissionRequest = await Permissions.request(targetSource);
|
||||
if (permissionRequest !== PermissionTypes.AUTHORIZED) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case PermissionTypes.DENIED: {
|
||||
const canOpenSettings = await Permissions.canOpenSettings();
|
||||
let grantOption = null;
|
||||
if (canOpenSettings) {
|
||||
grantOption = {
|
||||
switch (hasPermission) {
|
||||
case Permissions.RESULTS.DENIED:
|
||||
case Permissions.RESULTS.UNAVAILABLE: {
|
||||
const permissionRequest = await Permissions.request(targetSource);
|
||||
|
||||
return permissionRequest === Permissions.RESULTS.GRANTED;
|
||||
}
|
||||
case Permissions.RESULTS.BLOCKED: {
|
||||
const grantOption = {
|
||||
text: formatMessage({
|
||||
id: 'mobile.permission_denied_retry',
|
||||
defaultMessage: 'Settings',
|
||||
}),
|
||||
onPress: () => Permissions.openSettings(),
|
||||
};
|
||||
|
||||
const {title, text} = this.getPermissionDeniedMessage();
|
||||
|
||||
Alert.alert(
|
||||
title,
|
||||
text,
|
||||
[
|
||||
grantOption,
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'mobile.permission_denied_retry',
|
||||
defaultMessage: 'Settings',
|
||||
id: 'mobile.permission_denied_dismiss',
|
||||
defaultMessage: 'Don\'t Allow',
|
||||
}),
|
||||
onPress: () => Permissions.openSettings(),
|
||||
};
|
||||
}
|
||||
|
||||
const {title, text} = this.getPermissionDeniedMessage();
|
||||
|
||||
Alert.alert(
|
||||
title,
|
||||
text,
|
||||
[
|
||||
grantOption,
|
||||
{
|
||||
text: formatMessage({
|
||||
id: 'mobile.permission_denied_dismiss',
|
||||
defaultMessage: 'Don\'t Allow',
|
||||
}),
|
||||
},
|
||||
],
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
uploadFiles = async (files) => {
|
||||
const file = files[0];
|
||||
if (!file.fileSize | !file.fileName) {
|
||||
const path = (file.path || file.uri).replace('file://', '');
|
||||
const fileInfo = await RNFetchBlob.fs.stat(path);
|
||||
file.fileSize = fileInfo.size;
|
||||
file.fileName = fileInfo.filename;
|
||||
}
|
||||
|
||||
if (!file.type) {
|
||||
file.type = lookupMimeType(file.fileName);
|
||||
}
|
||||
|
||||
const {validMimeTypes} = this.props;
|
||||
if (validMimeTypes.length && !validMimeTypes.includes(file.type)) {
|
||||
this.props.onShowUnsupportedMimeTypeWarning();
|
||||
} else if (file.fileSize > this.props.maxFileSize) {
|
||||
this.props.onShowFileSizeWarning(file.fileName);
|
||||
} else {
|
||||
this.props.uploadFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
handleButtonPress = () => {
|
||||
const {
|
||||
fileCount,
|
||||
@@ -199,26 +166,19 @@ export default class ImageUploadButton extends PureComponent {
|
||||
};
|
||||
|
||||
render() {
|
||||
const {theme} = this.props;
|
||||
const {theme, buttonContainerStyle} = this.props;
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={this.handleButtonPress}
|
||||
style={style.buttonContainer}
|
||||
style={buttonContainerStyle}
|
||||
type={'opacity'}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
color={theme.centerChannelColor}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||
name='image-outline'
|
||||
size={20}
|
||||
size={ICON_SIZE}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const style = StyleSheet.create({
|
||||
buttonContainer: {
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Alert, Platform} from 'react-native';
|
||||
import {shallow} from 'enzyme';
|
||||
import Permissions from 'react-native-permissions';
|
||||
|
||||
import Preferences from 'mattermost-redux/constants/preferences';
|
||||
|
||||
import ImageUploadButton from './image_upload_button';
|
||||
|
||||
jest.mock('react-intl');
|
||||
jest.mock('react-native-image-picker', () => ({
|
||||
launchCamera: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('ImageUploadButton', () => {
|
||||
const formatMessage = jest.fn();
|
||||
const baseProps = {
|
||||
blurTextBox: jest.fn(),
|
||||
fileCount: 0,
|
||||
maxFileCount: 5,
|
||||
onShowFileMaxWarning: jest.fn(),
|
||||
theme: Preferences.THEMES.default,
|
||||
uploadFiles: jest.fn(),
|
||||
buttonContainerStyle: {},
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(<ImageUploadButton {...baseProps}/>);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should return permission false if permission is denied in iOS', async () => {
|
||||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.UNAVAILABLE);
|
||||
jest.spyOn(Permissions, 'request').mockReturnValue(Permissions.RESULTS.DENIED);
|
||||
|
||||
const wrapper = shallow(
|
||||
<ImageUploadButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPermission = await wrapper.instance().hasPhotoPermission();
|
||||
expect(Permissions.check).toHaveBeenCalled();
|
||||
expect(Permissions.request).toHaveBeenCalled();
|
||||
expect(Alert.alert).not.toHaveBeenCalled();
|
||||
expect(hasPermission).toBe(false);
|
||||
});
|
||||
|
||||
test('should show permission denied alert and return permission false if permission is blocked in iOS', async () => {
|
||||
jest.spyOn(Permissions, 'check').mockReturnValue(Permissions.RESULTS.BLOCKED);
|
||||
jest.spyOn(Alert, 'alert').mockReturnValue(true);
|
||||
|
||||
const wrapper = shallow(
|
||||
<ImageUploadButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
|
||||
const hasPermission = await wrapper.instance().hasPhotoPermission();
|
||||
expect(Permissions.check).toHaveBeenCalled();
|
||||
expect(Permissions.request).not.toHaveBeenCalled();
|
||||
expect(Alert.alert).toHaveBeenCalled();
|
||||
expect(hasPermission).toBe(false);
|
||||
});
|
||||
|
||||
test('hasPhotoPermission returns true when permission has been granted', async () => {
|
||||
const platformPermissions = [{
|
||||
platform: 'ios',
|
||||
permission: Permissions.PERMISSIONS.IOS.PHOTO_LIBRARY,
|
||||
}, {
|
||||
platform: 'android',
|
||||
permission: Permissions.PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE,
|
||||
}];
|
||||
|
||||
for (let i = 0; i < platformPermissions.length; i++) {
|
||||
const {platform, permission} = platformPermissions[i];
|
||||
Platform.OS = platform;
|
||||
|
||||
const check = jest.spyOn(Permissions, 'check');
|
||||
const request = jest.spyOn(Permissions, 'request');
|
||||
request.mockReturnValue(Permissions.RESULTS.GRANTED);
|
||||
|
||||
const wrapper = shallow(
|
||||
<ImageUploadButton {...baseProps}/>,
|
||||
{context: {intl: {formatMessage}}},
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
|
||||
check.mockReturnValueOnce(Permissions.RESULTS.DENIED);
|
||||
let hasPermission = await instance.hasPhotoPermission(); // eslint-disable-line no-await-in-loop
|
||||
expect(check).toHaveBeenCalledWith(permission);
|
||||
expect(request).toHaveBeenCalled();
|
||||
expect(hasPermission).toBe(true);
|
||||
|
||||
check.mockReturnValueOnce(Permissions.RESULTS.UNAVAILABLE);
|
||||
hasPermission = await instance.hasPhotoPermission(); // eslint-disable-line no-await-in-loop
|
||||
expect(check).toHaveBeenCalledWith(permission);
|
||||
expect(request).toHaveBeenCalled();
|
||||
expect(hasPermission).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,6 @@ function mapStateToProps(state, ownProps) {
|
||||
channelTeamId: currentChannel ? currentChannel.team_id : '',
|
||||
canUploadFiles: canUploadFilesOnMobile(state),
|
||||
channelDisplayName: state.views.channel.displayName || (currentChannel ? currentChannel.display_name : ''),
|
||||
channelIsLoading: state.views.channel.loading,
|
||||
channelIsReadOnly: isCurrentChannelReadOnly(state) || false,
|
||||
channelIsArchived: ownProps.channelIsArchived || (currentChannel ? currentChannel.delete_at !== 0 : false),
|
||||
currentUserId,
|
||||
|
||||
@@ -14,9 +14,9 @@ import PasteableTextInput from 'app/components/pasteable_text_input';
|
||||
import EphemeralStore from 'app/store/ephemeral_store';
|
||||
|
||||
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
|
||||
import FileUploadButton from './components/fileUploadButton';
|
||||
import ImageUploadButton from './components/imageUploadButton';
|
||||
import CameraButton from './components/cameraButton';
|
||||
import FileUploadButton from './components/file_upload_button';
|
||||
import ImageUploadButton from './components/image_upload_button';
|
||||
import CameraButton from './components/camera_button';
|
||||
|
||||
import PostTextbox from './post_textbox.ios';
|
||||
|
||||
@@ -46,7 +46,6 @@ describe('PostTextBox', () => {
|
||||
channelId: 'channel-id',
|
||||
channelDisplayName: 'Test Channel',
|
||||
channelTeamId: 'channel-team-id',
|
||||
channelIsLoading: false,
|
||||
channelIsReadOnly: false,
|
||||
currentUserId: 'current-user-id',
|
||||
deactivatedChannel: false,
|
||||
@@ -344,7 +343,7 @@ describe('PostTextBox', () => {
|
||||
mockResolvedValue({data: 'success'});
|
||||
|
||||
const wrapper = shallowWithIntl(
|
||||
<PostTextbox {...props}/>
|
||||
<PostTextbox {...props}/>,
|
||||
);
|
||||
|
||||
const msg = '/fail preserve this text in the post draft';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user