Use immer

This commit is contained in:
Ken-Håvard Lieng 2018-04-25 05:36:27 +02:00
parent 7f755d2a83
commit 4f72e164d7
33 changed files with 1236 additions and 1153 deletions

File diff suppressed because one or more lines are too long

View File

@ -8,7 +8,12 @@
}
],
"@babel/preset-react",
"@babel/preset-stage-0"
[
"@babel/preset-stage-0",
{
"decoratorsLegacy": true
}
]
],
"env": {
"development": {

View File

@ -8,12 +8,12 @@
"ie 11"
],
"devDependencies": {
"@babel/core": "^7.0.0-beta.44",
"@babel/plugin-transform-react-constant-elements": "^7.0.0-beta.44",
"@babel/plugin-transform-react-inline-elements": "^7.0.0-beta.44",
"@babel/preset-env": "^7.0.0-beta.44",
"@babel/preset-react": "^7.0.0-beta.44",
"@babel/preset-stage-0": "^7.0.0-beta.44",
"@babel/core": "^7.0.0-beta.46",
"@babel/plugin-transform-react-constant-elements": "^7.0.0-beta.46",
"@babel/plugin-transform-react-inline-elements": "^7.0.0-beta.46",
"@babel/preset-env": "^7.0.0-beta.46",
"@babel/preset-react": "^7.0.0-beta.46",
"@babel/preset-stage-0": "^7.0.0-beta.46",
"autoprefixer": "^8.2.0",
"babel-core": "^7.0.0-0",
"babel-eslint": "^8.2.2",
@ -32,18 +32,18 @@
"eslint-plugin-prettier": "^2.6.0",
"eslint-plugin-react": "^7.7.0",
"express": "^4.14.1",
"express-http-proxy": "^1.0.1",
"express-http-proxy": "^1.2.0",
"gulp": "^3.9.1",
"gulp-util": "^3.0.8",
"jest": "^22.4.3",
"mini-css-extract-plugin": "^0.4.0",
"postcss-loader": "^2.1.3",
"prettier": "1.12.1",
"style-loader": "^0.20.3",
"style-loader": "^0.21.0",
"through2": "^2.0.3",
"webpack": "^4.1.1",
"webpack-dev-middleware": "^3.0.1",
"webpack-hot-middleware": "^2.17.0"
"webpack-hot-middleware": "^2.22.1"
},
"dependencies": {
"autolinker": "^1.4.3",
@ -51,12 +51,12 @@
"base64-arraybuffer": "^0.1.5",
"fontfaceobserver": "^2.0.9",
"history": "4.5.1",
"immutable": "^3.8.1",
"immer": "^1.2.1",
"js-cookie": "^2.1.4",
"lodash": "^4.17.4",
"lodash": "^4.17.10",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-hot-loader": "^4.0.0",
"react-hot-loader": "^4.1.2",
"react-redux": "^5.0.2",
"react-virtualized": "^9.3.0",
"redux": "^4.0.0",
@ -65,7 +65,7 @@
"url-pattern": "^1.0.3"
},
"scripts": {
"prettier": "prettier --write {.*,*.js,src/css/*.css}",
"prettier": "prettier --write {.*,*.js,src/css/*.css,src/**/*.test.js}",
"prettier:all": "prettier --write {.*,*.js,src/**/*.js,src/css/*.css}",
"test": "jest",
"test:verbose": "jest --verbose",

View File

@ -79,13 +79,13 @@ export default createCommandMiddleware(COMMAND, {
topic({ dispatch, getState, server, channel }, ...newTopic) {
if (newTopic.length > 0) {
dispatch(setTopic(newTopic.join(' '), channel, server));
} else {
const topic = getState().channels.getIn([server, channel, 'topic']);
} else if (channel) {
const { topic } = getState().channels[server][channel];
if (topic) {
return text(topic);
}
return 'No topic set';
}
return 'No topic set';
},
msg({ dispatch, server }, target, ...message) {

View File

@ -11,20 +11,21 @@ export default class TabList extends PureComponent {
const className = showTabList ? 'tablist off-canvas' : 'tablist';
const tabs = [];
channels.forEach((server, address) => {
const srv = servers.get(address);
channels.forEach(server => {
const { address } = server;
const srv = servers[address];
tabs.push(
<TabListItem
key={address}
server={address}
content={srv.name}
selected={tab.server === address && tab.name === null}
selected={tab.server === address && !tab.name}
connected={srv.status.connected}
onClick={this.handleTabClick}
/>
);
server.forEach((channel, name) =>
server.channels.forEach(name =>
tabs.push(
<TabListItem
key={address + name}
@ -37,27 +38,25 @@ export default class TabList extends PureComponent {
)
);
if (privateChats.has(address) && privateChats.get(address).size > 0) {
if (privateChats[address] && privateChats[address].length > 0) {
tabs.push(
<div key={`${address}-pm}`} className="tab-label">
Private messages
</div>
);
privateChats
.get(address)
.forEach(nick =>
tabs.push(
<TabListItem
key={address + nick}
server={address}
target={nick}
content={nick}
selected={tab.server === address && tab.name === nick}
onClick={this.handleTabClick}
/>
)
);
privateChats[address].forEach(nick =>
tabs.push(
<TabListItem
key={address + nick}
server={address}
target={nick}
content={nick}
selected={tab.server === address && tab.name === nick}
onClick={this.handleTabClick}
/>
)
);
}
});

View File

@ -1,4 +1,5 @@
import React, { Component } from 'react';
import { isChannel } from 'utils';
import ChatTitle from './ChatTitle';
import Search from './Search';
import MessageBox from './MessageBox';
@ -9,7 +10,7 @@ export default class Chat extends Component {
handleCloseClick = () => {
const { tab, part, closePrivateChat, disconnect } = this.props;
if (tab.isChannel()) {
if (isChannel(tab)) {
part([tab.name], tab.server);
} else if (tab.name) {
closePrivateChat(tab.server, tab.name);
@ -20,7 +21,7 @@ export default class Chat extends Component {
handleSearch = phrase => {
const { tab, searchMessages } = this.props;
if (tab.isChannel()) {
if (isChannel(tab)) {
searchMessages(tab.server, tab.name, phrase);
}
};
@ -70,7 +71,7 @@ export default class Chat extends Component {
} = this.props;
let chatClass;
if (tab.isChannel()) {
if (isChannel(tab)) {
chatClass = 'chat-channel';
} else if (tab.name) {
chatClass = 'chat-private';

View File

@ -1,9 +1,8 @@
import React, { PureComponent } from 'react';
import { List } from 'immutable';
import Navicon from 'containers/Navicon';
import Editable from 'components/ui/Editable';
import { isValidServerName } from 'state/servers';
import { linkify } from 'utils';
import { isChannel, linkify } from 'utils';
export default class ChatTitle extends PureComponent {
render() {
@ -19,7 +18,7 @@ export default class ChatTitle extends PureComponent {
} = this.props;
let closeTitle;
if (tab.isChannel()) {
if (isChannel(tab)) {
closeTitle = 'Leave';
} else if (tab.name) {
closeTitle = 'Close';
@ -49,7 +48,7 @@ export default class ChatTitle extends PureComponent {
</Editable>
<div className="chat-topic-wrap">
<span className="chat-topic">
{linkify(channel.get('topic')) || null}
{channel && linkify(channel.topic)}
</span>
{serverError}
</div>
@ -64,7 +63,7 @@ export default class ChatTitle extends PureComponent {
<div className="userlist-bar">
<i className="icon-user" />
<span className="chat-usercount">
{channel.get('users', List()).size}
{channel && channel.users.length}
</span>
</div>
</div>

View File

@ -31,13 +31,13 @@ export default class MessageBox extends PureComponent {
this.bottom = false;
}
if (nextProps.messages.get(0) !== this.props.messages.get(0)) {
if (nextProps.messages[0] !== this.props.messages[0]) {
if (nextProps.tab === this.props.tab) {
const addedMessages =
nextProps.messages.size - this.props.messages.size;
nextProps.messages.length - this.props.messages.length;
let addedHeight = 0;
for (let i = 0; i < addedMessages; i++) {
addedHeight += nextProps.messages.get(i).height;
addedHeight += nextProps.messages[i].height;
}
this.nextScrollTop = addedHeight + this.container.scrollTop;
@ -57,7 +57,7 @@ export default class MessageBox extends PureComponent {
this.container.scrollTop = this.nextScrollTop;
this.nextScrollTop = 0;
} else if (this.bottom) {
this.list.scrollToRow(this.props.messages.size);
this.list.scrollToRow(this.props.messages.length);
}
}
@ -72,7 +72,7 @@ export default class MessageBox extends PureComponent {
}
return 0;
}
return this.props.messages.get(index - 1).height;
return this.props.messages[index - 1].height;
};
listRef = el => {
@ -101,7 +101,7 @@ export default class MessageBox extends PureComponent {
} else {
this.bottom = true;
if (scroll) {
this.list.scrollToRow(this.props.messages.size);
this.list.scrollToRow(this.props.messages.length);
}
}
};
@ -177,7 +177,7 @@ export default class MessageBox extends PureComponent {
}
const { messages, onNickClick } = this.props;
const message = messages.get(index - 1);
const message = messages[index - 1];
return (
<Message
@ -192,7 +192,7 @@ export default class MessageBox extends PureComponent {
render() {
const props = {};
if (this.bottom) {
props.scrollToIndex = this.props.messages.size;
props.scrollToIndex = this.props.messages.length;
} else if (this.scrollTop >= 0) {
props.scrollTop = this.scrollTop;
}
@ -209,7 +209,7 @@ export default class MessageBox extends PureComponent {
ref={this.listRef}
width={width}
height={height - 14}
rowCount={this.props.messages.size + 1}
rowCount={this.props.messages.length + 1}
rowHeight={this.getRowHeight}
rowRenderer={this.renderMessage}
onScroll={this.handleScroll}

View File

@ -20,7 +20,7 @@ export default class UserList extends PureComponent {
return (
<UserListItem
key={key}
user={users.get(index)}
user={users[index]}
style={style}
onClick={onNickClick}
/>
@ -39,7 +39,7 @@ export default class UserList extends PureComponent {
ref={this.listRef}
width={200}
height={height - 20}
rowCount={users.size}
rowCount={users.length}
rowHeight={24}
rowRenderer={this.renderUser}
className="rvlist-users"

View File

@ -1,6 +1,12 @@
import React, { Component } from 'react';
import { createSelector } from 'reselect';
import Navicon from 'containers/Navicon';
const getSortedDefaultChannels = createSelector(
defaults => defaults.channels,
channels => channels.concat().sort()
);
export default class Connect extends Component {
state = {
showOptionals: false,
@ -90,7 +96,9 @@ export default class Connect extends Component {
{defaults.showDetails && (
<div className="connect-details">
<h2>{defaults.address}</h2>
{defaults.channels.sort().map(channel => <p>{channel}</p>)}
{getSortedDefaultChannels(defaults).map(channel => (
<p>{channel}</p>
))}
</div>
)}
<input name="nick" type="text" placeholder="Nick" />

View File

@ -3,8 +3,8 @@ import Navicon from 'containers/Navicon';
import FileInput from 'components/ui/FileInput';
const Settings = ({ settings, onCertChange, onKeyChange, uploadCert }) => {
const status = settings.get('uploadingCert') ? 'Uploading...' : 'Upload';
const error = settings.get('certError');
const status = settings.uploadingCert ? 'Uploading...' : 'Upload';
const error = settings.certError;
return (
<div className="settings">
@ -14,14 +14,14 @@ const Settings = ({ settings, onCertChange, onKeyChange, uploadCert }) => {
<div>
<p>Certificate</p>
<FileInput
name={settings.get('certFile') || 'Select Certificate'}
name={settings.certFile || 'Select Certificate'}
onChange={onCertChange}
/>
</div>
<div>
<p>Private Key</p>
<FileInput
name={settings.get('keyFile') || 'Select Key'}
name={settings.keyFile || 'Select Key'}
onChange={onKeyChange}
/>
</div>

View File

@ -3,7 +3,7 @@ import { createStructuredSelector } from 'reselect';
import App from 'components/App';
import { getConnected } from 'state/app';
import { getSortedChannels } from 'state/channels';
import { getSortedPrivateChats } from 'state/privateChats';
import { getPrivateChats } from 'state/privateChats';
import { getServers } from 'state/servers';
import { getSelectedTab, select } from 'state/tab';
import { getShowTabList, hideMenu } from 'state/ui';
@ -12,7 +12,7 @@ import { push } from 'utils/router';
const mapState = createStructuredSelector({
channels: getSortedChannels,
connected: getConnected,
privateChats: getSortedPrivateChats,
privateChats: getPrivateChats,
servers: getServers,
showTabList: getShowTabList,
tab: getSelectedTab

View File

@ -9,7 +9,7 @@ import {
} from 'state/messages';
import { reconnect } from 'state/servers';
import { select } from 'state/tab';
import { normalizeChannel } from 'utils';
import { find, normalizeChannel } from 'utils';
function withReason(message, reason) {
return message + (reason ? ` (${reason})` : '');
@ -18,9 +18,9 @@ function withReason(message, reason) {
function findChannels(state, server, user) {
const channels = [];
state.channels.get(server).forEach((channel, channelName) => {
if (channel.get('users').find(u => u.nick === user)) {
channels.push(channelName);
Object.keys(state.channels[server]).forEach(channel => {
if (find(state.channels[server][channel].users, u => u.nick === user)) {
channels.push(channel);
}
});
@ -49,7 +49,7 @@ export default function handleSocket({
const tab = state.tab.selected;
const [joinedChannel] = channels;
if (tab.server && tab.name) {
const { nick } = state.servers.get(tab.server);
const { nick } = state.servers[tab.server];
if (
tab.server === server &&
nick === user &&

View File

@ -1,16 +1,17 @@
import Cookie from 'js-cookie';
import debounce from 'lodash/debounce';
import { getSelectedTab } from 'state/tab';
import { isChannel, stringifyTab } from 'utils';
import { observe } from 'utils/observe';
const saveTab = debounce(
tab => Cookie.set('tab', tab.toString(), { expires: 30 }),
tab => Cookie.set('tab', stringifyTab(tab), { expires: 30 }),
1000
);
export default function storage({ store }) {
observe(store, getSelectedTab, tab => {
if (tab.isChannel() || (tab.server && !tab.name)) {
if (isChannel(tab) || (tab.server && !tab.name)) {
saveTab(tab);
}
});

View File

@ -1,11 +1,10 @@
import Immutable from 'immutable';
import reducer, { compareUsers } from '../channels';
import reducer, { compareUsers, getSortedChannels } from '../channels';
import { connect } from '../servers';
import * as actions from '../actions';
describe('channel reducer', () => {
it('removes channels on PART', () => {
let state = Immutable.fromJS({
let state = {
srv1: {
chan1: {},
chan2: {},
@ -14,7 +13,7 @@ describe('channel reducer', () => {
srv2: {
chan1: {}
}
});
};
state = reducer(state, {
type: actions.PART,
@ -22,7 +21,7 @@ describe('channel reducer', () => {
channels: ['chan1', 'chan3']
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv1: {
chan2: {}
},
@ -44,7 +43,7 @@ describe('channel reducer', () => {
user: 'nick2'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: {
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
@ -59,7 +58,7 @@ describe('channel reducer', () => {
it('handles SOCKET_JOIN', () => {
const state = reducer(undefined, socket_join('srv', 'chan1', 'nick1'));
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: {
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
@ -79,7 +78,7 @@ describe('channel reducer', () => {
user: 'nick2'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: {
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
@ -103,7 +102,7 @@ describe('channel reducer', () => {
newNick: 'nick3'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: {
users: [
@ -126,7 +125,7 @@ describe('channel reducer', () => {
users: ['user3', 'user2', '@user4', 'user1', '+user5']
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: {
users: [
@ -149,10 +148,11 @@ describe('channel reducer', () => {
topic: 'the topic'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: {
topic: 'the topic'
topic: 'the topic',
users: []
}
}
});
@ -165,7 +165,7 @@ describe('channel reducer', () => {
state = reducer(state, socket_mode('srv', 'chan1', 'nick1', 'o', ''));
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: {
users: [
@ -183,7 +183,7 @@ describe('channel reducer', () => {
state = reducer(state, socket_mode('srv', 'chan1', 'nick2', 'o', ''));
state = reducer(state, socket_mode('srv', 'chan2', 'not_there', 'x', ''));
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: {
users: [
@ -208,7 +208,7 @@ describe('channel reducer', () => {
]
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
chan1: { topic: 'the topic', users: [] },
chan2: { users: [] }
@ -225,7 +225,7 @@ describe('channel reducer', () => {
data: [{ host: '127.0.0.1' }, { host: 'thehost' }]
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
'127.0.0.1': {},
thehost: {}
});
@ -234,23 +234,23 @@ describe('channel reducer', () => {
it('optimistically adds the server on CONNECT', () => {
const state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
expect(state.toJS()).toEqual({
expect(state).toEqual({
'127.0.0.1': {}
});
});
it('removes the server on DISCONNECT', () => {
let state = Immutable.fromJS({
let state = {
srv: {},
srv2: {}
});
};
state = reducer(state, {
type: actions.DISCONNECT,
server: 'srv2'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {}
});
});
@ -301,3 +301,32 @@ describe('compareUsers()', () => {
]);
});
});
describe('getSortedChannels', () => {
it('sorts servers and channels', () => {
expect(
getSortedChannels({
channels: {
'bob.com': {},
'127.0.0.1': {
'#chan1': {
users: [],
topic: 'cake'
},
'#pie': {},
'##apples': {}
}
}
})
).toEqual([
{
address: '127.0.0.1',
channels: ['##apples', '#chan1', '#pie']
},
{
address: 'bob.com',
channels: []
}
]);
});
});

View File

@ -1,4 +1,3 @@
import { Map, fromJS } from 'immutable';
import reducer, { broadcast, getMessageTab } from '../messages';
import * as actions from '../actions';
import appReducer from '../app';
@ -15,7 +14,7 @@ describe('message reducer', () => {
}
});
expect(state.toJS()).toMatchObject({
expect(state).toMatchObject({
srv: {
'#chan1': [
{
@ -49,7 +48,7 @@ describe('message reducer', () => {
]
});
expect(state.toJS()).toMatchObject({
expect(state).toMatchObject({
srv: {
'#chan1': [
{
@ -72,11 +71,11 @@ describe('message reducer', () => {
});
it('handles prepending of messages on ADD_MESSAGES', () => {
let state = fromJS({
let state = {
srv: {
'#chan1': [{ id: 0 }]
}
});
};
state = reducer(state, {
type: actions.ADD_MESSAGES,
@ -86,7 +85,7 @@ describe('message reducer', () => {
messages: [{ id: 1 }, { id: 2 }]
});
expect(state.toJS()).toMatchObject({
expect(state).toMatchObject({
srv: {
'#chan1': [{ id: 1 }, { id: 2 }, { id: 0 }]
}
@ -103,7 +102,7 @@ describe('message reducer', () => {
state.messages = reducer(undefined, action);
}, () => state);
const messages = state.messages.toJS();
const messages = state.messages;
expect(messages.srv).not.toHaveProperty('srv');
expect(messages.srv['#chan1']).toHaveLength(1);
@ -113,7 +112,7 @@ describe('message reducer', () => {
});
it('deletes all messages related to server when disconnecting', () => {
let state = fromJS({
let state = {
srv: {
'#chan1': [{ content: 'msg1' }, { content: 'msg2' }],
'#chan2': [{ content: 'msg' }]
@ -121,14 +120,14 @@ describe('message reducer', () => {
srv2: {
'#chan1': [{ content: 'msg' }]
}
});
};
state = reducer(state, {
type: actions.DISCONNECT,
server: 'srv'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv2: {
'#chan1': [{ content: 'msg' }]
}
@ -136,7 +135,7 @@ describe('message reducer', () => {
});
it('deletes all messages related to channel when parting', () => {
let state = fromJS({
let state = {
srv: {
'#chan1': [{ content: 'msg1' }, { content: 'msg2' }],
'#chan2': [{ content: 'msg' }]
@ -144,7 +143,7 @@ describe('message reducer', () => {
srv2: {
'#chan1': [{ content: 'msg' }]
}
});
};
state = reducer(state, {
type: actions.PART,
@ -152,7 +151,7 @@ describe('message reducer', () => {
channels: ['#chan1']
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
'#chan2': [{ content: 'msg' }]
},

View File

@ -1,4 +1,3 @@
import Immutable from 'immutable';
import reducer, { connect, setServerName } from '../servers';
import * as actions from '../actions';
@ -6,7 +5,7 @@ describe('server reducer', () => {
it('adds the server on CONNECT', () => {
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
expect(state.toJS()).toEqual({
expect(state).toEqual({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',
@ -20,7 +19,7 @@ describe('server reducer', () => {
state = reducer(state, connect('127.0.0.1:1337', 'nick', {}));
expect(state.toJS()).toEqual({
expect(state).toEqual({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',
@ -39,7 +38,7 @@ describe('server reducer', () => {
})
);
expect(state.toJS()).toEqual({
expect(state).toEqual({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',
@ -62,31 +61,31 @@ describe('server reducer', () => {
});
it('removes the server on DISCONNECT', () => {
let state = Immutable.fromJS({
let state = {
srv: {},
srv2: {}
});
};
state = reducer(state, {
type: actions.DISCONNECT,
server: 'srv2'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {}
});
});
it('handles SET_SERVER_NAME', () => {
let state = Immutable.fromJS({
let state = {
srv: {
name: 'cake'
}
});
};
state = reducer(state, setServerName('pie', 'srv'));
expect(state.toJS()).toEqual({
expect(state).toEqual({
srv: {
name: 'pie'
}
@ -102,7 +101,7 @@ describe('server reducer', () => {
editing: true
});
expect(state.toJS()).toMatchObject({
expect(state).toMatchObject({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',
@ -125,7 +124,7 @@ describe('server reducer', () => {
nick: ''
});
expect(state.toJS()).toMatchObject({
expect(state).toMatchObject({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',
@ -143,7 +142,7 @@ describe('server reducer', () => {
newNick: 'nick2'
});
expect(state.toJS()).toMatchObject({
expect(state).toMatchObject({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick2',
@ -165,7 +164,7 @@ describe('server reducer', () => {
server: '127.0.0.1'
});
expect(state.toJS()).toMatchObject({
expect(state).toMatchObject({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',
@ -197,23 +196,19 @@ describe('server reducer', () => {
]
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
'127.0.0.1': {
name: 'stuff',
nick: 'nick',
editedNick: null,
status: {
connected: true,
error: null
connected: true
}
},
'127.0.0.2': {
name: 'stuffz',
nick: 'nick2',
editedNick: null,
status: {
connected: false,
error: null
connected: false
}
}
});
@ -227,14 +222,13 @@ describe('server reducer', () => {
connected: true
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',
editedNick: null,
status: {
connected: true,
error: null
connected: true
}
}
});
@ -246,7 +240,7 @@ describe('server reducer', () => {
error: 'Bad stuff happened'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
'127.0.0.1': {
name: '127.0.0.1',
nick: 'nick',

View File

@ -6,14 +6,14 @@ describe('tab reducer', () => {
it('selects the tab and adds it to history', () => {
let state = reducer(undefined, setSelectedTab('srv', '#chan'));
expect(state.toJS()).toEqual({
expect(state).toEqual({
selected: { server: 'srv', name: '#chan' },
history: [{ server: 'srv', name: '#chan' }]
});
state = reducer(state, setSelectedTab('srv', 'user1'));
expect(state.toJS()).toEqual({
expect(state).toEqual({
selected: { server: 'srv', name: 'user1' },
history: [
{ server: 'srv', name: '#chan' },
@ -34,7 +34,7 @@ describe('tab reducer', () => {
channels: ['#chan']
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
selected: { server: 'srv', name: '#chan3' },
history: [
{ server: 'srv1', name: 'bob' },
@ -55,7 +55,7 @@ describe('tab reducer', () => {
nick: 'bob'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
selected: { server: 'srv', name: '#chan3' },
history: [
{ server: 'srv', name: '#chan' },
@ -76,7 +76,7 @@ describe('tab reducer', () => {
server: 'srv'
});
expect(state.toJS()).toEqual({
expect(state).toEqual({
selected: { server: 'srv', name: '#chan3' },
history: [{ server: 'srv1', name: 'bob' }]
});
@ -87,8 +87,8 @@ describe('tab reducer', () => {
state = reducer(state, locationChanged('settings'));
expect(state.toJS()).toEqual({
selected: { server: null, name: null },
expect(state).toEqual({
selected: {},
history: [{ server: 'srv', name: '#chan' }]
});
});
@ -102,7 +102,7 @@ describe('tab reducer', () => {
})
);
expect(state.toJS()).toEqual({
expect(state).toEqual({
selected: { server: 'srv', name: '#chan' },
history: [{ server: 'srv', name: '#chan' }]
});

View File

@ -1,4 +1,3 @@
import { Record } from 'immutable';
import createReducer from 'utils/createReducer';
import * as actions from './actions';
@ -9,34 +8,31 @@ export const getCharWidth = state => state.app.charWidth;
export const getWindowWidth = state => state.app.windowWidth;
export const getConnectDefaults = state => state.app.connectDefaults;
const ConnectDefaults = Record({
name: '',
address: '',
channels: [],
ssl: false,
password: false,
readonly: false,
showDetails: false
});
const App = Record({
const initialState = {
connected: true,
wrapWidth: 0,
charWidth: 0,
windowWidth: 0,
connectDefaults: new ConnectDefaults()
});
connectDefaults: {
name: '',
address: '',
channels: [],
ssl: false,
password: false,
readonly: false,
showDetails: false
}
};
export default createReducer(new App(), {
[actions.APP_SET](state, action) {
return state.set(action.key, action.value);
export default createReducer(initialState, {
[actions.APP_SET](state, { key, value }) {
state[key] = value;
},
[actions.UPDATE_MESSAGE_HEIGHT](state, action) {
return state
.set('wrapWidth', action.wrapWidth)
.set('charWidth', action.charWidth)
.set('windowWidth', action.windowWidth);
state.wrapWidth = action.wrapWidth;
state.charWidth = action.charWidth;
state.windowWidth = action.windowWidth;
}
});
@ -57,5 +53,5 @@ export function setCharWidth(width) {
}
export function setConnectDefaults(defaults) {
return appSet('connectDefaults', new ConnectDefaults(defaults));
return appSet('connectDefaults', defaults);
}

View File

@ -1,15 +1,11 @@
import { Map, List, Record } from 'immutable';
import { createSelector } from 'reselect';
import get from 'lodash/get';
import sortBy from 'lodash/sortBy';
import createReducer from 'utils/createReducer';
import { find, findIndex } from 'utils';
import { getSelectedTab, updateSelection } from './tab';
import * as actions from './actions';
const User = Record({
nick: null,
renderName: null,
mode: ''
});
const modePrefixes = [
{ mode: 'q', prefix: '~' }, // Owner
{ mode: 'a', prefix: '&' }, // Admin
@ -18,24 +14,23 @@ const modePrefixes = [
{ mode: 'v', prefix: '+' } // Voice
];
function updateRenderName(user) {
function getRenderName(user) {
for (let i = 0; i < modePrefixes.length; i++) {
if (user.mode.indexOf(modePrefixes[i].mode) !== -1) {
return user.set('renderName', `${modePrefixes[i].prefix}${user.nick}`);
return `${modePrefixes[i].prefix}${user.nick}`;
}
}
return user.set('renderName', user.nick);
return user.nick;
}
function createUser(nick, mode) {
return updateRenderName(
new User({
nick,
renderName: nick,
mode: mode || ''
})
);
const user = {
nick,
mode: mode || ''
};
user.renderName = getRenderName(user);
return user;
}
function loadUser(nick) {
@ -54,6 +49,22 @@ function loadUser(nick) {
return createUser(nick);
}
function removeUser(users, nick) {
const i = findIndex(users, u => u.nick === nick);
if (i !== -1) {
users.splice(i, 1);
}
}
function init(state, server, channel) {
if (!state[server]) {
state[server] = {};
}
if (channel && !state[server][channel]) {
state[server][channel] = { users: [] };
}
}
export function compareUsers(a, b) {
a = a.renderName.toLowerCase();
b = b.renderName.toLowerCase();
@ -80,152 +91,119 @@ export function compareUsers(a, b) {
export const getChannels = state => state.channels;
const key = (v, k) => k.toLowerCase();
export const getSortedChannels = createSelector(getChannels, channels =>
channels
.withMutations(c =>
c.forEach((server, address) =>
c.update(address, chans => chans.sortBy(key))
sortBy(
Object.keys(channels).map(server => ({
address: server,
channels: sortBy(Object.keys(channels[server]), channel =>
channel.toLowerCase()
)
)
.sortBy(key)
})),
server => server.address.toLowerCase()
)
);
export const getSelectedChannel = createSelector(
getSelectedTab,
getChannels,
(tab, channels) => channels.getIn([tab.server, tab.name], Map())
(tab, channels) => get(channels, [tab.server, tab.name])
);
export const getSelectedChannelUsers = createSelector(
getSelectedChannel,
channel => channel.get('users', List()).sort(compareUsers)
channel => {
if (channel) {
return channel.users.concat().sort(compareUsers);
}
return [];
}
);
export default createReducer(Map(), {
[actions.PART](state, { server, channels }) {
return state.withMutations(s => {
channels.forEach(channel => s.deleteIn([server, channel]));
});
},
export default createReducer(
{},
{
[actions.PART](state, { server, channels }) {
channels.forEach(channel => delete state[server][channel]);
},
[actions.socket.JOIN](state, { server, channels, user }) {
return state.updateIn([server, channels[0], 'users'], List(), users =>
users.push(createUser(user))
);
},
[actions.socket.JOIN](state, { server, channels, user }) {
const channel = channels[0];
init(state, server, channel);
state[server][channel].users.push(createUser(user));
},
[actions.socket.PART](state, { server, channel, user }) {
if (state.hasIn([server, channel])) {
return state.updateIn([server, channel, 'users'], users =>
users.filter(u => u.nick !== user)
);
}
return state;
},
[actions.socket.QUIT](state, { server, user }) {
return state.withMutations(s => {
s.get(server).forEach((v, channel) => {
s.updateIn([server, channel, 'users'], users =>
users.filter(u => u.nick !== user)
);
});
});
},
[actions.socket.NICK](state, { server, oldNick, newNick }) {
return state.withMutations(s => {
s.get(server).forEach((v, channel) => {
s.updateIn([server, channel, 'users'], users => {
const i = users.findIndex(user => user.nick === oldNick);
if (i < 0) {
return users;
}
return users.update(i, user =>
updateRenderName(user.set('nick', newNick))
);
});
});
});
},
[actions.socket.USERS](state, { server, channel, users }) {
return state.setIn(
[server, channel, 'users'],
List(users.map(user => loadUser(user)))
);
},
[actions.socket.TOPIC](state, { server, channel, topic }) {
return state.setIn([server, channel, 'topic'], topic);
},
[actions.socket.MODE](state, { server, channel, user, remove, add }) {
return state.updateIn([server, channel, 'users'], users => {
const i = users.findIndex(u => u.nick === user);
if (i < 0) {
return users;
[actions.socket.PART](state, { server, channel, user }) {
if (state[server][channel]) {
removeUser(state[server][channel].users, user);
}
},
return users.update(i, u => {
let { mode } = u;
[actions.socket.QUIT](state, { server, user }) {
Object.keys(state[server]).forEach(channel => {
removeUser(state[server][channel].users, user);
});
},
[actions.socket.NICK](state, { server, oldNick, newNick }) {
Object.keys(state[server]).forEach(channel => {
const user = find(
state[server][channel].users,
u => u.nick === oldNick
);
if (user) {
user.nick = newNick;
user.renderName = getRenderName(user);
}
});
},
[actions.socket.USERS](state, { server, channel, users }) {
init(state, server, channel);
state[server][channel].users = users.map(nick => loadUser(nick));
},
[actions.socket.TOPIC](state, { server, channel, topic }) {
init(state, server, channel);
state[server][channel].topic = topic;
},
[actions.socket.MODE](state, { server, channel, user, remove, add }) {
const u = find(state[server][channel].users, v => v.nick === user);
if (u) {
let j = remove.length;
while (j--) {
mode = mode.replace(remove[j], '');
u.mode = u.mode.replace(remove[j], '');
}
return updateRenderName(u.set('mode', mode + add));
});
});
},
u.mode += add;
u.renderName = getRenderName(u);
}
},
[actions.socket.CHANNELS](state, { data }) {
if (!data) {
return state;
[actions.socket.CHANNELS](state, { data }) {
if (data) {
data.forEach(({ server, name, topic }) => {
init(state, server, name);
state[server][name].topic = topic;
});
}
},
[actions.socket.SERVERS](state, { data }) {
if (data) {
data.forEach(({ host }) => init(state, host));
}
},
[actions.CONNECT](state, { host }) {
init(state, host);
},
[actions.DISCONNECT](state, { server }) {
delete state[server];
}
return state.withMutations(s => {
data.forEach(channel => {
s.setIn(
[channel.server, channel.name],
Map({
users: List(),
topic: channel.topic
})
);
});
});
},
[actions.socket.SERVERS](state, { data }) {
if (!data) {
return state;
}
return state.withMutations(s => {
data.forEach(server => {
if (!s.has(server.host)) {
s.set(server.host, Map());
}
});
});
},
[actions.CONNECT](state, { host }) {
if (!state.has(host)) {
return state.set(host, Map());
}
return state;
},
[actions.DISCONNECT](state, { server }) {
return state.delete(server);
}
});
);
export function join(channels, server) {
return {

View File

@ -1,54 +1,45 @@
import { List, Record } from 'immutable';
import createReducer from 'utils/createReducer';
import * as actions from './actions';
const HISTORY_MAX_LENGTH = 128;
const State = Record({
history: List(),
const initialState = {
history: [],
index: 0
});
};
export const getCurrentInputHistoryEntry = state => {
if (state.input.index === -1) {
return null;
}
return state.input.history.get(state.input.index);
return state.input.history[state.input.index];
};
export default createReducer(new State(), {
[actions.INPUT_HISTORY_ADD](state, action) {
const { line } = action;
if (line.trim() && line !== state.history.get(0)) {
export default createReducer(initialState, {
[actions.INPUT_HISTORY_ADD](state, { line }) {
if (line.trim() && line !== state.history[0]) {
if (history.length === HISTORY_MAX_LENGTH) {
return state.set('history', state.history.unshift(line).pop());
state.history.pop();
}
return state.set('history', state.history.unshift(line));
state.history.unshift(line);
}
return state;
},
[actions.INPUT_HISTORY_RESET](state) {
return state.set('index', -1);
state.index = -1;
},
[actions.INPUT_HISTORY_INCREMENT](state) {
if (state.index < state.history.size - 1) {
return state.set('index', state.index + 1);
if (state.index < state.history.length - 1) {
state.index++;
}
return state;
},
[actions.INPUT_HISTORY_DECREMENT](state) {
if (state.index >= 0) {
return state.set('index', state.index - 1);
state.index--;
}
return state;
}
});

View File

@ -1,5 +1,5 @@
import { List, Map, Record } from 'immutable';
import { createSelector } from 'reselect';
import has from 'lodash/has';
import {
findBreakpoints,
messageHeight,
@ -12,95 +12,85 @@ import { getApp } from './app';
import { getSelectedTab } from './tab';
import * as actions from './actions';
const Message = Record({
id: null,
from: null,
content: '',
time: null,
type: null,
channel: false,
next: false,
height: 0,
length: 0,
breakpoints: null
});
export const getMessages = state => state.messages;
export const getSelectedMessages = createSelector(
getSelectedTab,
getMessages,
(tab, messages) =>
messages.getIn([tab.server, tab.name || tab.server], List())
(tab, messages) => {
const target = tab.name || tab.server;
if (has(messages, [tab.server, target])) {
return messages[tab.server][target];
}
return [];
}
);
export const getHasMoreMessages = createSelector(
getSelectedMessages,
messages => {
const first = messages.get(0);
const first = messages[0];
return first && first.next;
}
);
export default createReducer(Map(), {
[actions.ADD_MESSAGE](state, { server, tab, message }) {
return state.updateIn([server, tab], List(), list =>
list.push(new Message(message))
);
},
[actions.ADD_MESSAGES](state, { server, tab, messages, prepend }) {
return state.withMutations(s => {
if (prepend) {
for (let i = messages.length - 1; i >= 0; i--) {
s.updateIn([server, tab], List(), list =>
list.unshift(new Message(messages[i]))
);
}
} else {
messages.forEach(message =>
s.updateIn([server, message.tab || tab], List(), list =>
list.push(new Message(message))
)
);
}
});
},
[actions.DISCONNECT](state, { server }) {
return state.delete(server);
},
[actions.PART](state, { server, channels }) {
return state.withMutations(s =>
channels.forEach(channel => s.deleteIn([server, channel]))
);
},
[actions.UPDATE_MESSAGE_HEIGHT](
state,
{ wrapWidth, charWidth, windowWidth }
) {
return state.withMutations(s =>
s.forEach((server, serverKey) =>
server.forEach((target, targetKey) =>
target.forEach((message, index) =>
s.setIn(
[serverKey, targetKey, index, 'height'],
messageHeight(
message,
wrapWidth,
charWidth,
6 * charWidth,
windowWidth
)
)
)
)
)
);
function init(state, server, tab) {
if (!state[server]) {
state[server] = {};
}
});
if (!state[server][tab]) {
state[server][tab] = [];
}
}
export default createReducer(
{},
{
[actions.ADD_MESSAGE](state, { server, tab, message }) {
init(state, server, tab);
state[server][tab].push(message);
},
[actions.ADD_MESSAGES](state, { server, tab, messages, prepend }) {
if (prepend) {
init(state, server, tab);
state[server][tab].unshift(...messages);
} else {
messages.forEach(message => {
init(state, server, message.tab || tab);
state[server][message.tab || tab].push(message);
});
}
},
[actions.DISCONNECT](state, { server }) {
delete state[server];
},
[actions.PART](state, { server, channels }) {
channels.forEach(channel => delete state[server][channel]);
},
[actions.UPDATE_MESSAGE_HEIGHT](
state,
{ wrapWidth, charWidth, windowWidth }
) {
Object.keys(state).forEach(server =>
Object.keys(state[server]).forEach(target =>
state[server][target].forEach(message => {
message.height = messageHeight(
message,
wrapWidth,
charWidth,
6 * charWidth,
windowWidth
);
})
)
);
}
}
);
let nextID = 0;
@ -156,14 +146,14 @@ export function getMessageTab(server, to) {
export function fetchMessages() {
return (dispatch, getState) => {
const state = getState();
const first = getSelectedMessages(state).get(0);
const first = getSelectedMessages(state)[0];
if (!first) {
return;
}
const tab = state.tab.selected;
if (tab.isChannel()) {
if (isChannel(tab)) {
dispatch({
type: actions.FETCH_MESSAGES,
socket: {
@ -206,7 +196,7 @@ export function sendMessage(content, to, server) {
tab: to,
message: initMessage(
{
from: state.servers.getIn([server, 'nick']),
from: state.servers[server].nick,
content
},
to,

View File

@ -1,48 +1,46 @@
import { Set, Map } from 'immutable';
import { createSelector } from 'reselect';
import sortBy from 'lodash/sortBy';
import { findIndex } from 'utils';
import createReducer from 'utils/createReducer';
import { updateSelection } from './tab';
import * as actions from './actions';
export const getPrivateChats = state => state.privateChats;
const lowerCaseValue = v => v.toLowerCase();
export const getSortedPrivateChats = createSelector(
getPrivateChats,
privateChats =>
privateChats.withMutations(p =>
p.forEach((server, address) =>
p.update(address, chats => chats.sortBy(lowerCaseValue))
)
)
);
function open(state, server, nick) {
return state.update(server, Set(), chats => chats.add(nick));
if (!state[server]) {
state[server] = [];
}
if (findIndex(state[server], n => n === nick) === -1) {
state[server].push(nick);
state[server] = sortBy(state[server], v => v.toLowerCase());
}
}
export default createReducer(Map(), {
[actions.OPEN_PRIVATE_CHAT](state, action) {
return open(state, action.server, action.nick);
},
export default createReducer(
{},
{
[actions.OPEN_PRIVATE_CHAT](state, action) {
open(state, action.server, action.nick);
},
[actions.CLOSE_PRIVATE_CHAT](state, action) {
return state.update(action.server, chats => chats.delete(action.nick));
},
[actions.CLOSE_PRIVATE_CHAT](state, { server, nick }) {
const i = findIndex(state[server], n => n === nick);
if (i !== -1) {
state[server].splice(i, 1);
}
},
[actions.socket.PM](state, action) {
if (action.from.indexOf('.') === -1) {
return open(state, action.server, action.from);
[actions.socket.PM](state, action) {
if (action.from.indexOf('.') === -1) {
open(state, action.server, action.from);
}
},
[actions.DISCONNECT](state, { server }) {
delete state[server];
}
return state;
},
[actions.DISCONNECT](state, action) {
return state.delete(action.server);
}
});
);
export function openPrivateChat(server, nick) {
return {

View File

@ -1,21 +1,20 @@
import { List, Record } from 'immutable';
import createReducer from 'utils/createReducer';
import * as actions from './actions';
const State = Record({
const initialState = {
show: false,
results: List()
});
results: []
};
export const getSearch = state => state.search;
export default createReducer(new State(), {
[actions.socket.SEARCH](state, action) {
return state.set('results', List(action.results));
export default createReducer(initialState, {
[actions.socket.SEARCH](state, { results }) {
state.results = results;
},
[actions.TOGGLE_SEARCH](state) {
return state.set('show', !state.show);
state.show = !state.show;
}
});

View File

@ -1,4 +1,5 @@
import { createSelector } from 'reselect';
import get from 'lodash/get';
import { getServers } from './servers';
import { getSelectedTab } from './tab';
@ -6,5 +7,5 @@ import { getSelectedTab } from './tab';
export const getSelectedTabTitle = createSelector(
getSelectedTab,
getServers,
(tab, servers) => tab.name || servers.getIn([tab.server, 'name'])
(tab, servers) => tab.name || get(servers, [tab.server, 'name'])
);

View File

@ -1,30 +1,21 @@
import { Map, Record } from 'immutable';
import { createSelector } from 'reselect';
import get from 'lodash/get';
import createReducer from 'utils/createReducer';
import { getSelectedTab, updateSelection } from './tab';
import * as actions from './actions';
const Status = Record({
connected: false,
error: null
});
const Server = Record({
nick: '',
editedNick: null,
name: '',
status: new Status()
});
export const getServers = state => state.servers;
export const getCurrentNick = createSelector(
getServers,
getSelectedTab,
(servers, tab) => {
const editedNick = servers.getIn([tab.server, 'editedNick']);
if (editedNick === null) {
return servers.getIn([tab.server, 'nick']);
if (!servers[tab.server]) {
return;
}
const { editedNick } = servers[tab.server];
if (!editedNick) {
return servers[tab.server].nick;
}
return editedNick;
}
@ -33,80 +24,77 @@ export const getCurrentNick = createSelector(
export const getCurrentServerName = createSelector(
getServers,
getSelectedTab,
(servers, tab) => servers.getIn([tab.server, 'name'])
(servers, tab) => get(servers, [tab.server, 'name'])
);
export const getCurrentServerStatus = createSelector(
getServers,
getSelectedTab,
(servers, tab) => servers.getIn([tab.server, 'status'])
(servers, tab) => get(servers, [tab.server, 'status'], {})
);
export default createReducer(Map(), {
[actions.CONNECT](state, { host, nick, options }) {
if (!state.has(host)) {
return state.set(
host,
new Server({
export default createReducer(
{},
{
[actions.CONNECT](state, { host, nick, options }) {
if (!state[host]) {
state[host] = {
nick,
name: options.name || host
})
);
}
editedNick: null,
name: options.name || host,
status: {
connected: false,
error: null
}
};
}
return state;
},
[actions.DISCONNECT](state, { server }) {
return state.delete(server);
},
[actions.SET_SERVER_NAME](state, { server, name }) {
return state.setIn([server, 'name'], name);
},
[actions.SET_NICK](state, { server, nick, editing }) {
if (editing) {
return state.setIn([server, 'editedNick'], nick);
} else if (nick === '') {
return state.setIn([server, 'editedNick'], null);
}
return state;
},
[actions.socket.NICK](state, { server, oldNick, newNick }) {
if (!oldNick || oldNick === state.get(server).nick) {
return state.update(server, s =>
s.set('nick', newNick).set('editedNick', null)
);
}
return state;
},
[actions.socket.NICK_FAIL](state, { server }) {
return state.setIn([server, 'editedNick'], null);
},
[actions.socket.SERVERS](state, { data }) {
if (!data) {
return state;
}
},
return state.withMutations(s => {
data.forEach(server => {
server.status = new Status(server.status);
s.set(server.host, new Server(server));
});
});
},
[actions.DISCONNECT](state, { server }) {
delete state[server];
},
[actions.socket.CONNECTION_UPDATE](state, action) {
if (state.has(action.server)) {
return state.setIn([action.server, 'status'], new Status(action));
[actions.SET_SERVER_NAME](state, { server, name }) {
state[server].name = name;
},
[actions.SET_NICK](state, { server, nick, editing }) {
if (editing) {
state[server].editedNick = nick;
} else if (nick === '') {
state[server].editedNick = null;
}
},
[actions.socket.NICK](state, { server, oldNick, newNick }) {
if (!oldNick || oldNick === state[server].nick) {
state[server].nick = newNick;
state[server].editedNick = null;
}
},
[actions.socket.NICK_FAIL](state, { server }) {
state[server].editedNick = null;
},
[actions.socket.SERVERS](state, { data }) {
if (data) {
data.forEach(({ host, name, nick, status }) => {
state[host] = { name, nick, status };
});
}
},
[actions.socket.CONNECTION_UPDATE](state, { server, connected, error }) {
if (state[server]) {
state[server].status.connected = connected;
state[server].status.error = error;
}
}
return state;
}
});
);
export function connect(server, nick, options) {
let host = server;

View File

@ -1,47 +1,45 @@
import { Map } from 'immutable';
import base64 from 'base64-arraybuffer';
import createReducer from 'utils/createReducer';
import * as actions from './actions';
export const getSettings = state => state.settings;
export default createReducer(Map(), {
[actions.UPLOAD_CERT](state) {
return state.set('uploadingCert', true);
},
export default createReducer(
{},
{
[actions.UPLOAD_CERT](state) {
state.uploadingCert = true;
},
[actions.socket.CERT_SUCCESS]() {
return Map({ uploadingCert: false });
},
[actions.socket.CERT_SUCCESS](state) {
state.uploadingCert = false;
delete state.certFile;
delete state.cert;
delete state.keyFile;
delete state.key;
},
[actions.socket.CERT_FAIL](state, action) {
return state.merge({
uploadingCert: false,
certError: action.message
});
},
[actions.socket.CERT_FAIL](state, action) {
state.uploadingCert = false;
state.certError = action.message;
},
[actions.SET_CERT_ERROR](state, action) {
return state.merge({
uploadingCert: false,
certError: action.message
});
},
[actions.SET_CERT_ERROR](state, action) {
state.uploadingCert = false;
state.certError = action.message;
},
[actions.SET_CERT](state, action) {
return state.merge({
certFile: action.fileName,
cert: action.cert
});
},
[actions.SET_CERT](state, action) {
state.certFile = action.fileName;
state.cert = action.cert;
},
[actions.SET_KEY](state, action) {
return state.merge({
keyFile: action.fileName,
key: action.key
});
[actions.SET_KEY](state, action) {
state.keyFile = action.fileName;
state.key = action.key;
}
}
});
);
export function setCertError(message) {
return {
@ -53,14 +51,14 @@ export function setCertError(message) {
export function uploadCert() {
return (dispatch, getState) => {
const { settings } = getState();
if (settings.has('cert') && settings.has('key')) {
if (settings.cert && settings.key) {
dispatch({
type: actions.UPLOAD_CERT,
socket: {
type: 'cert',
data: {
cert: settings.get('cert'),
key: settings.get('key')
cert: settings.cert,
key: settings.key
}
}
});

View File

@ -1,77 +1,48 @@
import { Record, List } from 'immutable';
import createReducer from 'utils/createReducer';
import { push, replace, LOCATION_CHANGED } from 'utils/router';
import * as actions from './actions';
const TabRecord = Record({
server: null,
name: null
});
class Tab extends TabRecord {
isChannel() {
return this.name && this.name.charAt(0) === '#';
}
toString() {
let str = this.server;
if (this.name) {
str += `;${this.name}`;
}
return str;
}
}
const State = Record({
selected: new Tab(),
history: List()
});
const initialState = {
selected: {},
history: []
};
function selectTab(state, action) {
const tab = new Tab(action);
return state
.set('selected', tab)
.update('history', history => history.push(tab));
state.selected = {
server: action.server,
name: action.name
};
state.history.push(state.selected);
}
export const getSelectedTab = state => state.tab.selected;
export default createReducer(new State(), {
export default createReducer(initialState, {
[actions.SELECT_TAB]: selectTab,
[actions.PART](state, action) {
return state.set(
'history',
state.history.filter(
tab =>
!(tab.server === action.server && tab.name === action.channels[0])
)
state.history = state.history.filter(
tab => !(tab.server === action.server && tab.name === action.channels[0])
);
},
[actions.CLOSE_PRIVATE_CHAT](state, action) {
return state.set(
'history',
state.history.filter(
tab => !(tab.server === action.server && tab.name === action.nick)
)
state.history = state.history.filter(
tab => !(tab.server === action.server && tab.name === action.nick)
);
},
[actions.DISCONNECT](state, action) {
return state.set(
'history',
state.history.filter(tab => tab.server !== action.server)
);
state.history = state.history.filter(tab => tab.server !== action.server);
},
[LOCATION_CHANGED](state, action) {
const { route, params } = action;
if (route === 'chat') {
return selectTab(state, params);
selectTab(state, params);
} else {
state.selected = {};
}
return state.set('selected', new Tab());
}
});
@ -89,16 +60,17 @@ export function updateSelection() {
const { history } = state.tab;
const { servers } = state;
const { server } = state.tab.selected;
const serverAddrs = Object.keys(servers);
if (servers.size === 0) {
if (serverAddrs.length === 0) {
dispatch(replace('/connect'));
} else if (history.size > 0) {
const tab = history.last();
} else if (history.length > 0) {
const tab = history[history.length - 1];
dispatch(select(tab.server, tab.name, true));
} else if (servers.has(server)) {
} else if (servers[server]) {
dispatch(select(server, null, true));
} else {
dispatch(select(servers.keySeq().first(), null, true));
dispatch(select(serverAddrs.sort()[0], null, true));
}
};
}

View File

@ -1,30 +1,29 @@
import { Record } from 'immutable';
import createReducer from 'utils/createReducer';
import { LOCATION_CHANGED } from 'utils/router';
import * as actions from './actions';
const State = Record({
const initialState = {
showTabList: false,
showUserList: false
});
};
export const getShowTabList = state => state.ui.showTabList;
export const getShowUserList = state => state.ui.showUserList;
function setMenuHidden(state) {
return state.set('showTabList', false);
state.showTabList = false;
}
export default createReducer(new State(), {
export default createReducer(initialState, {
[actions.TOGGLE_MENU](state) {
return state.update('showTabList', show => !show);
state.showTabList = !state.showTabList;
},
[actions.HIDE_MENU]: setMenuHidden,
[LOCATION_CHANGED]: setMenuHidden,
[actions.TOGGLE_USERLIST](state) {
return state.update('showUserList', show => !show);
state.showUserList = !state.showUserList;
}
});

View File

@ -1,6 +1,19 @@
import React from 'react';
import { isChannel } from '..';
import linkify from '../linkify';
describe('isChannel()', () => {
it('it handles strings', () => {
expect(isChannel('#cake')).toBe(true);
expect(isChannel('cake')).toBe(false);
});
it('handles tab objects', () => {
expect(isChannel({ name: '#cake' })).toBe(true);
expect(isChannel({ name: 'cake' })).toBe(false);
});
});
describe('linkify()', () => {
const proto = href => (href.indexOf('http') !== 0 ? `http://${href}` : href);
const linkTo = href => (

View File

@ -1,7 +1,10 @@
import produce from 'immer';
import has from 'lodash/has';
export default function createReducer(initialState, handlers) {
return function reducer(state = initialState, action) {
if (Object.prototype.hasOwnProperty.call(handlers, action.type)) {
return handlers[action.type](state, action);
if (has(handlers, action.type)) {
return produce(state, draft => handlers[action.type](draft, action));
}
return state;
};

View File

@ -16,9 +16,25 @@ export function normalizeChannel(channel) {
export function isChannel(name) {
// TODO: Handle other channel types
if (typeof name === 'object') {
({ name } = name);
}
return typeof name === 'string' && name[0] === '#';
}
export function stringifyTab(server, name) {
if (typeof server === 'object') {
if (server.name) {
return `${server.server};${server.name}`;
}
return server.server;
}
if (name) {
return `${server};${name}`;
}
return server;
}
export function timestamp(date = new Date()) {
const h = padStart(date.getHours(), 2, '0');
const m = padStart(date.getMinutes(), 2, '0');
@ -54,14 +70,24 @@ export function measureScrollBarWidth() {
return widthNoScroll - widthWithScroll;
}
export function find(arr, pred) {
export function findIndex(arr, pred) {
if (!arr) {
return null;
return -1;
}
for (let i = 0; i < arr.length; i++) {
if (pred(arr[i])) {
return arr[i];
return i;
}
}
return -1;
}
export function find(arr, pred) {
const i = findIndex(arr, pred);
if (i !== -1) {
return arr[i];
}
return null;
}

File diff suppressed because it is too large Load Diff