diff --git a/app/screens/index.js b/app/screens/index.js
index 5722c3155..92b2ef1e0 100644
--- a/app/screens/index.js
+++ b/app/screens/index.js
@@ -62,6 +62,7 @@ export function registerScreens(store, Provider) {
Navigation.registerComponent('TableImage', () => wrapWithContextProvider(require('app/screens/table_image').default), store, Provider);
Navigation.registerComponent('TermsOfService', () => wrapWithContextProvider(require('app/screens/terms_of_service').default), store, Provider);
Navigation.registerComponent('TextPreview', () => wrapWithContextProvider(require('app/screens/text_preview').default), store, Provider);
+ Navigation.registerComponent('ThemeSettings', () => wrapWithContextProvider(require('app/screens/theme').default), store, Provider);
Navigation.registerComponent('Thread', () => wrapWithContextProvider(require('app/screens/thread').default), store, Provider);
Navigation.registerComponent('TimezoneSettings', () => wrapWithContextProvider(require('app/screens/timezone').default), store, Provider);
Navigation.registerComponent('ErrorTeamsList', () => wrapWithContextProvider(require('app/screens/error_teams_list').default), store, Provider);
diff --git a/app/screens/settings/display_settings/__snapshots__/display_settings.test.js.snap b/app/screens/settings/display_settings/__snapshots__/display_settings.test.js.snap
new file mode 100644
index 000000000..21cf7668f
--- /dev/null
+++ b/app/screens/settings/display_settings/__snapshots__/display_settings.test.js.snap
@@ -0,0 +1,78 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DisplaySettings should match snapshot 1`] = `
+
+
+
+
+
+
+
+
+`;
diff --git a/app/screens/settings/display_settings/display_settings.js b/app/screens/settings/display_settings/display_settings.js
index 8ca20cbe9..fdcf4ccc0 100644
--- a/app/screens/settings/display_settings/display_settings.js
+++ b/app/screens/settings/display_settings/display_settings.js
@@ -20,6 +20,7 @@ export default class DisplaySettings extends PureComponent {
static propTypes = {
navigator: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired,
+ enableTheme: PropTypes.bool.isRequired,
enableTimezone: PropTypes.bool.isRequired,
};
@@ -72,12 +73,30 @@ export default class DisplaySettings extends PureComponent {
});
});
+ goToThemeSettings = preventDoubleTap(() => {
+ const {navigator, theme} = this.props;
+ const {intl} = this.context;
+
+ navigator.push({
+ screen: 'ThemeSettings',
+ title: intl.formatMessage({id: 'mobile.display_settings.theme', defaultMessage: 'Theme'}),
+ animated: true,
+ backButtonTitle: '',
+ navigatorStyle: {
+ navBarTextColor: theme.sidebarHeaderTextColor,
+ navBarBackgroundColor: theme.sidebarHeaderBg,
+ navBarButtonColor: theme.sidebarHeaderTextColor,
+ screenBackgroundColor: theme.centerChannelBg,
+ },
+ });
+ });
+
closeClockDisplaySettings = () => {
this.setState({showClockDisplaySettings: false});
};
render() {
- const {theme, enableTimezone} = this.props;
+ const {theme, enableTimezone, enableTheme} = this.props;
const {showClockDisplaySettings} = this.state;
const style = getStyleSheet(theme);
@@ -114,6 +133,18 @@ export default class DisplaySettings extends PureComponent {
+ {enableTheme && (
+
+ )}
{
+ const baseProps = {
+ theme: Preferences.THEMES.default,
+ enableTheme: false,
+ enableTimezone: false,
+ navigator: {push: () => {}}, // eslint-disable-line no-empty-function
+ };
+
+ test('should match snapshot', () => {
+ const wrapper = shallow(
+ ,
+ );
+
+ expect(wrapper.getElement()).toMatchSnapshot();
+ expect(wrapper.find(SettingsItem).length).toBe(1);
+ wrapper.setProps({enableTheme: true});
+ expect(wrapper.find(SettingsItem).length).toBe(2);
+ wrapper.setProps({enableTimezone: true});
+ expect(wrapper.find(SettingsItem).length).toBe(3);
+ });
+});
diff --git a/app/screens/settings/display_settings/index.js b/app/screens/settings/display_settings/index.js
index 2eb8b6b4b..a592dea5b 100644
--- a/app/screens/settings/display_settings/index.js
+++ b/app/screens/settings/display_settings/index.js
@@ -6,13 +6,17 @@ import {connect} from 'react-redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {isTimezoneEnabled} from 'app/utils/timezone';
+import {isThemeSwitchingEnabled} from 'app/utils/theme';
import DisplaySettings from './display_settings';
+import {getAllowedThemes} from 'app/selectors/theme';
function mapStateToProps(state) {
const enableTimezone = isTimezoneEnabled(state);
+ const enableTheme = isThemeSwitchingEnabled(state) && getAllowedThemes(state).length > 1;
return {
+ enableTheme,
enableTimezone,
theme: getTheme(state),
};
diff --git a/app/screens/theme/__snapshots__/theme.test.js.snap b/app/screens/theme/__snapshots__/theme.test.js.snap
new file mode 100644
index 000000000..1969d6b11
--- /dev/null
+++ b/app/screens/theme/__snapshots__/theme.test.js.snap
@@ -0,0 +1,69 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Theme should match snapshot 1`] = `
+
+
+
+
+
+
+`;
diff --git a/app/screens/theme/index.js b/app/screens/theme/index.js
new file mode 100644
index 000000000..b8fa82868
--- /dev/null
+++ b/app/screens/theme/index.js
@@ -0,0 +1,28 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+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 Theme from './theme';
+
+const mapStateToProps = (state) => ({
+ userId: getCurrentUserId(state),
+ teamId: getCurrentTeamId(state),
+ theme: getTheme(state),
+ allowedThemes: getAllowedThemes(state),
+ customTheme: getCustomTheme(state),
+});
+
+const mapDispatchToProps = (dispatch) => ({
+ actions: bindActionCreators({
+ savePreferences,
+ }, dispatch),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Theme);
diff --git a/app/screens/theme/theme.js b/app/screens/theme/theme.js
new file mode 100644
index 000000000..707fd2fbf
--- /dev/null
+++ b/app/screens/theme/theme.js
@@ -0,0 +1,134 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {View, FlatList} from 'react-native';
+import PropTypes from 'prop-types';
+import {intlShape} from 'react-intl';
+
+import StatusBar from 'app/components/status_bar';
+import Section from 'app/screens/settings/section';
+import SectionItem from 'app/screens/settings/section_item';
+import FormattedText from 'app/components/formatted_text';
+
+import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
+import Preferences from 'mattermost-redux/constants/preferences';
+
+export default class Theme extends React.PureComponent {
+ static propTypes = {
+ teamId: PropTypes.string.isRequired,
+ theme: PropTypes.object.isRequired,
+ userId: PropTypes.string.isRequired,
+ actions: PropTypes.shape({
+ savePreferences: PropTypes.func.isRequired,
+ }).isRequired,
+ allowedThemes: PropTypes.arrayOf(PropTypes.object),
+ customTheme: PropTypes.object,
+ };
+
+ static contextTypes = {
+ intl: intlShape.isRequired,
+ };
+
+ state = {
+ customTheme: null,
+ };
+
+ static getDerivedStateFromProps(props, state) {
+ if (!state.customTheme && props.customTheme) {
+ return {
+ customTheme: props.customTheme,
+ };
+ }
+ return null;
+ }
+
+ setTheme = (key) => {
+ const {userId, teamId, actions: {savePreferences}, allowedThemes} = this.props;
+ const {customTheme} = this.state;
+ const selectedTheme = allowedThemes.concat(customTheme).find((theme) => theme.key === key);
+
+ savePreferences(userId, [{
+ user_id: userId,
+ category: Preferences.CATEGORY_THEME,
+ name: teamId,
+ value: JSON.stringify(selectedTheme),
+ }]);
+ }
+
+ renderThemeRow = ({item, title}) => {
+ const {theme} = this.props;
+ const style = getStyleSheet(theme);
+
+ return (
+
+
+ )}
+ action={this.setTheme}
+ actionType='select'
+ actionValue={item.key}
+ selected={item.type.toLowerCase() === theme.type.toLowerCase()}
+ theme={theme}
+ />
+
+
+ );
+ };
+
+ keyExtractor = (item) => item.key;
+
+ render() {
+ const {theme, allowedThemes} = this.props;
+ const {customTheme} = this.state;
+ const style = getStyleSheet(theme);
+ return (
+
+
+
+
+
+ {customTheme &&
+
+ {this.renderThemeRow({item: customTheme, title: 'Custom'})}
+
+ }
+
+
+ );
+ }
+}
+
+const getStyleSheet = makeStyleSheetFromTheme((theme) => {
+ return {
+ container: {
+ flex: 1,
+ backgroundColor: theme.centerChannelBg,
+ },
+ wrapper: {
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.06),
+ flex: 1,
+ paddingTop: 35,
+ },
+ divider: {
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
+ height: 1,
+ },
+ };
+});
diff --git a/app/screens/theme/theme.test.js b/app/screens/theme/theme.test.js
new file mode 100644
index 000000000..31fc11052
--- /dev/null
+++ b/app/screens/theme/theme.test.js
@@ -0,0 +1,150 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {FlatList} from 'react-native';
+import {shallow} from 'enzyme';
+import Section from 'app/screens/settings/section';
+
+import Preferences from 'mattermost-redux/constants/preferences';
+
+import Theme from './theme';
+
+jest.mock('react-intl');
+
+describe('Theme', () => {
+ const baseProps = {
+ teamId: 'test-team',
+ theme: Preferences.THEMES.default,
+ userId: 'test-user',
+ actions: {
+ savePreferences: jest.fn(),
+ },
+ allowedThemes,
+ };
+
+ test('should match snapshot', () => {
+ const wrapper = shallow(
+ ,
+ );
+
+ expect(wrapper.getElement()).toMatchSnapshot();
+ expect(wrapper.find(Section).first().exists()).toEqual(true);
+ expect(wrapper.find(FlatList).first().exists()).toEqual(true);
+ });
+});
+
+const allowedThemes = [
+ {
+ default: {
+ type: 'Mattermost',
+ sidebarBg: '#145dbf',
+ sidebarText: '#ffffff',
+ sidebarUnreadText: '#ffffff',
+ sidebarTextHoverBg: '#4578bf',
+ sidebarTextActiveBorder: '#579eff',
+ sidebarTextActiveColor: '#ffffff',
+ sidebarHeaderBg: '#1153ab',
+ sidebarHeaderTextColor: '#ffffff',
+ onlineIndicator: '#06d6a0',
+ awayIndicator: '#ffbc42',
+ dndIndicator: '#f74343',
+ mentionBg: '#ffffff',
+ mentionColor: '#145dbf',
+ centerChannelBg: '#ffffff',
+ centerChannelColor: '#3d3c40',
+ newMessageSeparator: '#ff8800',
+ linkColor: '#2389d7',
+ buttonBg: '#166de0',
+ buttonColor: '#ffffff',
+ errorTextColor: '#fd5960',
+ mentionHighlightBg: '#ffe577',
+ mentionHighlightLink: '#166de0',
+ codeTheme: 'github',
+ },
+ },
+ {
+ organization: {
+ type: 'Organization',
+ sidebarBg: '#2071a7',
+ sidebarText: '#ffffff',
+ sidebarUnreadText: '#ffffff',
+ sidebarTextHoverBg: '#136197',
+ sidebarTextActiveBorder: '#7ab0d6',
+ sidebarTextActiveColor: '#ffffff',
+ sidebarHeaderBg: '#2f81b7',
+ sidebarHeaderTextColor: '#ffffff',
+ onlineIndicator: '#7dbe00',
+ awayIndicator: '#dcbd4e',
+ dndIndicator: '#ff6a6a',
+ mentionBg: '#fbfbfb',
+ mentionColor: '#2071f7',
+ centerChannelBg: '#f2f4f8',
+ centerChannelColor: '#333333',
+ newMessageSeparator: '#ff8800',
+ linkColor: '#2f81b7',
+ buttonBg: '#1dacfc',
+ buttonColor: '#ffffff',
+ errorTextColor: '#a94442',
+ mentionHighlightBg: '#f3e197',
+ mentionHighlightLink: '#2f81b7',
+ codeTheme: 'github',
+ },
+ },
+ {
+ mattermostDark: {
+ type: 'Mattermost Dark',
+ sidebarBg: '#1b2c3e',
+ sidebarText: '#ffffff',
+ sidebarUnreadText: '#ffffff',
+ sidebarTextHoverBg: '#4a5664',
+ sidebarTextActiveBorder: '#66b9a7',
+ sidebarTextActiveColor: '#ffffff',
+ sidebarHeaderBg: '#1b2c3e',
+ sidebarHeaderTextColor: '#ffffff',
+ onlineIndicator: '#65dcc8',
+ awayIndicator: '#c1b966',
+ dndIndicator: '#e81023',
+ mentionBg: '#b74a4a',
+ mentionColor: '#ffffff',
+ centerChannelBg: '#2f3e4e',
+ centerChannelColor: '#dddddd',
+ newMessageSeparator: '#5de5da',
+ linkColor: '#a4ffeb',
+ buttonBg: '#4cbba4',
+ buttonColor: '#ffffff',
+ errorTextColor: '#ff6461',
+ mentionHighlightBg: '#984063',
+ mentionHighlightLink: '#a4ffeb',
+ codeTheme: 'solarized-dark',
+ },
+ },
+ {
+ windows10: {
+ type: 'Windows Dark',
+ sidebarBg: '#171717',
+ sidebarText: '#ffffff',
+ sidebarUnreadText: '#ffffff',
+ sidebarTextHoverBg: '#302e30',
+ sidebarTextActiveBorder: '#196caf',
+ sidebarTextActiveColor: '#ffffff',
+ sidebarHeaderBg: '#1f1f1f',
+ sidebarHeaderTextColor: '#ffffff',
+ onlineIndicator: '#399fff',
+ awayIndicator: '#c1b966',
+ dndIndicator: '#e81023',
+ mentionBg: '#0177e7',
+ mentionColor: '#ffffff',
+ centerChannelBg: '#1f1f1f',
+ centerChannelColor: '#dddddd',
+ newMessageSeparator: '#cc992d',
+ linkColor: '#0d93ff',
+ buttonBg: '#0177e7',
+ buttonColor: '#ffffff',
+ errorTextColor: '#ff6461',
+ mentionHighlightBg: '#784098',
+ mentionHighlightLink: '#a4ffeb',
+ codeTheme: 'monokai',
+ },
+ },
+];
\ No newline at end of file
diff --git a/app/selectors/theme.js b/app/selectors/theme.js
new file mode 100644
index 000000000..2ca7a526e
--- /dev/null
+++ b/app/selectors/theme.js
@@ -0,0 +1,39 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {createSelector} from 'reselect';
+
+import Preferences from 'mattermost-redux/constants/preferences';
+import {getConfig} from 'mattermost-redux/selectors/entities/general';
+import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
+
+export const getAllowedThemes = createSelector(
+ getConfig,
+ getTheme,
+ (config) => {
+ const allThemes = Object.keys(Preferences.THEMES).map((key) => ({
+ ...Preferences.THEMES[key],
+ key,
+ }));
+ let acceptableThemes = allThemes;
+ const allowedThemeKeys = (config.AllowedThemes || '').split(',').filter(String);
+ if (allowedThemeKeys.length) {
+ acceptableThemes = allThemes.filter((theme) => allowedThemeKeys.includes(theme.key));
+ }
+ return acceptableThemes;
+ }
+);
+
+export const getCustomTheme = createSelector(
+ getConfig,
+ getTheme,
+ (config, activeTheme) => {
+ if (config.AllowCustomThemes === 'true' && activeTheme.type === 'custom') {
+ return {
+ ...activeTheme,
+ key: 'custom',
+ };
+ }
+ return null;
+ }
+);
\ No newline at end of file
diff --git a/app/utils/theme.js b/app/utils/theme.js
index 66061f9c0..f307f6398 100644
--- a/app/utils/theme.js
+++ b/app/utils/theme.js
@@ -27,3 +27,8 @@ export function setNavigatorStyles(navigator, theme) {
screenBackgroundColor: theme.centerChannelBg,
});
}
+
+export function isThemeSwitchingEnabled(state) {
+ const {config} = state.entities.general;
+ return config.EnableThemeSelection === 'true';
+}
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index 72e6fb023..31a76ab90 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -202,6 +202,8 @@
"mobile.create_channel.public": "New Public Channel",
"mobile.create_post.read_only": "This channel is read-only",
"mobile.custom_list.no_results": "No Results",
+ "mobile.display_settings.theme": "Theme",
+ "mobile.display_settings.custom_theme": "Custom Theme",
"mobile.document_preview.failed_description": "An error occurred while opening the document. Please make sure you have a {fileType} viewer installed and try again.\n",
"mobile.document_preview.failed_title": "Open Document failed",
"mobile.downloader.android_complete": "Download complete",