Added basic theming support (#131)

* Moved all view action types into a single file

* Moved preferences into their own constants section

* Added basic theming support
This commit is contained in:
Harrison Healey 2016-12-12 19:05:59 -05:00 committed by enahum
parent 155a923cd0
commit 61c4d8b585
17 changed files with 341 additions and 36 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <Loading/>;
}
return (
<View style={{flex: 1}}>
<View style={{flex: 1, backgroundColor: theme.centerChannelBg}}>
<StatusBar barStyle='default'/>
<Drawer
open={this.state.leftSidebarOpen}
type='displace'
content={
<ChannelSidebar
currentTeam={this.props.currentTeam}
channels={this.props.channels}
currentTeam={currentTeam}
channels={channels}
/>
}
side='left'
@ -87,22 +95,24 @@ export default class Channel extends React.Component {
onCloseStart={this.closeRightSidebar}
openDrawerOffset={0.2}
>
<View style={{backgroundColor: 'skyblue', flex: 1, flexDirection: 'row', justifyContent: 'space-between', marginTop: 20}}>
<View style={{backgroundColor: theme.sidebarHeaderBg, flexDirection: 'row', justifyContent: 'space-between', marginTop: 20}}>
<TouchableHighlight
onPress={this.openLeftSidebar}
style={{height: 50, width: 50}}
>
<Text>{'<'}</Text>
<Text style={{color: theme.sidebarHeaderTextColor}}>{'<'}</Text>
</TouchableHighlight>
<TouchableHighlight
onPress={this.openRightSidebar}
style={{height: 50, width: 50}}
>
<Text>{'>'}</Text>
<Text style={{color: theme.sidebarHeaderTextColor}}>{'>'}</Text>
</TouchableHighlight>
</View>
<Text>{this.props.currentTeam.id + ' - ' + this.props.currentTeam.name}</Text>
<Text>{this.props.currentChannel.id + ' - ' + this.props.currentChannel.name}</Text>
<View style={{flex: 1}}>
<Text style={{color: theme.centerChannelColor}}>{currentTeam.id + ' - ' + currentTeam.name}</Text>
<Text style={{color: theme.centerChannelColor}}>{currentChannel.id + ' - ' + currentChannel.name}</Text>
</View>
</Drawer>
</Drawer>
</View>

View file

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

View file

@ -1,5 +1,6 @@
{
"DefaultServerUrl": "http://localhost:8065",
"MockFetchInTests": true,
"DefaultLocale": "en"
"DefaultLocale": "en",
"DefaultTheme": "default"
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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