diff --git a/app/actions/views/login.js b/app/actions/views/login.js index 2e82b30bb..dcedf9e23 100644 --- a/app/actions/views/login.js +++ b/app/actions/views/login.js @@ -1,12 +1,12 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import {UsersViewTypes} from 'app/constants'; +import {ViewTypes} from 'app/constants'; export function handleLoginIdChanged(loginId) { return async (dispatch, getState) => { dispatch({ - type: UsersViewTypes.LOGIN_ID_CHANGED, + type: ViewTypes.LOGIN_ID_CHANGED, loginId }, getState); }; @@ -15,7 +15,7 @@ export function handleLoginIdChanged(loginId) { export function handlePasswordChanged(password) { return async (dispatch, getState) => { dispatch({ - type: UsersViewTypes.PASSWORD_CHANGED, + type: ViewTypes.PASSWORD_CHANGED, password }, getState); }; diff --git a/app/actions/views/select_server.js b/app/actions/views/select_server.js index 10e077a6f..667c18fe4 100644 --- a/app/actions/views/select_server.js +++ b/app/actions/views/select_server.js @@ -1,12 +1,12 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import {GeneralTypes} from 'service/constants'; +import {ViewTypes} from 'app/constants'; export function handleServerUrlChanged(serverUrl) { return async (dispatch, getState) => { dispatch({ - type: GeneralTypes.SERVER_URL_CHANGED, + type: ViewTypes.SERVER_URL_CHANGED, serverUrl }, getState); }; diff --git a/app/constants/index.js b/app/constants/index.js index 68624e6be..e42c42e3e 100644 --- a/app/constants/index.js +++ b/app/constants/index.js @@ -2,9 +2,9 @@ // See License.txt for license information. import NavigationTypes from './navigation'; -import UsersViewTypes from './users'; +import ViewTypes from './view'; export { NavigationTypes, - UsersViewTypes + ViewTypes }; diff --git a/app/constants/users.js b/app/constants/view.js similarity index 69% rename from app/constants/users.js rename to app/constants/view.js index ea86cf78a..b450fae19 100644 --- a/app/constants/users.js +++ b/app/constants/view.js @@ -3,9 +3,11 @@ import keymirror from 'keymirror'; -const UsersViewTypes = keymirror({ +const ViewTypes = keymirror({ + SERVER_URL_CHANGED: null, + LOGIN_ID_CHANGED: null, PASSWORD_CHANGED: null }); -export default UsersViewTypes; +export default ViewTypes; diff --git a/app/reducers/views/login.js b/app/reducers/views/login.js index f46a9aa0e..2287fb0d0 100644 --- a/app/reducers/views/login.js +++ b/app/reducers/views/login.js @@ -3,11 +3,11 @@ import {combineReducers} from 'redux'; import {UsersTypes} from 'service/constants'; -import {UsersViewTypes} from 'app/constants'; +import {ViewTypes} from 'app/constants'; function loginId(state = '', action) { switch (action.type) { - case UsersViewTypes.LOGIN_ID_CHANGED: + case ViewTypes.LOGIN_ID_CHANGED: return action.loginId; case UsersTypes.LOGOUT_SUCCESS: return ''; @@ -18,7 +18,7 @@ function loginId(state = '', action) { function password(state = '', action) { switch (action.type) { - case UsersViewTypes.PASSWORD_CHANGED: + case ViewTypes.PASSWORD_CHANGED: return action.password; case UsersTypes.LOGOUT_SUCCESS: return ''; diff --git a/app/reducers/views/select_server.js b/app/reducers/views/select_server.js index fc8fb2a8a..6c6b6af85 100644 --- a/app/reducers/views/select_server.js +++ b/app/reducers/views/select_server.js @@ -5,11 +5,11 @@ import {combineReducers} from 'redux'; import Config from 'config'; -import {GeneralTypes} from 'service/constants'; +import {ViewTypes} from 'app/constants'; function serverUrl(state = Config.DefaultServerUrl, action) { switch (action.type) { - case GeneralTypes.SERVER_URL_CHANGED: + case ViewTypes.SERVER_URL_CHANGED: return action.serverUrl; default: diff --git a/app/scenes/channel/channel.js b/app/scenes/channel/channel.js index fbc0ebf00..d909f33a7 100644 --- a/app/scenes/channel/channel.js +++ b/app/scenes/channel/channel.js @@ -16,7 +16,8 @@ export default class Channel extends React.Component { actions: React.PropTypes.object.isRequired, currentTeam: React.PropTypes.object.isRequired, currentChannel: React.PropTypes.object, - channels: React.PropTypes.arrayOf(React.PropTypes.object).isRequired + channels: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, + theme: React.PropTypes.object.isRequired }; constructor(props) { @@ -57,20 +58,27 @@ export default class Channel extends React.Component { } render() { - if (!this.props.currentChannel) { + const { + currentChannel, + currentTeam, + channels, + theme + } = this.props; + + if (!currentChannel) { return ; } return ( - + } side='left' @@ -87,22 +95,24 @@ export default class Channel extends React.Component { onCloseStart={this.closeRightSidebar} openDrawerOffset={0.2} > - + - {'<'} + {'<'} - {'>'} + {'>'} - {this.props.currentTeam.id + ' - ' + this.props.currentTeam.name} - {this.props.currentChannel.id + ' - ' + this.props.currentChannel.name} + + {currentTeam.id + ' - ' + currentTeam.name} + {currentChannel.id + ' - ' + currentChannel.name} + diff --git a/app/scenes/channel/channel_container.js b/app/scenes/channel/channel_container.js index e93d17634..20ca5f459 100644 --- a/app/scenes/channel/channel_container.js +++ b/app/scenes/channel/channel_container.js @@ -3,7 +3,10 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; + import {fetchMyChannelsAndMembers} from 'service/actions/channels'; +import {getTheme} from 'service/selectors/entities/preferences'; + import Channel from './channel.js'; function mapStateToProps(state, ownProps) { @@ -32,7 +35,8 @@ function mapStateToProps(state, ownProps) { ...ownProps, currentTeam, currentChannel, - channels + channels, + theme: getTheme(state) }; } diff --git a/config/config.json b/config/config.json index 7c7a3e066..d65c581c8 100644 --- a/config/config.json +++ b/config/config.json @@ -1,5 +1,6 @@ { "DefaultServerUrl": "http://localhost:8065", "MockFetchInTests": true, - "DefaultLocale": "en" + "DefaultLocale": "en", + "DefaultTheme": "default" } diff --git a/service/actions/channels.js b/service/actions/channels.js index 433c6abe4..8af0cd089 100644 --- a/service/actions/channels.js +++ b/service/actions/channels.js @@ -1,7 +1,13 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import {Constants, ChannelTypes, PreferencesTypes, UsersTypes} from 'service/constants'; +import { + Constants, + ChannelTypes, + Preferences, + PreferencesTypes, + UsersTypes +} from 'service/constants'; import {forceLogoutIfNecessary} from './helpers'; import {batchActions} from 'redux-batched-actions'; import Client from 'service/client'; @@ -90,7 +96,7 @@ export function createDirectChannel(userId, otherUserId) { }, { type: PreferencesTypes.RECEIVED_PREFERENCES, - data: [{category: Constants.CATEGORY_DIRECT_CHANNEL_SHOW, name: otherUserId, value: 'true'}] + data: [{category: Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, name: otherUserId, value: 'true'}] }, { type: ChannelTypes.CREATE_CHANNEL_SUCCESS diff --git a/service/constants/constants.js b/service/constants/constants.js index a117a6fa8..f0cd7ebdf 100644 --- a/service/constants/constants.js +++ b/service/constants/constants.js @@ -10,9 +10,7 @@ const Constants = { TEAM_ADMIN_ROLE: 'team_admin', CHANNEL_USER_ROLE: 'channel_user', - CHANNEL_ADMIN_ROLE: 'channel_admin', - - CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show' + CHANNEL_ADMIN_ROLE: 'channel_admin' }; export default Constants; diff --git a/service/constants/general.js b/service/constants/general.js index ab80d3a36..f25af4346 100644 --- a/service/constants/general.js +++ b/service/constants/general.js @@ -20,9 +20,7 @@ const GeneralTypes = keymirror({ LOG_CLIENT_ERROR_REQUEST: null, LOG_CLIENT_ERROR_SUCCESS: null, - LOG_CLIENT_ERROR_FAILURE: null, - - SERVER_URL_CHANGED: null + LOG_CLIENT_ERROR_FAILURE: null }); export default GeneralTypes; diff --git a/service/constants/index.js b/service/constants/index.js index ae257a294..5fe222e42 100644 --- a/service/constants/index.js +++ b/service/constants/index.js @@ -9,6 +9,12 @@ import TeamsTypes from './teams'; import PostsTypes from './posts'; import PreferencesTypes from './preferences'; import RequestStatus from './request_status'; +import Themes from './themes'; + +const Preferences = { + CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show', + CATEGORY_THEME: 'theme' +}; export { Constants, @@ -18,5 +24,7 @@ export { ChannelTypes, PostsTypes, PreferencesTypes, - RequestStatus + Preferences, + RequestStatus, + Themes }; diff --git a/service/constants/themes.js b/service/constants/themes.js new file mode 100644 index 000000000..e459ccdd5 --- /dev/null +++ b/service/constants/themes.js @@ -0,0 +1,97 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +export default { + default: { + sidebarBg: '#2071a7', + sidebarText: '#fff', + sidebarUnreadText: '#fff', + sidebarTextHoverBg: '#136197', + sidebarTextActiveBorder: '#7AB0D6', + sidebarTextActiveColor: '#FFFFFF', + sidebarHeaderBg: '#2f81b7', + sidebarHeaderTextColor: '#FFFFFF', + onlineIndicator: '#7DBE00', + awayIndicator: '#DCBD4E', + mentionBj: '#FBFBFB', + mentionColor: '#2071A7', + centerChannelBg: '#f2f4f8', + centerChannelColor: '#333333', + newMessageSeparator: '#FF8800', + linkColor: '#2f81b7', + buttonBg: '#1dacfc', + buttonColor: '#FFFFFF', + mentionHighlightBg: '#fff2bb', + mentionHighlightLink: '#2f81b7', + codeTheme: 'github' + }, + mattermost: { + sidebarBg: '#fafafa', + sidebarText: '#333333', + sidebarUnreadText: '#333333', + sidebarTextHoverBg: '#e6f2fa', + sidebarTextActiveBorder: '#378FD2', + sidebarTextActiveColor: '#111111', + sidebarHeaderBg: '#3481B9', + sidebarHeaderTextColor: '#ffffff', + onlineIndicator: '#7DBE00', + awayIndicator: '#DCBD4E', + mentionBj: '#2389d7', + mentionColor: '#ffffff', + centerChannelBg: '#ffffff', + centerChannelColor: '#333333', + newMessageSeparator: '#FF8800', + linkColor: '#2389d7', + buttonBg: '#23A2FF', + buttonColor: '#FFFFFF', + mentionHighlightBg: '#fff2bb', + mentionHighlightLink: '#2f81b7', + codeTheme: 'github' + }, + mattermostDark: { + sidebarBg: '#1B2C3E', + sidebarText: '#fff', + sidebarUnreadText: '#fff', + sidebarTextHoverBg: '#4A5664', + sidebarTextActiveBorder: '#66B9A7', + sidebarTextActiveColor: '#FFFFFF', + sidebarHeaderBg: '#1B2C3E', + sidebarHeaderTextColor: '#FFFFFF', + onlineIndicator: '#55C5B2', + awayIndicator: '#A9A14C', + mentionBj: '#B74A4A', + mentionColor: '#FFFFFF', + centerChannelBg: '#2F3E4E', + centerChannelColor: '#DDDDDD', + newMessageSeparator: '#5de5da', + linkColor: '#A4FFEB', + buttonBg: '#4CBBA4', + buttonColor: '#FFFFFF', + mentionHighlightBg: '#984063', + mentionHighlightLink: '#A4FFEB', + codeTheme: 'solarized-dark' + }, + windows10: { + sidebarBg: '#171717', + sidebarText: '#fff', + sidebarUnreadText: '#fff', + sidebarTextHoverBg: '#302e30', + sidebarTextActiveBorder: '#196CAF', + sidebarTextActiveColor: '#FFFFFF', + sidebarHeaderBg: '#1f1f1f', + sidebarHeaderTextColor: '#FFFFFF', + onlineIndicator: '#0177e7', + awayIndicator: '#A9A14C', + mentionBj: '#0177e7', + mentionColor: '#FFFFFF', + centerChannelBg: '#1F1F1F', + centerChannelColor: '#DDDDDD', + newMessageSeparator: '#CC992D', + linkColor: '#0D93FF', + buttonBg: '#0177e7', + buttonColor: '#FFFFFF', + mentionHighlightBg: '#784098', + mentionHighlightLink: '#A4FFEB', + codeTheme: 'monokai' + } +}; diff --git a/service/reducers/entities/preferences.js b/service/reducers/entities/preferences.js index 55940d820..22cf9b686 100644 --- a/service/reducers/entities/preferences.js +++ b/service/reducers/entities/preferences.js @@ -5,7 +5,7 @@ import {combineReducers} from 'redux'; import {PreferencesTypes, UsersTypes} from 'service/constants'; function getKey(preference) { - return `${preference.category}-${preference.name}`; + return `${preference.category}--${preference.name}`; } function myPreferences(state = {}, action) { diff --git a/service/selectors/entities/preferences.js b/service/selectors/entities/preferences.js new file mode 100644 index 000000000..d03ea77ca --- /dev/null +++ b/service/selectors/entities/preferences.js @@ -0,0 +1,42 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import Config from 'config'; + +import {Preferences, Themes} from 'service/constants'; + +export function getTheme(state) { + const myPreferences = state.entities.preferences.myPreferences; + const currentTeamId = state.entities.teams.currentId; + + // Prefer the user's current team-specific theme over the user's current global theme over the default theme + let themePreference; + if (currentTeamId) { + themePreference = myPreferences[`${Preferences.CATEGORY_THEME}--${currentTeamId}`]; + } + + if (!themePreference) { + themePreference = myPreferences[`${Preferences.CATEGORY_THEME}--`]; + } + + let theme; + if (themePreference) { + theme = themePreference.value; + } else { + theme = Config.DefaultTheme; + } + + if (typeof theme === 'string') { + try { + // A custom theme will be a JSON-serialized object stored in a preference + theme = JSON.parse(theme); + } catch (e) { + // But if it's not a JSON object, it must be the name of a default theme + theme = Themes[theme]; + } + } + + // At this point, the theme should be a plain object + + return theme; +} diff --git a/test/service/selectors/preferences.test.js b/test/service/selectors/preferences.test.js new file mode 100644 index 000000000..6c7e2ef69 --- /dev/null +++ b/test/service/selectors/preferences.test.js @@ -0,0 +1,139 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import assert from 'assert'; + +import Config from 'config'; + +import {Preferences, Themes} from 'service/constants'; +import {getTheme} from 'service/selectors/entities/preferences'; + +describe('Selectors.Preferences', () => { + it.only('getTheme', () => { + it('should return default theme', () => { + assert.deepEqual( + getTheme({ + entities: { + teams: { + currentId: '' + }, + preferences: { + myPreferences: {} + } + } + }), + typeof Config.DefaultTheme === 'string' ? Themes[Config.DefaultTheme] : Config.DefaultTheme + ); + }); + + it('should return global theme by name', () => { + assert.deepEqual( + getTheme({ + entities: { + teams: { + currentId: '' + }, + preferences: { + myPreferences: { + [`${Preferences.CATEGORY_THEME}--`]: {value: 'mattermost'} + } + } + } + }), + Themes.mattermost + ); + }); + + it('should return global custom theme', () => { + assert.deepEqual( + getTheme({ + entities: { + teams: { + currentId: '' + }, + preferences: { + myPreferences: { + [`${Preferences.CATEGORY_THEME}--`]: {value: '{"sidebarBg": "#ff0000"}'} + } + } + } + }), + {sidebarBg: '#ff0000'} + ); + }); + + it('should return global theme by name when on team', () => { + assert.deepEqual( + getTheme({ + entities: { + teams: { + currentId: '1234' + }, + preferences: { + myPreferences: { + [`${Preferences.CATEGORY_THEME}--`]: {value: 'mattermost'} + } + } + } + }), + Themes.mattermost + ); + }); + + it('should return global custom theme when on team', () => { + assert.deepEqual( + getTheme({ + entities: { + teams: { + currentId: '1234' + }, + preferences: { + myPreferences: { + [`${Preferences.CATEGORY_THEME}--`]: {value: '{"sidebarBg": "#ff0000"}'} + } + } + } + }), + {sidebarBg: '#ff0000'} + ); + }); + + it('should return team-specific theme by name when on team', () => { + assert.deepEqual( + getTheme({ + entities: { + teams: { + currentId: '1234' + }, + preferences: { + myPreferences: { + [`${Preferences.CATEGORY_THEME}--`]: {value: 'mattermost'}, + [`${Preferences.CATEGORY_THEME}--1234`]: {value: 'mattermostDark'} + } + } + } + }), + Themes.mattermostDark + ); + }); + + it('should return team-specific custom theme when on team', () => { + assert.deepEqual( + getTheme({ + entities: { + teams: { + currentId: '1234' + }, + preferences: { + myPreferences: { + [`${Preferences.CATEGORY_THEME}--`]: {value: '{"sidebarBg": "#ff0000"}'}, + [`${Preferences.CATEGORY_THEME}--1234`]: {value: '{"sidebarBg": "#00ff00"}'} + } + } + } + }), + {sidebarBg: '#00ff00'} + ); + }); + }); +});