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