Colocate reducers, actions and selectors

This commit is contained in:
Ken-Håvard Lieng 2017-05-26 08:20:00 +02:00
parent 1e7d4c3fe4
commit 889e3b88b7
53 changed files with 1031 additions and 914 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,55 +0,0 @@
import * as actions from '../actions';
import { updateSelection } from './tab';
export function join(channels, server) {
return {
type: actions.JOIN,
channels,
server,
socket: {
type: 'join',
data: { channels, server }
}
};
}
export function part(channels, server) {
return dispatch => {
dispatch({
type: actions.PART,
channels,
server,
socket: {
type: 'part',
data: { channels, server }
}
});
dispatch(updateSelection());
};
}
export function invite(user, channel, server) {
return {
type: actions.INVITE,
user,
channel,
server,
socket: {
type: 'invite',
data: { user, channel, server }
}
};
}
export function kick(user, channel, server) {
return {
type: actions.KICK,
user,
channel,
server,
socket: {
type: 'kick',
data: { user, channel, server }
}
};
}

View File

@ -1,17 +0,0 @@
import * as actions from '../actions';
export function setEnvironment(key, value) {
return {
type: actions.SET_ENVIRONMENT,
key,
value
};
}
export function setWrapWidth(width) {
return setEnvironment('wrapWidth', width);
}
export function setCharWidth(width) {
return setEnvironment('charWidth', width);
}

View File

@ -1,26 +0,0 @@
import * as actions from '../actions';
export function addInputHistory(line) {
return {
type: actions.INPUT_HISTORY_ADD,
line
};
}
export function resetInputHistory() {
return {
type: actions.INPUT_HISTORY_RESET
};
}
export function incrementInputHistory() {
return {
type: actions.INPUT_HISTORY_INCREMENT
};
}
export function decrementInputHistory() {
return {
type: actions.INPUT_HISTORY_DECREMENT
};
}

View File

@ -1,21 +0,0 @@
import * as actions from '../actions';
import { updateSelection } from './tab';
export function openPrivateChat(server, nick) {
return {
type: actions.OPEN_PRIVATE_CHAT,
server,
nick
};
}
export function closePrivateChat(server, nick) {
return dispatch => {
dispatch({
type: actions.CLOSE_PRIVATE_CHAT,
server,
nick
});
dispatch(updateSelection());
};
}

View File

@ -1,20 +0,0 @@
import * as actions from '../actions';
export function searchMessages(server, channel, phrase) {
return {
type: actions.SEARCH_MESSAGES,
server,
channel,
phrase,
socket: {
type: 'search',
data: { server, channel, phrase }
}
};
}
export function toggleSearch() {
return {
type: actions.TOGGLE_SEARCH
};
}

View File

@ -1,82 +0,0 @@
import * as actions from '../actions';
import { updateSelection } from './tab';
export function connect(server, nick, options) {
let host = server;
const i = server.indexOf(':');
if (i > 0) {
host = server.slice(0, i);
}
return {
type: actions.CONNECT,
host,
nick,
options,
socket: {
type: 'connect',
data: {
server,
nick,
username: options.username || nick,
password: options.password,
realname: options.realname || nick,
tls: options.tls || false,
name: options.name || server
}
}
};
}
export function disconnect(server) {
return dispatch => {
dispatch({
type: actions.DISCONNECT,
server,
socket: {
type: 'quit',
data: { server }
}
});
dispatch(updateSelection());
};
}
export function whois(user, server) {
return {
type: actions.WHOIS,
user,
server,
socket: {
type: 'whois',
data: { user, server }
}
};
}
export function away(message, server) {
return {
type: actions.AWAY,
message,
server,
socket: {
type: 'away',
data: { message, server }
}
};
}
export function setNick(nick, server) {
return {
type: actions.SET_NICK,
nick,
server,
socket: {
type: 'nick',
data: {
new: nick,
server
}
}
};
}

View File

@ -1,45 +0,0 @@
import base64 from 'base64-arraybuffer';
import * as actions from '../actions';
export function setCertError(message) {
return {
type: actions.SET_CERT_ERROR,
message
};
}
export function uploadCert() {
return (dispatch, getState) => {
const { settings } = getState();
if (settings.has('cert') && settings.has('key')) {
dispatch({
type: actions.UPLOAD_CERT,
socket: {
type: 'cert',
data: {
cert: settings.get('cert'),
key: settings.get('key')
}
}
});
} else {
dispatch(setCertError('Missing certificate or key'));
}
};
}
export function setCert(fileName, cert) {
return {
type: actions.SET_CERT,
fileName,
cert: base64.encode(cert)
};
}
export function setKey(fileName, key) {
return {
type: actions.SET_KEY,
fileName,
key: base64.encode(key)
};
}

View File

@ -1,38 +0,0 @@
import * as actions from '../actions';
import { push, replace } from '../util/router';
export function select(server, name, doReplace) {
const navigate = doReplace ? replace : push;
if (name) {
return navigate(`/${server}/${encodeURIComponent(name)}`);
}
return navigate(`/${server}`);
}
export function updateSelection() {
return (dispatch, getState) => {
const state = getState();
const history = state.tab.history;
const { servers } = state;
const { server } = state.tab.selected;
if (servers.size === 0) {
dispatch(replace('/connect'));
} else if (history.size > 0) {
const tab = history.last();
dispatch(select(tab.server, tab.name, true));
} else if (servers.has(server)) {
dispatch(select(server, null, true));
} else {
dispatch(select(servers.keySeq().first(), null, true));
}
};
}
export function setSelectedTab(server, name = null) {
return {
type: actions.SELECT_TAB,
server,
name
};
}

View File

@ -1,19 +0,0 @@
import * as actions from '../actions';
export function hideMenu() {
return {
type: actions.HIDE_MENU
};
}
export function toggleMenu() {
return {
type: actions.TOGGLE_MENU
};
}
export function toggleUserList() {
return {
type: actions.TOGGLE_USERLIST
};
}

View File

