Compare commits

...

42 Commits

Author SHA1 Message Date
Mattermost Build
6394f89869 Automated cherry pick of #3927 (#3928)
* Bump app build number to 268

* Update server version in Android description

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-13 20:33:06 -07:00
Mattermost Build
31e5e0426e Call completionHandler in sendReply (#3926)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-13 20:27:24 -07:00
Mattermost Build
81292df787 Bump app build number to 267 (#3918)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-12 13:36:42 -07:00
Elias Nahum
882bc6b32b MM-22487 Fix race condition causing the user to logout (#3916) 2020-02-12 17:28:06 -03:00
Mattermost Build
5a6b389b5b Bump app build number to 266 (#3915)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-02-12 09:53:34 -03:00
Elias Nahum
b60b9985d6 translations PR 20200211 (#3912) 2020-02-12 09:42:46 -03:00
Mattermost Build
8e31c5c1b9 Bump app build number to 265 (#3910)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-10 16:08:34 -07:00
Mattermost Build
1e40d31b30 Re-enable ram-bundles (#3908)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-02-10 11:31:02 -07:00
Mattermost Build
fd1b8ce219 Dispatch loadConfigAndLicense on launchApp (#3906)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-10 13:50:58 -03:00
Mattermost Build
62c244cd72 Automated cherry pick of #3898 (#3905)
* Tighten up post draft UI

* Revert "MM-15307 Updated to use InteractionManager (#3666)"

This reverts commit e08155c81b.

* Address PR review comments

* Update snapshot test

* Don't return null if no files

* Fix progress text and padding issues

* Fixes per Matt's review

* Make linter happy

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-10 11:53:25 -03:00
Mattermost Build
af715828b6 MM-22253 Reduce unnecessary re-renders in channel switching (#3901)
Re-renders were occuring because of prop and state updates that created new object references, despite being of identical value. A key culprit was `postListHeight` in `PostList` which goes through a few calls to `handleContentSizeChange` when loading posts.

Also, "New Messages" divider line is a pure component now (via `memo`) to reduce unnecessary re-renders here too.

Co-authored-by: Amit Uttam <changingrainbows@gmail.com>
2020-02-07 14:16:49 -07:00
Mattermost Build
4b016a5272 Fix typo in error.message (#3899)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-07 09:54:16 -03:00
Mattermost Build
b7970c3a34 Remove ChannelPeek (#3897)
Introduced large number of unnecessary re-renders when opening channels, and ideally should not be a part of the channel switching/opening code path. Although this was discovered while trying to investigate an [Android-specific issue](https://mattermost.atlassian.net/browse/MM-22253), this extra code path made it difficult to see what Android is potentially doing differently than iOS.

Functionality originally introduced in #1203.

Conversation for removal is [here](https://community.mattermost.com/core/pl/hfcogf6pr7rw8k3ryq14c69c7e)
2020-02-06 22:46:09 -03:00
Mattermost Build
6806337b23 Automated cherry pick of #3892 (#3895)
* Check agains Permissions.RESULTS.GRANTED

* Fix file and image upload as well

* Fix permissions

* Make linter happy

* Use toHaveBeenCalledWith

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-06 12:14:44 -07:00
Mattermost Build
51e6b1e1aa Automated cherry pick of #3890 (#3893)
* Use dismissModal to close ChannelInfo screen

* Missing semicolon

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-05 17:22:43 -07:00
Mattermost Build
dc7f068b15 Bump app build number to 264 (#3889)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-04 14:57:19 -07:00
Mattermost Build
3daa365e44 Automated cherry pick of #3884 (#3887)
* Handle iOS reply action on native side

* Make linter happy

* Revert rn vector changes to .pbxproj

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-02-04 08:53:00 -07:00
Mattermost Build
5f0df6eb49 MM-22165 Fix channel sidebar close gesture (#3882)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-02-01 13:15:32 -03:00
Mattermost Build
ccc9e7c75c Automated cherry pick of #3877 (#3878)
* Bump app build number to 263

* Update ESR

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-30 10:39:44 -03:00
Mattermost Build
61c9110d41 Automated cherry pick of #3874 (#3876)
* Import from semver/preload

* Add unit tests

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-01-30 09:59:53 -03:00
Mattermost Build
bf73bf4ecc Bump app build number to 262 (#3872)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-01-28 15:47:53 -07:00
Mattermost Build
c04d2e6040 Automated cherry pick of #3865 (#3870)
* Sort emojis in EmojiPicker

* Pass search term to compareEmojis

* Sort emojis that include search term first

* Fix sorting

* Handle compareEmojis without search term

* Return doDefaultComparison

* Check includes only if needed

* Make linter happy

* Use doDefaultComparison

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-01-28 15:09:04 -07:00
Mattermost Build
1e0ead398f Automated cherry pick of #3860 (#3868)
* Fix iOS photo/camera denied permissions

* Add unit test and rename files

* Request for permission if returns denied

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-28 16:56:18 -03:00
Mattermost Build
352a103b48 Handle links with no server URL provided (#3867)
Default to current server or site URL.

Example case: `<jump to convo>` links generated from autolink plugin have their server/site URL stripped, and it is assumed that generated links are relative to the current server.

Conversation: https://community.mattermost.com/core/pl/78j4a7ozupbci8qxwx1sczc1ua
2020-01-28 09:18:58 -03:00
Mattermost Build
1f3ffee26f iOS Slide open main sidebar from anywhere (#3866)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-27 19:57:46 -07:00
Mattermost Build
8be5649ee6 MM-21961 Fix Incorrect Timestamp on Android Mobile App (#3864)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-27 13:00:58 -07:00
Mattermost Build
1d75287892 Automated cherry pick of #3845 (#3863)
* Don't use localPath when it's the share extension cache dir

* Move android pasted images to cache image folder

* Use Files.move instead of FileInput / FileOutput stream

* Remove commented code and not needed imports

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-27 12:59:36 -07:00
Mattermost Build
96e017e9eb Handle com.compuserve.gif type with dataForPasteboardType (#3857)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-01-24 10:45:29 -07:00
Mattermost Build
44c3910ce6 Automated cherry pick of #3848 (#3849)
* Bump app build number to 261

* Bump app version number to 1.28.0

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-23 13:38:09 -03:00
Mattermost Build
23db3b75e2 Temporary replace Hermes with V8 (#3850)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-23 09:30:12 -07:00
Mattermost Build
dddcbefefe Call Linking.getInitialURL() in launchApp (#3844)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-01-22 11:45:19 -07:00
Mattermost Build
a7dc68b40b Fix Android unsigned releases to work with Hermes (#3842) 2020-01-21 15:50:27 -03:00
Andre Vasconcelos
0b81a9b4e0 Fixing post draft style to comply with design specs (#3818)
* Polishing post draft to comply with design specs

* changed maxHeight in landscape mode, fixed icon sizes

- Refactored code so that post draft icon sizes are taken from same constant value

- Set maxHeight value in landscape mode to be smaller (tests pending)

- Removed repeated styles for button wrappers (passing them down as props to child components)

- Increased size of image attachment remote icon, and increased tappable area

* Removing repeated logic for file upload

* Fixing failed snapshot tests / style checks

* Fixing file upload remove icon to have 64% opacity

* post draft UX/UI improvements

* Fix input box extra spacing

* input box line height and attachment border

* Animate to original state even if error is showing

* Fix permissions

* Improve attachment error animation

* Fix iOS post input height

* Update snapshots
2020-01-21 15:25:28 -03:00
Mattermost Build
e05207412f MM-21723 Handle deep link errors to inaccessible teams, channels & permalinks (#3840)
* Consolidated error handling for a user's reachable teams.
* Consolidated error handling for a user's reachable channels.
2020-01-21 14:47:10 -03:00
Mattermost Build
3b909101f2 MM-21892 Fix TypeError cause by mm-redux#1006 (#3839)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-21 13:30:57 -03:00
Mattermost Build
96f5ae009d Bump app version number to 1.27.1 (#3834)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-01-20 12:33:32 -07:00
Mattermost Build
a44032f0fb Bump app build number to 260 (#3831)
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-01-20 12:24:45 -07:00
Mattermost Build
9dd5a1c2ed Set default app scheme to mattermost (#3828)
Was originally set to `mattermost-beta` as per #3767 .
2020-01-20 16:18:55 -03:00
Elias Nahum
0c42c0d976 Deps update (#3806)
* Dependecy updates

* Update dependencies
2020-01-20 13:22:07 -03:00
Mattermost Build
e8398cb880 MM-21634 Fix keyboard glitch when returning to channel screen from the code screen (#3824)
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2020-01-20 12:53:34 -03:00
Mattermost Build
4d83724092 Automated cherry pick of #3819 (#3821)
* Dispatch loadConfigAndLicense on successful login

* Emit server version changed event

* Make linter happy

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
2020-01-20 08:30:59 -07:00
Mattermost Build
8f8d32ff7a Automated cherry pick of #3793 (#3820)
* MM-21632: fix toggling interactive dialog boolean

My fix for MM-17519 broke the /rendering/ of the boolean toggle, but not
the underlying interactive dialog state (and the thrust of the original
issue).

* MM-21683: fix handling of boolean defaults

* unit tests

Co-authored-by: Jesse Hallam <jesse.hallam@gmail.com>
2020-01-20 21:33:31 +08:00
254 changed files with 11205 additions and 5082 deletions

View File

@@ -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+

View File

@@ -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 268
versionName "1.28.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

View File

@@ -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" />

View File

@@ -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());

View File

@@ -16,8 +16,11 @@ 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.nio.file.Files;
import java.nio.file.Paths;
import java.util.regex.Matcher;
public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteListener {
@@ -83,6 +86,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 +130,17 @@ public class RNPasteableEditTextOnPasteListener implements RNEditTextOnPasteList
event
);
}
private String moveToImagesCache(String src, String fileName) {
ReactContext ctx = (ReactContext)mEditText.getContext();
String dest = ctx.getCacheDir().getAbsolutePath() + "/Images/" + fileName;
try {
Files.move(Paths.get(src), Paths.get(dest));
} catch (Exception err) {
return null;
}
return dest;
}
}

View File

@@ -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();
}

View File

@@ -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();

View File

@@ -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"

View File

@@ -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'

View File

@@ -37,6 +37,8 @@ import {
} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamId, getTeamByName} from 'mattermost-redux/selectors/entities/teams';
import {getChannelReachable} from 'app/selectors/channel';
import telemetry from 'app/telemetry';
import {
@@ -62,12 +64,16 @@ export function loadChannelsIfNecessary(teamId) {
};
}
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));
}
@@ -400,7 +406,7 @@ export function handleSelectChannel(channelId, fromPushNotification = false) {
};
}
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 +415,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};
}

View File

@@ -135,6 +135,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);
@@ -181,6 +184,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);

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -23,7 +23,7 @@ describe('AnnouncementBanner', () => {
test('should match snapshot', () => {
const wrapper = shallow(
<AnnouncementBanner {...baseProps}/>
<AnnouncementBanner {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();

View File

@@ -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'}});

View File

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

View File

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

View File

@@ -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);

View File

@@ -24,7 +24,7 @@ const getEmojisByName = createSelector(
}
return Array.from(emoticons);
}
},
);
function mapStateToProps(state) {

View File

@@ -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),

View File

@@ -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(),

View File

@@ -18,7 +18,7 @@ describe('Badge', () => {
test('should match snapshot', () => {
const wrapper = shallow(
<Badge {...baseProps}/>
<Badge {...baseProps}/>,
);
expect(wrapper.instance().renderText()).toMatchSnapshot();

View File

@@ -24,7 +24,7 @@ function makeMapStateToProps() {
(currentUserId, profilesInChannel) => {
const currentChannelMembers = profilesInChannel || [];
return currentChannelMembers.filter((m) => m.id !== currentUserId);
}
},
);
return function mapStateToProps(state, ownProps) {

View File

@@ -24,7 +24,7 @@ function makeGetChannelNamesMap() {
}
return channelsNameMap;
}
},
);
}

View File

@@ -93,7 +93,7 @@ export default class ChannelLoader extends PureComponent {
stopLoadingAnimation = () => {
Animated.timing(
this.state.barsOpacity
this.state.barsOpacity,
).stop();
}

View File

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

View File

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

View File

@@ -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

View File

@@ -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();

View File

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

View File

@@ -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) {

View File

@@ -31,7 +31,7 @@ export default class Fade extends PureComponent {
toValue: prevProps.visible ? 0 : 1,
duration: this.props.duration || FADE_DURATION,
useNativeDriver: true,
}
},
).start();
}
}

View File

@@ -24,7 +24,7 @@ describe('Fade', () => {
{...props}
>
<Text>{dummyText}</Text>
</Fade>
</Fade>,
);
}

View File

@@ -37,7 +37,7 @@ describe('FileAttachment', () => {
test('should match snapshot', () => {
const wrapper = shallow(
<FileAttachment {...baseProps}/>
<FileAttachment {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();

View File

@@ -256,7 +256,7 @@ export default class FileAttachmentDocument extends PureComponent {
id: 'mobile.server_upgrade.button',
defaultMessage: 'OK',
}),
}]
}],
);
this.onDonePreviewingFile();
RNFetchBlob.fs.unlink(path);
@@ -303,7 +303,7 @@ export default class FileAttachmentDocument extends PureComponent {
id: 'mobile.server_upgrade.button',
defaultMessage: 'OK',
}),
}]
}],
);
};
@@ -324,7 +324,7 @@ export default class FileAttachmentDocument extends PureComponent {
id: 'mobile.server_upgrade.button',
defaultMessage: 'OK',
}),
}]
}],
);
};

View File

@@ -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,
},
}));

