Compare commits

...

75 Commits

Author SHA1 Message Date
Mattermost Build
c9e5da40ec Automated cherry pick of #3142 (#3143)
* Bump app build number to 221

* Update fastlane
2019-08-17 14:44:50 -04:00
Mattermost Build
51050e31a9 Fix Regression right buttons color on Android navbar when enable (#3141) 2019-08-17 09:07:56 -07:00
Mattermost Build
9c00f51058 Fix regression close/leave/archive channel redirect to channel screen on Android (#3140) 2019-08-17 09:07:46 -07:00
Mattermost Build
bfd50568cc Bump app build number to 220 (#3133) 2019-08-15 15:41:18 -07:00
Miguel Alatzar
5bef5c2da8 Remove renderHasGuestText (#3131) 2019-08-16 03:45:34 +05:30
Miguel Alatzar
b064312e70 Revert "Remove renderHasGuestText"
This reverts commit e82db7840f.
2019-08-15 15:08:25 -07:00
Miguel Alatzar
e82db7840f Remove renderHasGuestText 2019-08-15 15:06:10 -07:00
Mattermost Build
c4d8308802 Bump app build number to 219 (#3130) 2019-08-16 00:30:07 +05:30
Elias Nahum
b8b7d54185 Migrate to moment to fix timestamps based on timezone or local time (#3126) 2019-08-15 12:40:07 -04:00
Mattermost Build
8416525487 Fix loosing the server config when handleManagedConfig (#3128) 2019-08-15 08:27:52 -07:00
Mattermost Build
d7a85fe2de Automated cherry pick of #3123 (#3124)
* Set header buttons after mount

* Do the same for private channel and dm
2019-08-14 15:54:46 -04:00
Mattermost Build
c1957ac6a0 Apply setNavigatorStyles to all navigation components (#3122) 2019-08-15 02:06:57 +08:00
Mattermost Build
90dba40a92 Automated cherry pick of #3116 (#3120)
* Fix delete the root post on a thread to properly close the thread screen

* Fix unit test
2019-08-14 09:34:18 -07:00
Mattermost Build
f8efcf73b7 Bump app build number to 218 (#3119) 2019-08-14 16:45:19 +05:30
Mattermost Build
ff7548251b Automated cherry pick of #3115 (#3117)
* Await getSource

* Use server url from EphemeralStore

* Set currentServerUrl in getAppCredentials too
2019-08-14 16:11:55 +05:30
Harrison Healey
e03b72861c MM-17425 Disable Send button when sending post (1.22) (#3100)
* MM-17425 Disable Send button when sending post (1.22)

* Update snapshot and add tests
2019-08-14 16:02:48 +05:30
Mattermost Build
94aa1be435 Automated cherry pick of #3108 (#3113)
* MM-17693 Android fix close settings screen

* Fix unit tests

* fix snapshot
2019-08-13 10:34:07 -04:00
Mattermost Build
7bf3020490 Automated cherry pick of #3107 (#3112)
* MM-17692 execute callback after closing post options

* Fix unit tests

* Fix snapshot
2019-08-13 10:29:32 -04:00
Saturnino Abril
880cbd9f3d MM-15218 Open as alert dialog when interactive message is without element (#3088) 2019-08-13 22:23:21 +08:00
Mattermost Build
390c30af00 Do not track foreground notifications when notification is opened (#3111) 2019-08-13 10:18:55 -04:00
Elias Nahum
e3fd88b7fb translations PR 20190812 (#3109) 2019-08-13 10:15:37 -04:00
Mattermost Build
3ed078f4ce Automated cherry pick of #3087 (#3106)
* Add flex 1

* Add snapshot test

* Fix placeholder text color
2019-08-12 10:13:29 -04:00
Sudheer
e7042e4907 Bump app build number to 217 (#3104) 2019-08-10 23:05:59 +05:30
Mattermost Build
9ae69d9b43 MM-17630 add Navigation bar to terms of service modal (#3103) 2019-08-10 22:54:53 +05:30
Mattermost Build
eb314d714a MM-17612 Fix fetch periodic status updates when re-connecting (#3101) 2019-08-09 18:53:29 -04:00
Asaad Mahmood
cdd7a54ef4 MM-17484 - Updating loading rows (#3076)
* MM-17484 - Updating loading rows

* Updating test

* Updating mobile loader css

* Updating test

* Updating changes

* Updating tests
2019-08-09 14:40:07 -04:00
Mattermost Build
41ddb5cc1a Fix pop thread screen when deleting the root post (#3093) 2019-08-09 11:06:16 -04:00
Mattermost Build
aceef20257 Increase ReplyIcon dimensions (#3091) 2019-08-09 06:09:19 -07:00
Mattermost Build
a5f8b9bdcc MM-15218 allow interactive dialogs to have no elements (#3090) 2019-08-09 08:31:13 -04:00
Mattermost Build
18b7a3c0a1 Automated cherry pick of #3074 (#3085)
* MM-17562 scroll to new message line indicator on componentDidUpdate

* feedback review

* Cancel animation frame requests
2019-08-08 15:52:40 -04:00
Elias Nahum
7b75868101 Various fixes (#3078)
* MM-17588 Remove navigation component from stack

* MM-175986 Fix Clock Display Settings on iOS

* Fix markdown and team icon currentServerUrl

* Fix closing permalink logs out the user

* Fix file attachment document ref

* Fix applyTheme when changing a theme in the app

* Feedback review

* remove / when fetching the image on the markdown table on relative paths
2019-08-08 22:39:27 +08:00
Mattermost Build
24bd57ad3f Automated cherry pick of #3079 (#3083)
* Remove flex for horizontalRule

* Fix snapshot tests
2019-08-08 10:10:22 -04:00
Mattermost Build
b931733695 Fix theme thumbnail width (#3082) 2019-08-08 09:59:55 -04:00
Mattermost Build
088d375ff2 Automated cherry pick of #3075 (#3077)
* Update NOTICE.txt

* Update NOTICE.txt
2019-08-07 11:13:26 -04:00
Elias Nahum
4b06636d9b translations PR 20190805 (#3073) 2019-08-06 10:38:30 -04:00
Mattermost Build
05db1aaa71 Bump app build number to 216 (#3069) 2019-08-03 02:24:12 +05:30
Elias Nahum
e94dfb5389 MM-16752 Fix unhandled promise rejection TypeError state.websocket.lastConnectAt (#3039) 2019-08-02 16:13:55 -04:00
Elias Nahum
58b95e7609 translations PR 20190729 (#3055) 2019-08-02 15:23:23 -04:00
Elias Nahum
da911a2b34 MM-17424 Setting to enable/disable fixed sidebar (#3060) 2019-08-02 13:53:48 -04:00
Miguel Alatzar
d842d7881a Include archived teams filter change from mattermost-redux 2019-08-02 10:18:47 -07:00
Miguel Alatzar
98dc141ee3 Bump app build number to 215 (#3066) 2019-08-01 12:28:10 -04:00
Mattermost Build
8d0cb0663b Bump app build number to 214 (#3064) 2019-07-31 19:40:26 -04:00
Miguel Alatzar
612d284cbb Include null check on name from mattermost-redux 2019-07-31 13:31:28 -07:00
Elias Nahum
be727fec9e Bump app build number to 213 (#3058) 2019-07-31 09:33:23 -04:00
Mattermost Build
d0f059e1f9 Load Android WebView package instead of module (#3054) 2019-07-29 09:19:59 -04:00
Elias Nahum
df74521ff3 translations PR 20190724 (#3029) 2019-07-29 08:11:33 -04:00
Mattermost Build
eff961d109 Check if ackId is nil first (#3050) 2019-07-26 13:18:06 -04:00
Mattermost Build
59f3633d94 Fix test (#3052) 2019-07-26 10:13:15 -07:00
Mattermost Build
31bd391e56 Automated cherry pick of #3033 (#3047)
* Clear foreground notifications from AsyncStorage

* Remove unnecessary call to clearNotifications
2019-07-26 08:11:58 -04:00
Mattermost Build
810f73e3ad Automated cherry pick of #3026 (#3031)
* Parse source URI

* Revert "Parse source URI"

This reverts commit 1cf421c9b9.

* Pass imageUri instead of defaultSource to ProgressiveImage

* Parse source URI in ImageCacheManager
2019-07-25 11:05:13 -07:00
Mattermost Build
14421ba8e9 MM-17100 setCSRFFromCookie with subpath support (#3040) 2019-07-25 11:01:49 -07:00
Mattermost Build
5c4405278b MM-17120 Update fastlane image to a working one (#3045)
https://mattermost.atlassian.net/browse/MM-17120
2019-07-25 09:21:12 -04:00
Elias Nahum
067a5481ff Bump app build number to 212 (#3042) 2019-07-25 08:46:44 -04:00
Mattermost Build
c267b8dd13 Check initialIndex in callback (#3032) 2019-07-24 14:23:06 -07:00
Mattermost Build
ce325a4ab1 Automated cherry pick of #3001 (#3030)
* Include in-app notifications in iOS badge number

* Make foreground notifications key a const
2019-07-24 14:05:25 -07:00
Mattermost Build
1f8e853e41 Apply new theme to all navigation components (#3021) 2019-07-24 09:24:54 -04:00
Jesús Espino
8887319324 Revert Guest Accounts feature (#3024)
* Revert "Automated cherry pick of #3019 (#3020)"

This reverts commit a0b021d21d.

* Revert "Adding guest accounts feature (#2990) (#3015)"

This reverts commit 60030defb8.
2019-07-23 20:55:15 +02:00
Mattermost Build
e214039cee Call scrollToIndex only if flatListRef.current is not null (#3023) 2019-07-23 11:26:39 -07:00
Mattermost Build
0a93ec134c User popToRoot over resetToChannel (#3018) 2019-07-23 09:16:33 -07:00
Mattermost Build
a0b021d21d Automated cherry pick of #3019 (#3020)
* Fixing guest login

* Fixing eslint
2019-07-23 11:22:43 +02:00
Mattermost Build
75aedb8aa1 Automated cherry pick of #3014 (#3017)
* Add channel_id to failed push notification reply

* Fix typo
2019-07-22 15:40:40 -07:00
Jesús Espino
60030defb8 Adding guest accounts feature (#2990) (#3015)
* MM-15059: Restict the permissions for guests (#2791)

* MM-15057: Adding guest badge to identify guest users (#2774)

* MM-15057: Adding guest badge to identify guest users

* Adding Guest tags in the channel title

* Adding i18n translations

* Adding DM and GM guest warnings

* Fixing check-style

* Adding and fixing tests

* Adding i18n text

* Only showing the subtitle when there is enough space

* Addressing new design changes

* Fixing eslint

* Addressing PR comments

* Moving getChannelStats to the handleSelectChannel action

* Adding the guest info in android landscape channel headers

* simplified the guest warning text generation

* Fixing i18n

* Fixing leaving channel behavior for guests (#2989)

* Fixing leaving channel behavior for guests

* Fixing tests and adding a new one

* fixing typo

* Addressing PR comments

* Addressing PR comments

* Fixing tests
2019-07-22 23:46:15 +02:00
Mattermost Build
50cc6f827e Bump app build number to 211 (#3013) 2019-07-23 02:04:24 +05:30
Mattermost Build
ac11b7fec3 Bump app version number to 1.22.0 (#3011) 2019-07-23 00:40:08 +05:30
Elias Nahum
c9575b464d Update Fastlane (#3008) 2019-07-22 10:15:17 -04:00
Mattermost Build
a4284666a3 Automated cherry pick of #3002 (#3004)
* Set footerColor the same as navBarBackgroundColor

* Oops, forgot __snapshots__
2019-07-20 09:17:32 +08:00
Elias Nahum
4d5422e98b Bump app build number to 210 (#3000) 2019-07-19 16:53:40 -04:00
Elias Nahum
a3783b1bf5 MM-16829 Fix for websocket reconnects for android 2019-07-19 16:49:19 -04:00
Elias Nahum
c31ff56149 Bump Version to 1.21.1 and build number to 209 (#2995)
* Bump app build number to 209

* Bump app version number to 1.21.1
2019-07-18 18:43:48 -04:00
Mattermost Build
93498a3ab5 Fix Options modal on iOS (#2994) 2019-07-18 16:00:49 -04:00
Sudheer
6d10915aad MM-16829 Fix for sso with server subpaths (#2987) 2019-07-19 00:08:19 +05:30
Mattermost Build
79653ad814 MM-16815 Remove the failed posts banner on WS reconnect (#2988) 2019-07-18 11:38:37 -04:00
Ewe Tek Min
13bb83c11c [MM-14303] Able to copy channel header and purpose (#2923) 2019-07-18 11:24:02 -04:00
Mattermost Build
3f6e706fa1 Select server fix login options transition (#2986) 2019-07-18 11:00:29 -04:00
Mattermost Build
5492cd5e46 Automated cherry pick of #2961 (#2985)
* Enable emojis for profile icons from webhooks

Removes borders from profile icons when it is from a custom URL.

* feat: reapply border for all profile pictures and shrink emojis

* feat: remove border from emoji profile pictures

* feat: decide if post has emoji icon through props instead of url

* refactor: better checking of potentially undefined property

Co-Authored-By: Elias Nahum <nahumhbl@gmail.com>
2019-07-18 10:26:39 -04:00
144 changed files with 3199 additions and 879 deletions

View File

@@ -312,6 +312,37 @@ SOFTWARE.
---
## core-js
This product contains 'core-js' by Denis Pushkarev.
Modular standard library for JavaScript.
* HOMEPAGE:
* https://github.com/zloirock/core-js
* LICENSE: Copyright (c) 2014-2019 Denis Pushkarev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---
## deep-equal
This product contains 'deep-equal' by James Halliday.
@@ -344,6 +375,39 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
---
## deepmerge
This product contains 'deepmerge' by Josh Duff.
A library for deep (recursive) merging of Javascript objects.
* HOMEPAGE:
* https://github.com/TehShrike/deepmerge
* LICENSE: The MIT License (MIT)
Copyright (c) 2012 James Halliday, Josh Duff, and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
---
## emoji-regex
This product contains 'emoji-regex' by Mathias Bynens.
@@ -1216,41 +1280,6 @@ SOFTWARE.
---
## react-native-bottom-sheet
This product contains 'react-native-bottom-sheet' by WhatAKitty.
React Native Bottom sheet for android
* HOMEPAGE:
* https://github.com/WhatAKitty/react-native-bottom-sheet#readme
* LICENSE: MIT
The MIT License (MIT)
Copyright (c) 2016 WhatAKitty
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## react-native-button
This product contains 'react-native-button' by James Ide.

View File

@@ -123,8 +123,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
missingDimensionStrategy "RNN.reactNativeVersion", "reactNative57_5"
versionCode 208
versionName "1.21.0"
versionCode 221
versionName "1.22.0"
multiDexEnabled = true
ndk {
abiFilters 'armeabi-v7a','arm64-v8a','x86','x86_64'

View File

@@ -32,7 +32,7 @@ import com.reactnativedocumentpicker.DocumentPicker;
import com.oblador.keychain.KeychainModule;
import com.reactnativecommunity.asyncstorage.AsyncStorageModule;
import com.reactnativecommunity.netinfo.NetInfoModule;
import com.reactnativecommunity.webview.RNCWebViewModule;
import com.reactnativecommunity.webview.RNCWebViewPackage;
import com.brentvatne.react.ReactVideoPackage;
import com.BV.LinearGradient.LinearGradientPackage;
@@ -151,8 +151,6 @@ public class MainApplication extends NavigationApplication implements INotificat
return new AsyncStorageModule(reactContext);
case NetInfoModule.NAME:
return new NetInfoModule(reactContext);
case RNCWebViewModule.MODULE_NAME:
return new RNCWebViewModule(reactContext);
default:
throw new IllegalArgumentException("Could not find module " + name);
}
@@ -186,12 +184,12 @@ public class MainApplication extends NavigationApplication implements INotificat
map.put("RNKeychainManager", new ReactModuleInfo("RNKeychainManager", "com.oblador.keychain.KeychainModule", false, false, true, false, false));
map.put(AsyncStorageModule.NAME, new ReactModuleInfo(AsyncStorageModule.NAME, "com.reactnativecommunity.asyncstorage.AsyncStorageModule", false, false, false, false, false));
map.put(NetInfoModule.NAME, new ReactModuleInfo(NetInfoModule.NAME, "com.reactnativecommunity.netinfo.NetInfoModule", false, false, false, false, false));
map.put(RNCWebViewModule.MODULE_NAME, new ReactModuleInfo(RNCWebViewModule.MODULE_NAME, "com.reactnativecommunity.webview.RNCWebViewModule", false, false, false, false, false));
return map;
}
};
}
},
new RNCWebViewPackage(),
new SvgPackage(),
new LinearGradientPackage(),
new ReactVideoPackage(),

View File

@@ -133,7 +133,7 @@ export function resetToTeams(name, title, passProps = {}, options = {}) {
export function goToScreen(name, title, passProps = {}, options = {}) {
return (dispatch, getState) => {
const state = getState();
const componentId = EphemeralStore.getTopComponentId();
const componentId = EphemeralStore.getNavigationTopComponentId();
const theme = getTheme(state);
const defaultOptions = {
layout: {
@@ -166,17 +166,20 @@ export function goToScreen(name, title, passProps = {}, options = {}) {
};
}
export function popTopScreen() {
export function popTopScreen(screenId) {
return () => {
const componentId = EphemeralStore.getTopComponentId();
Navigation.pop(componentId);
if (screenId) {
Navigation.pop(screenId);
} else {
const componentId = EphemeralStore.getNavigationTopComponentId();
Navigation.pop(componentId);
}
};
}
export function popToRoot() {
return () => {
const componentId = EphemeralStore.getTopComponentId();
const componentId = EphemeralStore.getNavigationTopComponentId();
Navigation.popToRoot(componentId).catch(() => {
// RNN returns a promise rejection if there are no screens
@@ -286,9 +289,13 @@ export function showSearchModal(initialValue = '') {
export function dismissModal(options = {}) {
return () => {
const componentId = EphemeralStore.getTopComponentId();
const componentId = EphemeralStore.getNavigationTopComponentId();
Navigation.dismissModal(componentId, options);
Navigation.dismissModal(componentId, options).catch(() => {
// RNN returns a promise rejection if there is no modal to
// dismiss. We'll do nothing in this case but we will catch
// the rejection here so that the caller doesn't have to.
});
};
}
@@ -304,7 +311,7 @@ export function dismissAllModals(options = {}) {
export function peek(name, passProps = {}, options = {}) {
return () => {
const componentId = EphemeralStore.getTopComponentId();
const componentId = EphemeralStore.getNavigationTopComponentId();
const defaultOptions = {
preview: {
commit: false,
@@ -359,3 +366,29 @@ export function dismissOverlay(componentId) {
});
};
}
export function applyTheme(componentId, skipBackButtonStyle = false) {
return (dispatch, getState) => {
const theme = getTheme(getState());
let backButton = {
color: theme.sidebarHeaderTextColor,
};
if (skipBackButtonStyle && Platform.OS === 'android') {
backButton = null;
}
Navigation.mergeOptions(componentId, {
topBar: {
backButton,
background: {
color: theme.sidebarHeaderBg,
},
title: {
color: theme.sidebarHeaderTextColor,
},
},
});
};
}

View File

@@ -198,7 +198,7 @@ export function loadPostsIfNecessaryWithRetry(channelId) {
});
}
} else {
const {lastConnectAt} = state.websocket;
const lastConnectAt = state.websocket?.lastConnectAt || 0;
const lastGetPosts = state.views.channel.lastGetPosts[channelId];
let since;

View File

@@ -4,7 +4,15 @@
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import {handleSelectChannelByName} from 'app/actions/views/channel';
import initialState from 'app/initial_state';
import testHelper from 'test/test_helper';
import {
handleSelectChannelByName,
loadPostsIfNecessaryWithRetry,
} from 'app/actions/views/channel';
import postReducer from 'mattermost-redux/reducers/entities/posts';
jest.mock('mattermost-redux/selectors/entities/channels', () => ({
getChannel: () => ({data: 'received-channel-id'}),
@@ -19,6 +27,9 @@ describe('Actions.Views.Channel', () => {
const MOCK_SELECT_CHANNEL_TYPE = 'MOCK_SELECT_CHANNEL_TYPE';
const MOCK_RECEIVE_CHANNEL_TYPE = 'MOCK_RECEIVE_CHANNEL_TYPE';
const MOCK_RECEIVED_POSTS = 'RECEIVED_POSTS';
const MOCK_RECEIVED_POSTS_IN_CHANNEL = 'RECEIVED_POSTS_IN_CHANNEL';
const MOCK_RECEIVED_POSTS_SINCE = 'MOCK_RECEIVED_POSTS_SINCE';
const actions = require('mattermost-redux/actions/channels');
actions.getChannelByNameAndTeamName = jest.fn((teamName) => {
@@ -38,14 +49,51 @@ describe('Actions.Views.Channel', () => {
type: MOCK_SELECT_CHANNEL_TYPE,
data: 'selected-channel-id',
});
const postActions = require('mattermost-redux/actions/posts');
postActions.getPostsSince = jest.fn(() => {
return {
type: MOCK_RECEIVED_POSTS_SINCE,
data: {
order: [],
posts: {},
},
};
});
postActions.getPosts = jest.fn((channelId) => {
const order = [];
const posts = {};
for (let i = 0; i < 60; i++) {
const p = testHelper.fakePost(channelId);
order.push(p.id);
posts[p.id] = p;
}
return {
type: MOCK_RECEIVED_POSTS,
data: {
order,
posts,
},
};
});
const postUtils = require('mattermost-redux/utils/post_utils');
postUtils.getLastCreateAt = jest.fn((array) => {
return array[0].create_at;
});
let nextPostState = {};
const currentUserId = 'current-user-id';
const currentChannelId = 'channel-id';
const currentChannelName = 'channel-name';
const currentTeamId = 'current-team-id';
const currentTeamName = 'current-team-name';
const storeObj = {
...initialState,
entities: {
...initialState.entities,
users: {
currentUserId,
},
@@ -93,4 +141,84 @@ describe('Actions.Views.Channel', () => {
const storeBatchActions = storeActions.some(({type}) => type === 'BATCHING_REDUCER.BATCH');
expect(storeBatchActions).toBe(false);
});
test('loadPostsIfNecessaryWithRetry for the first time', async () => {
store = mockStore(storeObj);
await store.dispatch(loadPostsIfNecessaryWithRetry(currentChannelId));
expect(postActions.getPosts).toBeCalled();
const storeActions = store.getActions();
const storeBatchActions = storeActions.filter(({type}) => type === 'BATCHING_REDUCER.BATCH');
const receivedPosts = storeActions.find(({type}) => type === MOCK_RECEIVED_POSTS);
const receivedPostsAtAction = storeBatchActions[0].payload.some((action) => action.type === 'RECEIVED_POSTS_FOR_CHANNEL_AT_TIME');
nextPostState = postReducer(store.getState().entities.posts, receivedPosts);
nextPostState = postReducer(nextPostState, {
type: MOCK_RECEIVED_POSTS_IN_CHANNEL,
channelId: currentChannelId,
data: receivedPosts.data,
recent: true,
});
expect(receivedPostsAtAction).toBe(true);
});
test('loadPostsIfNecessaryWithRetry get posts since', async () => {
store = mockStore({
...storeObj,
entities: {
...storeObj.entities,
posts: nextPostState,
},
views: {
...storeObj.views,
channel: {
...storeObj.views.channel,
lastGetPosts: {
[currentChannelId]: Date.now(),
},
},
},
});
await store.dispatch(loadPostsIfNecessaryWithRetry(currentChannelId));
const storeActions = store.getActions();
const receivedPostsSince = storeActions.find(({type}) => type === MOCK_RECEIVED_POSTS_SINCE);
expect(postUtils.getLastCreateAt).toBeCalled();
expect(postActions.getPostsSince).toHaveBeenCalledWith(currentChannelId, Object.values(store.getState().entities.posts.posts)[0].create_at);
expect(receivedPostsSince).not.toBe(null);
});
test('loadPostsIfNecessaryWithRetry get posts since the websocket reconnected', async () => {
const time = Date.now();
store = mockStore({
...storeObj,
entities: {
...storeObj.entities,
posts: nextPostState,
},
views: {
...storeObj.views,
channel: {
...storeObj.views.channel,
lastGetPosts: {
[currentChannelId]: time,
},
},
},
websocket: {
lastConnectAt: time + (1 * 60 * 1000),
},
});
await store.dispatch(loadPostsIfNecessaryWithRetry(currentChannelId));
const storeActions = store.getActions();
const receivedPostsSince = storeActions.find(({type}) => type === MOCK_RECEIVED_POSTS_SINCE);
expect(postUtils.getLastCreateAt).not.toBeCalled();
expect(postActions.getPostsSince).toHaveBeenCalledWith(currentChannelId, store.getState().views.channel.lastGetPosts[currentChannelId]);
expect(receivedPostsSince).not.toBe(null);
});
});

View File

@@ -16,6 +16,10 @@ export function handleServerUrlChanged(serverUrl) {
};
}
export function setServerUrl(serverUrl) {
return {type: ViewTypes.SERVER_URL_CHANGED, serverUrl};
}
export default {
handleServerUrlChanged,
};

View File

@@ -7,6 +7,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
Array [
Object {
"flex": 1,
"overflow": "hidden",
},
undefined,
Object {

View File

@@ -149,6 +149,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
flex: 1,
overflow: 'hidden',
},
section: {
backgroundColor: theme.centerChannelBg,

View File

@@ -16,4 +16,4 @@ function mapDispatchToProps(dispatch) {
};
}
export default connect(null, mapDispatchToProps)(FileAttachmentDocument);
export default connect(null, mapDispatchToProps, null, {forwardRef: true})(FileAttachmentDocument);

View File

@@ -3,35 +3,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import {injectIntl, intlShape} from 'react-intl';
import moment from 'moment-timezone';
import {Text} from 'react-native';
class FormattedDate extends React.PureComponent {
export default class FormattedDate extends React.PureComponent {
static propTypes = {
intl: intlShape.isRequired,
value: PropTypes.any.isRequired,
format: PropTypes.string,
children: PropTypes.func,
timeZone: PropTypes.string,
value: PropTypes.any.isRequired,
};
static defaultProps = {
format: 'ddd, MMM DD, YYYY',
};
render() {
const {
intl,
format,
timeZone,
value,
children,
...props
} = this.props;
Reflect.deleteProperty(props, 'format');
const formattedDate = intl.formatDate(value, this.props);
if (typeof children === 'function') {
return children(formattedDate);
let formattedDate = moment(value).format(format);
if (timeZone) {
formattedDate = moment.tz(value, timeZone).format(format);
}
return <Text {...props}>{formattedDate}</Text>;
}
}
export default injectIntl(FormattedDate);

View File

@@ -3,7 +3,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import {intlShape} from 'react-intl';
import {Text} from 'react-native';
import moment from 'moment-timezone';
@@ -15,30 +14,19 @@ export default class FormattedTime extends React.PureComponent {
hour12: PropTypes.bool,
};
static contextTypes = {
intl: intlShape.isRequired,
};
getFormattedTime = () => {
const {intl} = this.context;
const {
value,
timeZone,
hour12,
} = this.props;
const format = hour12 ? 'hh:mm A' : 'HH:mm';
if (timeZone) {
const format = hour12 ? 'hh:mm A' : 'HH:mm';
return moment.tz(value, timeZone).format(format);
}
// If no timezone is defined fallback to the previous implementation
return intl.formatDate(new Date(value), {
hour: 'numeric',
minute: 'numeric',
hour12,
});
return moment(value).format(format);
};
render() {

View File

@@ -4,6 +4,8 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {submitInteractiveDialog} from 'mattermost-redux/actions/integrations';
import {showModal} from 'app/actions/navigation';
import InteractiveDialogController from './interactive_dialog_controller';
@@ -11,7 +13,7 @@ import InteractiveDialogController from './interactive_dialog_controller';
function mapStateToProps(state) {
return {
triggerId: state.entities.integrations.dialogTriggerId,
dialog: state.entities.integrations.dialog || {},
dialogData: state.entities.integrations.dialog || {},
};
}
@@ -19,6 +21,7 @@ function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
showModal,
submitInteractiveDialog,
}, dispatch),
};
}

View File

@@ -4,14 +4,17 @@
import {PureComponent} from 'react';
import PropTypes from 'prop-types';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import {Alert} from 'react-native';
import {intlShape} from 'react-intl';
export default class InteractiveDialogController extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
showModal: PropTypes.func.isRequired,
submitInteractiveDialog: PropTypes.func.isRequired,
}).isRequired,
triggerId: PropTypes.string,
dialog: PropTypes.object,
dialogData: PropTypes.object,
theme: PropTypes.object,
};
@@ -23,14 +26,18 @@ export default class InteractiveDialogController extends PureComponent {
});
}
static contextTypes = {
intl: intlShape,
};
componentDidUpdate(prevProps) {
const {actions, triggerId} = this.props;
const {triggerId} = this.props;
if (!triggerId) {
return;
}
const dialogData = this.props.dialog || {};
const prevDialogData = prevProps.dialog || {};
const dialogData = this.props.dialogData || {};
const prevDialogData = prevProps.dialogData || {};
if (prevProps.triggerId === triggerId && dialogData.trigger_id === prevDialogData.trigger_id) {
return;
}
@@ -43,9 +50,30 @@ export default class InteractiveDialogController extends PureComponent {
return;
}
const screen = 'InteractiveDialog';
const title = dialogData.dialog.title;
const passProps = {};
if (dialogData.dialog.elements && dialogData.dialog.elements.length > 0) {
this.showInteractiveDialogScreen(dialogData.dialog);
} else {
this.showAlertDialog(dialogData.dialog, dialogData.url);
}
}
showAlertDialog(dialog, url) {
const {formatMessage} = this.context.intl;
Alert.alert(
dialog.title,
'',
[{
text: formatMessage({id: 'mobile.alert_dialog.alertCancel', defaultMessage: 'Cancel'}),
onPress: () => this.handleCancel(dialog, url),
}, {
text: dialog.submit_label,
onPress: () => this.props.actions.submitInteractiveDialog({...dialog, url}),
}],
);
}
showInteractiveDialogScreen = (dialog) => {
const options = {
topBar: {
leftButtons: [{
@@ -55,12 +83,18 @@ export default class InteractiveDialogController extends PureComponent {
rightButtons: [{
id: 'submit-dialog',
showAsAction: 'always',
text: dialogData.dialog.submit_label,
text: dialog.submit_label,
}],
},
};
actions.showModal(screen, title, passProps, options);
this.props.actions.showModal('InteractiveDialog', dialog.title, null, options);
}
handleCancel = (dialog, url) => {
if (dialog.notify_on_cancel) {
this.props.actions.submitInteractiveDialog({...dialog, url, cancelled: true});
}
}
render() {

View File

@@ -0,0 +1,82 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import InteractiveDialogController from './interactive_dialog_controller';
jest.mock('react-intl');
jest.mock('react-native-vector-icons/MaterialIcons', () => ({
getImageSource: jest.fn().mockResolvedValue({}),
}));
describe('InteractiveDialogController', () => {
test('should open interactive dialog as alert or screen depending on with or without element', () => {
let baseProps = getBaseProps('trigger_id_1');
const wrapper = shallow(
<InteractiveDialogController {...baseProps}/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
const instance = wrapper.instance();
instance.showAlertDialog = jest.fn();
instance.showInteractiveDialogScreen = jest.fn();
expect(instance.showAlertDialog).toHaveBeenCalledTimes(0);
expect(instance.showInteractiveDialogScreen).toHaveBeenCalledTimes(0);
baseProps = getBaseProps('trigger_id_2');
wrapper.setProps({...baseProps});
expect(instance.showAlertDialog).toHaveBeenCalledTimes(1);
expect(instance.showAlertDialog).toHaveBeenCalledWith(baseProps.dialogData.dialog, baseProps.dialogData.url);
expect(instance.showInteractiveDialogScreen).toHaveBeenCalledTimes(0);
const elements = [{
data_source: '',
default: '',
display_name: 'Number',
help_text: '',
max_length: 0,
min_length: 0,
name: 'somenumber',
optional: false,
options: null,
placeholder: '',
subtype: 'number',
type: 'text',
}];
baseProps = getBaseProps('trigger_id_3', elements);
wrapper.setProps({...baseProps});
expect(instance.showAlertDialog).toHaveBeenCalledTimes(1);
expect(instance.showInteractiveDialogScreen).toHaveBeenCalledTimes(1);
expect(instance.showInteractiveDialogScreen).toHaveBeenCalledWith(baseProps.dialogData.dialog);
});
});
function getBaseProps(triggerId, elements) {
const dialogData = {
dialog: {
callback_id: 'somecallbackid',
elements,
icon_url: 'icon_url',
notify_on_cancel: true,
state: 'somestate',
submit_label: 'Submit Test',
title: 'Dialog Test',
},
trigger_id: triggerId,
url: 'https://localhost:8065/dialog_submit',
};
return {
actions: {
showModal: jest.fn(),
submitInteractiveDialog: jest.fn(),
},
triggerId,
dialogData,
theme: {},
};
}

View File

@@ -18,6 +18,7 @@ import {
import FormattedText from 'app/components/formatted_text';
import ProgressiveImage from 'app/components/progressive_image';
import CustomPropTypes from 'app/constants/custom_prop_types';
import EphemeralStore from 'app/store/ephemeral_store';
import mattermostManaged from 'app/mattermost_managed';
import BottomSheet from 'app/utils/bottom_sheet';
import ImageCacheManager from 'app/utils/image_cache_manager';
@@ -42,7 +43,6 @@ export default class MarkdownImage extends React.Component {
imagesMetadata: PropTypes.object,
linkDestination: PropTypes.string,
isReplyPost: PropTypes.bool,
serverURL: PropTypes.string.isRequired,
source: PropTypes.string.isRequired,
errorTextStyle: CustomPropTypes.Style,
};
@@ -99,7 +99,7 @@ export default class MarkdownImage extends React.Component {
let source = this.props.source;
if (source.startsWith('/')) {
source = this.props.serverURL + '/' + source;
source = EphemeralStore.currentServerUrl + source;
}
return source;

View File

@@ -9,6 +9,7 @@ import {intlShape} from 'react-intl';
import CustomPropTypes from 'app/constants/custom_prop_types';
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 {preventDoubleTap} from 'app/utils/tap';
@@ -24,7 +25,7 @@ export default class MarkdownLink extends PureComponent {
children: CustomPropTypes.Children.isRequired,
href: PropTypes.string.isRequired,
onPermalinkPress: PropTypes.func,
serverURL: PropTypes.string.isRequired,
serverURL: PropTypes.string,
siteURL: PropTypes.string.isRequired,
};
@@ -38,7 +39,7 @@ export default class MarkdownLink extends PureComponent {
intl: intlShape.isRequired,
};
handlePress = preventDoubleTap(() => {
handlePress = preventDoubleTap(async () => {
const {href, onPermalinkPress, serverURL, siteURL} = this.props;
const url = normalizeProtocol(href);
@@ -46,7 +47,12 @@ export default class MarkdownLink extends PureComponent {
return;
}
const match = matchDeepLink(url, serverURL, siteURL);
let serverUrl = serverURL;
if (!serverUrl) {
serverUrl = await getCurrentServerUrl();
}
const match = matchDeepLink(url, serverUrl, siteURL);
if (match) {
if (match.type === DeepLinkTypes.CHANNEL) {
this.props.actions.handleSelectChannelByName(match.channelName, match.teamName);

View File

@@ -7,6 +7,7 @@ import {intlShape} from 'react-intl';
import {Text} from 'react-native';
import CustomPropTypes from 'app/constants/custom_prop_types';
import {getCurrentServerUrl} from 'app/init/credentials';
import {preventDoubleTap} from 'app/utils/tap';
export default class MarkdownTableImage extends React.PureComponent {
@@ -17,7 +18,7 @@ export default class MarkdownTableImage extends React.PureComponent {
children: PropTypes.node.isRequired,
source: PropTypes.string.isRequired,
textStyle: CustomPropTypes.Style.isRequired,
serverURL: PropTypes.string.isRequired,
serverURL: PropTypes.string,
theme: PropTypes.object.isRequired,
};
@@ -40,11 +41,16 @@ export default class MarkdownTableImage extends React.PureComponent {
actions.goToScreen(screen, title, passProps);
});
getImageSource = () => {
getImageSource = async () => {
let source = this.props.source;
let serverUrl = this.props.serverURL;
if (!serverUrl) {
serverUrl = await getCurrentServerUrl();
}
if (source.startsWith('/')) {
source = `${this.props.serverURL}/${source}`;
source = serverUrl + source;
}
return source;

View File

@@ -9,7 +9,7 @@ import {init as initWebSocket, close as closeWebSocket} from 'mattermost-redux/a
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';
import {connection} from 'app/actions/device';
import {markChannelViewedAndRead} from 'app/actions/views/channel';
import {markChannelViewedAndRead, setChannelRetryFailed} from 'app/actions/views/channel';
import {setCurrentUserStatusOffline} from 'app/actions/views/user';
import {getConnection, isLandscape} from 'app/selectors/device';
@@ -36,6 +36,7 @@ function mapDispatchToProps(dispatch) {
initWebSocket,
logout,
markChannelViewedAndRead,
setChannelRetryFailed,
setCurrentUserStatusOffline,
startPeriodicStatusUpdates,
stopPeriodicStatusUpdates,

View File

@@ -45,6 +45,7 @@ export default class NetworkIndicator extends PureComponent {
initWebSocket: PropTypes.func.isRequired,
markChannelViewedAndRead: PropTypes.func.isRequired,
logout: PropTypes.func.isRequired,
setChannelRetryFailed: PropTypes.func.isRequired,
setCurrentUserStatusOffline: PropTypes.func.isRequired,
startPeriodicStatusUpdates: PropTypes.func.isRequired,
stopPeriodicStatusUpdates: PropTypes.func.isRequired,
@@ -77,6 +78,7 @@ export default class NetworkIndicator extends PureComponent {
this.backgroundColor = new Animated.Value(0);
this.firstRun = true;
this.statusUpdates = false;
this.networkListener = networkConnectionListener(this.handleConnectionChange);
}
@@ -137,7 +139,7 @@ export default class NetworkIndicator extends PureComponent {
}
connect = (displayBar = false) => {
const {connection} = this.props.actions;
const {connection, startPeriodicStatusUpdates} = this.props.actions;
clearTimeout(this.connectionRetryTimeout);
NetInfo.fetch().then(async ({isConnected}) => {
@@ -148,7 +150,9 @@ export default class NetworkIndicator extends PureComponent {
this.serverReachable = serverReachable;
if (serverReachable) {
this.statusUpdates = true;
this.initializeWebSocket();
startPeriodicStatusUpdates();
} else {
if (displayBar) {
this.show();
@@ -165,6 +169,7 @@ export default class NetworkIndicator extends PureComponent {
};
connected = () => {
this.props.actions.setChannelRetryFailed(false);
Animated.sequence([
Animated.timing(
this.backgroundColor, {
@@ -218,9 +223,11 @@ export default class NetworkIndicator extends PureComponent {
} = actions;
if (open) {
this.statusUpdates = true;
this.initializeWebSocket();
startPeriodicStatusUpdates();
} else {
} else if (this.statusUpdates) {
this.statusUpdates = false;
closeWebSocket(true);
stopPeriodicStatusUpdates();
}
@@ -247,13 +254,12 @@ export default class NetworkIndicator extends PureComponent {
};
handleConnectionChange = ({hasInternet, serverReachable}) => {
const {connection, startPeriodicStatusUpdates} = this.props.actions;
const {connection} = this.props.actions;
// On first run always initialize the WebSocket
// if we have internet connection
if (hasInternet && this.firstRun) {
this.initializeWebSocket();
startPeriodicStatusUpdates();
this.firstRun = false;
// if the state of the internet connection was previously known to be false,

View File

@@ -0,0 +1,42 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PostAttachmentImage should match snapshot 1`] = `
<TouchableWithoutFeedback
onPress={[Function]}
style={
Array [
Object {
"alignItems": "flex-start",
"justifyContent": "flex-start",
"marginBottom": 6,
"marginTop": 10,
},
Object {
"height": 100,
},
]
}
>
<View>
<ForwardRef(forwardConnectRef)
imageUri="uri"
onError={[MockFunction]}
resizeMode="contain"
style={
Array [
Object {
"alignItems": "center",
"borderRadius": 3,
"justifyContent": "center",
"marginVertical": 1,
},
Object {
"height": 100,
"width": 100,
},
]
}
/>
</View>
</TouchableWithoutFeedback>
`;

View File

@@ -47,7 +47,7 @@ export default class PostAttachmentImage extends React.PureComponent {
<View ref={this.image}>
<ProgressiveImage
style={[styles.image, {width: this.props.width, height: this.props.height}]}
defaultSource={{uri: this.props.uri}}
imageUri={this.props.uri}
resizeMode='contain'
onError={this.props.onError}
/>

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import PostAttachmentImage from './index';
describe('PostAttachmentImage', () => {
const baseProps = {
height: 100,
width: 100,
onError: jest.fn(),
onImagePress: jest.fn(),
uri: 'uri',
};
it('should match snapshot', () => {
const wrapper = shallow(<PostAttachmentImage {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View File

@@ -202,7 +202,10 @@ export default class PostHeader extends PureComponent {
dateComponent = (
<View style={style.datetime}>
<Text style={style.time}>
<FormattedDate value={createAt}/>
<FormattedDate
timeZone={userTimezone}
value={createAt}
/>
</Text>
<Text style={style.time}>
<FormattedTime
@@ -240,8 +243,8 @@ export default class PostHeader extends PureComponent {
style={style.replyIconContainer}
>
<ReplyIcon
height={15}
width={15}
height={16}
width={16}
color={theme.linkColor}
/>
{!isSearchResult &&

View File

@@ -31,9 +31,8 @@ exports[`DateHeader component should match snapshot when timezone is set 1`] = `
}
}
>
<InjectIntl(FormattedDate)
day="2-digit"
month="short"
<FormattedDate
format="ddd, MMM DD, YYYY"
style={
Object {
"color": "#3d3c40",
@@ -41,9 +40,8 @@ exports[`DateHeader component should match snapshot when timezone is set 1`] = `
"fontWeight": "600",
}
}
value={1970-01-18T17:19:12.392Z}
weekday="short"
year="numeric"
timeZone="America/New_York"
value={1531152392}
/>
</View>
<View
@@ -90,9 +88,8 @@ exports[`DateHeader component should match snapshot with suffix 1`] = `
}
}
>
<InjectIntl(FormattedDate)
day="2-digit"
month="short"
<FormattedDate
format="ddd, MMM DD, YYYY"
style={
Object {
"color": "#3d3c40",
@@ -100,9 +97,8 @@ exports[`DateHeader component should match snapshot with suffix 1`] = `
"fontWeight": "600",
}
}
value={1970-01-18T17:19:12.392Z}
weekday="short"
year="numeric"
timeZone={null}
value={1531152392}
/>
</View>
<View
@@ -149,9 +145,8 @@ exports[`DateHeader component should match snapshot without suffix 1`] = `
}
}
>
<InjectIntl(FormattedDate)
day="2-digit"
month="short"
<FormattedDate
format="ddd, MMM DD, YYYY"
style={
Object {
"color": "#3d3c40",
@@ -159,9 +154,8 @@ exports[`DateHeader component should match snapshot without suffix 1`] = `
"fontWeight": "600",
}
}
value={1970-01-18T17:19:12.392Z}
weekday="short"
year="numeric"
timeZone={null}
value={1531152392}
/>
</View>
<View

View File

@@ -7,7 +7,6 @@ import {
View,
ViewPropTypes,
} from 'react-native';
import moment from 'moment-timezone';
import FormattedDate from 'app/components/formatted_date';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
@@ -25,25 +24,14 @@ export default class DateHeader extends PureComponent {
const {theme, timeZone, date} = this.props;
const style = getStyleSheet(theme);
const dateFormatProps = {
weekday: 'short',
day: '2-digit',
month: 'short',
year: 'numeric',
value: new Date(date),
};
if (timeZone) {
dateFormatProps.value = moment.tz(date, timeZone).toDate();
}
return (
<View style={[style.container, this.props.style]}>
<View style={style.line}/>
<View style={style.dateContainer}>
<FormattedDate
style={style.date}
{...dateFormatProps}
timeZone={timeZone}
value={date}
/>
</View>
<View style={style.line}/>

View File

@@ -100,6 +100,7 @@ export default class PostList extends PureComponent {
if (this.props.channelId !== nextProps.channelId) {
this.contentOffsetY = 0;
this.hasDoneInitialScroll = false;
this.setState({contentHeight: 0});
}
}
@@ -115,10 +116,22 @@ export default class PostList extends PureComponent {
this.scrollToBottom();
this.shouldScrollToBottom = false;
}
if (!this.hasDoneInitialScroll && this.props.initialIndex > 0 && this.state.contentHeight) {
this.scrollToInitialIndexIfNeeded(this.props.initialIndex);
}
}
componentWillUnmount() {
EventEmitter.off('scroll-to-bottom', this.handleSetScrollToBottom);
if (this.animationFrameIndexFailed) {
cancelAnimationFrame(this.animationFrameIndexFailed);
}
if (this.animationFrameInitialIndex) {
cancelAnimationFrame(this.animationFrameInitialIndex);
}
}
handleClosePermalink = () => {
@@ -128,12 +141,12 @@ export default class PostList extends PureComponent {
};
handleContentSizeChange = (contentWidth, contentHeight) => {
this.scrollToInitialIndexIfNeeded(contentWidth, 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();
}
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) => {
@@ -201,9 +214,11 @@ export default class PostList extends PureComponent {
};
handleScrollToIndexFailed = () => {
requestAnimationFrame(() => {
this.hasDoneInitialScroll = false;
this.scrollToInitialIndexIfNeeded(1, 1);
this.animationFrameIndexFailed = requestAnimationFrame(() => {
if (this.props.initialIndex > 0 && this.state.contentHeight > 0) {
this.hasDoneInitialScroll = false;
this.scrollToInitialIndexIfNeeded(this.props.initialIndex);
}
});
};
@@ -286,23 +301,17 @@ export default class PostList extends PureComponent {
}, 250);
};
scrollToInitialIndexIfNeeded = (width, height) => {
if (
width > 0 &&
height > 0 &&
this.props.initialIndex > 0 &&
!this.hasDoneInitialScroll &&
this.flatListRef?.current
) {
requestAnimationFrame(() => {
scrollToInitialIndexIfNeeded = (index) => {
if (!this.hasDoneInitialScroll && this.flatListRef?.current) {
this.hasDoneInitialScroll = true;
this.animationFrameInitialIndex = requestAnimationFrame(() => {
this.flatListRef.current.scrollToIndex({
animated: false,
index: this.props.initialIndex,
index,
viewOffset: 0,
viewPosition: 1, // 0 is at bottom
});
});
this.hasDoneInitialScroll = true;
}
};

View File

@@ -4,6 +4,7 @@
import {connect} from 'react-redux';
import {isSystemMessage} from 'mattermost-redux/utils/post_utils';
import {Client4} from 'mattermost-redux/client';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
@@ -19,14 +20,17 @@ function mapStateToProps(state, ownProps) {
const post = ownProps.post;
const user = getUser(state, post.user_id);
const overrideIconUrl = Client4.getAbsoluteUrl(post?.props?.override_icon_url); // eslint-disable-line camelcase
return {
enablePostIconOverride: config.EnablePostIconOverride === 'true' && post?.props?.use_user_icon !== 'true', // eslint-disable-line camelcase
fromWebHook: post?.props?.from_webhook === 'true', // eslint-disable-line camelcase
isSystemMessage: isSystemMessage(post),
fromAutoResponder: fromAutoResponder(post),
overrideIconUrl: post?.props?.override_icon_url, // eslint-disable-line camelcase
overrideIconUrl,
userId: post.user_id,
isBot: (user ? user.is_bot : false),
isEmoji: Boolean(post?.props?.override_icon_emoji), // eslint-disable-line camelcase
theme: getTheme(state),
};
}

View File

@@ -22,6 +22,7 @@ export default class PostProfilePicture extends PureComponent {
theme: PropTypes.object,
userId: PropTypes.string,
isBot: PropTypes.bool,
isEmoji: PropTypes.bool,
};
static defaultProps = {
@@ -39,6 +40,7 @@ export default class PostProfilePicture extends PureComponent {
theme,
userId,
isBot,
isEmoji,
} = this.props;
if (isSystemMessage && !fromAutoResponder && !isBot) {
@@ -55,15 +57,26 @@ export default class PostProfilePicture extends PureComponent {
if (fromWebHook && enablePostIconOverride) {
const icon = overrideIconUrl ? {uri: overrideIconUrl} : webhookIcon;
const frameSize = ViewTypes.PROFILE_PICTURE_SIZE;
const pictureSize = isEmoji ? ViewTypes.PROFILE_PICTURE_EMOJI_SIZE : ViewTypes.PROFILE_PICTURE_SIZE;
const borderRadius = isEmoji ? 0 : ViewTypes.PROFILE_PICTURE_SIZE / 2;
return (
<View>
<View
style={{
borderRadius,
overflow: 'hidden',
justifyContent: 'center',
alignItems: 'center',
height: frameSize,
width: frameSize,
}}
>
<Image
source={icon}
style={{
height: ViewTypes.PROFILE_PICTURE_SIZE,
width: ViewTypes.PROFILE_PICTURE_SIZE,
borderRadius: ViewTypes.PROFILE_PICTURE_SIZE / 2,
height: pictureSize,
width: pictureSize,
}}
/>
</View>

View File

@@ -98,7 +98,7 @@ exports[`PostTextBox should match, full snapshot 1`] = `
visible={false}
>
<SendButton
disabled={false}
disabled={true}
handleSendMessage={[Function]}
theme={
Object {

View File

@@ -6,6 +6,9 @@ import {shallowWithIntl} from 'test/intl-test-helper';
import Preferences from 'mattermost-redux/constants/preferences';
import Fade from 'app/components/fade';
import SendButton from 'app/components/send_button';
import PostTextbox from './post_textbox.ios';
jest.mock('NativeEventEmitter');
@@ -84,4 +87,73 @@ describe('PostTextBox', () => {
expect(baseProps.actions.handlePostDraftChanged).toHaveBeenCalledWith(baseProps.channelId, value);
expect(baseProps.actions.handlePostDraftChanged).toHaveBeenCalledTimes(1);
});
describe('send button', () => {
test('should initially disable and hide the send button', () => {
const wrapper = shallowWithIntl(
<PostTextbox {...baseProps}/>
);
expect(wrapper.find(Fade).prop('visible')).toBe(false);
expect(wrapper.find(SendButton).prop('disabled')).toBe(true);
});
test('should show a disabled send button when uploading a file', () => {
const props = {
...baseProps,
files: [{loading: true}],
};
const wrapper = shallowWithIntl(
<PostTextbox {...props}/>
);
expect(wrapper.find(Fade).prop('visible')).toBe(true);
expect(wrapper.find(SendButton).prop('disabled')).toBe(true);
});
test('should show an enabled send button after uploading a file', () => {
const props = {
...baseProps,
files: [{loading: false}],
};
const wrapper = shallowWithIntl(
<PostTextbox {...props}/>
);
expect(wrapper.find(Fade).prop('visible')).toBe(true);
expect(wrapper.find(SendButton).prop('disabled')).toBe(false);
});
test('should show an enabled send button with a message', () => {
const props = {
...baseProps,
value: 'test',
};
const wrapper = shallowWithIntl(
<PostTextbox {...props}/>
);
expect(wrapper.find(Fade).prop('visible')).toBe(true);
expect(wrapper.find(SendButton).prop('disabled')).toBe(false);
});
test('should show a disabled send button while sending the message', () => {
const props = {
...baseProps,
value: 'test',
};
const wrapper = shallowWithIntl(
<PostTextbox {...props}/>
);
wrapper.setState({sendingMessage: true});
expect(wrapper.find(Fade).prop('visible')).toBe(true);
expect(wrapper.find(SendButton).prop('disabled')).toBe(true);
});
});
});

View File

@@ -91,6 +91,7 @@ export default class PostTextBoxBase extends PureComponent {
this.state = {
cursorPosition: 0,
keyboardType: 'default',
sendingMessage: false,
top: 0,
value: props.value,
};
@@ -278,10 +279,12 @@ export default class PostTextBoxBase extends PureComponent {
};
handleSendMessage = () => {
if (!this.canSend()) {
if (!this.canSend() || this.state.sendingMessage) {
return;
}
this.setState({sendingMessage: true});
const {actions, channelId, files, rootId} = this.props;
const {value} = this.state;
@@ -307,6 +310,9 @@ export default class PostTextBoxBase extends PureComponent {
}),
[{
text: intl.formatMessage({id: 'mobile.channel_info.alertNo', defaultMessage: 'No'}),
onPress: () => {
this.setState({sendingMessage: false});
},
}, {
text: intl.formatMessage({id: 'mobile.channel_info.alertYes', defaultMessage: 'Yes'}),
onPress: () => {
@@ -388,81 +394,72 @@ export default class PostTextBoxBase extends PureComponent {
return this.canSend() || this.isFileLoading();
}
isSendButtonEnabled() {
return this.canSend() && !this.state.sendingMessage;
}
sendMessage = () => {
const {actions, currentUserId, channelId, files, rootId} = this.props;
const {intl} = this.context;
const {value} = this.state;
if (files.length === 0 && !value) {
Alert.alert(
intl.formatMessage({
id: 'mobile.post_textbox.empty.title',
defaultMessage: 'Empty Message',
}),
intl.formatMessage({
id: 'mobile.post_textbox.empty.message',
defaultMessage: 'You are trying to send an empty message.\nPlease make sure you have a message or at least one attached file.',
}),
[{
text: intl.formatMessage({id: 'mobile.post_textbox.empty.ok', defaultMessage: 'OK'}),
}],
);
if (value.indexOf('/') === 0) {
this.sendCommand(value);
} else {
if (value.indexOf('/') === 0) {
this.sendCommand(value);
} else {
const postFiles = files.filter((f) => !f.failed);
const post = {
user_id: currentUserId,
channel_id: channelId,
root_id: rootId,
parent_id: rootId,
message: value,
};
const postFiles = files.filter((f) => !f.failed);
const post = {
user_id: currentUserId,
channel_id: channelId,
root_id: rootId,
parent_id: rootId,
message: value,
};
actions.createPost(post, postFiles);
actions.createPost(post, postFiles);
if (postFiles.length) {
actions.handleClearFiles(channelId, rootId);
}
if (postFiles.length) {
actions.handleClearFiles(channelId, rootId);
}
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('');
}, 250);
} else {
this.handleTextChange('');
}
this.changeDraft('');
let callback;
if (Platform.OS === 'android') {
// Fixes the issue where Android predictive text would prepend suggestions to the post draft when messages
// are typed successively without blurring the input
const nextState = {
keyboardType: 'email-address',
};
callback = () => this.setState({keyboardType: 'default'});
this.setState(nextState, callback);
}
EventEmitter.emit('scroll-to-bottom');
}
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('');
let callback;
if (Platform.OS === 'android') {
// Fixes the issue where Android predictive text would prepend suggestions to the post draft when messages
// are typed successively without blurring the input
const nextState = {
keyboardType: 'email-address',
};
callback = () => this.setState({keyboardType: 'default'});
this.setState(nextState, callback);
}
EventEmitter.emit('scroll-to-bottom');
};
getStatusFromSlashCommand = (message) => {
@@ -516,6 +513,7 @@ export default class PostTextBoxBase extends PureComponent {
const {actions, rootId} = this.props;
actions.addReactionToLatestPost(emoji, rootId);
this.handleTextChange('');
this.setState({sendingMessage: false});
this.changeDraft('');
};
@@ -628,7 +626,7 @@ export default class PostTextBoxBase extends PureComponent {
/>
<Fade visible={this.isSendButtonVisible()}>
<SendButton
disabled={this.isFileLoading()}
disabled={!this.isSendButtonEnabled()}
handleSendMessage={this.handleSendMessage}
theme={theme}
/>

View File

@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MainSidebar should match, full snapshot 1`] = `
<DrawerLayout
drawerPosition="left"
drawerWidth={-30}
isTablet={false}
onDrawerClose={[Function]}
onDrawerOpen={[Function]}
renderNavigationView={[Function]}
useNativeAnimations={true}
/>
`;

View File

@@ -11,6 +11,7 @@ import {
View,
} from 'react-native';
import {intlShape} from 'react-intl';
import AsyncStorage from '@react-native-community/async-storage';
import {General, WebsocketEvents} from 'mattermost-redux/constants';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
@@ -79,9 +80,11 @@ export default class ChannelSidebar extends Component {
this.mounted = true;
this.props.actions.getTeams();
this.handleDimensions();
this.handlePermanentSidebar();
EventEmitter.on('close_channel_drawer', this.closeChannelDrawer);
EventEmitter.on('renderDrawer', this.handleShowDrawerContent);
EventEmitter.on(WebsocketEvents.CHANNEL_UPDATED, this.handleUpdateTitle);
EventEmitter.on(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS, this.handlePermanentSidebar);
BackHandler.addEventListener('hardwareBackPress', this.handleAndroidBack);
Dimensions.addEventListener('change', this.handleDimensions);
}
@@ -103,7 +106,7 @@ export default class ChannelSidebar extends Component {
shouldComponentUpdate(nextProps, nextState) {
const {currentTeamId, deviceWidth, isLandscape, teamsCount} = this.props;
const {openDrawerOffset, isSplitView, show, searching} = this.state;
const {openDrawerOffset, isSplitView, permanentSidebar, show, searching} = this.state;
if (nextState.openDrawerOffset !== openDrawerOffset || nextState.show !== show || nextState.searching !== searching) {
return true;
@@ -112,7 +115,8 @@ export default class ChannelSidebar extends Component {
return nextProps.currentTeamId !== currentTeamId ||
nextProps.isLandscape !== isLandscape || nextProps.deviceWidth !== deviceWidth ||
nextProps.teamsCount !== teamsCount ||
nextState.isSplitView !== isSplitView;
nextState.isSplitView !== isSplitView ||
nextState.permanentSidebar !== permanentSidebar;
}
componentWillUnmount() {
@@ -120,6 +124,7 @@ export default class ChannelSidebar extends Component {
EventEmitter.off('close_channel_drawer', this.closeChannelDrawer);
EventEmitter.off(WebsocketEvents.CHANNEL_UPDATED, this.handleUpdateTitle);
EventEmitter.off('renderDrawer', this.handleShowDrawerContent);
EventEmitter.off(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS, this.handlePermanentSidebar);
BackHandler.removeEventListener('hardwareBackPress', this.handleAndroidBack);
Dimensions.addEventListener('change', this.handleDimensions);
}
@@ -142,6 +147,13 @@ export default class ChannelSidebar extends Component {
}
};
handlePermanentSidebar = async () => {
if (DeviceTypes.IS_TABLET && this.mounted) {
const enabled = await AsyncStorage.getItem(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS);
this.setState({permanentSidebar: enabled === 'true'});
}
};
handleShowDrawerContent = () => {
requestAnimationFrame(() => this.setState({show: true}));
};
@@ -378,7 +390,7 @@ export default class ChannelSidebar extends Component {
<SafeAreaView
navBarBackgroundColor={theme.sidebarBg}
backgroundColor={theme.sidebarHeaderBg}
footerColor={theme.sidebarHeaderBg}
footerColor={theme.sidebarBg}
>
<DrawerSwiper
ref={this.drawerSwiperRef}
@@ -396,7 +408,7 @@ export default class ChannelSidebar extends Component {
render() {
const {children, deviceWidth} = this.props;
const {openDrawerOffset} = this.state;
const isTablet = DeviceTypes.IS_TABLET && !this.state.isSplitView;
const isTablet = DeviceTypes.IS_TABLET && !this.state.isSplitView && this.state.permanentSidebar;
const drawerWidth = DeviceTypes.IS_TABLET ? TABLET_WIDTH : (deviceWidth - openDrawerOffset);
return (

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import Preferences from 'mattermost-redux/constants/preferences';
import {DeviceTypes} from 'app/constants';
import MainSidebar from './main_sidebar';
jest.mock('react-intl');
describe('MainSidebar', () => {
const baseProps = {
actions: {
getTeams: jest.fn(),
logChannelSwitch: jest.fn(),
makeDirectChannel: jest.fn(),
setChannelDisplayName: jest.fn(),
setChannelLoading: jest.fn(),
},
blurPostTextBox: jest.fn(),
currentTeamId: 'current-team-id',
currentUserId: 'current-user-id',
deviceWidth: 10,
isLandscape: false,
teamsCount: 2,
theme: Preferences.THEMES.default,
};
test('should match, full snapshot', () => {
const wrapper = shallow(
<MainSidebar {...baseProps}/>
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should not set the permanentSidebar state if not Tablet', () => {
const wrapper = shallow(
<MainSidebar {...baseProps}/>
);
wrapper.instance().handlePermanentSidebar();
expect(wrapper.state('permanentSidebar')).toBeUndefined();
});
test('should set the permanentSidebar state if Tablet', async () => {
const wrapper = shallow(
<MainSidebar {...baseProps}/>
);
DeviceTypes.IS_TABLET = true;
await wrapper.instance().handlePermanentSidebar();
expect(wrapper.state('permanentSidebar')).toBeDefined();
});
});

View File

@@ -4,14 +4,12 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {getCurrentUrl} from 'mattermost-redux/selectors/entities/general';
import {getCurrentTeamId, getMySortedTeamIds, getJoinableTeamIds} from 'mattermost-redux/selectors/entities/teams';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {showModal} from 'app/actions/navigation';
import {handleTeamChange} from 'app/actions/views/select_team';
import {getCurrentLocale} from 'app/selectors/i18n';
import {removeProtocol} from 'app/utils/url';
import TeamsList from './teams_list';
@@ -20,7 +18,6 @@ function mapStateToProps(state) {
return {
currentTeamId: getCurrentTeamId(state),
currentUrl: removeProtocol(getCurrentUrl(state)),
hasOtherJoinableTeams: getJoinableTeamIds(state).length > 0,
teamIds: getMySortedTeamIds(state, locale),
theme: getTheme(state),

View File

@@ -17,8 +17,10 @@ import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import FormattedText from 'app/components/formatted_text';
import {DeviceTypes, ListTypes, ViewTypes} from 'app/constants';
import {getCurrentServerUrl} from 'app/init/credentials';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {removeProtocol} from 'app/utils/url';
import tracker from 'app/utils/time_tracker';
import telemetry from 'app/telemetry';
@@ -38,7 +40,6 @@ export default class TeamsList extends PureComponent {
}).isRequired,
closeChannelDrawer: PropTypes.func.isRequired,
currentTeamId: PropTypes.string.isRequired,
currentUrl: PropTypes.string.isRequired,
hasOtherJoinableTeams: PropTypes.bool,
teamIds: PropTypes.array.isRequired,
theme: PropTypes.object.isRequired,
@@ -51,9 +52,17 @@ export default class TeamsList extends PureComponent {
constructor(props) {
super(props);
this.state = {
serverUrl: '',
};
MaterialIcon.getImageSource('close', 20, props.theme.sidebarHeaderTextColor).then((source) => {
this.closeButton = source;
});
getCurrentServerUrl().then((url) => {
this.setState({serverUrl: removeProtocol(url)});
});
}
selectTeam = (teamId) => {
@@ -75,13 +84,14 @@ export default class TeamsList extends PureComponent {
});
};
goToSelectTeam = preventDoubleTap(() => {
goToSelectTeam = preventDoubleTap(async () => {
const {intl} = this.context;
const {currentUrl, theme, actions} = this.props;
const {theme, actions} = this.props;
const {serverUrl} = this.state;
const screen = 'SelectTeam';
const title = intl.formatMessage({id: 'mobile.routes.selectTeam', defaultMessage: 'Select Team'});
const passProps = {
currentUrl,
currentUrl: serverUrl,
theme,
};
const options = {
@@ -117,6 +127,7 @@ export default class TeamsList extends PureComponent {
renderItem = ({item}) => {
return (
<TeamsListItem
currentUrl={this.state.serverUrl}
selectTeam={this.selectTeam}
teamId={item}
/>
@@ -155,6 +166,7 @@ export default class TeamsList extends PureComponent {
{moreAction}
</View>
<FlatList
extraData={this.state.serverUrl}
contentContainerStyle={this.listContentPadding()}
data={teamIds}
renderItem={this.renderItem}

View File

@@ -3,12 +3,9 @@
import {connect} from 'react-redux';
import {getCurrentUrl} from 'mattermost-redux/selectors/entities/general';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeamId, getTeam, makeGetBadgeCountForTeamId} from 'mattermost-redux/selectors/entities/teams';
import {removeProtocol} from 'app/utils/url';
import TeamsListItem from './teams_list_item.js';
function makeMapStateToProps() {
@@ -19,7 +16,6 @@ function makeMapStateToProps() {
return {
currentTeamId: getCurrentTeamId(state),
currentUrl: removeProtocol(getCurrentUrl(state)),
displayName: team.display_name,
mentionCount: getMentionCount(state, ownProps.teamId),
name: team.name,

View File

@@ -130,12 +130,12 @@ export default class SlideUpPanel extends PureComponent {
}).start();
}
closeWithAnimation = () => {
closeWithAnimation = (cb) => {
Animated.timing(this.translateYOffset, {
duration: 200,
toValue: this.snapPoints[2],
useNativeDriver: true,
}).start(() => this.props.onRequestClose());
}).start(() => this.props.onRequestClose(cb));
};
onHeaderHandlerStateChange = ({nativeEvent}) => {

View File

@@ -14,6 +14,10 @@ export default class TextInputWithLocalizedPlaceholder extends PureComponent {
placeholder: PropTypes.object.isRequired,
};
static defaultProps = {
placeholderTextColor: changeOpacity('#000', 0.5),
};
static contextTypes = {
intl: intlShape.isRequired,
};
@@ -39,7 +43,6 @@ export default class TextInputWithLocalizedPlaceholder extends PureComponent {
ref='input'
{...otherProps}
placeholder={placeholderString}
placeholderTextColor={changeOpacity('#000', 0.5)}
disableFullscreenUI={true}
/>
);

View File

@@ -20,4 +20,5 @@ export default {
IS_IPHONE_X: DeviceInfo.getModel().includes('iPhone X'),
IS_TABLET: DeviceInfo.isTablet(),
VIDEOS_PATH: `${RNFetchBlobFS.dirs.CacheDir}/Videos`,
PERMANENT_SIDEBAR_SETTINGS: '@PERMANENT_SIDEBAR_SETTINGS',
};

View File

@@ -110,6 +110,7 @@ export default {
IOSX_TOP_PORTRAIT: 88,
STATUS_BAR_HEIGHT: 20,
PROFILE_PICTURE_SIZE: 32,
PROFILE_PICTURE_EMOJI_SIZE: 28,
DATA_SOURCE_USERS: 'users',
DATA_SOURCE_CHANNELS: 'channels',
NotificationLevels,

View File

@@ -4,6 +4,7 @@
import 'intl';
import {addLocaleData} from 'react-intl';
import enLocaleData from 'react-intl/locale-data/en';
import moment from 'moment';
import en from 'assets/i18n/en.json';
@@ -16,72 +17,92 @@ addLocaleData(enLocaleData);
function loadTranslation(locale) {
try {
let localeData;
let momentData;
switch (locale) {
case 'de':
TRANSLATIONS.de = require('assets/i18n/de.json');
localeData = require('react-intl/locale-data/de');
momentData = require('moment/locale/de');
break;
case 'es':
TRANSLATIONS.es = require('assets/i18n/es.json');
localeData = require('react-intl/locale-data/es');
momentData = require('moment/locale/es');
break;
case 'fr':
TRANSLATIONS.fr = require('assets/i18n/fr.json');
localeData = require('react-intl/locale-data/fr');
momentData = require('moment/locale/fr');
break;
case 'it':
TRANSLATIONS.it = require('assets/i18n/it.json');
localeData = require('react-intl/locale-data/it');
momentData = require('moment/locale/it');
break;
case 'ja':
TRANSLATIONS.ja = require('assets/i18n/ja.json');
localeData = require('react-intl/locale-data/ja');
momentData = require('moment/locale/ja');
break;
case 'ko':
TRANSLATIONS.ko = require('assets/i18n/ko.json');
localeData = require('react-intl/locale-data/ko');
momentData = require('moment/locale/ko');
break;
case 'nl':
TRANSLATIONS.nl = require('assets/i18n/nl.json');
localeData = require('react-intl/locale-data/nl');
momentData = require('moment/locale/nl');
break;
case 'pl':
TRANSLATIONS.pl = require('assets/i18n/pl.json');
localeData = require('react-intl/locale-data/pl');
momentData = require('moment/locale/pl');
break;
case 'pt-BR':
TRANSLATIONS[locale] = require('assets/i18n/pt-BR.json');
localeData = require('react-intl/locale-data/pt');
momentData = require('moment/locale/pt-br');
break;
case 'ro':
TRANSLATIONS.ro = require('assets/i18n/ro.json');
localeData = require('react-intl/locale-data/ro');
momentData = require('moment/locale/ro');
break;
case 'ru':
TRANSLATIONS.ru = require('assets/i18n/ru.json');
localeData = require('react-intl/locale-data/ru');
momentData = require('moment/locale/ru');
break;
case 'tr':
TRANSLATIONS.tr = require('assets/i18n/tr.json');
localeData = require('react-intl/locale-data/tr');
momentData = require('moment/locale/tr');
break;
case 'uk':
TRANSLATIONS.tr = require('assets/i18n/uk.json');
localeData = require('react-intl/locale-data/uk');
momentData = require('moment/locale/uk');
break;
case 'zh-CN':
TRANSLATIONS[locale] = require('assets/i18n/zh-CN.json');
localeData = require('react-intl/locale-data/zh');
momentData = require('moment/locale/zh-cn');
break;
case 'zh-TW':
TRANSLATIONS[locale] = require('assets/i18n/zh-TW.json');
localeData = require('react-intl/locale-data/zh');
momentData = require('moment/locale/zh-tw');
break;
}
if (localeData) {
addLocaleData(localeData);
}
if (momentData) {
moment.updateLocale(locale, momentData);
}
} catch (e) {
console.error('NO Translation found', e); //eslint-disable-line no-console
}

View File

@@ -27,6 +27,7 @@ export const setAppCredentials = (deviceToken, currentUserId, token, url) => {
const username = `${deviceToken}, ${currentUserId}`;
EphemeralStore.deviceToken = deviceToken;
EphemeralStore.currentServerUrl = url;
AsyncStorage.setItem(CURRENT_SERVER, url);
KeyChain.setInternetCredentials(url, username, token, {accessGroup: mattermostManaged.appGroupIdentifier});
} catch (e) {
@@ -39,6 +40,7 @@ export const getAppCredentials = async () => {
const serverUrl = await AsyncStorage.getItem(CURRENT_SERVER);
if (serverUrl) {
EphemeralStore.currentServerUrl = serverUrl;
return getInternetCredentials(serverUrl);
}
@@ -59,6 +61,7 @@ export const removeAppCredentials = async () => {
}
KeyChain.resetGenericPassword();
EphemeralStore.currentServerUrl = null;
AsyncStorage.removeItem(CURRENT_SERVER);
};

13
app/init/device.js Normal file
View File

@@ -0,0 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import AsyncStorage from '@react-native-community/async-storage';
import {DeviceTypes} from 'app/constants';
if (DeviceTypes.IS_TABLET) {
AsyncStorage.getItem(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS).then((value) => {
if (!value) {
AsyncStorage.setItem(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS, 'true');
}
});
}

View File

@@ -4,7 +4,7 @@
import {Alert, Platform} from 'react-native';
import {handleLoginIdChanged} from 'app/actions/views/login';
import {handleServerUrlChanged} from 'app/actions/views/select_server';
import {setServerUrl} from 'app/actions/views/select_server';
import {getTranslations} from 'app/i18n';
import mattermostBucket from 'app/mattermost_bucket';
import mattermostManaged from 'app/mattermost_managed';
@@ -93,7 +93,7 @@ class EMMProvider {
const {dispatch} = store;
if (LocalConfig.AutoSelectServerUrl) {
dispatch(handleServerUrlChanged(LocalConfig.DefaultServerUrl));
dispatch(setServerUrl(LocalConfig.DefaultServerUrl));
this.allowOtherServers = false;
}
@@ -122,7 +122,7 @@ class EMMProvider {
}
if (this.emmServerUrl) {
dispatch(handleServerUrlChanged(this.emmServerUrl));
dispatch(setServerUrl(this.emmServerUrl));
}
if (this.emmUsername) {

View File

@@ -143,8 +143,7 @@ class GlobalEventHandler {
deleteFileCache();
removeAppCredentials();
PushNotifications.setApplicationIconBadgeNumber(0);
PushNotifications.cancelAllLocalNotifications(); // TODO: Only cancel the notification that belongs to this server
PushNotifications.clearNotifications();
if (this.launchApp) {
this.launchApp();
@@ -225,8 +224,6 @@ class GlobalEventHandler {
dispatch(setServerVersion(''));
Client4.serverVersion = '';
PushNotifications.setApplicationIconBadgeNumber(0);
PushNotifications.cancelAllLocalNotifications(); // TODO: Only cancel the notification that belongs to this server
const credentials = await getAppCredentials();

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import intitialState from 'app/initial_state';
import PushNotification from 'app/push_notifications';
import GlobalEventHandler from './global_event_handler';
jest.mock('app/init/credentials', () => ({
getCurrentServerUrl: jest.fn().mockResolvedValue(''),
getAppCredentials: jest.fn(),
removeAppCredentials: jest.fn(),
}));
jest.mock('react-native-notifications', () => ({
addEventListener: jest.fn(),
cancelAllLocalNotifications: jest.fn(),
setBadgesCount: jest.fn(),
NotificationAction: jest.fn(),
NotificationCategory: jest.fn(),
}));
const mockStore = configureMockStore([thunk]);
const store = mockStore(intitialState);
GlobalEventHandler.store = store;
// TODO: Add Android test as part of https://mattermost.atlassian.net/browse/MM-17110
describe('GlobalEventHandler', () => {
it('should clear notifications on logout', async () => {
const clearNotifications = jest.spyOn(PushNotification, 'clearNotifications');
await GlobalEventHandler.onLogout();
expect(clearNotifications).toHaveBeenCalled();
});
});

View File

@@ -12,6 +12,7 @@ import {setDeepLinkURL} from 'app/actions/views/root';
import initialState from 'app/initial_state';
import {getAppCredentials} from 'app/init/credentials';
import emmProvider from 'app/init/emm_provider';
import 'app/init/device';
import 'app/init/fetch';
import globalEventHandler from 'app/init/global_event_handler';
import {registerScreens} from 'app/screens';
@@ -87,11 +88,12 @@ const launchAppAndAuthenticateIfNeeded = async () => {
Navigation.events().registerAppLaunchedListener(() => {
init();
// Keep track of the latest componentId to appear and disappear
// Keep track of the latest componentId to appear
Navigation.events().registerComponentDidAppearListener(({componentId}) => {
EphemeralStore.addComponentIdToStack(componentId);
EphemeralStore.addNavigationComponentId(componentId);
});
Navigation.events().registerComponentDidDisappearListener(({componentId}) => {
EphemeralStore.removeComponentIdFromStack(componentId);
EphemeralStore.removeNavigationComponentId(componentId);
});
});

View File

@@ -113,6 +113,16 @@ class PushNotification {
NotificationPreferences.removeDeliveredNotifications(notificationForChannel.identifier, channelId);
}
}
clearForegroundNotifications = () => {
// TODO: Implement as part of https://mattermost.atlassian.net/browse/MM-17110
};
clearNotifications = () => {
this.setApplicationIconBadgeNumber(0);
this.cancelAllLocalNotifications(); // TODO: Only cancel the local notifications that belong to this server
this.clearForegroundNotifications(); // TODO: Only clear the foreground notifications that belong to this server
}
}
export default new PushNotification();

View File

@@ -2,12 +2,14 @@
// See LICENSE.txt for license information.
import {AppState} from 'react-native';
import AsyncStorage from '@react-native-community/async-storage';
import NotificationsIOS, {NotificationAction, NotificationCategory} from 'react-native-notifications';
import ephemeralStore from 'app/store/ephemeral_store';
const CATEGORY = 'CAN_REPLY';
const REPLY_ACTION = 'REPLY_ACTION';
export const FOREGROUND_NOTIFICATIONS_KEY = '@FOREGROUND_NOTIFICATIONS';
let replyCategory;
const replies = new Set();
@@ -51,6 +53,10 @@ class PushNotification {
if (this.onNotification) {
this.onNotification(this.deviceNotification);
}
if (foreground) {
this.trackForegroundNotification(data.channel_id);
}
};
handleReply = (action, completed) => {
@@ -96,13 +102,13 @@ class PushNotification {
}
localNotification(notification) {
const deviceNotification = {
this.deviceNotification = {
alertBody: notification.message,
alertAction: '',
userInfo: notification.userInfo,
};
NotificationsIOS.localNotification(deviceNotification);
NotificationsIOS.localNotification(this.deviceNotification);
}
cancelAllLocalNotifications() {
@@ -130,6 +136,10 @@ class PushNotification {
message: notification.getMessage(),
};
this.handleNotification(info, true, false);
NotificationsIOS.getBadgesCount((count) => {
this.setApplicationIconBadgeNumber(count + 1);
});
};
onNotificationOpened = (notification) => {
@@ -160,10 +170,22 @@ class PushNotification {
}
clearChannelNotifications(channelId) {
NotificationsIOS.getDeliveredNotifications((notifications) => {
const ids = [];
let badgeCount = notifications.length;
NotificationsIOS.getDeliveredNotifications(async (notifications) => {
let foregroundNotifications;
try {
const value = await AsyncStorage.getItem(FOREGROUND_NOTIFICATIONS_KEY);
foregroundNotifications = JSON.parse(value) || {};
} catch (e) {
foregroundNotifications = {};
}
Reflect.deleteProperty(foregroundNotifications, channelId);
AsyncStorage.setItem(FOREGROUND_NOTIFICATIONS_KEY, JSON.stringify(foregroundNotifications));
const foregroundCount = Object.values(foregroundNotifications).reduce((a, b) => a + b, 0);
let badgeCount = notifications.length + foregroundCount;
const ids = [];
for (let i = 0; i < notifications.length; i++) {
const notification = notifications[i];
@@ -180,6 +202,26 @@ class PushNotification {
this.setApplicationIconBadgeNumber(badgeCount);
});
}
trackForegroundNotification = async (channelId) => {
const value = await AsyncStorage.getItem(FOREGROUND_NOTIFICATIONS_KEY);
const foregroundNotifications = value ? JSON.parse(value) : {};
if (!foregroundNotifications.hasOwnProperty(channelId)) {
foregroundNotifications[channelId] = 0;
}
foregroundNotifications[channelId] += 1;
await AsyncStorage.setItem(FOREGROUND_NOTIFICATIONS_KEY, JSON.stringify(foregroundNotifications));
}
clearForegroundNotifications = () => {
AsyncStorage.removeItem(FOREGROUND_NOTIFICATIONS_KEY);
};
clearNotifications = () => {
this.setApplicationIconBadgeNumber(0);
this.cancelAllLocalNotifications(); // TODO: Only cancel the local notifications that belong to this server
this.clearForegroundNotifications(); // TODO: Only clear the foreground notifications that belong to this server
}
}
export default new PushNotification();

View File

@@ -0,0 +1,164 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import AsyncStorage from '@react-native-community/async-storage';
import NotificationsIOS from 'react-native-notifications';
import PushNotification, {FOREGROUND_NOTIFICATIONS_KEY} from './push_notifications.ios';
jest.mock('react-native-notifications', () => {
let badgesCount = 0;
let deliveredNotifications = {};
return {
getBadgesCount: jest.fn((callback) => callback(badgesCount)),
setBadgesCount: jest.fn((count) => {
badgesCount = count;
}),
addEventListener: jest.fn(),
setDeliveredNotifications: jest.fn((notifications) => {
deliveredNotifications = notifications;
}),
getDeliveredNotifications: jest.fn(async (callback) => {
await callback(deliveredNotifications);
}),
removeDeliveredNotifications: jest.fn((ids) => {
deliveredNotifications = deliveredNotifications.filter((n) => !ids.includes(n.identifier));
}),
cancelAllLocalNotifications: jest.fn(),
NotificationAction: jest.fn(),
NotificationCategory: jest.fn(),
};
});
describe('PushNotification', () => {
const channel1ID = 'channel-1-id';
const channel2ID = 'channel-2-id';
const notification = {
getData: jest.fn(),
getMessage: jest.fn(),
};
afterEach(() => AsyncStorage.clear());
it('should track foreground notifications for channel', async () => {
let item = await AsyncStorage.getItem(FOREGROUND_NOTIFICATIONS_KEY);
expect(item).toBe(null);
await PushNotification.trackForegroundNotification(channel1ID);
item = await AsyncStorage.getItem(FOREGROUND_NOTIFICATIONS_KEY);
expect(item).not.toBe(null);
let foregroundNotifications = JSON.parse(item);
expect(foregroundNotifications[channel1ID]).toBe(1);
expect(foregroundNotifications[channel2ID]).toBe(undefined);
await PushNotification.trackForegroundNotification(channel1ID);
item = await AsyncStorage.getItem(FOREGROUND_NOTIFICATIONS_KEY);
expect(item).not.toBe(null);
foregroundNotifications = JSON.parse(item);
expect(foregroundNotifications[channel1ID]).toBe(2);
expect(foregroundNotifications[channel2ID]).toBe(undefined);
});
it('should NOT track foreground notifications for channel when opened', async () => {
let item = await AsyncStorage.getItem(FOREGROUND_NOTIFICATIONS_KEY);
expect(item).toBe(null);
PushNotification.trackForegroundNotification = jest.fn();
PushNotification.onNotificationOpened(notification);
expect(PushNotification.trackForegroundNotification).not.toBeCalled();
item = await AsyncStorage.getItem(FOREGROUND_NOTIFICATIONS_KEY);
expect(item).toBe(null);
});
it('should increment badge number when foreground notification is received', () => {
const setApplicationIconBadgeNumber = jest.spyOn(PushNotification, 'setApplicationIconBadgeNumber');
NotificationsIOS.getBadgesCount((count) => expect(count).toBe(0));
PushNotification.onNotificationReceivedForeground(notification);
expect(setApplicationIconBadgeNumber).toHaveBeenCalledWith(1);
NotificationsIOS.getBadgesCount((count) => expect(count).toBe(1));
PushNotification.onNotificationReceivedForeground(notification);
expect(setApplicationIconBadgeNumber).toHaveBeenCalledWith(2);
NotificationsIOS.getBadgesCount((count) => expect(count).toBe(2));
});
it('should clear channel notifications and set correct badge number', async () => {
const deliveredNotifications = [
// Three channel1 delivered notifications
{
identifier: 'channel1-1',
userInfo: {channel_id: channel1ID},
},
{
identifier: 'channel1-2',
userInfo: {channel_id: channel1ID},
},
{
identifier: 'channel1-3',
userInfo: {channel_id: channel1ID},
},
// Two channel2 delivered notifications
{
identifier: 'channel2-1',
userInfo: {channel_id: channel2ID},
},
{
identifier: 'channel2-2',
userInfo: {channel_id: channel2ID},
},
];
NotificationsIOS.setDeliveredNotifications(deliveredNotifications);
const foregroundNotifications = {
[channel1ID]: 1,
[channel2ID]: 1,
};
await AsyncStorage.setItem(FOREGROUND_NOTIFICATIONS_KEY, JSON.stringify(foregroundNotifications));
const notificationCount = deliveredNotifications.length + Object.values(foregroundNotifications).reduce((a, b) => a + b);
expect(notificationCount).toBe(7);
NotificationsIOS.setBadgesCount(notificationCount);
NotificationsIOS.getBadgesCount((count) => expect(count).toBe(notificationCount));
// Clear channel1 notifications
const setApplicationIconBadgeNumber = jest.spyOn(PushNotification, 'setApplicationIconBadgeNumber');
await PushNotification.clearChannelNotifications(channel1ID);
await NotificationsIOS.getDeliveredNotifications(async (deliveredNotifs) => {
expect(deliveredNotifs.length).toBe(2);
const channel1DeliveredNotifications = deliveredNotifs.filter((n) => n.userInfo.channel_id === channel1ID);
const channel2DeliveredNotifications = deliveredNotifs.filter((n) => n.userInfo.channel_id === channel2ID);
expect(channel1DeliveredNotifications.length).toBe(0);
expect(channel2DeliveredNotifications.length).toBe(2);
const item = await AsyncStorage.getItem(FOREGROUND_NOTIFICATIONS_KEY);
const foregroundNotifs = JSON.parse(item);
const channel1ForegroundNotifications = foregroundNotifs[channel1ID];
const channel2ForegroundNotifications = foregroundNotifs[channel2ID];
expect(channel1ForegroundNotifications).toBe(undefined);
expect(channel2ForegroundNotifications).toBe(1);
const badgeNumber = channel2DeliveredNotifications.length + channel2ForegroundNotifications;
expect(setApplicationIconBadgeNumber).toHaveBeenCalledWith(badgeNumber);
});
});
it('should clear all notifications', () => {
const setApplicationIconBadgeNumber = jest.spyOn(PushNotification, 'setApplicationIconBadgeNumber');
const cancelAllLocalNotifications = jest.spyOn(PushNotification, 'cancelAllLocalNotifications');
const clearForegroundNotifications = jest.spyOn(PushNotification, 'clearForegroundNotifications');
PushNotification.clearNotifications();
expect(setApplicationIconBadgeNumber).toHaveBeenCalledWith(0);
expect(NotificationsIOS.setBadgesCount).toHaveBeenCalledWith(0);
expect(cancelAllLocalNotifications).toHaveBeenCalled();
expect(NotificationsIOS.cancelAllLocalNotifications).toHaveBeenCalled();
expect(clearForegroundNotifications).toHaveBeenCalled();
});
});

View File

@@ -169,16 +169,24 @@ export default class ChannelBase extends PureComponent {
};
showTermsOfServiceModal = async () => {
const {intl} = this.context;
const {actions, theme} = this.props;
const closeButton = await MaterialIcon.getImageSource('close', 20, theme.sidebarHeaderTextColor);
const screen = 'TermsOfService';
const passProps = {
closeButton,
};
const passProps = {closeButton};
const title = intl.formatMessage({id: 'mobile.tos_link', defaultMessage: 'Terms of Service'});
const options = {
layout: {
backgroundColor: theme.centerChannelBg,
},
topBar: {
visible: true,
height: null,
title: {
color: theme.sidebarHeaderTextColor,
text: title,
},
},
};
actions.showModalOverCurrentContext(screen, passProps, options);
@@ -310,6 +318,6 @@ export const style = StyleSheet.create({
flex: 1,
},
iOSHomeIndicator: {
paddingBottom: 5,
paddingBottom: 50,
},
});

View File

@@ -0,0 +1,64 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ChannelNavBar should match, full snapshot 1`] = `
<View
style={
Array [
Object {
"backgroundColor": "#1153ab",
"flexDirection": "row",
"justifyContent": "flex-start",
"width": "100%",
"zIndex": 10,
},
Object {
"paddingHorizontal": 0,
},
Object {
"height": 44,
},
]
}
>
<Connect(ChannelDrawerButton)
openDrawer={[MockFunction]}
visible={true}
/>
<Connect(ChannelTitle)
onPress={[MockFunction]}
/>
<Connect(ChannelSearchButton)
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"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",
}
}
/>
<Connect(SettingDrawerButton)
openDrawer={[MockFunction]}
/>
</View>
`;

View File

@@ -4,6 +4,9 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Dimensions, Platform, View} from 'react-native';
import AsyncStorage from '@react-native-community/async-storage';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {DeviceTypes, ViewTypes} from 'app/constants';
import mattermostManaged from 'app/mattermost_managed';
@@ -38,12 +41,15 @@ export default class ChannelNavBar extends PureComponent {
componentDidMount() {
this.mounted = true;
this.handleDimensions();
this.handlePermanentSidebar();
Dimensions.addEventListener('change', this.handleDimensions);
EventEmitter.on(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS, this.handlePermanentSidebar);
}
componentWillUnmount() {
this.mounted = false;
Dimensions.removeEventListener('change', this.handleDimensions);
EventEmitter.off(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS, this.handlePermanentSidebar);
}
handleDimensions = () => {
@@ -55,6 +61,14 @@ export default class ChannelNavBar extends PureComponent {
}
};
handlePermanentSidebar = () => {
if (DeviceTypes.IS_TABLET && this.mounted) {
AsyncStorage.getItem(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS).then((enabled) => {
this.setState({permanentSidebar: enabled === 'true'});
});
}
};
render() {
const {isLandscape, onPress, theme} = this.props;
const {openChannelDrawer, openSettingsDrawer} = this.props;
@@ -84,7 +98,7 @@ export default class ChannelNavBar extends PureComponent {
}
let drawerButtonVisible = false;
if (!DeviceTypes.IS_TABLET || this.state.isSplitView) {
if (!DeviceTypes.IS_TABLET || this.state.isSplitView || !this.state.permanentSidebar) {
drawerButtonVisible = true;
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import Preferences from 'mattermost-redux/constants/preferences';
import {DeviceTypes} from 'app/constants';
import ChannelNavBar from './channel_nav_bar';
jest.mock('react-intl');
describe('ChannelNavBar', () => {
const baseProps = {
isLandscape: false,
openChannelDrawer: jest.fn(),
openSettingsDrawer: jest.fn(),
onPress: jest.fn(),
theme: Preferences.THEMES.default,
};
test('should match, full snapshot', () => {
const wrapper = shallow(
<ChannelNavBar {...baseProps}/>
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should not set the permanentSidebar state if not Tablet', () => {
const wrapper = shallow(
<ChannelNavBar {...baseProps}/>
);
wrapper.instance().handlePermanentSidebar();
expect(wrapper.state('permanentSidebar')).toBeUndefined();
});
test('should set the permanentSidebar state if Tablet', async () => {
const wrapper = shallow(
<ChannelNavBar {...baseProps}/>
);
DeviceTypes.IS_TABLET = true;
await wrapper.instance().handlePermanentSidebar();
expect(wrapper.state('permanentSidebar')).toBeDefined();
});
});

View File

@@ -6,7 +6,6 @@ import PropTypes from 'prop-types';
import {intlShape} from 'react-intl';
import {
Alert,
Platform,
ScrollView,
View,
} from 'react-native';
@@ -113,11 +112,8 @@ export default class ChannelInfo extends PureComponent {
if (redirect) {
actions.setChannelDisplayName('');
}
if (Platform.OS === 'android') {
actions.dismissModal();
} else {
actions.popTopScreen();
}
actions.popTopScreen();
};
goToChannelAddMembers = preventDoubleTap(() => {

View File

@@ -4,15 +4,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Text,
View,
Clipboard,
Platform,
Text,
TouchableHighlight,
View,
} from 'react-native';
import {intlShape} from 'react-intl';
import ChannelIcon from 'app/components/channel_icon';
import FormattedDate from 'app/components/formatted_date';
import FormattedText from 'app/components/formatted_text';
import Markdown from 'app/components/markdown';
import mattermostManaged from 'app/mattermost_managed';
import BottomSheet from 'app/utils/bottom_sheet';
import {getMarkdownTextStyles, getMarkdownBlockStyles} from 'app/utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
@@ -31,8 +36,54 @@ export default class ChannelInfoHeader extends React.PureComponent {
isArchived: PropTypes.bool.isRequired,
isBot: PropTypes.bool.isRequired,
isGroupConstrained: PropTypes.bool,
timeZone: PropTypes.string,
};
static contextTypes = {
intl: intlShape.isRequired,
};
handleLongPress = (text, actionText) => {
const {formatMessage} = this.context.intl;
const config = mattermostManaged.getCachedConfig();
if (config?.copyAndPasteProtection !== 'true') {
const cancelText = formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'});
BottomSheet.showBottomSheetWithOptions({
options: [actionText, cancelText],
cancelButtonIndex: 1,
}, (value) => {
if (value === 0) {
this.handleCopy(text);
}
});
}
};
handleCopy = (text) => {
Clipboard.setString(text);
}
handleHeaderLongPress = () => {
const {formatMessage} = this.context.intl;
const {header} = this.props;
this.handleLongPress(
header,
formatMessage({id: 'mobile.channel_info.copy_header', defaultMessage: 'Copy Header'})
);
}
handlePurposeLongPress = () => {
const {formatMessage} = this.context.intl;
const {purpose} = this.props;
this.handleLongPress(
purpose,
formatMessage({id: 'mobile.channel_info.copy_purpose', defaultMessage: 'Copy Purpose'})
);
}
render() {
const {
createAt,
@@ -48,6 +99,7 @@ export default class ChannelInfoHeader extends React.PureComponent {
isArchived,
isBot,
isGroupConstrained,
timeZone,
} = this.props;
const style = getStyleSheet(theme);
@@ -59,7 +111,7 @@ export default class ChannelInfoHeader extends React.PureComponent {
return (
<View style={style.container}>
<View style={style.channelNameContainer}>
<View style={[style.channelNameContainer, style.row]}>
<ChannelIcon
isInfo={true}
membersCount={memberCount - 1}
@@ -79,61 +131,74 @@ export default class ChannelInfoHeader extends React.PureComponent {
</Text>
</View>
{purpose.length > 0 &&
<View style={style.section}>
<FormattedText
style={style.header}
id='channel_info.purpose'
defaultMessage='Purpose'
/>
<Markdown
onPermalinkPress={onPermalinkPress}
baseTextStyle={baseTextStyle}
textStyles={textStyles}
blockStyles={blockStyles}
value={purpose}
/>
</View>
<View style={style.section}>
<TouchableHighlight
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
onLongPress={this.handlePurposeLongPress}
>
<View style={style.row}>
<FormattedText
style={style.header}
id='channel_info.purpose'
defaultMessage='Purpose'
/>
<Markdown
onPermalinkPress={onPermalinkPress}
baseTextStyle={baseTextStyle}
textStyles={textStyles}
blockStyles={blockStyles}
value={purpose}
/>
</View>
</TouchableHighlight>
</View>
}
{header.length > 0 &&
<View style={style.section}>
<FormattedText
style={style.header}
id='channel_info.header'
defaultMessage='Header'
/>
<Markdown
onPermalinkPress={onPermalinkPress}
baseTextStyle={baseTextStyle}
textStyles={textStyles}
blockStyles={blockStyles}
value={header}
/>
</View>
<View style={style.section}>
<TouchableHighlight
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
onLongPress={this.handleHeaderLongPress}
>
<View style={style.row}>
<FormattedText
style={style.header}
id='channel_info.header'
defaultMessage='Header'
/>
<Markdown
onPermalinkPress={onPermalinkPress}
baseTextStyle={baseTextStyle}
textStyles={textStyles}
blockStyles={blockStyles}
value={header}
/>
</View>
</TouchableHighlight>
</View>
}
{isGroupConstrained &&
<Text style={style.createdBy}>
<FormattedText
id='mobile.routes.channelInfo.groupManaged'
defaultMessage='Members are managed by linked groups'
/>
</Text>
<Text style={[style.createdBy, style.row]}>
<FormattedText
id='mobile.routes.channelInfo.groupManaged'
defaultMessage='Members are managed by linked groups'
/>
</Text>
}
{creator &&
<Text style={style.createdBy}>
<FormattedText
id='mobile.routes.channelInfo.createdBy'
defaultMessage='Created by {creator} on '
values={{
creator,
}}
/>
<FormattedDate
value={new Date(createAt)}
year='numeric'
month='long'
day='2-digit'
/>
</Text>
<Text style={[style.createdBy, style.row]}>
<FormattedText
id='mobile.routes.channelInfo.createdBy'
defaultMessage='Created by {creator} on '
values={{
creator,
}}
/>
<FormattedDate
format='LL'
timeZone={timeZone}
value={createAt}
/>
</Text>
}
</View>
);
@@ -145,7 +210,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
container: {
backgroundColor: theme.centerChannelBg,
marginBottom: 40,
padding: 15,
paddingVertical: 15,
borderBottomWidth: 1,
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1),
},
@@ -180,5 +245,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
section: {
marginTop: 15,
},
row: {
paddingHorizontal: 15,
},
};
});

View File

@@ -29,6 +29,8 @@ import {getCurrentUserId, getUser, getStatusForUserId, getCurrentUserRoles} from
import {areChannelMentionsIgnored, getUserIdFromChannelName, isChannelMuted, showDeleteOption, showManagementOptions} from 'mattermost-redux/utils/channel_utils';
import {isAdmin as checkIsAdmin, isChannelAdmin as checkIsChannelAdmin, isSystemAdmin as checkIsSystemAdmin} from 'mattermost-redux/utils/user_utils';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {isTimezoneEnabled} from 'mattermost-redux/selectors/entities/timezone';
import {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils';
import {
popTopScreen,
@@ -45,6 +47,7 @@ import {
selectPenultimateChannel,
setChannelDisplayName,
} from 'app/actions/views/channel';
import {isLandscape} from 'app/selectors/device';
import ChannelInfo from './channel_info';
@@ -87,6 +90,12 @@ function mapStateToProps(state) {
const canEditChannel = !channelIsReadOnly && showManagementOptions(state, config, license, currentChannel, isAdmin, isSystemAdmin, isChannelAdmin);
const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true';
const enableTimezone = isTimezoneEnabled(state);
let timeZone = null;
if (enableTimezone) {
timeZone = getUserCurrentTimezone(currentUser.timezone);
}
return {
canDeleteChannel: showDeleteOption(state, config, license, currentChannel, isAdmin, isSystemAdmin, isChannelAdmin),
viewArchivedChannels,
@@ -103,6 +112,8 @@ function mapStateToProps(state) {
theme: getTheme(state),
canManageUsers,
isBot,
isLandscape: isLandscape(state),
timeZone,
};
}

View File

@@ -63,20 +63,11 @@ export default class CreateChannel extends PureComponent {
};
this.rightButton.text = context.intl.formatMessage({id: 'mobile.create_channel', defaultMessage: 'Create'});
this.rightButton.color = props.theme.sidebarHeaderTextColor;
if (props.closeButton) {
this.left = {...this.leftButton, icon: props.closeButton};
}
const buttons = {
rightButtons: [this.rightButton],
};
if (this.left) {
buttons.leftButtons = [this.left];
}
props.actions.setButtons(props.componentId, buttons);
}
componentDidMount() {

View File

@@ -99,6 +99,7 @@ export default class EditChannel extends PureComponent {
header,
};
this.rightButton.color = props.theme.sidebarHeaderTextColor;
this.rightButton.text = context.intl.formatMessage({id: 'mobile.edit_channel', defaultMessage: 'Save'});
const buttons = {

View File

@@ -51,6 +51,7 @@ export default class EditPost extends PureComponent {
super(props);
this.state = {message: props.post.message};
this.rightButton.color = props.theme.sidebarHeaderTextColor;
this.rightButton.text = context.intl.formatMessage({id: 'edit_post.save', defaultMessage: 'Save'});
props.actions.setButtons(props.componentId, {

View File

@@ -119,6 +119,7 @@ export default class EditProfile extends PureComponent {
const buttons = {
rightButtons: [this.rightButton],
};
this.rightButton.color = props.theme.sidebarHeaderTextColor;
this.rightButton.text = context.intl.formatMessage({id: t('mobile.account.settings.save'), defaultMessage: 'Save'});
props.actions.setButtons(props.componentId, buttons);

View File

@@ -28,7 +28,7 @@ export function registerScreens(store, Provider) {
Navigation.registerComponent('ChannelMembers', () => wrapper(require('app/screens/channel_members').default), () => require('app/screens/channel_members').default);
Navigation.registerComponent('ChannelPeek', () => wrapper(require('app/screens/channel_peek').default), () => require('app/screens/channel_peek').default);
Navigation.registerComponent('ClientUpgrade', () => wrapper(require('app/screens/client_upgrade').default), () => require('app/screens/client_upgrade').default);
Navigation.registerComponent('ClockDisplay', () => wrapper(require('app/screens/clock_display').default), () => require('app/screens/clock_display').default);
Navigation.registerComponent('ClockDisplaySettings', () => wrapper(require('app/screens/settings/clock_display').default), () => require('app/screens/settings/clock_display').default);
Navigation.registerComponent('Code', () => wrapper(require('app/screens/code').default), () => require('app/screens/code').default);
Navigation.registerComponent('CreateChannel', () => wrapper(require('app/screens/create_channel').default), () => require('app/screens/create_channel').default);
Navigation.registerComponent('DisplaySettings', () => wrapper(require('app/screens/settings/display_settings').default), () => require('app/screens/settings/display_settings').default);
@@ -64,16 +64,17 @@ export function registerScreens(store, Provider) {
Navigation.registerComponent('SelectorScreen', () => wrapper(require('app/screens/selector_screen').default), () => require('app/screens/selector_screen').default);
Navigation.registerComponent('SelectServer', () => wrapper(SelectServer), () => SelectServer);
Navigation.registerComponent('SelectTeam', () => wrapper(require('app/screens/select_team').default), () => require('app/screens/select_team').default);
Navigation.registerComponent('SelectTimezone', () => wrapper(require('app/screens/timezone/select_timezone').default), () => require('app/screens/timezone/select_timezone').default);
Navigation.registerComponent('SelectTimezone', () => wrapper(require('app/screens/settings/timezone/select_timezone').default), () => require('app/screens/settings/timezone/select_timezone').default);
Navigation.registerComponent('Settings', () => wrapper(require('app/screens/settings/general').default), () => require('app/screens/settings/general').default);
Navigation.registerComponent('SidebarSettings', () => wrapper(require('app/screens/settings/sidebar').default), () => require('app/screens/settings/sidebar').default);
Navigation.registerComponent('SSO', () => wrapper(require('app/screens/sso').default), () => require('app/screens/sso').default);
Navigation.registerComponent('Table', () => wrapper(require('app/screens/table').default), () => require('app/screens/table').default);
Navigation.registerComponent('TableImage', () => wrapper(require('app/screens/table_image').default), () => require('app/screens/table_image').default);
Navigation.registerComponent('TermsOfService', () => wrapper(require('app/screens/terms_of_service').default), () => require('app/screens/terms_of_service').default);
Navigation.registerComponent('TextPreview', () => wrapper(require('app/screens/text_preview').default), () => require('app/screens/text_preview').default);
Navigation.registerComponent('ThemeSettings', () => wrapper(require('app/screens/theme').default), () => require('app/screens/theme').default);
Navigation.registerComponent('ThemeSettings', () => wrapper(require('app/screens/settings/theme').default), () => require('app/screens/settings/theme').default);
Navigation.registerComponent('Thread', () => wrapper(require('app/screens/thread').default), () => require('app/screens/thread').default);
Navigation.registerComponent('TimezoneSettings', () => wrapper(require('app/screens/timezone').default), () => require('app/screens/timezone').default);
Navigation.registerComponent('TimezoneSettings', () => wrapper(require('app/screens/settings/timezone').default), () => require('app/screens/settings/timezone').default);
Navigation.registerComponent('ErrorTeamsList', () => wrapper(require('app/screens/error_teams_list').default), () => require('app/screens/error_teams_list').default);
Navigation.registerComponent('UserProfile', () => wrapper(require('app/screens/user_profile').default), () => require('app/screens/user_profile').default);
}
}

View File

@@ -19,7 +19,7 @@ export default class InteractiveDialog extends PureComponent {
static propTypes = {
url: PropTypes.string.isRequired,
callbackId: PropTypes.string,
elements: PropTypes.arrayOf(PropTypes.object).isRequired,
elements: PropTypes.arrayOf(PropTypes.object),
notifyOnCancel: PropTypes.bool,
state: PropTypes.string,
theme: PropTypes.object,
@@ -38,9 +38,11 @@ export default class InteractiveDialog extends PureComponent {
super(props);
const values = {};
props.elements.forEach((e) => {
values[e.name] = e.default || null;
});
if (props.elements != null) {
props.elements.forEach((e) => {
values[e.name] = e.default || null;
});
}
this.state = {
values,
@@ -69,18 +71,21 @@ export default class InteractiveDialog extends PureComponent {
const {elements} = this.props;
const values = this.state.values;
const errors = {};
elements.forEach((elem) => {
const error = checkDialogElementForError(elem, values[elem.name]);
if (error) {
errors[elem.name] = (
<FormattedText
id={error.id}
defaultMessage={error.defaultMessage}
values={error.values}
/>
);
}
});
if (elements) {
elements.forEach((elem) => {
const error = checkDialogElementForError(elem, values[elem.name]);
if (error) {
errors[elem.name] = (
<FormattedText
id={error.id}
defaultMessage={error.defaultMessage}
values={error.values}
/>
);
}
});
}
this.setState({errors});
@@ -152,7 +157,7 @@ export default class InteractiveDialog extends PureComponent {
<View style={style.container}>
<ScrollView style={style.scrollView}>
<StatusBar/>
{elements.map((e) => {
{elements && elements.map((e) => {
return (
<DialogElement
key={'dialogelement' + e.name}
@@ -190,4 +195,4 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
marginTop: 10,
},
};
});
});

View File

@@ -84,4 +84,26 @@ describe('InteractiveDialog', () => {
wrapper.instance().navigationButtonPressed({buttonId: 'close-dialog'});
expect(baseProps.actions.submitInteractiveDialog).not.toHaveBeenCalled();
});
test('should handle display with no elements', async () => {
baseProps.elements = null;
const wrapper = shallow(
<InteractiveDialog
{...baseProps}
notifyOnCancel={false}
/>,
);
const dialog = {
url: baseProps.url,
callback_id: baseProps.callbackId,
state: baseProps.state,
submission: {},
};
wrapper.instance().handleSubmit();
expect(baseProps.actions.submitInteractiveDialog).toHaveBeenCalledTimes(1);
expect(baseProps.actions.submitInteractiveDialog).toHaveBeenCalledWith(dialog);
});
});

View File

@@ -75,21 +75,12 @@ export default class MoreChannels extends PureComponent {
id: 'close-more-channels',
icon: props.closeButton,
};
const buttons = {
leftButtons: [this.leftButton],
};
if (props.canCreateChannels) {
buttons.rightButtons = [this.rightButton];
}
props.actions.setButtons(props.componentId, buttons);
}
componentDidMount() {
this.navigationEventListener = Navigation.events().bindComponent(this);
this.mounted = true;
this.setHeaderButtons(this.props.canCreateChannels);
this.doGetChannels();
}
@@ -165,7 +156,7 @@ export default class MoreChannels extends PureComponent {
getChannels = debounce(this.doGetChannels, 100);
headerButtons = (createEnabled) => {
setHeaderButtons = (createEnabled) => {
const {actions, canCreateChannels, componentId} = this.props;
const buttons = {
leftButtons: [this.leftButton],
@@ -194,7 +185,7 @@ export default class MoreChannels extends PureComponent {
const {actions, currentTeamId, currentUserId} = this.props;
const {channels} = this.state;
this.headerButtons(false);
this.setHeaderButtons(false);
this.setState({adding: true});
const channel = channels.find((c) => c.id === id);
@@ -212,7 +203,7 @@ export default class MoreChannels extends PureComponent {
displayName: channel ? channel.display_name : '',
}
);
this.headerButtons(true);
this.setHeaderButtons(true);
this.setState({adding: false});
} else {
if (channel) {

View File

@@ -52,14 +52,14 @@ describe('MoreChannels', () => {
expect(baseProps.actions.dismissModal).toHaveBeenCalledTimes(1);
});
test('should call props.actions.setButtons on headerButtons', () => {
test('should call props.actions.setButtons on setHeaderButtons', () => {
const wrapper = shallow(
<MoreChannels {...baseProps}/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
expect(baseProps.actions.setButtons).toHaveBeenCalledTimes(1);
wrapper.instance().headerButtons(true);
wrapper.instance().setHeaderButtons(true);
expect(baseProps.actions.setButtons).toHaveBeenCalledTimes(2);
});

View File

@@ -73,13 +73,12 @@ export default class MoreDirectMessages extends PureComponent {
selectedIds: {},
selectedCount: 0,
};
this.updateNavigationButtons(false, context);
}
componentDidMount() {
this.navigationEventListener = Navigation.events().bindComponent(this);
this.mounted = true;
this.updateNavigationButtons(false);
this.getProfiles();
}

View File

@@ -16,6 +16,7 @@ import {Navigation} from 'react-native-navigation';
import * as Animatable from 'react-native-animatable';
import {PanGestureHandler} from 'react-native-gesture-handler';
import {Client4} from 'mattermost-redux/client';
import {isDirectChannel} from 'mattermost-redux/utils/channel_utils';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {displayUsername} from 'mattermost-redux/utils/user_utils';
@@ -167,7 +168,8 @@ export default class Notification extends PureComponent {
);
if (data.from_webhook && config.EnablePostIconOverride === 'true' && data.use_user_icon !== 'true') {
const wsIcon = data.override_icon_url ? {uri: data.override_icon_url} : webhookIcon;
const overrideIconURL = Client4.getAbsoluteUrl(data.override_icon_url); // eslint-disable-line camelcase
const wsIcon = data.override_icon_url ? {uri: overrideIconURL} : webhookIcon;
icon = (
<Image
source={wsIcon}

View File

@@ -5,6 +5,7 @@ import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Animated,
Platform,
StyleSheet,
TouchableWithoutFeedback,
View,
@@ -74,7 +75,11 @@ export default class OptionsModal extends PureComponent {
};
onItemPress = () => {
this.close();
if (Platform.OS === 'android') {
this.close();
} else {
this.props.actions.dismissModal();
}
};
render() {

View File

@@ -267,41 +267,43 @@ export default class Permalink extends PureComponent {
const {formatMessage} = intl;
let focusChannelId = channelId;
const post = await actions.getPostThread(focusedPostId, false);
if (post.error && (!postIds || !postIds.length)) {
if (this.mounted && isPermalink && post.error.message.toLowerCase() !== 'network request failed') {
this.setState({
error: formatMessage({
id: 'permalink.error.access',
defaultMessage: 'Permalink belongs to a deleted message or to a channel to which you do not have access.',
}),
title: formatMessage({
id: 'mobile.search.no_results',
defaultMessage: 'No Results Found',
}),
});
} else if (this.mounted) {
this.setState({error: post.error.message, retry: true});
if (focusedPostId) {
const post = await actions.getPostThread(focusedPostId, false);
if (post.error && (!postIds || !postIds.length)) {
if (this.mounted && isPermalink && post.error.message.toLowerCase() !== 'network request failed') {
this.setState({
error: formatMessage({
id: 'permalink.error.access',
defaultMessage: 'Permalink belongs to a deleted message or to a channel to which you do not have access.',
}),
title: formatMessage({
id: 'mobile.search.no_results',
defaultMessage: 'No Results Found',
}),
});
} else if (this.mounted) {
this.setState({error: post.error.message, retry: true});
}
return;
}
return;
}
if (!channelId) {
const focusedPost = post.data && post.data.posts ? post.data.posts[focusedPostId] : null;
focusChannelId = focusedPost ? focusedPost.channel_id : '';
if (focusChannelId) {
const {data: channel} = await actions.getChannel(focusChannelId);
if (!this.props.myMembers[focusChannelId] && channel && channel.type === General.OPEN_CHANNEL) {
await actions.joinChannel(currentUserId, channel.team_id, channel.id);
if (!channelId) {
const focusedPost = post.data && post.data.posts ? post.data.posts[focusedPostId] : null;
focusChannelId = focusedPost ? focusedPost.channel_id : '';
if (focusChannelId) {
const {data: channel} = await actions.getChannel(focusChannelId);
if (!this.props.myMembers[focusChannelId] && channel && channel.type === General.OPEN_CHANNEL) {
await actions.joinChannel(currentUserId, channel.team_id, channel.id);
}
}
}
}
await actions.getPostsAround(focusChannelId, focusedPostId, 10);
await actions.getPostsAround(focusChannelId, focusedPostId, 10);
if (this.mounted) {
this.setState({loading: false});
if (this.mounted) {
this.setState({loading: false});
}
}
};

View File

@@ -49,15 +49,19 @@ export default class PostOptions extends PureComponent {
intl: intlShape.isRequired,
};
close = () => {
this.props.actions.dismissModal();
close = async (cb) => {
await this.props.actions.dismissModal();
if (typeof cb === 'function') {
setTimeout(cb, 300);
}
};
closeWithAnimation = () => {
closeWithAnimation = (cb) => {
if (this.slideUpPanel) {
this.slideUpPanel.closeWithAnimation();
this.slideUpPanel.closeWithAnimation(cb);
} else {
this.close();
this.close(cb);
}
};
@@ -268,8 +272,7 @@ export default class PostOptions extends PureComponent {
const {actions, theme} = this.props;
const {formatMessage} = this.context.intl;
this.close();
setTimeout(() => {
this.close(() => {
MaterialIcon.getImageSource('close', 20, theme.sidebarHeaderTextColor).then((source) => {
const screen = 'AddReaction';
const title = formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'});
@@ -280,15 +283,14 @@ export default class PostOptions extends PureComponent {
actions.showModal(screen, title, passProps);
});
}, 300);
});
};
handleReply = () => {
const {post} = this.props;
this.closeWithAnimation();
setTimeout(() => {
this.closeWithAnimation(() => {
EventEmitter.emit('goToThread', post);
}, 250);
});
};
handleAddReactionToPost = (emoji) => {
@@ -347,9 +349,10 @@ export default class PostOptions extends PureComponent {
text: formatMessage({id: 'post_info.del', defaultMessage: 'Delete'}),
style: 'destructive',
onPress: () => {
actions.deletePost(post);
actions.removePost(post);
this.closeWithAnimation();
this.closeWithAnimation(() => {
actions.deletePost(post);
actions.removePost(post);
});
},
}]
);
@@ -359,8 +362,7 @@ export default class PostOptions extends PureComponent {
const {actions, theme, post} = this.props;
const {intl} = this.context;
this.close();
setTimeout(() => {
this.close(() => {
MaterialIcon.getImageSource('close', 20, theme.sidebarHeaderTextColor).then((source) => {
const screen = 'EditPost';
const title = intl.formatMessage({id: 'mobile.edit_post.title', defaultMessage: 'Editing Message'});
@@ -371,7 +373,7 @@ export default class PostOptions extends PureComponent {
actions.showModal(screen, title, passProps);
});
}, 300);
});
};
handleUnflagPost = () => {

View File

@@ -110,8 +110,14 @@ describe('PostOptions', () => {
expect(Alert.alert).toBeCalled();
// Trigger on press of Delete in the Alert
const closeWithAnimation = jest.spyOn(wrapper.instance(), 'closeWithAnimation');
Alert.alert.mock.calls[0][2][1].onPress();
expect(closeWithAnimation).toBeCalled();
// get the callback that gets called by closeWithAnimation
const callback = closeWithAnimation.mock.calls[0][0];
callback();
expect(actions.deletePost).toBeCalled();
expect(actions.removePost).toBeCalled();
});

View File

@@ -107,15 +107,15 @@ export default class SelectServer extends PureComponent {
if (this.state.connected && this.props.hasConfigAndLicense && !(prevState.connected && prevProps.hasConfigAndLicense)) {
if (LocalConfig.EnableMobileClientUpgrade) {
this.props.actions.setLastUpgradeCheck();
const {currentVersion, minVersion, latestVersion} = prevProps;
const {currentVersion, minVersion, latestVersion} = this.props;
const upgradeType = checkUpgradeType(currentVersion, minVersion, latestVersion);
if (isUpgradeAvailable(upgradeType)) {
this.handleShowClientUpgrade(upgradeType);
} else {
this.handleLoginOptions(prevProps);
this.handleLoginOptions(this.props);
}
} else {
this.handleLoginOptions(prevProps);
this.handleLoginOptions(this.props);
}
}
}

View File

@@ -28,6 +28,7 @@ import Config from 'assets/config';
class AdvancedSettings extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
dismissAllModals: PropTypes.func.isRequired,
purgeOfflineStore: PropTypes.func.isRequired,
}).isRequired,
intl: intlShape.isRequired,
@@ -73,6 +74,10 @@ class AdvancedSettings extends PureComponent {
await deleteFileCache();
this.setState({cacheSize: 0, cacheSizedFetched: true});
actions.purgeOfflineStore();
if (Platform.OS === 'android') {
actions.dismissAllModals();
}
});
renderCacheFileSize = () => {

View File

@@ -4,6 +4,7 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {dismissAllModals} from 'app/actions/navigation';
import {purgeOfflineStore} from 'app/actions/views/root';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
@@ -18,6 +19,7 @@ function mapStateToProps(state) {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
dismissAllModals,
purgeOfflineStore,
}, dispatch),
};

View File

@@ -19,7 +19,7 @@ import ClockDisplayBase from './clock_display_base';
export default class ClockDisplay extends ClockDisplayBase {
static propTypes = {
showModal: PropTypes.bool.isRequired,
militaryTime: PropTypes.bool.isRequired,
militaryTime: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
};

View File

@@ -76,3 +76,118 @@ exports[`DisplaySettings should match snapshot 1`] = `
</View>
</View>
`;
exports[`DisplaySettings should match snapshot on Tablet devices 1`] = `
<View
style={
Object {
"backgroundColor": "#ffffff",
"flex": 1,
}
}
>
<Connect(StatusBar) />
<View
style={
Object {
"backgroundColor": "rgba(61,60,64,0.06)",
"flex": 1,
"paddingTop": 35,
}
}
>
<View
style={
Object {
"backgroundColor": "rgba(61,60,64,0.1)",
"height": 1,
}
}
/>
<SettingsItem
defaultMessage="Sidebar"
i18nId="mobile.display_settings.sidebar"
iconName="columns"
iconType="fontawesome"
isDestructor={false}
onPress={[Function]}
separator={true}
showArrow={false}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"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",
}
}
/>
<SettingsItem
defaultMessage="Clock Display"
i18nId="mobile.advanced_settings.clockDisplay"
iconName="ios-time"
iconType="ion"
isDestructor={false}
onPress={[Function]}
separator={false}
showArrow={false}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"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
style={
Object {
"backgroundColor": "rgba(61,60,64,0.1)",
"height": 1,
}
}
/>
</View>
</View>
`;

View File

@@ -9,13 +9,13 @@ import {
View,
} from 'react-native';
import SettingsItem from 'app/screens/settings/settings_item';
import {DeviceTypes} from 'app/constants';
import StatusBar from 'app/components/status_bar';
import ClockDisplay from 'app/screens/settings/clock_display';
import SettingsItem from 'app/screens/settings/settings_item';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import ClockDisplay from 'app/screens/clock_display';
export default class DisplaySettings extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
@@ -44,7 +44,7 @@ export default class DisplaySettings extends PureComponent {
const {intl} = this.context;
if (Platform.OS === 'ios') {
const screen = 'ClockDisplay';
const screen = 'ClockDisplaySettings';
const title = intl.formatMessage({id: 'user.settings.display.clockDisplay', defaultMessage: 'Clock Display'});
actions.goToScreen(screen, title);
return;
@@ -53,6 +53,15 @@ export default class DisplaySettings extends PureComponent {
this.setState({showClockDisplaySettings: true});
});
goToSidebarSettings = preventDoubleTap(() => {
const {actions, theme} = this.props;
const {intl} = this.context;
const screen = 'SidebarSettings';
const title = intl.formatMessage({id: 'mobile.display_settings.sidebar', defaultMessage: 'Sidebar'});
actions.goToScreen(screen, title, {theme});
});
goToTimezoneSettings = preventDoubleTap(() => {
const {actions} = this.props;
const {intl} = this.context;
@@ -104,11 +113,28 @@ export default class DisplaySettings extends PureComponent {
);
}
let sidebar;
if (DeviceTypes.IS_TABLET) {
sidebar = (
<SettingsItem
defaultMessage='Sidebar'
i18nId='mobile.display_settings.sidebar'
iconName='columns'
iconType='fontawesome'
onPress={this.goToSidebarSettings}
separator={true}
showArrow={false}
theme={theme}
/>
);
}
return (
<View style={style.container}>
<StatusBar/>
<View style={style.wrapper}>
<View style={style.divider}/>
{sidebar}
{enableTheme && (
<SettingsItem
defaultMessage='Theme'

View File

@@ -3,9 +3,12 @@
import React from 'react';
import {shallow} from 'enzyme';
import SettingsItem from 'app/screens/settings/settings_item';
import Preferences from 'mattermost-redux/constants/preferences';
import {DeviceTypes} from 'app/constants';
import SettingsItem from 'app/screens/settings/settings_item';
import DisplaySettings from './display_settings';
jest.mock('react-intl');
@@ -33,4 +36,19 @@ describe('DisplaySettings', () => {
wrapper.setProps({enableTimezone: true});
expect(wrapper.find(SettingsItem).length).toBe(3);
});
test('should match snapshot on Tablet devices', () => {
DeviceTypes.IS_TABLET = true;
const wrapper = shallow(
<DisplaySettings {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find(SettingsItem).length).toBe(2);
wrapper.setProps({enableTheme: true});
expect(wrapper.find(SettingsItem).length).toBe(3);
wrapper.setProps({enableTimezone: true});
expect(wrapper.find(SettingsItem).length).toBe(4);
});
});

View File

@@ -0,0 +1,254 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NotificationSettingsMentionsKeywords should match snapshot 1`] = `
NotificationSettingsMentionsKeywords {
"context": Object {},
"handleSubmit": [Function],
"keywordsRef": [Function],
"navigationEventListener": Object {
"remove": [Function],
},
"onKeywordsChangeText": [Function],
"props": Object {
"actions": Object {
"popTopScreen": [MockFunction],
},
"componentId": "component-id",
"intl": Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": "span",
"timeZone": null,
},
"isLandscape": false,
"keywords": "",
"onBack": [MockFunction],
"theme": Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"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",
},
},
"refs": Object {},
"setState": [Function],
"state": Object {
"keywords": "",
},
"updater": Updater {
"_callbacks": Array [],
"_renderer": ReactShallowRenderer {
"_context": Object {},
"_didScheduleRenderPhaseUpdate": false,
"_dispatcher": Object {
"readContext": [Function],
"useCallback": [Function],
"useContext": [Function],
"useDebugValue": [Function],
"useEffect": [Function],
"useImperativeHandle": [Function],
"useLayoutEffect": [Function],
"useMemo": [Function],
"useReducer": [Function],
"useRef": [Function],
"useState": [Function],
},
"_element": <NotificationSettingsMentionsKeywords
actions={
Object {
"popTopScreen": [MockFunction],
}
}
componentId="component-id"
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": "span",
"timeZone": null,
}
}
isLandscape={false}
keywords=""
onBack={[MockFunction]}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"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",
}
}
/>,
"_firstWorkInProgressHook": null,
"_forcedUpdate": false,
"_instance": [Circular],
"_isReRender": false,
"_newState": null,
"_numberOfReRenders": 0,
"_renderPhaseUpdates": null,
"_rendered": <View
style={
Object {
"backgroundColor": "#ffffff",
"flex": 1,
}
}
>
<Connect(StatusBar) />
<ScrollViewMock
alwaysBounceVertical={false}
contentContainerStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.06)",
"flex": 1,
"paddingTop": 35,
}
}
>
<View
style={
Object {
"backgroundColor": "#ffffff",
"borderBottomColor": "rgba(61,60,64,0.1)",
"borderBottomWidth": 1,
"borderTopColor": "rgba(61,60,64,0.1)",
"borderTopWidth": 1,
}
}
>
<TextInputWithLocalizedPlaceholder
autoCapitalize="none"
autoCorrect={false}
autoFocus={true}
blurOnSubmit={true}
multiline={true}
numberOfLines={1}
onChangeText={[Function]}
onSubmitEditing={[Function]}
placeholder={
Object {
"defaultMessage": "Other words that trigger a mention",
"id": "mobile.notification_settings_mentions.keywordsDescription",
}
}
placeholderTextColor="rgba(61,60,64,0.4)"
returnKeyType="done"
style={
Object {
"color": "#3d3c40",
"fontSize": 15,
"height": 150,
"paddingHorizontal": 15,
"paddingVertical": 10,
}
}
value=""
/>
</View>
<View
style={
Object {
"marginTop": 10,
"paddingHorizontal": 15,
}
}
>
<FormattedText
defaultMessage="Keywords are non-case sensitive and should be separated by a comma."
id="mobile.notification_settings_mentions.keywordsHelp"
style={
Object {
"color": "rgba(61,60,64,0.4)",
"fontSize": 13,
}
}
/>
</View>
</ScrollViewMock>
</View>,
"_rendering": false,
"_updater": [Circular],
"_workInProgressHook": null,
},
},
}
`;

View File

@@ -107,6 +107,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
},
wrapper: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.06),
flex: 1,
paddingTop: 35,
},
inputContainer: {

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import Preferences from 'mattermost-redux/constants/preferences';
import {shallowWithIntl} from 'test/intl-test-helper';
import NotificationSettingsMentionsKeywords from './notification_settings_mentions_keywords';
describe('NotificationSettingsMentionsKeywords', () => {
const baseProps = {
actions: {
popTopScreen: jest.fn(),
},
componentId: 'component-id',
keywords: '',
isLandscape: false,
onBack: jest.fn(),
theme: Preferences.THEMES.default,
};
test('should match snapshot', () => {
const wrapper = shallowWithIntl(
<NotificationSettingsMentionsKeywords {...baseProps}/>
);
expect(wrapper.instance()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,119 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SidebarSettings should match, full snapshot 1`] = `null`;
exports[`SidebarSettings should match, full snapshot 2`] = `
<View
style={
Object {
"backgroundColor": "#ffffff",
"flex": 1,
}
}
>
<Connect(StatusBar) />
<View
style={
Object {
"backgroundColor": "rgba(61,60,64,0.06)",
"flex": 1,
"paddingTop": 35,
}
}
>
<section
disableHeader={true}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"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
style={
Object {
"backgroundColor": "rgba(61,60,64,0.1)",
"height": 1,
}
}
/>
<sectionItem
action={[Function]}
actionType="toggle"
description={
<FormattedText
defaultMessage="Keep the sidebar open permanently"
id="mobile.sidebar_settings.permanent_description"
/>
}
label={
<FormattedText
defaultMessage="Permanent Sidebar"
id="mobile.sidebar_settings.permanent"
/>
}
selected={false}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"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
style={
Object {
"backgroundColor": "rgba(61,60,64,0.1)",
"height": 1,
}
}
/>
</section>
</View>
</View>
`;

View File

@@ -0,0 +1,119 @@
// 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,
Platform,
} from 'react-native';
import {intlShape} from 'react-intl';
import AsyncStorage from '@react-native-community/async-storage';
import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {DeviceTypes} from 'app/constants';
import FormattedText from 'app/components/formatted_text';
import StatusBar from 'app/components/status_bar';
import Section from 'app/screens/settings/section';
import SectionItem from 'app/screens/settings/section_item';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
export default class SidebarSettings extends PureComponent {
static propTypes = {
theme: PropTypes.object.isRequired,
};
static contextTypes = {
intl: intlShape,
};
constructor(props) {
super(props);
this.loadSetting();
}
loadSetting = async () => {
const value = await AsyncStorage.getItem(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS);
const enabled = Boolean(value === 'true');
this.setState({enabled});
};
saveSetting = (enabled) => {
AsyncStorage.setItem(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS, enabled.toString());
this.setState({enabled}, () => EventEmitter.emit(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS));
};
render() {
if (!this.state) {
return null;
}
const {
theme,
} = this.props;
const {enabled} = this.state;
const style = getStyleSheet(theme);
return (
<View style={style.container}>
<StatusBar/>
<View style={style.wrapper}>
<Section
disableHeader={true}
theme={theme}
>
<View style={style.divider}/>
<SectionItem
label={(
<FormattedText
id='mobile.sidebar_settings.permanent'
defaultMessage='Permanent Sidebar'
/>
)}
description={(
<FormattedText
id='mobile.sidebar_settings.permanent_description'
defaultMessage='Keep the sidebar open permanently'
/>
)}
action={this.saveSetting}
actionType='toggle'
selected={enabled}
theme={theme}
/>
<View style={style.divider}/>
</Section>
</View>
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
container: {
flex: 1,
backgroundColor: theme.centerChannelBg,
},
wrapper: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.06),
flex: 1,
...Platform.select({
ios: {
paddingTop: 35,
},
}),
},
divider: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
height: 1,
},
separator: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
height: 1,
marginLeft: 15,
},
};
});

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import Preferences from 'mattermost-redux/constants/preferences';
import {DeviceTypes} from 'app/constants';
import MainSidebar from 'app/components/sidebars/main/main_sidebar';
import SidebarSettings from './index';
jest.mock('react-intl');
jest.mock('app/mattermost_managed', () => ({
isRunningInSplitView: jest.fn().mockResolvedValue(false),
}));
describe('SidebarSettings', () => {
const baseProps = {
theme: Preferences.THEMES.default,
};
test('should match, full snapshot', async () => {
const wrapper = shallow(
<SidebarSettings {...baseProps}/>
);
expect(wrapper.getElement()).toMatchSnapshot();
await wrapper.instance().loadSetting();
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should set the Permanent Sidebar value to false', async () => {
const wrapper = shallow(
<SidebarSettings {...baseProps}/>
);
await wrapper.instance().loadSetting();
expect(wrapper.state('enabled')).toBe(false);
});
test('should set the Permanent Sidebar value to true and update the sidebar', async () => {
DeviceTypes.IS_TABLET = true;
const wrapper = shallow(
<SidebarSettings {...baseProps}/>
);
const mainProps = {
actions: {
getTeams: jest.fn(),
logChannelSwitch: jest.fn(),
makeDirectChannel: jest.fn(),
setChannelDisplayName: jest.fn(),
setChannelLoading: jest.fn(),
},
blurPostTextBox: jest.fn(),
currentTeamId: 'current-team-id',
currentUserId: 'current-user-id',
deviceWidth: 10,
isLandscape: false,
teamsCount: 2,
theme: Preferences.THEMES.default,
};
const mainSidebar = shallow(
<MainSidebar {...mainProps}/>
);
await wrapper.instance().loadSetting();
expect(wrapper.state('enabled')).toBe(false);
await wrapper.instance().saveSetting(true);
expect(wrapper.state('enabled')).toBe(true);
expect(mainSidebar.state('permanentSidebar')).toBe(true);
});
});

View File

@@ -3,10 +3,12 @@
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getAllowedThemes, getCustomTheme} from 'app/selectors/theme';
import {isLandscape, isTablet} from 'app/selectors/device';

View File

@@ -6,14 +6,16 @@ import {Text, View} from 'react-native';
import PropTypes from 'prop-types';
import {intlShape} from 'react-intl';
import Preferences from 'mattermost-redux/constants/preferences';
import StatusBar from 'app/components/status_bar';
import Section from 'app/screens/settings/section';
import SectionItem from 'app/screens/settings/section_item';
import ThemeTile from './theme_tile';
import FormattedText from 'app/components/formatted_text';
import {changeOpacity, makeStyleSheetFromTheme, setNavigatorStyles} from 'app/utils/theme';
import Preferences from 'mattermost-redux/constants/preferences';
import EphemeralStore from 'app/store/ephemeral_store';
import ThemeTile from './theme_tile';
const thumbnailImages = {
default: require('assets/images/themes/mattermost.png'),
@@ -56,12 +58,19 @@ export default class Theme extends React.PureComponent {
componentDidUpdate(prevProps) {
if (prevProps.theme !== this.props.theme) {
setNavigatorStyles(this.props.componentId, this.props.theme);
EphemeralStore.allNavigationComponentIds.forEach((componentId) => {
setNavigatorStyles(componentId, this.props.theme);
});
}
}
setTheme = (key) => {
const {userId, teamId, actions: {savePreferences}, allowedThemes} = this.props;
const {
userId,
teamId,
allowedThemes,
actions: {savePreferences},
} = this.props;
const {customTheme} = this.state;
const selectedTheme = allowedThemes.concat(customTheme).find((theme) => theme.key === key);
@@ -71,7 +80,7 @@ export default class Theme extends React.PureComponent {
name: teamId,
value: JSON.stringify(selectedTheme),
}]);
}
};
renderAllowedThemeTiles = () => {
const {theme, allowedThemes, isLandscape, isTablet} = this.props;

View File

@@ -3,15 +3,23 @@
import React from 'react';
import {shallow} from 'enzyme';
import ThemeTile from './theme_tile';
import {Navigation} from 'react-native-navigation';
import Preferences from 'mattermost-redux/constants/preferences';
import EphemeralStore from 'app/store/ephemeral_store';
import Theme from './theme';
import ThemeTile from './theme_tile';
jest.mock('react-intl');
jest.mock('react-native-navigation', () => ({
Navigation: {
mergeOptions: jest.fn(),
},
}));
const allowedThemes = [
{
type: 'Mattermost',
@@ -144,4 +152,43 @@ describe('Theme', () => {
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.find(ThemeTile)).toHaveLength(4);
});
test('should call Navigation.mergeOptions on all navigation components when theme changes', () => {
const componentIds = ['component-1', 'component-2', 'component-3'];
componentIds.forEach((componentId) => {
EphemeralStore.addNavigationComponentId(componentId);
});
const wrapper = shallow(
<Theme {...baseProps}/>,
);
const newTheme = allowedThemes[1];
wrapper.setProps({theme: newTheme});
const options = {
topBar: {
backButton: {
color: newTheme.sidebarHeaderTextColor,
},
background: {
color: newTheme.sidebarHeaderBg,
},
title: {
color: newTheme.sidebarHeaderTextColor,
},
leftButtonColor: newTheme.sidebarHeaderTextColor,
rightButtonColor: newTheme.sidebarHeaderTextColor,
},
layout: {
backgroundColor: newTheme.centerChannelBg,
},
};
expect(Navigation.mergeOptions.mock.calls).toEqual([
[componentIds[2], options],
[componentIds[1], options],
[componentIds[0], options],
]);
});
});

View File

@@ -12,8 +12,6 @@ import {
import {makeStyleSheetFromTheme} from 'app/utils/theme';
const {width: deviceWidth, height: deviceHeight} = Dimensions.get('window');
const checkmark = require('assets/images/themes/check.png');
const tilePadding = 8;
@@ -38,7 +36,8 @@ const ThemeTile = (props) => {
);
const tilesPerLine = isLandscape || isTablet ? 4 : 2;
const fullWidth = isLandscape ? deviceHeight - 40 : deviceWidth;
const {width: deviceWidth} = Dimensions.get('window');
const fullWidth = isLandscape ? deviceWidth - 40 : deviceWidth;
const layoutStyle = {
container: {
width: (fullWidth / tilesPerLine) - tilePadding,

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