@ -1,9 +1,9 @@
import createCommandMiddleware from './middleware/command';
import { COMMAND } from './actions';
import { setNick, disconnect, whois, away } from './actions/server';
import { join, part, invite, kick } from './actions/channel';
import { select } from './actions/tab';
import { sendMessage, addMessage, raw } from './actions/message';
import { COMMAND } from './state/actions';
import { join, part, invite, kick } from './state/channels';
import { sendMessage, addMessage, raw } from './state/messages';
import { setNick, disconnect, whois, away } from './state/servers';
import { select } from './state/tab';
const help = [
'/join <channel> - Join a channel',

View File

@ -6,8 +6,8 @@ export default class MessageInput extends PureComponent {
};
handleKey = e => {
const { tab, runCommand, sendMessage, addInputHistory, incrementInputHistory,
decrementInputHistory, resetInputHistory, history } = this.props;
const { tab, runCommand, sendMessage,
add, reset, increment, decrement, currentHistoryEntry } = this.props;
if (e.key === 'Enter' && e.target.value) {
if (e.target.value[0] === '/') {
@ -16,17 +16,17 @@ export default class MessageInput extends PureComponent {
sendMessage(e.target.value, tab.name, tab.server);
}
addInputHistory(e.target.value);
resetInputHistory();
add(e.target.value);
reset();
this.setState({ value: '' });
} else if (e.key === 'ArrowUp') {
e.preventDefault();
incrementInputHistory();
increment();
} else if (e.key === 'ArrowDown') {
decrementInputHistory();
} else if (history) {
decrement();
} else if (currentHistoryEntry) {
this.setState({ value: e.target.value });
resetInputHistory();
reset();
}
};
@ -35,14 +35,14 @@ export default class MessageInput extends PureComponent {
};
render() {
const { nick } = this.props;
const { nick, currentHistoryEntry } = this.props;
return (
<div className="message-input-wrap">
<span className="message-input-nick">{nick}</span>
<input
className="message-input"
type="text"
value={this.props.history || this.state.value}
value={currentHistoryEntry || this.state.value}
onKeyDown={this.handleKey}
onChange={this.handleChange}
/>

View File

@ -1,6 +1,6 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { toggleMenu } from '../actions/ui';
import { toggleMenu } from '../state/ui';
class Navicon extends PureComponent {
render() {

View File

@ -35,7 +35,7 @@ export default class TabList extends PureComponent {
));
if (privateChats.has(address) && privateChats.get(address).size > 0) {
tabs.push(<div className="tab-label">Private messages</div>);
tabs.push(<div key={`${address}-pm}`} className="tab-label">Private messages</div>);
privateChats.get(address).forEach(nick => tabs.push(
<TabListItem

View File

@ -1,13 +1,17 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { createStructuredSelector } from 'reselect';
import { push } from '../util/router';
import Route from './Route';
import Chat from './Chat';
import Connect from './Connect';
import Settings from './Settings';
import TabList from '../components/TabList';
import { select } from '../actions/tab';
import { hideMenu } from '../actions/ui';
import { getChannels } from '../state/channels';
import { getPrivateChats } from '../state/privateChats';
import { getServers } from '../state/servers';
import { getSelectedTab, select } from '../state/tab';
import { getShowTabList, hideMenu } from '../state/ui';
class App extends PureComponent {
handleClick = () => {
@ -33,14 +37,12 @@ class App extends PureComponent {
}
}
function mapStateToProps(state) {
return {
servers: state.servers,
channels: state.channels,
privateChats: state.privateChats,
showTabList: state.ui.showTabList,
tab: state.tab.selected
};
}
const mapState = createStructuredSelector({
channels: getChannels,
privateChats: getPrivateChats,
servers: getServers,
showTabList: getShowTabList,
tab: getSelectedTab
});
export default connect(mapStateToProps, { pushPath: push, select, hideMenu })(App);
export default connect(mapState, { pushPath: push, select, hideMenu })(App);

View File

@ -1,24 +1,23 @@
import React, { PureComponent } from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { createSelector, createStructuredSelector } from 'reselect';
import { List, Map } from 'immutable';
import { createStructuredSelector } from 'reselect';
import ChatTitle from '../components/ChatTitle';
import Search from '../components/Search';
import MessageBox from '../components/MessageBox';
import MessageInput from '../components/MessageInput';
import UserList from '../components/UserList';
import { part } from '../actions/channel';
import { openPrivateChat, closePrivateChat } from '../actions/privateChat';
import { searchMessages, toggleSearch } from '../actions/search';
import { select } from '../actions/tab';
import { runCommand, sendMessage, fetchMessages } from '../actions/message';
import { disconnect } from '../actions/server';
import { toggleUserList } from '../actions/ui';
import * as inputHistoryActions from '../actions/inputHistory';
import { getSelectedTab } from '../reducers/tab';
import { getSelectedMessages } from '../reducers/messages';
import { getCurrentNick } from '../reducers/servers';
import { getSelectedTabTitle } from '../state';
import { getSelectedChannel, getSelectedChannelUsers, part } from '../state/channels';
import { getCurrentInputHistoryEntry, addInputHistory, resetInputHistory,
incrementInputHistory, decrementInputHistory } from '../state/input';
import { getSelectedMessages, getHasMoreMessages,
runCommand, sendMessage, fetchMessages } from '../state/messages';
import { openPrivateChat, closePrivateChat } from '../state/privateChats';
import { getSearch, searchMessages, toggleSearch } from '../state/search';
import { getCurrentNick, disconnect } from '../state/servers';
import { getSelectedTab, select } from '../state/tab';
import { getShowUserList, toggleUserList } from '../state/ui';
class Chat extends PureComponent {
handleSearch = phrase => {
@ -38,7 +37,7 @@ class Chat extends PureComponent {
handleFetchMore = () => this.props.dispatch(fetchMessages());
render() {
const { title, tab, channel, search, history,
const { title, tab, channel, search, currentInputHistoryEntry,
messages, hasMoreMessages, users, showUserList, nick, inputActions } = this.props;
let chatClass;
@ -76,7 +75,7 @@ class Chat extends PureComponent {
<MessageInput
tab={tab}
channel={channel}
history={history}
currentHistoryEntry={currentInputHistoryEntry}
nick={nick}
runCommand={this.props.runCommand}
sendMessage={this.props.sendMessage}
@ -94,73 +93,41 @@ class Chat extends PureComponent {
}
}
const serverSelector = state => state.servers;
const channelSelector = state => state.channels;
const searchSelector = state => state.search;
const showUserListSelector = state => state.ui.showUserList;
const historySelector = state => {
if (state.input.index === -1) {
return null;
}
return state.input.history.get(state.input.index);
};
const selectedChannelSelector = createSelector(
getSelectedTab,
channelSelector,
(tab, channels) => channels.getIn([tab.server, tab.name], Map())
);
const usersSelector = createSelector(
selectedChannelSelector,
channel => channel.get('users', List())
);
const titleSelector = createSelector(
getSelectedTab,
serverSelector,
(tab, servers) => tab.name || servers.getIn([tab.server, 'name'])
);
const getHasMoreMessages = createSelector(
getSelectedMessages,
messages => {
const first = messages.get(0);
return first && first.next;
}
);
const mapStateToProps = createStructuredSelector({
title: titleSelector,
tab: getSelectedTab,
channel: selectedChannelSelector,
messages: getSelectedMessages,
const mapState = createStructuredSelector({
channel: getSelectedChannel,
currentInputHistoryEntry: getCurrentInputHistoryEntry,
hasMoreMessages: getHasMoreMessages,
users: usersSelector,
showUserList: showUserListSelector,
search: searchSelector,
history: historySelector,
nick: getCurrentNick
messages: getSelectedMessages,
nick: getCurrentNick,
search: getSearch,
showUserList: getShowUserList,
tab: getSelectedTab,
title: getSelectedTabTitle,
users: getSelectedChannelUsers
});
function mapDispatchToProps(dispatch) {
function mapDispatch(dispatch) {
return {
dispatch,
...bindActionCreators({
select,
toggleSearch,
toggleUserList,
searchMessages,
runCommand,
sendMessage,
part,
closePrivateChat,
disconnect,
openPrivateChat,
closePrivateChat
part,
runCommand,
searchMessages,
select,
sendMessage,
toggleSearch,
toggleUserList
}, dispatch),
inputActions: bindActionCreators(inputHistoryActions, dispatch)
inputActions: bindActionCreators({
add: addInputHistory,
reset: resetInputHistory,
increment: incrementInputHistory,
decrement: decrementInputHistory
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(Chat);
export default connect(mapState, mapDispatch)(Chat);

View File

@ -1,9 +1,11 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { createStructuredSelector } from 'reselect';
import Navicon from '../components/Navicon';
import * as serverActions from '../actions/server';
import { join } from '../actions/channel';
import { select } from '../actions/tab';
import { join } from '../state/channels';
import { getConnectDefaults } from '../state/environment';
import { connect as connectServer } from '../state/servers';
import { select } from '../state/tab';
class Connect extends PureComponent {
state = {
@ -33,7 +35,7 @@ class Connect extends PureComponent {
}
if (address.indexOf('.') > 0 && nick) {
dispatch(serverActions.connect(address, nick, opts));
dispatch(connectServer(address, nick, opts));
const i = address.indexOf(':');
if (i > 0) {
@ -102,10 +104,8 @@ class Connect extends PureComponent {
}
}
function mapStateToProps(state) {
return {
defaults: state.environment.get('connect_defaults')
};
}
const mapState = createStructuredSelector({
defaults: getConnectDefaults
});
export default connect(mapStateToProps)(Connect);
export default connect(mapState)(Connect);

View File

@ -10,8 +10,8 @@ const Route = ({ route, name, children }) => {
const getRoute = state => state.router.route;
const mapStateToProps = createStructuredSelector({
const mapState = createStructuredSelector({
route: getRoute
});
export default connect(mapStateToProps)(Route);
export default connect(mapState)(Route);

View File

@ -1,8 +1,9 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { createStructuredSelector } from 'reselect';
import Navicon from '../components/Navicon';
import FileInput from '../components/FileInput';
import { setCert, setKey, uploadCert } from '../actions/settings';
import { getSettings, setCert, setKey, uploadCert } from '../state/settings';
class Settings extends PureComponent {
handleCertChange = (name, data) => this.props.dispatch(setCert(name, data));
@ -40,6 +41,8 @@ class Settings extends PureComponent {
}
}
export default connect(state => ({
settings: state.settings
}))(Settings);
const mapState = createStructuredSelector({
settings: getSettings
});
export default connect(mapState)(Settings);

View File

@ -1,4 +1,4 @@
import { inform } from '../actions/message';
import { inform } from '../state/messages';
const notFound = 'commandNotFound';

View File

@ -1,14 +1,10 @@
import capitalize from 'lodash/capitalize';
import observe from '../util/observe';
import { getCurrentServerName } from '../reducers/servers';
const getRouter = state => state.router;
import { getRouter } from '../state';
import { getCurrentServerName } from '../state/servers';
import { observe } from '../util/observe';
export default function documentTitle({ store }) {
observe(
store,
[getRouter, getCurrentServerName],
(router, serverName) => {
observe(store, [getRouter, getCurrentServerName], (router, serverName) => {
let title;
if (router.route === 'chat') {
@ -23,6 +19,5 @@ export default function documentTitle({ store }) {
}
document.title = `${title} | Dispatch`;
}
);
});
}

View File

@ -0,0 +1,22 @@
import FontFaceObserver from 'fontfaceobserver';
import { setCharWidth } from '../state/environment';
import { stringWidth } from '../util';
export default function fonts({ store }) {
let charWidth = localStorage.charWidth;
if (charWidth) {
store.dispatch(setCharWidth(parseFloat(charWidth)));
}
new FontFaceObserver('Roboto Mono').load().then(() => {
if (!charWidth) {
charWidth = stringWidth(' ', '16px Roboto Mono');
store.dispatch(setCharWidth(charWidth));
localStorage.charWidth = charWidth;
}
});
new FontFaceObserver('Montserrat').load();
new FontFaceObserver('Montserrat', { weight: 700 }).load();
new FontFaceObserver('Roboto Mono', { weight: 700 }).load();
}

View File

@ -1,12 +1,16 @@
import documentTitle from './documentTitle';
import handleSocket from './handleSocket';
import fonts from './fonts';
import initialState from './initialState';
import socket from './socket';
import storage from './storage';
import widthUpdates from './widthUpdates';
export default function runModules(ctx) {
fonts(ctx);
initialState(ctx);
documentTitle(ctx);
handleSocket(ctx);
socket(ctx);
storage(ctx);
widthUpdates(ctx);
}

View File

@ -1,9 +1,10 @@
import Cookie from 'js-cookie';
import { setEnvironment } from '../actions/environment';
import { addMessages } from '../actions/message';
import { select, updateSelection } from '../actions/tab';
import { socket as socketActions } from '../state/actions';
import { getWrapWidth, setEnvironment } from '../state/environment';
import { addMessages } from '../state/messages';
import { select, updateSelection } from '../state/tab';
import { find } from '../util';
import { initWidthUpdates } from '../util/messageHeight';
import { when } from '../util/observe';
import { replace } from '../util/router';
export default function initialState({ store }) {
@ -13,7 +14,7 @@ export default function initialState({ store }) {
if (env.servers) {
store.dispatch({
type: 'SOCKET_SERVERS',
type: socketActions.SERVERS,
data: env.servers
});
@ -37,19 +38,21 @@ export default function initialState({ store }) {
if (env.channels) {
store.dispatch({
type: 'SOCKET_CHANNELS',
type: socketActions.CHANNELS,
data: env.channels
});
}
if (env.users) {
store.dispatch({
type: 'SOCKET_USERS',
type: socketActions.USERS,
...env.users
});
}
initWidthUpdates(store, () => {
// Wait until wrapWidth gets initialized so that height calculations
// only happen once for these messages
when(store, getWrapWidth, () => {
if (env.messages) {
const { messages, server, to, next } = env.messages;
store.dispatch(addMessages(messages, server, to, false, next));

View File

@ -1,7 +1,8 @@
import { broadcast, inform, addMessage, addMessages } from '../actions/message';
import { select } from '../actions/tab';
import { replace } from '../util/router';
import { socketAction } from '../state/actions';
import { broadcast, inform, addMessage, addMessages } from '../state/messages';
import { select } from '../state/tab';
import { normalizeChannel } from '../util';
import { replace } from '../util/router';
function withReason(message, reason) {
return message + (reason ? ` (${reason})` : '');
@ -97,7 +98,7 @@ export default function handleSocket({ socket, store: { dispatch, getState } })
handlers[type](data);
}
type = `SOCKET_${type.toUpperCase()}`;
type = socketAction(type);
if (Array.isArray(data)) {
dispatch({ type, data });
} else {

View File

@ -1,7 +1,7 @@
import Cookie from 'js-cookie';
import debounce from 'lodash/debounce';
import observe from '../util/observe';
import { getSelectedTab } from '../reducers/tab';
import { observe } from '../util/observe';
import { getSelectedTab } from '../state/tab';
const saveTab = debounce(tab =>
Cookie.set('tab', tab.toString(), { expires: 30 })

View File

@ -0,0 +1,41 @@
import { when } from '../util/observe';
import { measureScrollBarWidth } from '../util';
import { getCharWidth } from '../state/environment';
import { updateMessageHeight } from '../state/messages';
const menuWidth = 200;
const messagePadding = 30;
const smallScreen = 600;
export default function widthUpdates({ store }) {
when(store, getCharWidth, charWidth => {
window.messageIndent = 6 * charWidth;
const scrollBarWidth = measureScrollBarWidth();
let prevWrapWidth;
function updateWidth() {
const windowWidth = window.innerWidth;
let wrapWidth = windowWidth - scrollBarWidth - messagePadding;
if (windowWidth > smallScreen) {
wrapWidth -= menuWidth;
}
if (wrapWidth !== prevWrapWidth) {
prevWrapWidth = wrapWidth;
store.dispatch(updateMessageHeight(wrapWidth, charWidth, windowWidth));
}
}
let resizeRAF;
function resize() {
if (resizeRAF) {
window.cancelAnimationFrame(resizeRAF);
}
resizeRAF = window.requestAnimationFrame(updateWidth);
}
updateWidth();
window.addEventListener('resize', resize);
});
}

View File

@ -1,15 +0,0 @@
import { Map } from 'immutable';
import createReducer from '../util/createReducer';
import * as actions from '../actions';
export default createReducer(Map(), {
[actions.SET_ENVIRONMENT](state, action) {
return state.set(action.key, action.value);
},
[actions.UPDATE_MESSAGE_HEIGHT](state, action) {
return state
.set('wrapWidth', action.wrapWidth)
.set('charWidth', action.charWidth);
}
});

View File

@ -1,71 +0,0 @@
import { List, Map, Record } from 'immutable';
import { createSelector } from 'reselect';
import createReducer from '../util/createReducer';
import { messageHeight } from '../util';
import * as actions from '../actions';
import { getSelectedTab } from './tab';
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())
);
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 }) {
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))
)
)
)
);
}
});

View File

@ -1,18 +0,0 @@
import { List, Record } from 'immutable';
import createReducer from '../util/createReducer';
import * as actions from '../actions';
const State = Record({
show: false,
results: List()
});
export default createReducer(new State(), {
[actions.SOCKET_SEARCH](state, action) {
return state.set('results', List(action.results));
},
[actions.TOGGLE_SEARCH](state) {
return state.set('show', !state.show);
}
});

View File

@ -1,74 +0,0 @@
import { Map, Record } from 'immutable';
import { createSelector } from 'reselect';
import createReducer from '../util/createReducer';
import * as actions from '../actions';
import { getSelectedTab } from './tab';
const Server = Record({
nick: null,
name: null,
connected: false
});
export const getServers = state => state.servers;
export const getCurrentNick = createSelector(
getServers,
getSelectedTab,
(servers, tab) => servers.getIn([tab.server, 'nick'], '')
);
export const getCurrentServerName = createSelector(
getServers,
getSelectedTab,
(servers, tab) => servers.getIn([tab.server, 'name'], '')
);
export default createReducer(Map(), {
[actions.CONNECT](state, action) {
const { host, nick, options } = action;
if (!state.has(host)) {
return state.set(host, new Server({
nick,
name: options.name || host
}));
}
return state;
},
[actions.DISCONNECT](state, action) {
return state.delete(action.server);
},
[actions.SOCKET_NICK](state, action) {
const { server, old } = action;
if (!old || old === state.get(server).nick) {
return state.update(server, s => s.set('nick', action.new));
}
return state;
},
[actions.SOCKET_SERVERS](state, action) {
if (!action.data) {
return state;
}
return state.withMutations(s => {
action.data.forEach(server => {
s.set(server.host, new Server(server));
});
});
},
[actions.SOCKET_CONNECTION_UPDATE](state, action) {
return state.withMutations(s =>
Object.keys(action).forEach(server => {
if (s.has(server)) {
s.setIn([server, 'connected'], action[server]);
}
})
);
}
});

View File

@ -1,41 +0,0 @@
import { Map } from 'immutable';
import createReducer from '../util/createReducer';
import * as actions from '../actions';
export default createReducer(Map(), {
[actions.UPLOAD_CERT](state) {
return state.set('uploadingCert', true);
},
[actions.SOCKET_CERT_SUCCESS]() {
return Map({ uploadingCert: false });
},
[actions.SOCKET_CERT_FAIL](state, action) {
return state.merge({
uploadingCert: false,
certError: action.message
});
},
[actions.SET_CERT_ERROR](state, action) {
return state.merge({
uploadingCert: false,
certError: action.message
});
},
[actions.SET_CERT](state, action) {
return state.merge({
certFile: action.fileName,
cert: action.cert
});
},
[actions.SET_KEY](state, action) {
return state.merge({
keyFile: action.fileName,
key: action.key
});
}
});

View File

@ -1,26 +0,0 @@
import { Record } from 'immutable';
import createReducer from '../util/createReducer';
import * as actions from '../actions';
import { LOCATION_CHANGED } from '../util/router';
const State = Record({
showTabList: false,
showUserList: false
});
function hideMenu(state) {
return state.set('showTabList', false);
}
export default createReducer(new State(), {
[actions.TOGGLE_MENU](state) {
return state.update('showTabList', show => !show);
},
[actions.HIDE_MENU]: hideMenu,
[LOCATION_CHANGED]: hideMenu,
[actions.TOGGLE_USERLIST](state) {
return state.update('showUserList', show => !show);
}
});

View File

@ -1,7 +1,7 @@
import Immutable from 'immutable';
import reducer from '../reducers/channels';
import reducer from '../channels';
import { connect } from '../servers';
import * as actions from '../actions';
import { connect } from '../actions/server';
describe('reducers/channels', () => {
it('removes channels on PART', () => {
@ -36,7 +36,7 @@ describe('reducers/channels', () => {
state = reducer(state, socket_join('srv', 'chan2', 'nick2'));
state = reducer(state, {
type: actions.SOCKET_PART,
type: actions.socket.PART,
server: 'srv',
channel: 'chan1',
user: 'nick2'
@ -78,7 +78,7 @@ describe('reducers/channels', () => {
state = reducer(state, socket_join('srv', 'chan2', 'nick2'));
state = reducer(state, {
type: actions.SOCKET_QUIT,
type: actions.socket.QUIT,
server: 'srv',
user: 'nick2'
});
@ -103,7 +103,7 @@ describe('reducers/channels', () => {
state = reducer(state, socket_join('srv', 'chan2', 'nick2'));
state = reducer(state, {
type: actions.SOCKET_NICK,
type: actions.socket.NICK,
server: 'srv',
old: 'nick1',
new: 'nick3'
@ -128,7 +128,7 @@ describe('reducers/channels', () => {
it('handles SOCKET_USERS', () => {
const state = reducer(undefined, {
type: actions.SOCKET_USERS,
type: actions.socket.USERS,
server: 'srv',
channel: 'chan1',
users: [
@ -157,7 +157,7 @@ describe('reducers/channels', () => {
it('handles SOCKET_TOPIC', () => {
const state = reducer(undefined, {
type: actions.SOCKET_TOPIC,
type: actions.socket.TOPIC,
server: 'srv',
channel: 'chan1',
topic: 'the topic'
@ -218,7 +218,7 @@ describe('reducers/channels', () => {
it('handles SOCKET_CHANNELS', () => {
const state = reducer(undefined, {
type: actions.SOCKET_CHANNELS,
type: actions.socket.CHANNELS,
data: [
{ server: 'srv', name: 'chan1', topic: 'the topic' },
{ server: 'srv', name: 'chan2' },
@ -239,7 +239,7 @@ describe('reducers/channels', () => {
it('handles SOCKET_SERVERS', () => {
const state = reducer(undefined, {
type: actions.SOCKET_SERVERS,
type: actions.socket.SERVERS,
data: [
{ host: '127.0.0.1' },
{ host: 'thehost' }
@ -279,7 +279,7 @@ describe('reducers/channels', () => {
function socket_join(server, channel, user) {
return {
type: 'SOCKET_JOIN',
type: actions.socket.JOIN,
server, user,
channels: [channel]
};
@ -287,7 +287,7 @@ function socket_join(server, channel, user) {
function socket_mode(server, channel, user, add, remove) {
return {
type: 'SOCKET_MODE',
type: actions.socket.MODE,
server, channel, user, add, remove
};
}

View File

@ -1,7 +1,6 @@
import { Map, fromJS } from 'immutable';
import reducer from '../reducers/messages';
import reducer, { broadcast } from '../messages';
import * as actions from '../actions';
import { broadcast } from '../actions/message';
describe('reducers/messages', () => {
it('adds the message on ADD_MESSAGE', () => {

View File

@ -1,7 +1,6 @@
import Immutable from 'immutable';
import reducer from '../reducers/servers';
import reducer, { connect } from '../servers';
import * as actions from '../actions';
import { connect } from '../actions/server';
describe('reducers/servers', () => {
it('adds the server on CONNECT', () => {
@ -62,7 +61,7 @@ describe('reducers/servers', () => {
it('updates the nick on SOCKET_NICK', () => {
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
state = reducer(state, {
type: actions.SOCKET_NICK,
type: actions.socket.NICK,
server: '127.0.0.1',
old: 'nick',
new: 'nick2'
@ -79,7 +78,7 @@ describe('reducers/servers', () => {
it('adds the servers on SOCKET_SERVERS', () => {
let state = reducer(undefined, {
type: 'SOCKET_SERVERS',
type: actions.socket.SERVERS,
data: [
{
host: '127.0.0.1',
@ -113,7 +112,7 @@ describe('reducers/servers', () => {
it('updates connection status on SOCKET_CONNECTION_UPDATE', () => {
let state = reducer(undefined, connect('127.0.0.1:1337', 'nick', {}));
state = reducer(state, {
type: actions.SOCKET_CONNECTION_UPDATE,
type: actions.socket.CONNECTION_UPDATE,
'127.0.0.1': true
});

View File

@ -1,7 +1,6 @@
import reducer from '../reducers/tab';
import reducer, { setSelectedTab } from '../tab';
import * as actions from '../actions';
import { setSelectedTab } from '../actions/tab';
import { locationChanged } from '../util/router';
import { locationChanged } from '../../util/router';
describe('reducers/tab', () => {
it('selects the tab and adds it to history', () => {

View File

@ -1,48 +1,71 @@
export const ADD_MESSAGE = 'ADD_MESSAGE';
export const ADD_MESSAGES = 'ADD_MESSAGES';
export const AWAY = 'AWAY';
export const CLOSE_PRIVATE_CHAT = 'CLOSE_PRIVATE_CHAT';
export const COMMAND = 'COMMAND';
export const CONNECT = 'CONNECT';
export const DISCONNECT = 'DISCONNECT';
export const FETCH_MESSAGES = 'FETCH_MESSAGES';
export const HIDE_MENU = 'HIDE_MENU';
export const INVITE = 'INVITE';
export const JOIN = 'JOIN';
export const KICK = 'KICK';
export const PART = 'PART';
export const SET_ENVIRONMENT = 'SET_ENVIRONMENT';
export const INPUT_HISTORY_ADD = 'INPUT_HISTORY_ADD';
export const INPUT_HISTORY_DECREMENT = 'INPUT_HISTORY_DECREMENT';
export const INPUT_HISTORY_INCREMENT = 'INPUT_HISTORY_INCREMENT';
export const INPUT_HISTORY_RESET = 'INPUT_HISTORY_RESET';
export const INVITE = 'INVITE';
export const JOIN = 'JOIN';
export const KICK = 'KICK';
export const OPEN_PRIVATE_CHAT = 'OPEN_PRIVATE_CHAT';
export const PART = 'PART';
export const ADD_MESSAGE = 'ADD_MESSAGE';
export const ADD_MESSAGES = 'ADD_MESSAGES';
export const COMMAND = 'COMMAND';
export const FETCH_MESSAGES = 'FETCH_MESSAGES';
export const RAW = 'RAW';
export const UPDATE_MESSAGE_HEIGHT = 'UPDATE_MESSAGE_HEIGHT';
export const CLOSE_PRIVATE_CHAT = 'CLOSE_PRIVATE_CHAT';
export const OPEN_PRIVATE_CHAT = 'OPEN_PRIVATE_CHAT';
export const SEARCH_MESSAGES = 'SEARCH_MESSAGES';
export const SELECT_TAB = 'SELECT_TAB';
export const TOGGLE_SEARCH = 'TOGGLE_SEARCH';
export const AWAY = 'AWAY';
export const CONNECT = 'CONNECT';
export const DISCONNECT = 'DISCONNECT';
export const SET_NICK = 'SET_NICK';
export const WHOIS = 'WHOIS';
export const SET_CERT = 'SET_CERT';
export const SET_CERT_ERROR = 'SET_CERT_ERROR';
export const SET_ENVIRONMENT = 'SET_ENVIRONMENT';
export const SET_KEY = 'SET_KEY';
export const SET_NICK = 'SET_NICK';
export const SOCKET_CERT_FAIL = 'SOCKET_CERT_FAIL';
export const SOCKET_CERT_SUCCESS = 'SOCKET_CERT_SUCCESS';
export const SOCKET_CHANNELS = 'SOCKET_CHANNELS';
export const SOCKET_CONNECTION_UPDATE = 'SOCKET_CONNECTION_UPDATE';
export const SOCKET_JOIN = 'SOCKET_JOIN';
export const SOCKET_MESSAGE = 'SOCKET_MESSAGE';
export const SOCKET_MODE = 'SOCKET_MODE';
export const SOCKET_NICK = 'SOCKET_NICK';
export const SOCKET_PART = 'SOCKET_PART';
export const SOCKET_PM = 'SOCKET_PM';
export const SOCKET_QUIT = 'SOCKET_QUIT';
export const SOCKET_SEARCH = 'SOCKET_SEARCH';
export const SOCKET_SERVERS = 'SOCKET_SERVERS';
export const SOCKET_TOPIC = 'SOCKET_TOPIC';
export const SOCKET_USERS = 'SOCKET_USERS';
export const TAB_HISTORY_POP = 'TAB_HISTORY_POP';
export const TOGGLE_MENU = 'TOGGLE_MENU';
export const TOGGLE_SEARCH = 'TOGGLE_SEARCH';
export const TOGGLE_USERLIST = 'TOGGLE_USERLIST';
export const UPDATE_MESSAGE_HEIGHT = 'UPDATE_MESSAGE_HEIGHT';
export const UPLOAD_CERT = 'UPLOAD_CERT';
export const WHOIS = 'WHOIS';
export const SELECT_TAB = 'SELECT_TAB';
export const HIDE_MENU = 'HIDE_MENU';
export const TOGGLE_MENU = 'TOGGLE_MENU';
export const TOGGLE_USERLIST = 'TOGGLE_USERLIST';
export function socketAction(type) {
return `SOCKET_${type.toUpperCase()}`;
}
function createSocketActions(types) {
const actions = {};
types.forEach(type => {
actions[type.toUpperCase()] = socketAction(type);
});
return actions;
}
export const socket = createSocketActions([
'cert_fail',
'cert_success',
'channels',
'connection_update',
'join',
'message',
'mode',
'nick',
'part',
'pm',
'quit',
'search',
'servers',
'topic',
'users'
]);

View File

@ -1,6 +1,8 @@
import { Map, List, Record } from 'immutable';
import { createSelector } from 'reselect';
import createReducer from '../util/createReducer';
import * as actions from '../actions';
import { getSelectedTab, updateSelection } from './tab';
import * as actions from './actions';
const User = Record({
nick: null,
@ -74,6 +76,19 @@ function compareUsers(a, b) {
return 0;
}
export const getChannels = state => state.channels;
export const getSelectedChannel = createSelector(
getSelectedTab,
getChannels,
(tab, channels) => channels.getIn([tab.server, tab.name], Map())
);
export const getSelectedChannelUsers = createSelector(
getSelectedChannel,
channel => channel.get('users', List())
);
export default createReducer(Map(), {
[actions.PART](state, action) {
const { channels, server } = action;
@ -82,14 +97,14 @@ export default createReducer(Map(), {
});
},
[actions.SOCKET_JOIN](state, action) {
[actions.socket.JOIN](state, action) {
const { server, channels, user } = action;
return state.updateIn([server, channels[0], 'users'], List(), users =>
users.push(createUser(user)).sort(compareUsers)
);
},
[actions.SOCKET_PART](state, action) {
[actions.socket.PART](state, action) {
const { server, channel, user } = action;
if (state.hasIn([server, channel])) {
return state.updateIn([server, channel, 'users'], users =>
@ -99,7 +114,7 @@ export default createReducer(Map(), {
return state;
},
[actions.SOCKET_QUIT](state, action) {
[actions.socket.QUIT](state, action) {
const { server, user } = action;
return state.withMutations(s => {
s.get(server).forEach((v, channel) => {
@ -108,7 +123,7 @@ export default createReducer(Map(), {
});
},
[actions.SOCKET_NICK](state, action) {
[actions.socket.NICK](state, action) {
const { server } = action;
return state.withMutations(s => {
s.get(server).forEach((v, channel) => {
@ -126,18 +141,18 @@ export default createReducer(Map(), {
});
},
[actions.SOCKET_USERS](state, action) {
[actions.socket.USERS](state, action) {
const { server, channel, users } = action;
return state.setIn([server, channel, 'users'],
List(users.map(user => loadUser(user)).sort(compareUsers)));
},
[actions.SOCKET_TOPIC](state, action) {
[actions.socket.TOPIC](state, action) {
const { server, channel, topic } = action;
return state.setIn([server, channel, 'topic'], topic);
},
[actions.SOCKET_MODE](state, action) {
[actions.socket.MODE](state, action) {
const { server, channel, user, remove, add } = action;
return state.updateIn([server, channel, 'users'], users => {
@ -158,7 +173,7 @@ export default createReducer(Map(), {
});
},
[actions.SOCKET_CHANNELS](state, action) {
[actions.socket.CHANNELS](state, action) {
if (!action.data) {
return state;
}
@ -173,7 +188,7 @@ export default createReducer(Map(), {
});
},
[actions.SOCKET_SERVERS](state, action) {
[actions.socket.SERVERS](state, action) {
if (!action.data) {
return state;
}
@ -201,3 +216,56 @@ export default createReducer(Map(), {
return state.delete(action.server);
}
});
export function join(channels, server) {
return {
type: actions.JOIN,
channels,
server,
socket: {
type: 'join',
data: { channels, server }
}
};
}
export function part(channels, server) {
return dispatch => {
dispatch({
type: actions.PART,
channels,
server,
socket: {
type: 'part',
data: { channels, server }
}
});
dispatch(updateSelection());
};
}
export function invite(user, channel, server) {
return {
type: actions.INVITE,
user,
channel,
server,
socket: {
type: 'invite',
data: { user, channel, server }
}
};
}
export function kick(user, channel, server) {
return {
type: actions.KICK,
user,
channel,
server,
socket: {
type: 'kick',
data: { user, channel, server }
}
};
}

View File

@ -0,0 +1,40 @@
import { Map } from 'immutable';
import createReducer from '../util/createReducer';
import * as actions from './actions';
export const getEnvironment = state => state.environment;
export const getWrapWidth = state => state.environment.get('wrapWidth');
export const getCharWidth = state => state.environment.get('charWidth');
export const getWindowWidth = state => state.environment.get('windowWidth');
export const getConnectDefaults = state => state.environment.get('connect_defaults');
export default createReducer(Map(), {
[actions.SET_ENVIRONMENT](state, action) {
return state.set(action.key, action.value);
},
[actions.UPDATE_MESSAGE_HEIGHT](state, action) {
return state
.set('wrapWidth', action.wrapWidth)
.set('charWidth', action.charWidth)
.set('windowWidth', action.windowWidth);
}
});
export function setEnvironment(key, value) {
return {
type: actions.SET_ENVIRONMENT,
key,
value
};
}
export function setWrapWidth(width) {
return setEnvironment('wrapWidth', width);
}
export function setCharWidth(width) {
return setEnvironment('charWidth', width);
}

View File

@ -10,6 +10,9 @@ import settings from './settings';
import tab from './tab';
import ui from './ui';
export * from './selectors';
export const getRouter = state => state.router;
export default function createReducer(router) {
return combineReducers({
router,

View File

@ -1,6 +1,6 @@
import { List, Record } from 'immutable';
import createReducer from '../util/createReducer';
import * as actions from '../actions';
import * as actions from './actions';
const HISTORY_MAX_LENGTH = 128;
@ -9,6 +9,14 @@ const State = Record({
index: 0
});
export const getCurrentInputHistoryEntry = state => {
if (state.input.index === -1) {
return null;
}
return state.input.history.get(state.input.index);
};
export default createReducer(new State(), {
[actions.INPUT_HISTORY_ADD](state, action) {
const { line } = action;
@ -43,3 +51,28 @@ export default createReducer(new State(), {
return state;
}
});
export function addInputHistory(line) {
return {
type: actions.INPUT_HISTORY_ADD,
line
};
}
export function resetInputHistory() {
return {
type: actions.INPUT_HISTORY_RESET
};
}
export function incrementInputHistory() {
return {
type: actions.INPUT_HISTORY_INCREMENT
};
}
export function decrementInputHistory() {
return {
type: actions.INPUT_HISTORY_DECREMENT
};
}

View File

@ -1,10 +1,87 @@
import * as actions from '../actions';
import { List, Map, Record } from 'immutable';
import { createSelector } from 'reselect';
import createReducer from '../util/createReducer';
import { getWrapWidth, getCharWidth, getWindowWidth } from './environment';
import { getSelectedTab } from './tab';
import { findBreakpoints, messageHeight, linkify, timestamp } from '../util';
import { getSelectedMessages } from '../reducers/messages';
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())
);
export const getHasMoreMessages = createSelector(
getSelectedMessages,
messages => {
const first = messages.get(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))
)
)
)
);
}
});
let nextID = 0;
function initMessage(message, server, tab, state) {
function initMessage(message, tab, state) {
if (message.time) {
message.time = timestamp(new Date(message.time * 1000));
} else {
@ -30,12 +107,13 @@ function initMessage(message, server, tab, state) {
message.content = from + message.content.slice(7, -1);
}
const charWidth = state.environment.get('charWidth');
const wrapWidth = state.environment.get('wrapWidth');
const wrapWidth = getWrapWidth(state);
const charWidth = getCharWidth(state);
const windowWidth = getWindowWidth(state);
message.length = message.content.length;
message.breakpoints = findBreakpoints(message.content);
message.height = messageHeight(message, wrapWidth, charWidth, 6 * charWidth);
message.height = messageHeight(message, wrapWidth, charWidth, 6 * charWidth, windowWidth);
message.content = linkify(message.content);
return message;
@ -74,11 +152,12 @@ export function fetchMessages() {
};
}
export function updateMessageHeight(wrapWidth, charWidth) {
export function updateMessageHeight(wrapWidth, charWidth, windowWidth) {
return {
type: actions.UPDATE_MESSAGE_HEIGHT,
wrapWidth,
charWidth
charWidth,
windowWidth
};
}
@ -93,7 +172,7 @@ export function sendMessage(content, to, server) {
message: initMessage({
from: state.servers.getIn([server, 'nick']),
content
}, server, to, state),
}, to, state),
socket: {
type: 'message',
data: { content, to, server }
@ -109,7 +188,7 @@ export function addMessage(message, server, to) {
type: actions.ADD_MESSAGE,
server,
tab,
message: initMessage(message, server, tab, getState())
message: initMessage(message, tab, getState())
});
}
@ -124,7 +203,7 @@ export function addMessages(messages, server, to, prepend, next) {
messages[0].next = true;
}
messages.forEach(message => initMessage(message, server, message.tab || tab, state));
messages.forEach(message => initMessage(message, message.tab || tab, state));
dispatch({
type: actions.ADD_MESSAGES,

View File

@ -1,6 +1,9 @@
import { Set, Map } from 'immutable';
import createReducer from '../util/createReducer';
import * as actions from '../actions';
import { updateSelection } from './tab';
import * as actions from './actions';
export const getPrivateChats = state => state.privateChats;
function open(state, server, nick) {
return state.update(server, Set(), chats => chats.add(nick));
@ -15,7 +18,7 @@ export default createReducer(Map(), {
return state.update(action.server, chats => chats.delete(action.nick));
},
[actions.SOCKET_PM](state, action) {
[actions.socket.PM](state, action) {
if (action.from.indexOf('.') === -1) {
return open(state, action.server, action.from);
}
@ -27,3 +30,22 @@ export default createReducer(Map(), {
return state.delete(action.server);
}
});
export function openPrivateChat(server, nick) {
return {
type: actions.OPEN_PRIVATE_CHAT,
server,
nick
};
}
export function closePrivateChat(server, nick) {
return dispatch => {
dispatch({
type: actions.CLOSE_PRIVATE_CHAT,
server,
nick
});
dispatch(updateSelection());
};
}

View File

@ -0,0 +1,39 @@
import { List, Record } from 'immutable';
import createReducer from '../util/createReducer';
import * as actions from './actions';
const State = Record({
show: false,
results: List()
});
export const getSearch = state => state.search;
export default createReducer(new State(), {
[actions.socket.SEARCH](state, action) {
return state.set('results', List(action.results));
},
[actions.TOGGLE_SEARCH](state) {
return state.set('show', !state.show);
}
});
export function searchMessages(server, channel, phrase) {
return {
type: actions.SEARCH_MESSAGES,
server,
channel,
phrase,
socket: {
type: 'search',
data: { server, channel, phrase }
}
};
}
export function toggleSearch() {
return {
type: actions.TOGGLE_SEARCH
};
}

View File

@ -0,0 +1,10 @@
import { createSelector } from 'reselect';
import { getServers } from './servers';
import { getSelectedTab } from './tab';
// eslint-disable-next-line import/prefer-default-export
export const getSelectedTabTitle = createSelector(
getSelectedTab,
getServers,
(tab, servers) => tab.name || servers.getIn([tab.server, 'name'])
);

View File

@ -0,0 +1,154 @@
import { Map, Record } from 'immutable';
import { createSelector } from 'reselect';
import createReducer from '../util/createReducer';
import { getSelectedTab, updateSelection } from './tab';
import * as actions from './actions';
const Server = Record({
nick: null,
name: null,
connected: false
});
export const getServers = state => state.servers;
export const getCurrentNick = createSelector(
getServers,
getSelectedTab,
(servers, tab) => servers.getIn([tab.server, 'nick'], '')
);
export const getCurrentServerName = createSelector(
getServers,
getSelectedTab,
(servers, tab) => servers.getIn([tab.server, 'name'], '')
);
export default createReducer(Map(), {
[actions.CONNECT](state, action) {
const { host, nick, options } = action;
if (!state.has(host)) {
return state.set(host, new Server({
nick,
name: options.name || host
}));
}
return state;
},
[actions.DISCONNECT](state, action) {
return state.delete(action.server);
},
[actions.socket.NICK](state, action) {
const { server, old } = action;
if (!old || old === state.get(server).nick) {
return state.update(server, s => s.set('nick', action.new));
}
return state;
},
[actions.socket.SERVERS](state, action) {
if (!action.data) {
return state;
}
return state.withMutations(s => {
action.data.forEach(server => {
s.set(server.host, new Server(server));
});
});
},
[actions.socket.CONNECTION_UPDATE](state, action) {
return state.withMutations(s =>
Object.keys(action).forEach(server => {
if (s.has(server)) {
s.setIn([server, 'connected'], action[server]);
}
})
);
}
});
export function connect(server, nick, options) {
let host = server;
const i = server.indexOf(':');
if (i > 0) {
host = server.slice(0, i);
}
return {
type: actions.CONNECT,
host,
nick,
options,
socket: {
type: 'connect',
data: {
server,
nick,
username: options.username || nick,
password: options.password,
realname: options.realname || nick,
tls: options.tls || false,
name: options.name || server
}
}
};
}
export function disconnect(server) {
return dispatch => {
dispatch({
type: actions.DISCONNECT,
server,
socket: {
type: 'quit',
data: { server }
}
});
dispatch(updateSelection());
};
}
export function whois(user, server) {
return {
type: actions.WHOIS,
user,
server,
socket: {
type: 'whois',
data: { user, server }
}
};
}
export function away(message, server) {
return {
type: actions.AWAY,
message,
server,
socket: {
type: 'away',
data: { message, server }
}
};
}
export function setNick(nick, server) {
return {
type: actions.SET_NICK,
nick,
server,
socket: {
type: 'nick',
data: {
new: nick,
server
}
}
};
}

View File

@ -0,0 +1,87 @@
import { Map } from 'immutable';
import base64 from 'base64-arraybuffer';
import createReducer from '../util/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);
},
[actions.socket.CERT_SUCCESS]() {
return Map({ uploadingCert: false });
},
[actions.socket.CERT_FAIL](state, action) {
return state.merge({
uploadingCert: false,
certError: action.message
});
},
[actions.SET_CERT_ERROR](state, action) {
return state.merge({
uploadingCert: false,
certError: action.message
});
},
[actions.SET_CERT](state, action) {
return state.merge({
certFile: action.fileName,
cert: action.cert
});
},
[actions.SET_KEY](state, action) {
return state.merge({
keyFile: action.fileName,
key: action.key
});
}
});
export function setCertError(message) {
return {
type: actions.SET_CERT_ERROR,
message
};
}
export function uploadCert() {
return (dispatch, getState) => {
const { settings } = getState();
if (settings.has('cert') && settings.has('key')) {
dispatch({
type: actions.UPLOAD_CERT,
socket: {
type: 'cert',
data: {
cert: settings.get('cert'),
key: settings.get('key')
}
}
});
} else {
dispatch(setCertError('Missing certificate or key'));
}
};
}
export function setCert(fileName, cert) {
return {
type: actions.SET_CERT,
fileName,
cert: base64.encode(cert)
};
}
export function setKey(fileName, key) {
return {
type: actions.SET_KEY,
fileName,
key: base64.encode(key)
};
}

View File

@ -1,7 +1,7 @@
import { Record, List } from 'immutable';
import { LOCATION_CHANGED } from '../util/router';
import { push, replace, LOCATION_CHANGED } from '../util/router';
import createReducer from '../util/createReducer';
import * as actions from '../actions';
import * as actions from './actions';
const TabRecord = Record({
server: null,
@ -64,3 +64,39 @@ export default createReducer(new State(), {
return state.set('selected', new Tab());
}
});
export function select(server, name, doReplace) {
const navigate = doReplace ? replace : push;
if (name) {
return navigate(`/${server}/${encodeURIComponent(name)}`);
}
return navigate(`/${server}`);
}
export function updateSelection() {
return (dispatch, getState) => {
const state = getState();
const history = state.tab.history;
const { servers } = state;
const { server } = state.tab.selected;
if (servers.size === 0) {
dispatch(replace('/connect'));
} else if (history.size > 0) {
const tab = history.last();
dispatch(select(tab.server, tab.name, true));
} else if (servers.has(server)) {
dispatch(select(server, null, true));
} else {
dispatch(select(servers.keySeq().first(), null, true));
}
};
}
export function setSelectedTab(server, name = null) {
return {
type: actions.SELECT_TAB,
server,
name
};
}

47
client/src/js/state/ui.js Normal file
View File

@ -0,0 +1,47 @@
import { Record } from 'immutable';
import createReducer from '../util/createReducer';
import { LOCATION_CHANGED } from '../util/router';
import * as actions from './actions';
const State = Record({
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);
}
export default createReducer(new State(), {
[actions.TOGGLE_MENU](state) {
return state.update('showTabList', show => !show);
},
[actions.HIDE_MENU]: setMenuHidden,
[LOCATION_CHANGED]: setMenuHidden,
[actions.TOGGLE_USERLIST](state) {
return state.update('showUserList', show => !show);
}
});
export function hideMenu() {
return {
type: actions.HIDE_MENU
};
}
export function toggleMenu() {
return {
type: actions.TOGGLE_MENU
};
}
export function toggleUserList() {
return {
type: actions.TOGGLE_USERLIST
};
}

View File

@ -1,9 +1,9 @@
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import createReducer from '../reducers';
import { routeReducer, routeMiddleware } from '../util/router';
import createSocketMiddleware from '../middleware/socket';
import commands from '../commands';
import createReducer from './state';
import { routeReducer, routeMiddleware } from './util/router';
import createSocketMiddleware from './middleware/socket';
import commands from './commands';
export default function configureStore(socket) {
// eslint-disable-next-line no-underscore-dangle

View File

@ -1,64 +1,6 @@
import FontFaceObserver from 'fontfaceobserver';
import { stringWidth, measureScrollBarWidth } from './index';
import { updateMessageHeight } from '../actions/message';
const lineHeight = 24;
const menuWidth = 200;
const userListWidth = 200;
const messagePadding = 30;
const smallScreen = 600;
let windowWidth;
function init(store, charWidth, done) {
window.messageIndent = 6 * charWidth;
const scrollBarWidth = measureScrollBarWidth();
let prevWrapWidth;
function updateWidth() {
windowWidth = window.innerWidth;
let wrapWidth = windowWidth - scrollBarWidth - messagePadding;
if (windowWidth > smallScreen) {
wrapWidth -= menuWidth;
}
if (wrapWidth !== prevWrapWidth) {
prevWrapWidth = wrapWidth;
store.dispatch(updateMessageHeight(wrapWidth, charWidth));
}
}
let resizeRAF;
function resize() {
if (resizeRAF) {
window.cancelAnimationFrame(resizeRAF);
}
resizeRAF = window.requestAnimationFrame(updateWidth);
}
updateWidth();
done();
window.addEventListener('resize', resize);
}
export function initWidthUpdates(store, done) {
let charWidth = localStorage.charWidth;
if (charWidth) {
init(store, parseFloat(charWidth), done);
}
new FontFaceObserver('Roboto Mono').load().then(() => {
if (!charWidth) {
charWidth = stringWidth(' ', '16px Roboto Mono');
init(store, charWidth, done);
localStorage.charWidth = charWidth;
}
});
new FontFaceObserver('Montserrat').load();
new FontFaceObserver('Montserrat', { weight: 700 }).load();
new FontFaceObserver('Roboto Mono', { weight: 700 }).load();
}
export function findBreakpoints(text) {
const breakpoints = [];
@ -76,15 +18,15 @@ export function findBreakpoints(text) {
return breakpoints;
}
export function messageHeight(message, width, charWidth, indent = 0) {
export function messageHeight(message, wrapWidth, charWidth, indent = 0, windowWidth) {
let pad = (6 + (message.from ? message.from.length + 1 : 0)) * charWidth;
let height = lineHeight + 8;
if (message.channel && windowWidth > smallScreen) {
width -= userListWidth;
wrapWidth -= userListWidth;
}
if (pad + (message.length * charWidth) < width) {
if (pad + (message.length * charWidth) < wrapWidth) {
return height;
}
@ -93,7 +35,7 @@ export function messageHeight(message, width, charWidth, indent = 0) {
let prevPos = 0;
for (let i = 0; i < breaks.length; i++) {
if (pad + ((breaks[i].end - prevBreak) * charWidth) >= width) {
if (pad + ((breaks[i].end - prevBreak) * charWidth) >= wrapWidth) {
prevBreak = prevPos;
pad = indent;
height += lineHeight;
@ -102,7 +44,7 @@ export function messageHeight(message, width, charWidth, indent = 0) {
prevPos = breaks[i].next;
}
if (pad + ((message.length - prevBreak) * charWidth) >= width) {
if (pad + ((message.length - prevBreak) * charWidth) >= wrapWidth) {
height += lineHeight;
}

View File

@ -1,22 +1,11 @@
function subscribe(store, selector, handler) {
let prev = selector(store.getState());
handler(prev);
store.subscribe(() => {
const next = selector(store.getState());
if (next !== prev) {
handler(next);
prev = next;
}
});
}
function subscribeArray(store, selectors, handler) {
function subscribeArray(store, selectors, handler, init) {
let state = store.getState();
let prev = selectors.map(selector => selector(state));
if (init) {
handler(...prev);
}
store.subscribe(() => {
return store.subscribe(() => {
state = store.getState();
const next = [];
let changed = false;
@ -35,10 +24,88 @@ function subscribeArray(store, selectors, handler) {
});
}
export default function observe(store, selector, handler) {
function subscribe(store, selector, handler, init) {
if (Array.isArray(selector)) {
subscribeArray(store, selector, handler);
} else {
subscribe(store, selector, handler);
return subscribeArray(store, selector, handler, init);
}
let prev = selector(store.getState());
if (init) {
handler(prev);
}
return store.subscribe(() => {
const next = selector(store.getState());
if (next !== prev) {
handler(next);
prev = next;
}
});
}
//
// Handler gets called every time the selector(s) change
//
export function observe(store, selector, handler) {
return subscribe(store, selector, handler, true);
}
//
// Handler gets called once the next time the selector(s) change
//
export function once(store, selector, handler) {
let done = false;
const unsubscribe = subscribe(store, selector, (...args) => {
if (!done) {
done = true;
handler(...args);
}
unsubscribe();
});
}
//
// Handler gets called once when the predicate returns true, the predicate gets passed
// the result of the selector(s), if no predicate is set it defaults to checking if the
// selector(s) return something truthy
//
export function when(store, selector, predicate, handler) {
if (arguments.length === 3) {
handler = predicate;
if (Array.isArray(selector)) {
predicate = (...args) => {
for (let i = 0; i < args.length; i++) {
if (!args[i]) {
return false;
}
}
return true;
};
} else {
predicate = o => o;
}
}
const state = store.getState();
if (Array.isArray(selector)) {
const val = selector.map(s => s(state));
if (predicate(...val)) {
return handler(...val);
}
} else {
const val = selector(state);
if (predicate(val)) {
return handler(val);
}
}
let done = false;
const unsubscribe = subscribe(store, selector, (...args) => {
if (!done && predicate(...args)) {
done = true;
handler(...args);
}
unsubscribe();
});
}