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-react",
"@babel/preset-stage-0" [
"@babel/preset-stage-0",
{
"decoratorsLegacy": true
}
]
], ],
"env": { "env": {
"development": { "development": {

View File

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

View File

@ -79,13 +79,13 @@ export default createCommandMiddleware(COMMAND, {
topic({ dispatch, getState, server, channel }, ...newTopic) { topic({ dispatch, getState, server, channel }, ...newTopic) {
if (newTopic.length > 0) { if (newTopic.length > 0) {
dispatch(setTopic(newTopic.join(' '), channel, server)); dispatch(setTopic(newTopic.join(' '), channel, server));
} else { } else if (channel) {
const topic = getState().channels.getIn([server, channel, 'topic']); const { topic } = getState().channels[server][channel];
if (topic) { if (topic) {
return text(topic); return text(topic);
} }
return 'No topic set';
} }
return 'No topic set';
}, },
msg({ dispatch, server }, target, ...message) { 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 className = showTabList ? 'tablist off-canvas' : 'tablist';
const tabs = []; const tabs = [];
channels.forEach((server, address) => { channels.forEach(server => {
const srv = servers.get(address); const { address } = server;
const srv = servers[address];
tabs.push( tabs.push(
<TabListItem <TabListItem
key={address} key={address}
server={address} server={address}
content={srv.name} content={srv.name}
selected={tab.server === address && tab.name === null} selected={tab.server === address && !tab.name}
connected={srv.status.connected} connected={srv.status.connected}
onClick={this.handleTabClick} onClick={this.handleTabClick}
/> />
); );
server.forEach((channel, name) => server.channels.forEach(name =>
tabs.push( tabs.push(
<TabListItem <TabListItem
key={address + name} 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( tabs.push(
<div key={`${address}-pm}`} className="tab-label"> <div key={`${address}-pm}`} className="tab-label">
Private messages Private messages
</div> </div>
); );
privateChats privateChats[address].forEach(nick =>
.get(address) tabs.push(
.forEach(nick => <TabListItem
tabs.push( key={address + nick}
<TabListItem server={address}
key={address + nick} target={nick}
server={address} content={nick}
target={nick} selected={tab.server === address && tab.name === nick}
content={nick} onClick={this.handleTabClick}
selected={tab.server === address && tab.name === nick} />
onClick={this.handleTabClick} )
/> );
)
);
} }
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,10 @@
import Immutable from 'immutable'; import reducer, { compareUsers, getSortedChannels } from '../channels';
import reducer, { compareUsers } from '../channels';
import { connect } from '../servers'; import { connect } from '../servers';
import * as actions from '../actions'; import * as actions from '../actions';
describe('channel reducer', () => { describe('channel reducer', () => {
it('removes channels on PART', () => { it('removes channels on PART', () => {
let state = Immutable.fromJS({ let state = {
srv1: { srv1: {
chan1: {}, chan1: {},
chan2: {}, chan2: {},
@ -14,7 +13,7 @@ describe('channel reducer', () => {
srv2: { srv2: {
chan1: {} chan1: {}
} }
}); };
state = reducer(state, { state = reducer(state, {
type: actions.PART, type: actions.PART,
@ -22,7 +21,7 @@ describe('channel reducer', () => {
channels: ['chan1', 'chan3'] channels: ['chan1', 'chan3']
}); });
expect(state.toJS()).toEqual({ expect(state).toEqual({
srv1: { srv1: {
chan2: {} chan2: {}
}, },
@ -44,7 +43,7 @@ describe('channel reducer', () => {
user: 'nick2' user: 'nick2'
}); });
expect(state.toJS()).toEqual({ expect(state).toEqual({
srv: { srv: {
chan1: { chan1: {
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }] users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
@ -59,7 +58,7 @@ describe('channel reducer', () => {
it('handles SOCKET_JOIN', () => { it('handles SOCKET_JOIN', () => {
const state = reducer(undefined, socket_join('srv', 'chan1', 'nick1')); const state = reducer(undefined, socket_join('srv', 'chan1', 'nick1'));
expect(state.toJS()).toEqual({ expect(state).toEqual({
srv: { srv: {
chan1: { chan1: {
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }] users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
@ -79,7 +78,7 @@ describe('channel reducer', () => {
user: 'nick2' user: 'nick2'
}); });
expect(state.toJS()).toEqual({ expect(state).toEqual({
srv: { srv: {
chan1: { chan1: {
users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }] users: [{ mode: '', nick: 'nick1', renderName: 'nick1' }]
@ -103,7 +102,7 @@ describe('channel reducer', () => {
newNick: 'nick3' newNick: 'nick3'
}); });
expect(state.toJS()).toEqual({ expect(state).toEqual({
srv: { srv: {
chan1: { chan1: {
users: [ users: [
@ -126,7 +125,7 @@ describe('channel reducer', () => {
users: ['user3', 'user2', '@user4', 'user1', '+user5'] users: ['user3', 'user2', '@user4', 'user1', '+user5']
}); });
expect(state.toJS()).toEqual({ expect(state).toEqual({
srv: { srv: {
chan1: { chan1: {
users: [ users: [
@ -149,10 +148,11 @@ describe('channel reducer', () => {
topic: 'the topic' topic: 'the topic'
}); });
expect(state.toJS()).toEqual({ expect(state).toEqual({
srv: { srv: {
chan1: { chan1: {
topic: 'the topic' topic: 'the topic',
users: []
} }
} }
}); });
@ -165,7 +165,7 @@ describe('channel reducer', () => {
state = reducer(state, socket_mode('srv', 'chan1', 'nick1', 'o', '')); state = reducer(state, socket_mode('srv', 'chan1', 'nick1', 'o', ''));
expect(state.toJS()).toEqual({ expect(state).toEqual({
srv: { srv: {
chan1: { chan1: {
users: [ users: [
@ -183,7 +183,7 @@ describe('channel reducer', () => {
state = reducer(state, socket_mode('srv', 'chan1', 'nick2', 'o', '')); state = reducer(state, socket_mode('srv', 'chan1', 'nick2', 'o', ''));
state = reducer(state, socket_mode('srv', 'chan2', 'not_there', 'x', '')); state = reducer(state, socket_mode('srv', 'chan2', 'not_there', 'x', ''));
expect(state.toJS()).toEqual({ expect(state).toEqual({
srv: { srv: {
chan1: { chan1: {
users: [ users: [
@ -208,7 +208,7 @@ describe('channel reducer', () => {
] ]
}); });
expect(state.toJS()).toEqual({ expect(state).toEqual({
srv: { srv: {
chan1: { topic: 'the topic', users: [] }, chan1: { topic: 'the topic', users: [] },
chan2: { users: [] } chan2: { users: [] }
@ -225,7 +225,7 @@ describe('channel reducer', () => {
data: [{ host: '127.0.0.1' }, { host: 'thehost' }] data: [{ host: '127.0.0.1' }, { host: 'thehost' }]
}); });
expect(state.toJS()).toEqual({ expect(state).toEqual({
'127.0.0.1': {}, '127.0.0.1': {},
thehost: {} thehost: {}
}); });
@ -234,23 +234,23 @@ describe('channel reducer', () => {
it('optimistically adds the server on CONNECT', () => { it('optimistically adds the server on CONNECT', () => {
const state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {})); const state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
expect(state.toJS()).toEqual({ expect(state).toEqual({
'127.0.0.1': {} '127.0.0.1': {}
}); });
}); });
it('removes the server on DISCONNECT', () => { it('removes the server on DISCONNECT', () => {
let state = Immutable.fromJS({ let state = {
srv: {}, srv: {},
srv2: {} srv2: {}
}); };
state = reducer(state, { state = reducer(state, {
type: actions.DISCONNECT, type: actions.DISCONNECT,
server: 'srv2' server: 'srv2'
}); });
expect(state.toJS()).toEqual({ expect(state).toEqual({
srv: {} 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 reducer, { broadcast, getMessageTab } from '../messages';
import * as actions from '../actions'; import * as actions from '../actions';
import appReducer from '../app'; import appReducer from '../app';
@ -15,7 +14,7 @@ describe('message reducer', () => {
} }
}); });
expect(state.toJS()).toMatchObject({ expect(state).toMatchObject({
srv: { srv: {
'#chan1': [ '#chan1': [
{ {
@ -49,7 +48,7 @@ describe('message reducer', () => {
] ]
}); });
expect(state.toJS()).toMatchObject({ expect(state).toMatchObject({
srv: { srv: {
'#chan1': [ '#chan1': [
{ {
@ -72,11 +71,11 @@ describe('message reducer', () => {
}); });
it('handles prepending of messages on ADD_MESSAGES', () => { it('handles prepending of messages on ADD_MESSAGES', () => {
let state = fromJS({ let state = {
srv: { srv: {
'#chan1': [{ id: 0 }] '#chan1': [{ id: 0 }]
} }
}); };
state = reducer(state, { state = reducer(state, {
type: actions.ADD_MESSAGES, type: actions.ADD_MESSAGES,
@ -86,7 +85,7 @@ describe('message reducer', () => {
messages: [{ id: 1 }, { id: 2 }] messages: [{ id: 1 }, { id: 2 }]
}); });
expect(state.toJS()).toMatchObject({ expect(state).toMatchObject({
srv: { srv: {
'#chan1': [{ id: 1 }, { id: 2 }, { id: 0 }] '#chan1': [{ id: 1 }, { id: 2 }, { id: 0 }]
} }
@ -103,7 +102,7 @@ describe('message reducer', () => {
state.messages = reducer(undefined, action); state.messages = reducer(undefined, action);
}, () => state); }, () => state);
const messages = state.messages.toJS(); const messages = state.messages;
expect(messages.srv).not.toHaveProperty('srv'); expect(messages.srv).not.toHaveProperty('srv');
expect(messages.srv['#chan1']).toHaveLength(1); expect(messages.srv['#chan1']).toHaveLength(1);
@ -113,7 +112,7 @@ describe('message reducer', () => {
}); });
it('deletes all messages related to server when disconnecting', () => { it('deletes all messages related to server when disconnecting', () => {
let state = fromJS({ let state = {
srv: { srv: {
'#chan1': [{ content: 'msg1' }, { content: 'msg2' }], '#chan1': [{ content: 'msg1' }, { content: 'msg2' }],
'#chan2': [{ content: 'msg' }] '#chan2': [{ content: 'msg' }]
@ -121,14 +120,14 @@ describe('message reducer', () => {
srv2: { srv2: {
'#chan1': [{ content: 'msg' }] '#chan1': [{ content: 'msg' }]
} }
}); };
state = reducer(state, { state = reducer(state, {
type: actions.DISCONNECT, type: actions.DISCONNECT,
server: 'srv' server: 'srv'
}); });
expect(state.toJS()).toEqual({ expect(state).toEqual({
srv2: { srv2: {
'#chan1': [{ content: 'msg' }] '#chan1': [{ content: 'msg' }]
} }
@ -136,7 +135,7 @@ describe('message reducer', () => {
}); });
it('deletes all messages related to channel when parting', () => { it('deletes all messages related to channel when parting', () => {
let state = fromJS({ let state = {
srv: { srv: {
'#chan1': [{ content: 'msg1' }, { content: 'msg2' }], '#chan1': [{ content: 'msg1' }, { content: 'msg2' }],
'#chan2': [{ content: 'msg' }] '#chan2': [{ content: 'msg' }]
@ -144,7 +143,7 @@ describe('message reducer', () => {
srv2: { srv2: {
'#chan1': [{ content: 'msg' }] '#chan1': [{ content: 'msg' }]
} }
}); };
state = reducer(state, { state = reducer(state, {
type: actions.PART, type: actions.PART,
@ -152,7 +151,7 @@ describe('message reducer', () => {
channels: ['#chan1'] channels: ['#chan1']
}); });
expect(state.toJS()).toEqual({ expect(state).toEqual({
srv: { srv: {
'#chan2': [{ content: 'msg' }] '#chan2': [{ content: 'msg' }]
}, },

View File

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

View File

@ -6,14 +6,14 @@ describe('tab reducer', () => {
it('selects the tab and adds it to history', () => { it('selects the tab and adds it to history', () => {
let state = reducer(undefined, setSelectedTab('srv', '#chan')); let state = reducer(undefined, setSelectedTab('srv', '#chan'));
expect(state.toJS()).toEqual({ expect(state).toEqual({
selected: { server: 'srv', name: '#chan' }, selected: { server: 'srv', name: '#chan' },
history: [{ server: 'srv', name: '#chan' }] history: [{ server: 'srv', name: '#chan' }]
}); });
state = reducer(state, setSelectedTab('srv', 'user1')); state = reducer(state, setSelectedTab('srv', 'user1'));
expect(state.toJS()).toEqual({ expect(state).toEqual({
selected: { server: 'srv', name: 'user1' }, selected: { server: 'srv', name: 'user1' },
history: [ history: [
{ server: 'srv', name: '#chan' }, { server: 'srv', name: '#chan' },
@ -34,7 +34,7 @@ describe('tab reducer', () => {
channels: ['#chan'] channels: ['#chan']
}); });
expect(state.toJS()).toEqual({ expect(state).toEqual({
selected: { server: 'srv', name: '#chan3' }, selected: { server: 'srv', name: '#chan3' },
history: [ history: [
{ server: 'srv1', name: 'bob' }, { server: 'srv1', name: 'bob' },
@ -55,7 +55,7 @@ describe('tab reducer', () => {
nick: 'bob' nick: 'bob'
}); });
expect(state.toJS()).toEqual({ expect(state).toEqual({
selected: { server: 'srv', name: '#chan3' }, selected: { server: 'srv', name: '#chan3' },
history: [ history: [
{ server: 'srv', name: '#chan' }, { server: 'srv', name: '#chan' },
@ -76,7 +76,7 @@ describe('tab reducer', () => {
server: 'srv' server: 'srv'
}); });
expect(state.toJS()).toEqual({ expect(state).toEqual({
selected: { server: 'srv', name: '#chan3' }, selected: { server: 'srv', name: '#chan3' },
history: [{ server: 'srv1', name: 'bob' }] history: [{ server: 'srv1', name: 'bob' }]
}); });
@ -87,8 +87,8 @@ describe('tab reducer', () => {
state = reducer(state, locationChanged('settings')); state = reducer(state, locationChanged('settings'));
expect(state.toJS()).toEqual({ expect(state).toEqual({
selected: { server: null, name: null }, selected: {},
history: [{ server: 'srv', name: '#chan' }] history: [{ server: 'srv', name: '#chan' }]
}); });
}); });
@ -102,7 +102,7 @@ describe('tab reducer', () => {
}) })
); );
expect(state.toJS()).toEqual({ expect(state).toEqual({
selected: { server: 'srv', name: '#chan' }, selected: { server: 'srv', name: '#chan' },
history: [{ 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 createReducer from 'utils/createReducer';
import * as actions from './actions'; 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 getWindowWidth = state => state.app.windowWidth;
export const getConnectDefaults = state => state.app.connectDefaults; export const getConnectDefaults = state => state.app.connectDefaults;
const ConnectDefaults = Record({ const initialState = {
name: '',
address: '',
channels: [],
ssl: false,
password: false,
readonly: false,
showDetails: false
});
const App = Record({
connected: true, connected: true,
wrapWidth: 0, wrapWidth: 0,
charWidth: 0, charWidth: 0,
windowWidth: 0, windowWidth: 0,
connectDefaults: new ConnectDefaults() connectDefaults: {
}); name: '',
address: '',
channels: [],
ssl: false,
password: false,
readonly: false,
showDetails: false
}
};
export default createReducer(new App(), { export default createReducer(initialState, {
[actions.APP_SET](state, action) { [actions.APP_SET](state, { key, value }) {
return state.set(action.key, action.value); state[key] = value;
}, },
[actions.UPDATE_MESSAGE_HEIGHT](state, action) { [actions.UPDATE_MESSAGE_HEIGHT](state, action) {
return state state.wrapWidth = action.wrapWidth;
.set('wrapWidth', action.wrapWidth) state.charWidth = action.charWidth;
.set('charWidth', action.charWidth) state.windowWidth = action.windowWidth;
.set('windowWidth', action.windowWidth);
} }
}); });
@ -57,5 +53,5 @@ export function setCharWidth(width) {
} }
export function setConnectDefaults(defaults) { 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 { createSelector } from 'reselect';
import get from 'lodash/get';
import sortBy from 'lodash/sortBy';
import createReducer from 'utils/createReducer'; import createReducer from 'utils/createReducer';
import { find, findIndex } from 'utils';
import { getSelectedTab, updateSelection } from './tab'; import { getSelectedTab, updateSelection } from './tab';
import * as actions from './actions'; import * as actions from './actions';
const User = Record({
nick: null,
renderName: null,
mode: ''
});
const modePrefixes = [ const modePrefixes = [
{ mode: 'q', prefix: '~' }, // Owner { mode: 'q', prefix: '~' }, // Owner
{ mode: 'a', prefix: '&' }, // Admin { mode: 'a', prefix: '&' }, // Admin
@ -18,24 +14,23 @@ const modePrefixes = [
{ mode: 'v', prefix: '+' } // Voice { mode: 'v', prefix: '+' } // Voice
]; ];
function updateRenderName(user) { function getRenderName(user) {
for (let i = 0; i < modePrefixes.length; i++) { for (let i = 0; i < modePrefixes.length; i++) {
if (user.mode.indexOf(modePrefixes[i].mode) !== -1) { 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) { function createUser(nick, mode) {
return updateRenderName( const user = {
new User({ nick,
nick, mode: mode || ''
renderName: nick, };
mode: mode || '' user.renderName = getRenderName(user);
}) return user;
);
} }
function loadUser(nick) { function loadUser(nick) {
@ -54,6 +49,22 @@ function loadUser(nick) {
return createUser(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) { export function compareUsers(a, b) {
a = a.renderName.toLowerCase(); a = a.renderName.toLowerCase();
b = b.renderName.toLowerCase(); b = b.renderName.toLowerCase();
@ -80,152 +91,119 @@ export function compareUsers(a, b) {
export const getChannels = state => state.channels; export const getChannels = state => state.channels;
const key = (v, k) => k.toLowerCase();
export const getSortedChannels = createSelector(getChannels, channels => export const getSortedChannels = createSelector(getChannels, channels =>
channels sortBy(
.withMutations(c => Object.keys(channels).map(server => ({
c.forEach((server, address) => address: server,
c.update(address, chans => chans.sortBy(key)) channels: sortBy(Object.keys(channels[server]), channel =>
channel.toLowerCase()
) )
) })),
.sortBy(key) server => server.address.toLowerCase()
)
); );
export const getSelectedChannel = createSelector( export const getSelectedChannel = createSelector(
getSelectedTab, getSelectedTab,
getChannels, getChannels,
(tab, channels) => channels.getIn([tab.server, tab.name], Map()) (tab, channels) => get(channels, [tab.server, tab.name])
); );
export const getSelectedChannelUsers = createSelector( export const getSelectedChannelUsers = createSelector(
getSelectedChannel, getSelectedChannel,
channel => channel.get('users', List()).sort(compareUsers) channel => {
if (channel) {
return channel.users.concat().sort(compareUsers);
}
return [];
}
); );
export default createReducer(Map(), { export default createReducer(
[actions.PART](state, { server, channels }) { {},
return state.withMutations(s => { {
channels.forEach(channel => s.deleteIn([server, channel])); [actions.PART](state, { server, channels }) {
}); channels.forEach(channel => delete state[server][channel]);
}, },
[actions.socket.JOIN](state, { server, channels, user }) { [actions.socket.JOIN](state, { server, channels, user }) {
return state.updateIn([server, channels[0], 'users'], List(), users => const channel = channels[0];
users.push(createUser(user)) init(state, server, channel);
); state[server][channel].users.push(createUser(user));
}, },
[actions.socket.PART](state, { server, channel, user }) { [actions.socket.PART](state, { server, channel, user }) {
if (state.hasIn([server, channel])) { if (state[server][channel]) {
return state.updateIn([server, channel, 'users'], users => removeUser(state[server][channel].users, user);
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;
} }
},
return users.update(i, u => { [actions.socket.QUIT](state, { server, user }) {
let { mode } = u; 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; let j = remove.length;
while (j--) { 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 }) { [actions.socket.CHANNELS](state, { data }) {
if (!data) { if (data) {
return state; 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) { export function join(channels, server) {
return { return {

View File

@ -1,54 +1,45 @@
import { List, Record } from 'immutable';
import createReducer from 'utils/createReducer'; import createReducer from 'utils/createReducer';
import * as actions from './actions'; import * as actions from './actions';
const HISTORY_MAX_LENGTH = 128; const HISTORY_MAX_LENGTH = 128;
const State = Record({ const initialState = {
history: List(), history: [],
index: 0 index: 0
}); };
export const getCurrentInputHistoryEntry = state => { export const getCurrentInputHistoryEntry = state => {
if (state.input.index === -1) { if (state.input.index === -1) {
return null; return null;
} }
return state.input.history.get(state.input.index); return state.input.history[state.input.index];
}; };
export default createReducer(new State(), { export default createReducer(initialState, {
[actions.INPUT_HISTORY_ADD](state, action) { [actions.INPUT_HISTORY_ADD](state, { line }) {
const { line } = action; if (line.trim() && line !== state.history[0]) {
if (line.trim() && line !== state.history.get(0)) {
if (history.length === HISTORY_MAX_LENGTH) { if (history.length === HISTORY_MAX_LENGTH) {
return state.set('history', state.history.unshift(line).pop()); state.history.pop();
} }
state.history.unshift(line);
return state.set('history', state.history.unshift(line));
} }
return state;
}, },
[actions.INPUT_HISTORY_RESET](state) { [actions.INPUT_HISTORY_RESET](state) {
return state.set('index', -1); state.index = -1;
}, },
[actions.INPUT_HISTORY_INCREMENT](state) { [actions.INPUT_HISTORY_INCREMENT](state) {
if (state.index < state.history.size - 1) { if (state.index < state.history.length - 1) {
return state.set('index', state.index + 1); state.index++;
} }
return state;
}, },
[actions.INPUT_HISTORY_DECREMENT](state) { [actions.INPUT_HISTORY_DECREMENT](state) {
if (state.index >= 0) { 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 { createSelector } from 'reselect';
import has from 'lodash/has';
import { import {
findBreakpoints, findBreakpoints,
messageHeight, messageHeight,
@ -12,95 +12,85 @@ import { getApp } from './app';
import { getSelectedTab } from './tab'; import { getSelectedTab } from './tab';
import * as actions from './actions'; 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 getMessages = state => state.messages;
export const getSelectedMessages = createSelector( export const getSelectedMessages = createSelector(
getSelectedTab, getSelectedTab,
getMessages, getMessages,
(tab, messages) => (tab, messages) => {
messages.getIn([tab.server, tab.name || tab.server], List()) const target = tab.name || tab.server;
if (has(messages, [tab.server, target])) {
return messages[tab.server][target];
}
return [];
}
); );
export const getHasMoreMessages = createSelector( export const getHasMoreMessages = createSelector(
getSelectedMessages, getSelectedMessages,
messages => { messages => {
const first = messages.get(0); const first = messages[0];
return first && first.next; return first && first.next;
} }
); );
export default createReducer(Map(), { function init(state, server, tab) {
[actions.ADD_MESSAGE](state, { server, tab, message }) { if (!state[server]) {
return state.updateIn([server, tab], List(), list => state[server] = {};
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
)
)
)
)
)
);
} }
}); 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; let nextID = 0;
@ -156,14 +146,14 @@ export function getMessageTab(server, to) {
export function fetchMessages() { export function fetchMessages() {
return (dispatch, getState) => { return (dispatch, getState) => {
const state = getState(); const state = getState();
const first = getSelectedMessages(state).get(0); const first = getSelectedMessages(state)[0];
if (!first) { if (!first) {
return; return;
} }
const tab = state.tab.selected; const tab = state.tab.selected;
if (tab.isChannel()) { if (isChannel(tab)) {
dispatch({ dispatch({
type: actions.FETCH_MESSAGES, type: actions.FETCH_MESSAGES,
socket: { socket: {
@ -206,7 +196,7 @@ export function sendMessage(content, to, server) {
tab: to, tab: to,
message: initMessage( message: initMessage(
{ {
from: state.servers.getIn([server, 'nick']), from: state.servers[server].nick,
content content
}, },
to, to,

View File

@ -1,48 +1,46 @@
import { Set, Map } from 'immutable'; import sortBy from 'lodash/sortBy';
import { createSelector } from 'reselect'; import { findIndex } from 'utils';
import createReducer from 'utils/createReducer'; import createReducer from 'utils/createReducer';
import { updateSelection } from './tab'; import { updateSelection } from './tab';
import * as actions from './actions'; import * as actions from './actions';
export const getPrivateChats = state => state.privateChats; 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) { 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(), { export default createReducer(
[actions.OPEN_PRIVATE_CHAT](state, action) { {},
return open(state, action.server, action.nick); {
}, [actions.OPEN_PRIVATE_CHAT](state, action) {
open(state, action.server, action.nick);
},
[actions.CLOSE_PRIVATE_CHAT](state, action) { [actions.CLOSE_PRIVATE_CHAT](state, { server, nick }) {
return state.update(action.server, chats => chats.delete(action.nick)); const i = findIndex(state[server], n => n === nick);
}, if (i !== -1) {
state[server].splice(i, 1);
}
},
[actions.socket.PM](state, action) { [actions.socket.PM](state, action) {
if (action.from.indexOf('.') === -1) { if (action.from.indexOf('.') === -1) {
return open(state, action.server, action.from); 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) { export function openPrivateChat(server, nick) {
return { return {

View File

@ -1,21 +1,20 @@
import { List, Record } from 'immutable';
import createReducer from 'utils/createReducer'; import createReducer from 'utils/createReducer';
import * as actions from './actions'; import * as actions from './actions';
const State = Record({ const initialState = {
show: false, show: false,
results: List() results: []
}); };
export const getSearch = state => state.search; export const getSearch = state => state.search;
export default createReducer(new State(), { export default createReducer(initialState, {
[actions.socket.SEARCH](state, action) { [actions.socket.SEARCH](state, { results }) {
return state.set('results', List(action.results)); state.results = results;
}, },
[actions.TOGGLE_SEARCH](state) { [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 { createSelector } from 'reselect';
import get from 'lodash/get';
import { getServers } from './servers'; import { getServers } from './servers';
import { getSelectedTab } from './tab'; import { getSelectedTab } from './tab';
@ -6,5 +7,5 @@ import { getSelectedTab } from './tab';
export const getSelectedTabTitle = createSelector( export const getSelectedTabTitle = createSelector(
getSelectedTab, getSelectedTab,
getServers, 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 { createSelector } from 'reselect';
import get from 'lodash/get';
import createReducer from 'utils/createReducer'; import createReducer from 'utils/createReducer';
import { getSelectedTab, updateSelection } from './tab'; import { getSelectedTab, updateSelection } from './tab';
import * as actions from './actions'; 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 getServers = state => state.servers;
export const getCurrentNick = createSelector( export const getCurrentNick = createSelector(
getServers, getServers,
getSelectedTab, getSelectedTab,
(servers, tab) => { (servers, tab) => {
const editedNick = servers.getIn([tab.server, 'editedNick']); if (!servers[tab.server]) {
if (editedNick === null) { return;
return servers.getIn([tab.server, 'nick']); }
const { editedNick } = servers[tab.server];
if (!editedNick) {
return servers[tab.server].nick;
} }
return editedNick; return editedNick;
} }
@ -33,80 +24,77 @@ export const getCurrentNick = createSelector(
export const getCurrentServerName = createSelector( export const getCurrentServerName = createSelector(
getServers, getServers,
getSelectedTab, getSelectedTab,
(servers, tab) => servers.getIn([tab.server, 'name']) (servers, tab) => get(servers, [tab.server, 'name'])
); );
export const getCurrentServerStatus = createSelector( export const getCurrentServerStatus = createSelector(
getServers, getServers,
getSelectedTab, getSelectedTab,
(servers, tab) => servers.getIn([tab.server, 'status']) (servers, tab) => get(servers, [tab.server, 'status'], {})
); );
export default createReducer(Map(), { export default createReducer(
[actions.CONNECT](state, { host, nick, options }) { {},
if (!state.has(host)) { {
return state.set( [actions.CONNECT](state, { host, nick, options }) {
host, if (!state[host]) {
new Server({ state[host] = {
nick, 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;
} },
return state.withMutations(s => { [actions.DISCONNECT](state, { server }) {
data.forEach(server => { delete state[server];
server.status = new Status(server.status); },
s.set(server.host, new Server(server));
});
});
},
[actions.socket.CONNECTION_UPDATE](state, action) { [actions.SET_SERVER_NAME](state, { server, name }) {
if (state.has(action.server)) { state[server].name = name;
return state.setIn([action.server, 'status'], new Status(action)); },
[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) { export function connect(server, nick, options) {
let host = server; let host = server;

View File

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

View File

@ -1,77 +1,48 @@
import { Record, List } from 'immutable';
import createReducer from 'utils/createReducer'; import createReducer from 'utils/createReducer';
import { push, replace, LOCATION_CHANGED } from 'utils/router'; import { push, replace, LOCATION_CHANGED } from 'utils/router';
import * as actions from './actions'; import * as actions from './actions';
const TabRecord = Record({ const initialState = {
server: null, selected: {},
name: null history: []
}); };
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()
});
function selectTab(state, action) { function selectTab(state, action) {
const tab = new Tab(action); state.selected = {
return state server: action.server,
.set('selected', tab) name: action.name
.update('history', history => history.push(tab)); };
state.history.push(state.selected);
} }
export const getSelectedTab = state => state.tab.selected; export const getSelectedTab = state => state.tab.selected;
export default createReducer(new State(), { export default createReducer(initialState, {
[actions.SELECT_TAB]: selectTab, [actions.SELECT_TAB]: selectTab,
[actions.PART](state, action) { [actions.PART](state, action) {
return state.set( state.history = state.history.filter(
'history', tab => !(tab.server === action.server && tab.name === action.channels[0])
state.history.filter(
tab =>
!(tab.server === action.server && tab.name === action.channels[0])
)
); );
}, },
[actions.CLOSE_PRIVATE_CHAT](state, action) { [actions.CLOSE_PRIVATE_CHAT](state, action) {
return state.set( state.history = state.history.filter(
'history', tab => !(tab.server === action.server && tab.name === action.nick)
state.history.filter(
tab => !(tab.server === action.server && tab.name === action.nick)
)
); );
}, },
[actions.DISCONNECT](state, action) { [actions.DISCONNECT](state, action) {
return state.set( state.history = state.history.filter(tab => tab.server !== action.server);
'history',
state.history.filter(tab => tab.server !== action.server)
);
}, },
[LOCATION_CHANGED](state, action) { [LOCATION_CHANGED](state, action) {
const { route, params } = action; const { route, params } = action;
if (route === 'chat') { 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 { history } = state.tab;
const { servers } = state; const { servers } = state;
const { server } = state.tab.selected; const { server } = state.tab.selected;
const serverAddrs = Object.keys(servers);
if (servers.size === 0) { if (serverAddrs.length === 0) {
dispatch(replace('/connect')); dispatch(replace('/connect'));
} else if (history.size > 0) { } else if (history.length > 0) {
const tab = history.last(); const tab = history[history.length - 1];
dispatch(select(tab.server, tab.name, true)); dispatch(select(tab.server, tab.name, true));
} else if (servers.has(server)) { } else if (servers[server]) {
dispatch(select(server, null, true)); dispatch(select(server, null, true));
} else { } 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 createReducer from 'utils/createReducer';
import { LOCATION_CHANGED } from 'utils/router'; import { LOCATION_CHANGED } from 'utils/router';
import * as actions from './actions'; import * as actions from './actions';
const State = Record({ const initialState = {
showTabList: false, showTabList: false,
showUserList: false showUserList: false
}); };
export const getShowTabList = state => state.ui.showTabList; export const getShowTabList = state => state.ui.showTabList;
export const getShowUserList = state => state.ui.showUserList; export const getShowUserList = state => state.ui.showUserList;
function setMenuHidden(state) { function setMenuHidden(state) {
return state.set('showTabList', false); state.showTabList = false;
} }
export default createReducer(new State(), { export default createReducer(initialState, {
[actions.TOGGLE_MENU](state) { [actions.TOGGLE_MENU](state) {
return state.update('showTabList', show => !show); state.showTabList = !state.showTabList;
}, },
[actions.HIDE_MENU]: setMenuHidden, [actions.HIDE_MENU]: setMenuHidden,
[LOCATION_CHANGED]: setMenuHidden, [LOCATION_CHANGED]: setMenuHidden,
[actions.TOGGLE_USERLIST](state) { [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 React from 'react';
import { isChannel } from '..';
import linkify from '../linkify'; 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()', () => { describe('linkify()', () => {
const proto = href => (href.indexOf('http') !== 0 ? `http://${href}` : href); const proto = href => (href.indexOf('http') !== 0 ? `http://${href}` : href);
const linkTo = 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) { export default function createReducer(initialState, handlers) {
return function reducer(state = initialState, action) { return function reducer(state = initialState, action) {
if (Object.prototype.hasOwnProperty.call(handlers, action.type)) { if (has(handlers, action.type)) {
return handlers[action.type](state, action); return produce(state, draft => handlers[action.type](draft, action));
} }
return state; return state;
}; };

View File

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