View File

@@ -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', () => {

View File

@@ -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',
},
};
});

View File

@@ -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,
}),
},
};
});

View File

@@ -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,
},
});

View File

@@ -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);

View File

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

View File

@@ -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 () => {

View File

@@ -19,7 +19,7 @@ describe('MarkdownEmoji', () => {
test('should match snapshot', () => {
const wrapper = shallow(
<MarkdownEmoji {...baseProps}/>
<MarkdownEmoji {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();

View File

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

View File

@@ -32,7 +32,7 @@ describe('MarkdownTable', () => {
test('should match snapshot', () => {
const wrapper = shallowWithIntl(
<MarkdownTable {...baseProps}/>
<MarkdownTable {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
@@ -40,7 +40,7 @@ describe('MarkdownTable', () => {
test('should slice rows and columns', () => {
const wrapper = shallowWithIntl(
<MarkdownTable {...baseProps}/>
<MarkdownTable {...baseProps}/>,
);
const {maxPreviewColumns} = wrapper.state();

View File

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

View File

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

View File

@@ -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>,
);
}

View File

@@ -55,7 +55,7 @@ export default class MessageAttachments extends PureComponent {
postId={postId}
theme={theme}
textStyles={textStyles}
/>
/>,
);
});

View File

@@ -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();
});

View File

@@ -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>
`;

View File

@@ -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();
});

View File

@@ -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}
/>
);
}
}

View File

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

View File

@@ -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);

View File

@@ -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 = [

View File

@@ -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 {

View File

@@ -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();
});

View File

@@ -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();

View File

@@ -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);

View File

@@ -18,6 +18,8 @@ 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';
@@ -165,12 +167,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) {
// We still have less than 1 screen of posts loaded with more to get, so load more
this.props.onLoadMoreUp();
}
});
}
};
handleDeepLink = (url) => {
@@ -180,7 +184,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 +205,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 +237,7 @@ export default class PostList extends PureComponent {
if (onPermalinkPress) {
onPermalinkPress(postId, true);
} else {
actions.loadChannelsByTeamName(teamName);
actions.loadChannelsByTeamName(teamName, this.errorBadTeam);
this.showPermalinkView(postId);
}
};
@@ -377,7 +403,9 @@ export default class PostList extends PureComponent {
resetPostList = () => {
this.contentOffsetY = 0;
this.hasDoneInitialScroll = false;
this.setState({contentHeight: 0});
if (this.state.contentHeight !== 0) {
this.setState({contentHeight: 0});
}
}
scrollToIndex = (index) => {
@@ -401,7 +429,7 @@ export default class PostList extends PureComponent {
}
};
showPermalinkView = (postId) => {
showPermalinkView = (postId, error = '') => {
const {actions} = this.props;
actions.selectFocusedPostId(postId);
@@ -411,6 +439,7 @@ export default class PostList extends PureComponent {
const passProps = {
isPermalink: true,
onClose: this.handleClosePermalink,
error,
};
const options = {
layout: {

View File

@@ -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,7 +103,7 @@ 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();

View File

@@ -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>

View File

@@ -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>
`;

View File

@@ -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>
`;

View File

@@ -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>
`;

View File

@@ -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,
},
});

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

View File

@@ -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,
},
});

View File

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

View File

@@ -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,
},
});

View File

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

View File

@@ -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';
@@ -344,7 +344,7 @@ describe('PostTextBox', () => {
mockResolvedValue({data: 'success'});
const wrapper = shallowWithIntl(
<PostTextbox {...props}/>
<PostTextbox {...props}/>,
);
const msg = '/fail preserve this text in the post draft';

View File

@@ -8,8 +8,6 @@ import {
AppState,
BackHandler,
findNodeHandle,
Image,
InteractionManager,
Keyboard,
NativeModules,
Platform,
@@ -17,8 +15,10 @@ import {
TouchableOpacity,
ScrollView,
View,
Image,
} from 'react-native';
import {intlShape} from 'react-intl';
import RNFetchBlob from 'rn-fetch-blob';
import Button from 'react-native-button';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import slashForwardBoxIcon from 'assets/images/icons/slash-forward-box.png';
@@ -27,15 +27,15 @@ import {General, RequestStatus} from 'mattermost-redux/constants';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {getFormattedFileSize} from 'mattermost-redux/utils/file_utils';
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 FormattedMarkdownText from 'app/components/formatted_markdown_text';
import FormattedText from 'app/components/formatted_text';
import PasteableTextInput from 'app/components/pasteable_text_input';
import {paddingHorizontal as padding} from 'app/components/safe_area_view/iphone_x_spacing';
import SendButton from 'app/components/send_button';
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT, IS_REACTION_REGEX, MAX_FILE_COUNT} from 'app/constants/post_textbox';
import {INSERT_TO_COMMENT, INSERT_TO_DRAFT, IS_REACTION_REGEX, MAX_FILE_COUNT, ICON_SIZE} from 'app/constants/post_textbox';
import {NOTIFY_ALL_MEMBERS} from 'app/constants/view';
import FileUploadPreview from 'app/components/file_upload_preview';
@@ -49,6 +49,8 @@ import {
} from 'app/utils/theme';
const {RNTextInputReset} = NativeModules;
const INPUT_LINE_HEIGHT = 20;
const EXTRA_INPUT_PADDING = 3;
export default class PostTextBoxBase extends PureComponent {
static propTypes = {
@@ -120,6 +122,7 @@ export default class PostTextBoxBase extends PureComponent {
channelId: props.channelId,
channelTimezoneCount: 0,
longMessageAlertShown: false,
extraInputPadding: 0,
};
}
@@ -239,13 +242,23 @@ export default class PostTextBoxBase extends PureComponent {
}
};
startAtMention = () => {
this.handleTextChange(`${this.state.value}@`, true);
this.focus();
};
startSlashCommand = () => {
this.handleTextChange('/', true);
this.focus();
};
getTextInputButton = (actionType) => {
const {channelIsReadOnly, theme} = this.props;
const style = getStyleSheet(theme);
let button = null;
const buttonStyle = [];
let iconColor = theme.centerChannelColor;
let iconColor = changeOpacity(theme.centerChannelColor, 0.64);
let isDisabled = false;
if (!channelIsReadOnly) {
@@ -253,21 +266,18 @@ export default class PostTextBoxBase extends PureComponent {
case 'at':
isDisabled = this.state.value[this.state.value.length - 1] === '@';
if (isDisabled) {
iconColor = changeOpacity(theme.centerChannelColor, 0.6);
iconColor = changeOpacity(theme.centerChannelColor, 0.16);
}
button = (
<TouchableOpacity
disabled={isDisabled}
onPress={() => {
this.handleTextChange(`${this.state.value}@`, true);
this.focus();
}}
onPress={this.startAtMention}
style={style.iconWrapper}
>
<MaterialCommunityIcons
color={iconColor}
name='at'
size={20}
size={ICON_SIZE}
/>
</TouchableOpacity>
);
@@ -282,10 +292,7 @@ export default class PostTextBoxBase extends PureComponent {
button = (
<TouchableOpacity
disabled={isDisabled}
onPress={() => {
this.handleTextChange('/', true);
this.focus();
}}
onPress={this.startSlashCommand}
style={style.iconWrapper}
>
<Image
@@ -303,6 +310,7 @@ export default class PostTextBoxBase extends PureComponent {
getMediaButton = (actionType) => {
const {canUploadFiles, channelIsReadOnly, files, maxFileSize, theme} = this.props;
const style = getStyleSheet(theme);
let button = null;
const props = {
blurTextBox: this.blur,
@@ -313,6 +321,7 @@ export default class PostTextBoxBase extends PureComponent {
uploadFiles: this.handleUploadFiles,
maxFileSize,
theme,
buttonContainerStyle: style.iconWrapper,
};
if (canUploadFiles && !channelIsReadOnly) {
@@ -504,8 +513,20 @@ export default class PostTextBoxBase extends PureComponent {
}
};
handleUploadFiles = (files) => {
this.props.actions.initUploadFiles(files, this.props.rootId);
handleUploadFiles = 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.fileSize > this.props.maxFileSize) {
this.onShowFileSizeWarning(file.fileName);
} else {
this.props.actions.initUploadFiles(files, this.props.rootId);
}
};
isFileLoading = () => {
@@ -621,10 +642,26 @@ export default class PostTextBoxBase extends PureComponent {
actions.handleClearFiles(channelId, rootId);
}
InteractionManager.runAfterInteractions(() => {
if (Platform.OS === 'ios') {
// On iOS, if the PostTextbox height increases from its
// initial height (due to a multiline post or a post whose
// message wraps, for example), then when the text is cleared
// the PostTextbox height decrease will be animated. This
// animation in conjunction with the PostList animation as it
// receives the newly created post is causing issues in the iOS
// PostList component as it fails to properly react to its content
// size changes. While a proper fix is determined for the PostList
// component, a small delay in triggering the height decrease
// animation gives the PostList enough time to first handle content
// size changes from the new post.
setTimeout(() => {
this.handleTextChange('');
this.setState({sendingMessage: false});
}, 250);
} else {
this.handleTextChange('');
this.setState({sendingMessage: false});
});
}
this.changeDraft('');
@@ -722,7 +759,7 @@ export default class PostTextBoxBase extends PureComponent {
EventEmitter.emit('fileSizeWarning', fileSizeWarning);
setTimeout(() => {
EventEmitter.emit('fileSizeWarning', null);
}, 3000);
}, 5000);
};
onCloseChannelPress = () => {
@@ -808,6 +845,16 @@ export default class PostTextBoxBase extends PureComponent {
}
};
handleInputSizeChange = ({nativeEvent: {contentSize}}) => {
const {extraInputPadding} = this.state;
const numLines = contentSize.height / INPUT_LINE_HEIGHT;
if (numLines >= 2 && extraInputPadding !== EXTRA_INPUT_PADDING) {
this.setState({extraInputPadding: EXTRA_INPUT_PADDING});
} else if (numLines < 2 && extraInputPadding !== 0) {
this.setState({extraInputPadding: 0});
}
}
renderDeactivatedChannel = () => {
const {intl} = this.context;
const style = getStyleSheet(this.props.theme);
@@ -831,10 +878,22 @@ export default class PostTextBoxBase extends PureComponent {
return this.archivedView(theme, style);
}
const {value} = this.state;
const {value, extraInputPadding} = this.state;
const textValue = channelIsLoading ? '' : value;
const placeholder = this.getPlaceHolder();
let maxHeight = 150;
if (isLandscape) {
maxHeight = 88;
}
const inputStyle = {};
if (extraInputPadding) {
inputStyle.paddingBottom = style.input.paddingBottom + extraInputPadding;
inputStyle.paddingTop = style.input.paddingTop + extraInputPadding;
}
return (
<View
style={[style.inputWrapper, padding(isLandscape)]}
@@ -854,6 +913,7 @@ export default class PostTextBoxBase extends PureComponent {
<PasteableTextInput
ref={this.input}
value={textValue}
style={{...style.input, ...inputStyle, maxHeight}}
onChangeText={this.handleTextChange}
onSelectionChange={this.handlePostDraftSelectionChanged}
placeholder={intl.formatMessage(placeholder, {channelDisplayName})}
@@ -861,40 +921,43 @@ export default class PostTextBoxBase extends PureComponent {
multiline={true}
blurOnSubmit={false}
underlineColorAndroid='transparent'
style={style.input}
keyboardType={this.state.keyboardType}
onEndEditing={this.handleEndEditing}
disableFullscreenUI={true}
editable={!channelIsReadOnly}
onPaste={this.handlePasteFiles}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
onContentSizeChange={Platform.OS === 'android' ? this.handleInputSizeChange : null}
/>
<FileUploadPreview
files={files}
rootId={rootId}
/>
<View style={style.buttonsContainer}>
<View style={style.quickActionsContainer}>
{this.getTextInputButton('at')}
{this.getTextInputButton('slash')}
{this.getMediaButton('file')}
{this.getMediaButton('image')}
{this.getMediaButton('camera')}
</View>
<SendButton
disabled={!this.isSendButtonEnabled()}
handleSendMessage={this.handleSendMessage}
theme={theme}
{!channelIsReadOnly &&
<React.Fragment>
<FileUploadPreview
files={files}
rootId={rootId}
/>
</View>
<View style={style.buttonsContainer}>
<View style={style.quickActionsContainer}>
{this.getTextInputButton('at')}
{this.getTextInputButton('slash')}
{this.getMediaButton('file')}
{this.getMediaButton('image')}
{this.getMediaButton('camera')}
</View>
<SendButton
disabled={!this.isSendButtonEnabled()}
handleSendMessage={this.handleSendMessage}
theme={theme}
/>
</View>
</React.Fragment>
}
</ScrollView>
</View>
);
@@ -908,48 +971,61 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingBottom: Platform.select({
ios: 1,
android: 2,
}),
},
slashIcon: {
width: 20,
height: 20,
width: ICON_SIZE,
height: ICON_SIZE,
opacity: 1,
tintColor: theme.centerChannelColor,
tintColor: changeOpacity(theme.centerChannelColor, 0.64),
},
iconDisabled: {
tintColor: changeOpacity(theme.centerChannelColor, 0.6),
tintColor: changeOpacity(theme.centerChannelColor, 0.16),
},
iconWrapper: {
paddingLeft: 10,
paddingRight: 10,
alignItems: 'center',
justifyContent: 'center',
padding: 10,
},
quickActionsContainer: {
display: 'flex',
flexDirection: 'row',
height: 44,
},
input: {
color: theme.centerChannelColor,
fontSize: 14,
paddingBottom: 8,
paddingLeft: 12,
paddingRight: 12,
paddingTop: 8,
maxHeight: 150,
fontSize: 15,
lineHeight: INPUT_LINE_HEIGHT,
paddingHorizontal: 12,
paddingTop: Platform.select({
ios: 6,
android: 8,
}),
paddingBottom: Platform.select({
ios: 6,
android: 2,
}),
minHeight: 30,
},
inputContainer: {
flex: 1,
flexDirection: 'column',
backgroundColor: theme.centerChannelBg,
marginRight: 10,
marginLeft: 10,
},
inputContentContainer: {
alignItems: 'stretch',
paddingTop: Platform.select({
ios: 7,
android: 0,
}),
},
inputWrapper: {
alignItems: 'flex-end',
flexDirection: 'row',
justifyContent: 'center',
paddingVertical: 4,
paddingBottom: 2,
backgroundColor: theme.centerChannelBg,
borderTopWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.20),

View File

@@ -58,7 +58,7 @@ describe('ProgressiveImage', () => {
<ProgressiveImage
{...baseProps}
thumbnailUri={null}
/>
/>,
);
const instance = wrapper.instance();
jest.spyOn(instance, 'setImage');
@@ -73,7 +73,7 @@ describe('ProgressiveImage', () => {
<ProgressiveImage
{...baseProps}
imageUri={null}
/>
/>,
);
const instance = wrapper.instance();
jest.spyOn(instance, 'setThumbnail');

View File

@@ -44,14 +44,14 @@ class RadioButton extends PureComponent {
{
toValue: 1,
duration: 150,
}
},
).start();
Animated.timing(
this.state.opacityValue,
{
toValue: 0.1,
duration: 100,
}
},
).start();
};
@@ -61,13 +61,13 @@ class RadioButton extends PureComponent {
{
toValue: 0.001,
duration: 1500,
}
},
).start();
Animated.timing(
this.state.opacityValue,
{
toValue: 0,
}
},
).start();
};

View File

@@ -162,13 +162,13 @@ export default class Reactions extends PureComponent {
case 'right':
reactionElements.push(
this.renderReactions(),
addMoreReactions
addMoreReactions,
);
break;
case 'left':
reactionElements.push(
addMoreReactions,
this.renderReactions()
this.renderReactions(),
);
break;
}

View File

@@ -103,6 +103,7 @@ export default class Root extends PureComponent {
return (
<IntlProvider
key={locale}
ref={this.setProviderRef}
locale={locale}
messages={getTranslations(locale)}

View File

@@ -111,7 +111,7 @@ export default class SafeAreaIos extends PureComponent {
if (this.mounted) {
this.setState({statusBarHeight: statusBarFrameData.height});
}
}
},
);
} catch (e) {
// not needed

View File

@@ -80,7 +80,7 @@ describe('SafeAreaIos', () => {
test('should match snapshot', () => {
const wrapper = shallow(
<SafeAreaIos {...baseProps}/>
<SafeAreaIos {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
@@ -91,7 +91,7 @@ describe('SafeAreaIos', () => {
mattermostManaged.hasSafeAreaInsets = false;
const wrapper = shallow(
<SafeAreaIos {...baseProps}/>
<SafeAreaIos {...baseProps}/>,
);
expect(SafeArea.getSafeAreaInsetsForRootView).toHaveBeenCalled();
@@ -104,7 +104,7 @@ describe('SafeAreaIos', () => {
mattermostManaged.hasSafeAreaInsets = true;
const wrapper = shallow(
<SafeAreaIos {...baseProps}/>
<SafeAreaIos {...baseProps}/>,
);
expect(SafeArea.getSafeAreaInsetsForRootView).toHaveBeenCalled();
@@ -117,7 +117,7 @@ describe('SafeAreaIos', () => {
mattermostManaged.hasSafeAreaInsets = false;
const wrapper = shallow(
<SafeAreaIos {...baseProps}/>
<SafeAreaIos {...baseProps}/>,
);
expect(SafeArea.getSafeAreaInsetsForRootView).not.toHaveBeenCalled();
@@ -130,7 +130,7 @@ describe('SafeAreaIos', () => {
mattermostManaged.hasSafeAreaInsets = false;
const wrapper = shallow(
<SafeAreaIos {...baseProps}/>
<SafeAreaIos {...baseProps}/>,
);
expect(wrapper.state().safeAreaInsets).not.toEqual(TEST_INSETS_2.safeAreaInsets);
@@ -145,7 +145,7 @@ describe('SafeAreaIos', () => {
mattermostManaged.hasSafeAreaInsets = true;
const wrapper = shallow(
<SafeAreaIos {...baseProps}/>
<SafeAreaIos {...baseProps}/>,
);
expect(wrapper.state().safeAreaInsets).not.toEqual(TEST_INSETS_2.safeAreaInsets);
@@ -160,7 +160,7 @@ describe('SafeAreaIos', () => {
mattermostManaged.hasSafeAreaInsets = false;
const wrapper = shallow(
<SafeAreaIos {...baseProps}/>
<SafeAreaIos {...baseProps}/>,
);
expect(wrapper.state().safeAreaInsets).not.toEqual(TEST_INSETS_2.safeAreaInsets);
@@ -175,7 +175,7 @@ describe('SafeAreaIos', () => {
mattermostManaged.hasSafeAreaInsets = true;
const wrapper = shallow(
<SafeAreaIos {...baseProps}/>
<SafeAreaIos {...baseProps}/>,
);
expect(wrapper.state().safeAreaInsets).not.toEqual(TEST_INSETS_2.safeAreaInsets);
@@ -188,7 +188,7 @@ describe('SafeAreaIos', () => {
test('should set portrait safe area insets', () => {
const wrapper = shallow(
<SafeAreaIos {...baseProps}/>
<SafeAreaIos {...baseProps}/>,
);
expect(wrapper.state().safeAreaInsets).not.toEqual(PORTRAIT_INSETS.safeAreaInsets);
@@ -205,7 +205,7 @@ describe('SafeAreaIos', () => {
test('should set portrait safe area insets from EphemeralStore', () => {
const wrapper = shallow(
<SafeAreaIos {...baseProps}/>
<SafeAreaIos {...baseProps}/>,
);
EphemeralStore.safeAreaInsets[PORTRAIT] = PORTRAIT_INSETS.safeAreaInsets;
@@ -221,7 +221,7 @@ describe('SafeAreaIos', () => {
test('should set landscape safe area insets', () => {
const wrapper = shallow(
<SafeAreaIos {...baseProps}/>
<SafeAreaIos {...baseProps}/>,
);
expect(wrapper.state().safeAreaInsets).not.toEqual(LANDSCAPE_INSETS.safeAreaInsets);
@@ -238,7 +238,7 @@ describe('SafeAreaIos', () => {
test('should set landscape safe area insets from EphemeralStore', () => {
const wrapper = shallow(
<SafeAreaIos {...baseProps}/>
<SafeAreaIos {...baseProps}/>,
);
EphemeralStore.safeAreaInsets[LANDSCAPE] = LANDSCAPE_INSETS.safeAreaInsets;
@@ -258,7 +258,7 @@ describe('SafeAreaIos', () => {
expect(EphemeralStore.safeAreaInsets[PORTRAIT]).toEqual(null);
expect(EphemeralStore.safeAreaInsets[LANDSCAPE]).toEqual(null);
let wrapper = shallow(
<SafeAreaIos {...baseProps}/>
<SafeAreaIos {...baseProps}/>,
);
let instance = wrapper.instance();
expect(addEventListener).toHaveBeenCalledWith('safeAreaInsetsForRootViewDidChange', instance.onSafeAreaInsetsForRootViewChange);
@@ -266,7 +266,7 @@ describe('SafeAreaIos', () => {
EphemeralStore.safeAreaInsets[PORTRAIT] = TEST_INSETS_1.safeAreaInsets;
wrapper = shallow(
<SafeAreaIos {...baseProps}/>
<SafeAreaIos {...baseProps}/>,
);
instance = wrapper.instance();
expect(addEventListener).toHaveBeenCalledWith('safeAreaInsetsForRootViewDidChange', instance.onSafeAreaInsetsForRootViewChange);
@@ -275,7 +275,7 @@ describe('SafeAreaIos', () => {
EphemeralStore.safeAreaInsets[PORTRAIT] = TEST_INSETS_1.safeAreaInsets;
EphemeralStore.safeAreaInsets[LANDSCAPE] = TEST_INSETS_1.safeAreaInsets;
wrapper = shallow(
<SafeAreaIos {...baseProps}/>
<SafeAreaIos {...baseProps}/>,
);
instance = wrapper.instance();
expect(addEventListener).not.toHaveBeenCalled();
@@ -285,7 +285,7 @@ describe('SafeAreaIos', () => {
const removeEventListener = jest.spyOn(SafeArea, 'removeEventListener');
const wrapper = shallow(
<SafeAreaIos {...baseProps}/>
<SafeAreaIos {...baseProps}/>,
);
const instance = wrapper.instance();
expect(EphemeralStore.safeAreaInsets[PORTRAIT]).toEqual(null);

View File

@@ -202,7 +202,7 @@ export default class Search extends Component {
toValue: (text.length > 0) ? 1 : 0,
duration: 200,
useNativeDriver: true,
}
},
).start();
if (this.props.onChangeText) {
@@ -228,7 +228,7 @@ export default class Search extends Component {
toValue: 0,
duration: 200,
useNativeDriver: true,
}
},
).start();
this.focus();
@@ -258,42 +258,42 @@ export default class Search extends Component {
{
toValue: this.contentWidth - 90,
duration: 200,
}
},
),
Animated.timing(
this.inputFocusAnimated,
{
toValue: this.state.leftComponentWidth,
duration: 200,
}
},
),
Animated.timing(
this.leftComponentAnimated,
{
toValue: this.contentWidth,
duration: 200,
}
},
),
Animated.timing(
this.btnCancelAnimated,
{
toValue: this.state.leftComponentWidth ? 15 - this.state.leftComponentWidth : 5,
duration: 200,
}
},
),
Animated.timing(
this.inputFocusPlaceholderAnimated,
{
toValue: this.props.placeholderExpandedMargin,
duration: 200,
}
},
),
Animated.timing(
this.iconSearchAnimated,
{
toValue: this.props.searchIconExpandedMargin,
duration: 200,
}
},
),
Animated.timing(
this.iconDeleteAnimated,
@@ -301,7 +301,7 @@ export default class Search extends Component {
toValue: (this.props.value.length > 0) ? 1 : 0,
duration: 200,
useNativeDriver: true,
}
},
),
Animated.timing(
this.shadowOpacityAnimated,
@@ -309,7 +309,7 @@ export default class Search extends Component {
toValue: this.props.shadowOpacityExpanded,
duration: 200,
useNativeDriver: true,
}
},
),
]).start();
this.shadowHeight = this.props.shadowOffsetHeightExpanded;
@@ -326,28 +326,28 @@ export default class Search extends Component {
{
toValue: this.contentWidth - this.state.leftComponentWidth - this.props.inputCollapsedMargin,
duration: 200,
}
},
),
Animated.timing(
this.inputFocusAnimated,
{
toValue: 0,
duration: 200,
}
},
),
Animated.timing(
this.leftComponentAnimated,
{
toValue: 0,
duration: 200,
}
},
),
Animated.timing(
this.btnCancelAnimated,
{
toValue: this.contentWidth,
duration: 200,
}
},
),
((this.props.keyboardShouldPersist === false) ?
Animated.timing(
@@ -355,7 +355,7 @@ export default class Search extends Component {
{
toValue: this.props.placeholderCollapsedMargin,
duration: 200,
}
},
) : null),
((this.props.keyboardShouldPersist === false || isForceAnim === true) ?
Animated.timing(
@@ -363,7 +363,7 @@ export default class Search extends Component {
{
toValue: (this.props.searchIconCollapsedMargin + this.state.leftComponentWidth),
duration: 200,
}
},
) : null),
Animated.timing(
this.iconDeleteAnimated,
@@ -371,7 +371,7 @@ export default class Search extends Component {
toValue: 0,
duration: 200,
useNativeDriver: true,
}
},
),
Animated.timing(
this.shadowOpacityAnimated,
@@ -379,7 +379,7 @@ export default class Search extends Component {
toValue: this.props.shadowOpacityCollapsed,
duration: 200,
useNativeDriver: true,
}
},
),
]).start(({finished}) => this.props.onAnimationComplete(finished));
this.shadowHeight = this.props.shadowOffsetHeightCollapsed;

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {Platform, View} from 'react-native';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import TouchableWithFeedback from 'app/components/touchable_with_feedback';
@@ -18,19 +18,15 @@ function SendButton(props) {
PaperPlane = require('app/components/paper_plane').default;
}
const icon = (
<PaperPlane
height={13}
width={15}
color={theme.buttonColor}
/>
);
if (props.disabled) {
return (
<View style={style.sendButtonContainer}>
<View style={[style.sendButton, style.disableButton]}>
{icon}
<PaperPlane
height={16}
width={19}
color={changeOpacity(theme.buttonColor, 0.5)}
/>
</View>
</View>
);
@@ -43,7 +39,11 @@ function SendButton(props) {
type={'opacity'}
>
<View style={style.sendButton}>
{icon}
<PaperPlane
height={16}
width={19}
color={theme.buttonColor}
/>
</View>
</TouchableWithFeedback>
);
@@ -62,20 +62,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
},
sendButtonContainer: {
justifyContent: 'flex-end',
paddingHorizontal: 5,
paddingVertical: Platform.select({
android: 8,
ios: 2,
}),
paddingRight: 8,
},
sendButton: {
backgroundColor: theme.buttonBg,
borderRadius: 4,
height: 28,
width: 72,
height: 32,
width: 80,
alignItems: 'center',
justifyContent: 'center',
paddingLeft: 3,
},
};
});

View File

@@ -21,7 +21,7 @@ describe('SendButton', () => {
<SendButton
{...baseProps}
{...props}
/>
/>,
);
}

View File

@@ -20,7 +20,7 @@ describe('ShowMoreButton', () => {
test('should match, full snapshot', () => {
const wrapper = shallow(
<ShowMoreButton {...baseProps}/>
<ShowMoreButton {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
@@ -28,7 +28,7 @@ describe('ShowMoreButton', () => {
test('should match, button snapshot', () => {
const wrapper = shallow(
<ShowMoreButton {...baseProps}/>
<ShowMoreButton {...baseProps}/>,
);
expect(wrapper.instance().renderButton(true, {button: {}, sign: {}, text: {}})).toMatchSnapshot();
@@ -37,7 +37,7 @@ describe('ShowMoreButton', () => {
test('should LinearGradient exists', () => {
const wrapper = shallow(
<ShowMoreButton {...baseProps}/>
<ShowMoreButton {...baseProps}/>,
);
expect(wrapper.find(LinearGradient).exists()).toBe(true);
@@ -51,7 +51,7 @@ describe('ShowMoreButton', () => {
<ShowMoreButton
{...baseProps}
onPress={onPress}
/>
/>,
);
wrapper.find(TouchableWithFeedback).props().onPress();

View File

@@ -417,7 +417,11 @@ export default class DrawerLayout extends Component {
return true;
}
} else {
if (moveX <= 35 && dx > 0) {
const filter = Platform.select({
ios: moveX > 0 && dx > 35,
android: moveX <= 35 && dx > 0
});
if (filter) {
this._isClosing = false;
return true;
}
@@ -449,8 +453,9 @@ export default class DrawerLayout extends Component {
this._emitStateChanged(DRAGGING);
};
_panResponderMove = (e: EventType, { moveX }: PanResponderEventType) => {
let openValue = this._getOpenValueForX(moveX);
_panResponderMove = (e: EventType, { moveX, dx }: PanResponderEventType) => {
const useDx = Platform.OS === 'ios' && this.getDrawerPosition() === 'left' && !this._isClosing;
let openValue = this._getOpenValueForX(useDx ? dx : moveX);
if (this._isClosing) {
openValue = 1 - (this._closingAnchorValue - openValue);

View File

@@ -4,13 +4,11 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Animated,
TouchableHighlight,
Text,
View,
} from 'react-native';
import {intlShape} from 'react-intl';
import {Navigation} from 'react-native-navigation';
import {General} from 'mattermost-redux/constants';
import {paddingLeft as padding} from 'app/components/safe_area_view/iphone_x_spacing';
@@ -19,8 +17,6 @@ import ChannelIcon from 'app/components/channel_icon';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
const {View: AnimatedView} = Animated;
export default class ChannelItem extends PureComponent {
static propTypes = {
channelId: PropTypes.string.isRequired,
@@ -40,7 +36,6 @@ export default class ChannelItem extends PureComponent {
unreadMsgs: PropTypes.number.isRequired,
isSearchResult: PropTypes.bool,
isBot: PropTypes.bool.isRequired,
previewChannel: PropTypes.func,
isLandscape: PropTypes.bool.isRequired,
};
@@ -61,27 +56,6 @@ export default class ChannelItem extends PureComponent {
});
});
onPreview = ({reactTag}) => {
const {channelId, previewChannel} = this.props;
if (previewChannel) {
const {intl} = this.context;
const passProps = {
channelId,
};
const options = {
preview: {
reactTag,
actions: [{
id: 'action-mark-as-read',
title: intl.formatMessage({id: 'mobile.channel.markAsRead', defaultMessage: 'Mark As Read'}),
}],
},
};
previewChannel(passProps, options);
}
};
showChannelAsUnread = () => {
return this.props.mentions > 0 || (this.props.unreadMsgs > 0 && this.props.showUnreadForMsgs);
};
@@ -192,29 +166,25 @@ export default class ChannelItem extends PureComponent {
);
return (
<AnimatedView>
<Navigation.TouchablePreview
touchableComponent={TouchableHighlight}
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
onPress={this.onPress}
onPressIn={this.onPreview}
>
<View style={[style.container, mutedStyle, padding(isLandscape)]}>
{extraBorder}
<View style={[style.item, extraItemStyle]}>
{icon}
<Text
style={[style.text, extraTextStyle]}
ellipsizeMode='tail'
numberOfLines={1}
>
{channelDisplayName}
</Text>
{badge}
</View>
<TouchableHighlight
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
onPress={this.onPress}
>
<View style={[style.container, mutedStyle, padding(isLandscape)]}>
{extraBorder}
<View style={[style.item, extraItemStyle]}>
{icon}
<Text
style={[style.text, extraTextStyle]}
ellipsizeMode='tail'
numberOfLines={1}
>
{channelDisplayName}
</Text>
{badge}
</View>
</Navigation.TouchablePreview>
</AnimatedView>
</View>
</TouchableHighlight>
);
}
}

View File

@@ -3,7 +3,7 @@
import React from 'react';
import {shallow} from 'enzyme';
import {Navigation} from 'react-native-navigation';
import {TouchableHighlight} from 'react-native';
import Preferences from 'mattermost-redux/constants/preferences';
@@ -217,7 +217,7 @@ describe('ChannelItem', () => {
{context: {intl: {formatMessage: jest.fn()}}},
);
wrapper.find(Navigation.TouchablePreview).simulate('press');
wrapper.find(TouchableHighlight).simulate('press');
jest.runAllTimers();
const expectedChannelParams = {id: baseProps.channelId, display_name: baseProps.displayName, fake: channel.fake, type: channel.type};

View File

@@ -34,7 +34,6 @@ export default class ChannelsList extends PureComponent {
onShowTeams: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
drawerOpened: PropTypes.bool,
previewChannel: PropTypes.func,
isLandscape: PropTypes.bool.isRequired,
};
@@ -98,7 +97,6 @@ export default class ChannelsList extends PureComponent {
const {
onShowTeams,
theme,
previewChannel,
isLandscape,
} = this.props;
@@ -112,7 +110,6 @@ export default class ChannelsList extends PureComponent {
onSelectChannel={this.onSelectChannel}
styles={styles}
term={term}
previewChannel={previewChannel}
/>
);
} else {
@@ -120,7 +117,6 @@ export default class ChannelsList extends PureComponent {
<List
onSelectChannel={this.onSelectChannel}
styles={styles}
previewChannel={previewChannel}
/>
);
}

View File

@@ -50,7 +50,6 @@ class FilteredList extends Component {
styles: PropTypes.object.isRequired,
term: PropTypes.string,
theme: PropTypes.object.isRequired,
previewChannel: PropTypes.func,
isLandscape: PropTypes.bool.isRequired,
};
@@ -350,7 +349,6 @@ class FilteredList extends Component {
isUnread={item.isUnread}
mentions={0}
onSelectChannel={this.onSelectChannel}
previewChannel={this.props.previewChannel}
/>
);
};

View File

@@ -29,7 +29,7 @@ const DEFAULT_SEARCH_ORDER = ['unreads', 'dms', 'channels', 'members', 'nonmembe
const pastDirectMessages = createSelector(
getDirectShowPreferences,
(directChannelsFromPreferences) => directChannelsFromPreferences.filter((d) => d.value === 'false').map((d) => d.name)
(directChannelsFromPreferences) => directChannelsFromPreferences.filter((d) => d.value === 'false').map((d) => d.name),
);
const getTeamProfiles = createSelector(
@@ -40,7 +40,7 @@ const getTeamProfiles = createSelector(
return memberProfiles;
}, {});
}
},
);
// Fill an object for each group channel with concatenated strings for username, email, fullname, and nickname
@@ -93,7 +93,7 @@ const getGroupChannelMemberDetails = createSelector(
getUserIdsInChannels,
getUsers,
getGroupChannels,
getGroupDetails
getGroupDetails,
);
function mapStateToProps(state) {

View File

@@ -48,7 +48,6 @@ export default class List extends PureComponent {
styles: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired,
orderedChannelIds: PropTypes.array.isRequired,
previewChannel: PropTypes.func,
isLandscape: PropTypes.bool.isRequired,
};
@@ -309,7 +308,7 @@ export default class List extends PureComponent {
};
renderItem = ({item}) => {
const {favoriteChannelIds, unreadChannelIds, previewChannel} = this.props;
const {favoriteChannelIds, unreadChannelIds} = this.props;
return (
<ChannelItem
@@ -317,7 +316,6 @@ export default class List extends PureComponent {
isUnread={unreadChannelIds.includes(item)}
isFavorite={favoriteChannelIds.includes(item)}
onSelectChannel={this.onSelectChannel}
previewChannel={previewChannel}
/>
);
};

View File

@@ -45,7 +45,6 @@ export default class ChannelSidebar extends Component {
currentUserId: PropTypes.string.isRequired,
teamsCount: PropTypes.number.isRequired,
theme: PropTypes.object.isRequired,
previewChannel: PropTypes.func,
};
static contextTypes = {
@@ -318,7 +317,6 @@ export default class ChannelSidebar extends Component {
const {
teamsCount,
theme,
previewChannel,
} = this.props;
const {
@@ -372,9 +370,8 @@ export default class ChannelSidebar extends Component {
onSearchEnds={this.onSearchEnds}
theme={theme}
drawerOpened={this.state.drawerOpened}
previewChannel={previewChannel}
/>
</View>
</View>,
);
return (

View File

@@ -34,7 +34,7 @@ describe('MainSidebar', () => {
test('should match, full snapshot', () => {
const wrapper = shallow(
<MainSidebar {...baseProps}/>
<MainSidebar {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
@@ -42,7 +42,7 @@ describe('MainSidebar', () => {
test('should not set the permanentSidebar state if not Tablet', () => {
const wrapper = shallow(
<MainSidebar {...baseProps}/>
<MainSidebar {...baseProps}/>,
);
wrapper.instance().handlePermanentSidebar();
@@ -51,7 +51,7 @@ describe('MainSidebar', () => {
test('should set the permanentSidebar state if Tablet', async () => {
const wrapper = shallow(
<MainSidebar {...baseProps}/>
<MainSidebar {...baseProps}/>,
);
DeviceTypes.IS_TABLET = true;
@@ -73,7 +73,7 @@ describe('MainSidebar', () => {
};
const wrapper = shallow(
<MainSidebar {...props}/>
<MainSidebar {...props}/>,
);
const instance = wrapper.instance();
@@ -88,7 +88,7 @@ describe('MainSidebar', () => {
Platform.OS = 'ios';
const wrapper = shallow(
<MainSidebar {...baseProps}/>
<MainSidebar {...baseProps}/>,
);
const drawer = wrapper.dive().childAt(1);
const drawerStyle = drawer.props().style.reduce((acc, obj) => ({...acc, ...obj}));
@@ -99,7 +99,7 @@ describe('MainSidebar', () => {
Platform.OS = 'android';
const wrapper = shallow(
<MainSidebar {...baseProps}/>
<MainSidebar {...baseProps}/>,
);
const drawer = wrapper.dive().childAt(1);
const drawerStyle = drawer.props().style.reduce((acc, obj) => ({...acc, ...obj}));

View File

@@ -178,7 +178,7 @@ export default class SettingsDrawer extends PureComponent {
this.openModal(
'EditProfile',
formatMessage({id: 'mobile.routes.edit_profile', defaultMessage: 'Edit Profile'}),
{currentUser, commandType}
{currentUser, commandType},
);
});
@@ -207,7 +207,7 @@ export default class SettingsDrawer extends PureComponent {
this.openModal(
'UserProfile',
formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}),
{userId, fromSettings: true}
{userId, fromSettings: true},
);
});

View File

@@ -111,13 +111,13 @@ export default class SlideUpPanel extends PureComponent {
this.reverseLastScrollY = Animated.multiply(
new Animated.Value(-1),
this.lastScrollY
this.lastScrollY,
);
this.translateYOffset = new Animated.Value(containerHeight);
this.translateY = Animated.add(
this.translateYOffset,
Animated.add(this.dragY, this.reverseLastScrollY)
Animated.add(this.dragY, this.reverseLastScrollY),
).interpolate({
inputRange: [marginFromTop, containerHeight],
outputRange: [marginFromTop, containerHeight],

View File

@@ -13,7 +13,7 @@ describe('SlideUpPanelIndicator', () => {
test('should match snapshot', () => {
const wrapper = shallow(
<SlideUpPanelIndicator {...baseProps}/>
<SlideUpPanelIndicator {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();

View File

@@ -1,36 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {View} from 'react-native';
import Svg, {
Path,
} from 'react-native-svg';
export default class ArchiveIcon extends PureComponent {
static propTypes = {
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
color: PropTypes.string.isRequired,
};
render() {
const {color, height, width} = this.props;
return (
<View style={{height, width, alignItems: 'flex-start'}}>
<Svg
width={width}
height={height}
viewBox='0 0 14 14'
>
<Path
d='M8.5 6.5q0-0.203-0.148-0.352t-0.352-0.148h-2q-0.203 0-0.352 0.148t-0.148 0.352 0.148 0.352 0.352 0.148h2q0.203 0 0.352-0.148t0.148-0.352zM13 5v7.5q0 0.203-0.148 0.352t-0.352 0.148h-11q-0.203 0-0.352-0.148t-0.148-0.352v-7.5q0-0.203 0.148-0.352t0.352-0.148h11q0.203 0 0.352 0.148t0.148 0.352zM13.5 1.5v2q0 0.203-0.148 0.352t-0.352 0.148h-12q-0.203 0-0.352-0.148t-0.148-0.352v-2q0-0.203 0.148-0.352t0.352-0.148h12q0.203 0 0.352 0.148t0.148 0.352z'
fill={color}
/>
</Svg>
</View>
);
}
}

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