Switch to redux and webpack

This commit is contained in:
Ken-Håvard Lieng 2015-12-29 00:34:32 +01:00
parent b247287075
commit e389454535
97 changed files with 2722 additions and 2656 deletions

15
.editorconfig Normal file
View File

@ -0,0 +1,15 @@
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
[*.go]
indent_style = tab
[*.md]
trim_trailing_whitespace = false

View File

@ -15,7 +15,7 @@ install:
- npm install
script:
- gulp -p
- gulp build
- cd ..
- go vet ./...
- go test -v -race ./...

View File

@ -54,8 +54,12 @@ npm install
Run the build:
```bash
gulp -p
gulp build
```
The server needs to be rebuilt after this. For development dropping the -p flag
will turn off minification and embedding, requiring only one initial server rebuild.
The server needs to be rebuilt after this.
For development with hot reloading enabled just run:
```bash
gulp
```

File diff suppressed because one or more lines are too long

20
client/.babelrc Normal file
View File

@ -0,0 +1,20 @@
{
"presets": ["react", "es2015", "stage-0"],
"plugins": ["transform-decorators-legacy"],
"env": {
"development": {
"plugins": [
["react-transform", {
"transforms": [{
"transform": "react-transform-hmr",
"imports": ["react"],
"locals": ["module"]
}, {
"transform": "react-transform-catch-errors",
"imports": ["react", "redbox-react"]
}]
}]
]
}
}
}

View File

@ -1,22 +1,14 @@
{
"ecmaFeatures": {
"jsx": true
},
"env": {
"browser": true,
"node": true
},
"extends": "airbnb",
"parser": "babel-eslint",
"plugins": [
"react"
],
"rules": {
"quotes": [2, "single"],
"strict": [2, "never"],
"eol-last": [0],
"new-cap": [2, { "capIsNew": false }],
"react/jsx-uses-react": 2,
"react/jsx-uses-vars": 2,
"react/react-in-jsx-scope": 2
"comma-dangle": [2, "never"],
"new-cap": [2, { "capIsNewExceptions": ["Map", "List", "Record", "Set"] }],
"no-console": 0,
"no-param-reassign": 0,
"react/prop-types": 0
},
"globals": {
"__DEV__": true
}
}

View File

@ -1,37 +1,23 @@
var path = require('path');
var exec = require('child_process').exec;
var gulp = require('gulp');
var gutil = require('gulp-util');
var gulpif = require('gulp-if');
var minifyHTML = require('gulp-minify-html');
var minifyCSS = require('gulp-minify-css');
var htmlmin = require('gulp-htmlmin');
var nano = require('gulp-cssnano');
var autoprefixer = require('gulp-autoprefixer');
var uglify = require('gulp-uglify');
var gzip = require('gulp-gzip');
var concat = require('gulp-concat');
var eslint = require('gulp-eslint');
var browserify = require('browserify');
var source = require('vinyl-source-stream');
var streamify = require('gulp-streamify');
var babelify = require('babelify');
var strictify = require('strictify');
var watchify = require('watchify');
var merge = require('merge-stream');
var cache = require('gulp-cached');
var argv = require('yargs')
.alias('p', 'production')
.argv;
if (argv.production) {
process.env['NODE_ENV'] = 'production';
}
var deps = Object.keys(require('./package.json').dependencies);
var express = require('express');
var webpack = require('webpack');
gulp.task('html', function() {
return gulp.src('src/*.html')
.pipe(minifyHTML())
.pipe(htmlmin({
collapseWhitespace: true,
removeAttributeQuotes: true
}))
.pipe(gulp.dest('dist'));
});
@ -39,64 +25,24 @@ gulp.task('css', function() {
return gulp.src(['src/css/fontello.css', 'src/css/style.css'])
.pipe(concat('bundle.css'))
.pipe(autoprefixer())
.pipe(minifyCSS())
.pipe(nano())
.pipe(gulp.dest('dist'));
});
gulp.task('js', function() {
return js(false);
});
gulp.task('js', function(cb) {
var config = require('./webpack.config.prod.js');
var compiler = webpack(config);
function js(watch) {
var bundler = browserify('./src/js/app.js', {
debug: !argv.production,
transform: [
babelify.configure({
presets: ['es2015', 'react']
}),
strictify
],
cache: {},
packageCache: {},
fullPaths: watch
process.env['NODE_ENV'] = 'production';
compiler.run(function(err, stats) {
if (err) throw new gutil.PluginError('webpack', err);
gutil.log('[webpack]', stats.toString({
colors: true
}));
cb();
});
bundler.external(deps);
var rebundle = function() {
return bundler.bundle()
.on('error', gutil.log)
.pipe(source('bundle.js'))
.pipe(gulpif(argv.production, streamify(uglify())))
.pipe(gulp.dest('dist'));
};
if (watch) {
bundler = watchify(bundler);
bundler.on('update', rebundle);
bundler.on('log', gutil.log);
}
var vendorBundler = browserify({
debug: !argv.production,
require: deps
});
var vendor = vendorBundler.bundle()
.on('error', gutil.log)
.pipe(source('vendor.js'))
.pipe(gulpif(argv.production, streamify(uglify())))
.pipe(gulp.dest('dist'));
return merge(rebundle(), vendor);
}
gulp.task('lint', function() {
return gulp.src('src/js/**/*.{js,jsx}')
.pipe(cache('lint'))
.pipe(eslint())
.pipe(eslint.format())
.pipe(eslint.failOnError());
});
gulp.task('fonts', function() {
@ -109,36 +55,54 @@ gulp.task('config', function() {
.pipe(gulp.dest('dist/gz'));
});
gulp.task('gzip', ['html', 'css', 'js', 'fonts'], function() {
function compress() {
return gulp.src(['dist/**/!(*.gz)', '!dist/{gz,gz/**}'])
.pipe(gzip())
.pipe(gulp.dest('dist/gz'));
});
gulp.task('gzip:watch', function() {
return gulp.src('dist/**/*.{html,css,js}')
.pipe(cache('gzip'))
.pipe(gzip())
.pipe(gulp.dest('dist/gz'));
});
function bindata(cb) {
if (argv.production) {
exec('go-bindata -nomemcopy -nocompress -pkg assets -o ../assets/bindata.go -prefix "dist/gz" dist/gz/...', cb);
} else {
exec('go-bindata -debug -pkg assets -o ../assets/bindata.go -prefix "dist/gz" dist/gz/...', cb);
}
}
gulp.task('bindata', ['gzip', 'config'], bindata);
gulp.task('bindata:watch', ['gzip:watch'], bindata);
gulp.task('gzip', ['html', 'css', 'js', 'fonts'], compress);
gulp.task('gzip:dev', ['html', 'css', 'fonts'], compress);
gulp.task('watch', ['default'], function() {
gulp.watch('dist/**/*.{html,css,js}', ['gzip:watch', 'bindata:watch'])
gulp.watch('src/*.html', ['html']);
gulp.watch('src/css/*.css', ['css']);
gulp.watch('src/js/**/*.{js,jsx}', ['lint']);
return js(true);
gulp.task('bindata', ['gzip', 'config'], function(cb) {
exec('go-bindata -nomemcopy -nocompress -pkg assets -o ../assets/bindata.go -prefix "dist/gz" dist/gz/...', cb);
});
gulp.task('default', ['html', 'css', 'js', 'lint', 'fonts', 'config', 'gzip', 'bindata']);
gulp.task('bindata:dev', ['gzip:dev', 'config'], function(cb) {
exec('go-bindata -debug -pkg assets -o ../assets/bindata.go -prefix "dist/gz" dist/gz/...', cb);
});
gulp.task('dev', ['html', 'css', 'fonts', 'config', 'gzip:dev', 'bindata:dev'], function() {
gulp.watch('src/*.html', ['html']);
gulp.watch('src/css/*.css', ['css']);
var config = require('./webpack.config.dev.js');
var compiler = webpack(config);
var app = express();
app.use(require('webpack-dev-middleware')(compiler, {
noInfo: true,
publicPath: config.output.publicPath
}));
app.use(require('webpack-hot-middleware')(compiler));
app.use('/', express.static('dist'));
app.get('*', function (req, res) {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
app.listen(3000, 'localhost', function (err) {
if (err) {
console.log(err);
return;
}
console.log('Listening at http://localhost:3000');
});
});
gulp.task('build', ['html', 'css', 'js', 'fonts', 'config', 'gzip', 'bindata']);
gulp.task('default', ['dev']);

View File

@ -4,43 +4,53 @@
"description": "",
"main": "index.js",
"devDependencies": {
"babel-core": "^6.3.26",
"babel-eslint": "^5.0.0-beta6",
"babel-loader": "^6.2.0",
"babel-plugin-react-transform": "^2.0.0",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-preset-es2015": "^6.3.13",
"babel-preset-react": "^6.3.13",
"babelify": "7.2.0",
"browserify": "12.0.1",
"babel-preset-stage-0": "^6.3.13",
"eslint": "^1.10.3",
"eslint-plugin-react": "^3.11.3",
"eslint-config-airbnb": "^2.1.1",
"eslint-loader": "^1.1.1",
"eslint-plugin-react": "^3.13.1",
"express": "^4.13.3",
"gulp": "~3.9.0",
"gulp-autoprefixer": "3.1.0",
"gulp-cached": "^1.1.0",
"gulp-concat": "~2.6.0",
"gulp-eslint": "^1.1.1",
"gulp-cssnano": "^2.0.0",
"gulp-gzip": "1.2.0",
"gulp-if": "~2.0.0",
"gulp-minify-css": "1.2.2",
"gulp-minify-html": "1.0.4",
"gulp-streamify": "1.0.2",
"gulp-uglify": "1.5.1",
"gulp-util": "^3.0.5",
"merge-stream": "^1.0.0",
"reactify": "^1.1.1",
"strictify": "~0.2.0",
"vinyl-source-stream": "~1.1.0",
"watchify": "3.6.1",
"yargs": "~3.31.0"
"gulp-htmlmin": "^1.3.0",
"gulp-util": "^3.0.7",
"react-transform-catch-errors": "^1.0.1",
"react-transform-hmr": "^1.0.1",
"redbox-react": "^1.2.0",
"redux-devtools": "^3.0.1",
"redux-devtools-dock-monitor": "^1.0.1",
"redux-devtools-log-monitor": "^1.0.1",
"webpack": "^1.12.9",
"webpack-dev-middleware": "^1.4.0",
"webpack-hot-middleware": "^2.6.0"
},
"dependencies": {
"autolinker": "khlieng/Autolinker.js",
"autolinker": "^0.22.0",
"backo": "^1.1.0",
"history": "1.13.1",
"immutable": "^3.7.5",
"lodash": "3.10.1",
"eventemitter2": "^0.4.14",
"history": "^1.17.0",
"immutable": "^3.7.6",
"lodash": "^3.10.1",
"pure-render-decorator": "^0.2.0",
"react": "^0.14.3",
"react-dom": "^0.14.3",
"react-infinite": "0.7.2",
"react-pure-render": "~1.0.1",
"react-router": "^1.0.1",
"reflux": "0.3.0"
"react-infinite": "0.7.3",
"react-redux": "^4.0.5",
"react-router": "^1.0.3",
"redux": "^3.0.5",
"redux-simple-router": "^1.0.2",
"redux-thunk": "^1.0.2",
"reselect": "^2.0.1"
}
}

View File

@ -4,14 +4,13 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>IRC</title>
<title>Dispatch</title>
<link href="//fonts.googleapis.com/css?family=Montserrat|Droid+Sans+Mono" rel="stylesheet">
<link href="bundle.css" rel="stylesheet">
<link href="/bundle.css" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script src="vendor.js"></script>
<script src="bundle.js"></script>
<script src="/bundle.js"></script>
</body>
</html>

37
client/src/js/actions.js Normal file
View File

@ -0,0 +1,37 @@
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 HIDE_MENU = 'HIDE_MENU';
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 SEARCH_MESSAGES = 'SEARCH_MESSAGES';
export const SELECT_TAB = 'SELECT_TAB';
export const SEND_MESSAGE = 'SEND_MESSAGE';
export const SET_ENVIRONMENT = 'SET_ENVIRONMENT';
export const SET_NICK = 'SET_NICK';
export const SOCKET_CHANNELS = 'SOCKET_CHANNELS';
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_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 WHOIS = 'WHOIS';

View File

@ -1,36 +1,55 @@
var Reflux = require('reflux');
import * as actions from '../actions';
import { updateSelection } from './tab';
var socket = require('../socket');
export function join(channels, server) {
return {
type: actions.JOIN,
channels,
server,
socket: {
type: 'join',
data: { channels, server }
}
};
}
var channelActions = Reflux.createActions([
'join',
'part',
'invite',
'kick',
'addUser',
'removeUser',
'removeUserAll',
'renameUser',
'setUsers',
'setTopic',
'setMode',
'load'
]);
export function part(channels, server) {
return dispatch => {
dispatch({
type: actions.PART,
channels,
server,
socket: {
type: 'part',
data: { channels, server }
}
});
dispatch(updateSelection());
};
}
channelActions.join.preEmit = (channels, server) => {
socket.send('join', { server, channels });
};
export function invite(user, channel, server) {
return {
type: actions.INVITE,
user,
channel,
server,
socket: {
type: 'invite',
data: { user, channel, server }
}
};
}
channelActions.part.preEmit = (channels, server) => {
socket.send('part', { server, channels });
};
channelActions.invite.preEmit = (user, channel, server) => {
socket.send('invite', { server, channel, user });
};
channelActions.kick.preEmit = (user, channel, server) => {
socket.send('kick', { server, channel, user });
};
module.exports = channelActions;
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,17 @@
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,10 +1,26 @@
var Reflux = require('reflux');
import * as actions from '../actions';
var inputHistoryActions = Reflux.createActions([
'add',
'reset',
'increment',
'decrement'
]);
export function addInputHistory(line) {
return {
type: actions.INPUT_HISTORY_ADD,
line
};
}
module.exports = inputHistoryActions;
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,19 +1,77 @@
var Reflux = require('reflux');
import * as actions from '../actions';
var socket = require('../socket');
export function sendMessage(message, to, server) {
return (dispatch, getState) => {
return dispatch({
type: actions.SEND_MESSAGE,
from: getState().servers.getIn([server, 'nick']),
message,
to,
server,
time: new Date(),
socket: {
type: 'chat',
data: { message, to, server }
}
});
};
}
var messageActions = Reflux.createActions([
'send',
'add',
'addAll',
'broadcast',
'inform',
'command',
'setWrapWidth'
]);
export function addMessage(message) {
message.time = new Date();
messageActions.send.preEmit = (message, to, server) => {
socket.send('chat', { server, to, message });
};
return {
type: actions.ADD_MESSAGE,
message
};
}
module.exports = messageActions;
export function addMessages(messages) {
const now = new Date();
messages.forEach(message => message.time = now);
return {
type: actions.ADD_MESSAGES,
messages
};
}
export function broadcast(message, server, channels) {
return addMessages(channels.map(channel => {
return {
server,
to: channel,
message,
type: 'info'
};
}));
}
export function inform(message, server, channel) {
if (Array.isArray(message)) {
return addMessages(message.map(msg => {
return {
server,
to: channel,
message: msg,
type: 'info'
};
}));
}
return addMessage({
server,
to: channel,
message,
type: 'info'
});
}
export function runCommand(command, channel, server) {
return {
type: actions.COMMAND,
command,
channel,
server
};
}

View File

@ -1,8 +1,21 @@
var Reflux = require('reflux');
import * as actions from '../actions';
import { updateSelection } from './tab';
var privateChatActions = Reflux.createActions([
'open',
'close'
]);
export function openPrivateChat(server, nick) {
return {
type: actions.OPEN_PRIVATE_CHAT,
server,
nick
};
}
module.exports = privateChatActions;
export function closePrivateChat(server, nick) {
return dispatch => {
dispatch({
type: actions.CLOSE_PRIVATE_CHAT,
server,
nick
});
dispatch(updateSelection());
};
}

View File

@ -1,7 +0,0 @@
var Reflux = require('reflux');
var routeActions = Reflux.createActions([
'navigate'
]);
module.exports = routeActions;

View File

@ -1,15 +1,20 @@
var Reflux = require('reflux');
import * as actions from '../actions';
var socket = require('../socket');
export function searchMessages(server, channel, phrase) {
return {
type: actions.SEARCH_MESSAGES,
server,
channel,
phrase,
socket: {
type: 'search',
data: { server, channel, phrase }
}
};
}
var searchActions = Reflux.createActions([
'search',
'searchDone',
'toggle'
]);
searchActions.search.preEmit = (server, channel, phrase) => {
socket.send('search', { server, channel, phrase });
};
module.exports = searchActions;
export function toggleSearch() {
return {
type: actions.TOGGLE_SEARCH
};
}

View File

@ -1,45 +1,76 @@
var Reflux = require('reflux');
import * as actions from '../actions';
import { updateSelection } from './tab';
var socket = require('../socket');
var serverActions = Reflux.createActions([
'connect',
'disconnect',
'whois',
'away',
'setNick',
'load'
]);
serverActions.connect.preEmit = (server, nick, opts) => {
socket.send('connect', {
export function connect(server, nick, options) {
return {
type: actions.CONNECT,
server,
nick,
username: opts.username || nick,
password: opts.password,
realname: opts.realname || nick,
tls: opts.tls || false,
name: opts.name || server
});
};
serverActions.disconnect.preEmit = (server) => {
socket.send('quit', { server });
};
serverActions.whois.preEmit = (user, server) => {
socket.send('whois', { server, user });
};
serverActions.away.preEmit = (message, server) => {
socket.send('away', { server, message });
};
serverActions.setNick.preEmit = (nick, server) => {
socket.send('nick', {
options,
socket: {
type: 'connect',
data: {
server,
new: nick
});
};
nick,
username: options.username || nick,
password: options.password,
realname: options.realname || nick,
tls: options.tls || false,
name: options.name || server
}
}
};
}
module.exports = serverActions;
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,9 +1,60 @@
var Reflux = require('reflux');
import { pushPath } from 'redux-simple-router';
import * as actions from '../actions';
var tabActions = Reflux.createActions([
'select',
'hideMenu',
'toggleMenu'
]);
export function select(server, channel, pm) {
if (pm) {
return pushPath(`/${server}/pm/${channel}`);
} else if (channel) {
return pushPath(`/${server}/${encodeURIComponent(channel)}`);
}
module.exports = tabActions;
return pushPath(`/${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(pushPath('/connect'));
} else if (history.size > 0) {
const tab = history.last();
dispatch(select(tab.server, tab.channel || tab.user, tab.user));
} else if (servers.has(server)) {
dispatch(select(server));
} else {
dispatch(pushPath('/'));
}
};
}
export function setSelectedChannel(server, channel = null) {
return {
type: actions.SELECT_TAB,
server,
channel
};
}
export function setSelectedUser(server, user = null) {
return {
type: actions.SELECT_TAB,
server,
user
};
}
export function hideMenu() {
return {
type: actions.HIDE_MENU
};
}
export function toggleMenu() {
return {
type: actions.TOGGLE_MENU
};
}

View File

@ -1,35 +0,0 @@
import React from 'react';
import { render } from 'react-dom';
import { Router, Route, IndexRoute } from 'react-router';
import createBrowserHistory from 'history/lib/createBrowserHistory';
import './irc';
import './command';
import socket from './socket';
import util from './util';
import App from './components/App.jsx';
import Connect from './components/Connect.jsx';
import Chat from './components/Chat.jsx';
import Settings from './components/Settings.jsx';
import routeActions from './actions/route';
let uuid = localStorage.uuid;
if (!uuid) {
routeActions.navigate('connect', true);
localStorage.uuid = uuid = util.UUID();
}
socket.on('connect', () => socket.send('uuid', uuid));
socket.on('error', error => console.log(error.server + ': ' + error.message));
const routes = (
<Route path="/" component={App}>
<Route path="connect" component={Connect} />
<Route path="settings" component={Settings} />
<Route path="/:server" component={Chat} />
<Route path="/:server/:channel" component={Chat} />
<IndexRoute component={Settings} />
</Route>
);
const history = createBrowserHistory();
render(<Router routes={routes} history={history} />, document.getElementById('root'));

View File

@ -1,114 +0,0 @@
var _ = require('lodash');
var channelStore = require('./stores/channel');
var channelActions = require('./actions/channel');
var messageActions = require('./actions/message');
var serverActions = require('./actions/server');
var tabActions = require('./actions/tab');
messageActions.command.listen(function(line, channel, server) {
var params = line.slice(1).split(' ');
var command = params[0].toLowerCase();
switch (command) {
case 'nick':
if (params[1]) {
serverActions.setNick(params[1], server);
}
break;
case 'quit':
serverActions.disconnect(server);
break;
case 'join':
if (params[1]) {
channelActions.join([params[1]], server);
tabActions.select(server, params[1]);
}
break;
case 'part':
if (params[1]) {
channelActions.part([params[1]], server);
} else if (channel) {
channelActions.part([channel], server);
}
break;
case 'me':
if (params.length > 1) {
messageActions.send('\x01ACTION ' + params.slice(1).join(' ') + '\x01', channel, server);
}
break;
case 'topic':
let topic = channelStore.getTopic(server, channel);
if (topic) {
messageActions.add({
server: server,
to: channel,
message: topic
});
} else {
messageActions.inform('No topic set', server, channel);
}
break;
case 'invite':
if (params[1] && params[2] && server) {
channelActions.invite(params[1], params[2], server);
} else if (params[1] && channel) {
channelActions.invite(params[1], channel, server);
}
break;
case 'kick':
if (params[1] && channel) {
channelActions.kick(params[1], channel, server);
}
break;
case 'msg':
if (params.length > 2) {
let dest = params[1];
let message = params.slice(2).join(' ');
messageActions.send(message, dest, server);
}
break;
case 'say':
if (params.length > 1) {
let message = params.slice(1).join(' ');
messageActions.send(message, channel, server);
}
break;
case 'whois':
if (params[1]) {
serverActions.whois(params[1], server);
}
break;
case 'away':
serverActions.away(params[1], server);
break;
case 'help':
messageActions.inform([
_.escape('/join <channel> - Join a channel'),
'/part [channel] - Leave the current or entered channel',
_.escape('/nick <nick> - Change nick'),
'/quit - Disconnect from the current server',
_.escape('/me <message> - Send action message'),
'/topic - Show topic for the current channel',
_.escape('/msg <target> <message> - Send message to the entered channel or user'),
_.escape('/say <message> - Send message to the current chat'),
'/away [message] - Set or clear away message'
], server, channel);
break;
}
});

108
client/src/js/commands.js Normal file
View File

@ -0,0 +1,108 @@
import _ from 'lodash';
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, inform } from './actions/message';
const help = [
'/join <channel> - Join a channel',
'/part [channel] - Leave the current or specified channel',
'/nick <nick> - Change nick',
'/quit - Disconnect from the current server',
'/me <message> - Send action message',
'/topic - Show topic for the current channel',
'/msg <target> <message> - Send message to the specified channel or user',
'/say <message> - Send message to the current chat',
'/invite <user> [channel] - Invite user to the current or specified channel',
'/kick <user> - Kick user from the current channel',
'/whois <user> - Get information about user',
'/away [message] - Set or clear away message'
].map(_.escape);
export default createCommandMiddleware(COMMAND, {
join({ dispatch, server }, channel) {
if (channel) {
dispatch(join([channel], server));
dispatch(select(server, channel));
}
},
part({ dispatch, server, channel }, partChannel) {
if (partChannel) {
dispatch(part([partChannel], server));
} else {
dispatch(part([channel], server));
}
},
nick({ dispatch, server }, nick) {
if (nick) {
dispatch(setNick(nick, server));
}
},
quit({ dispatch, server }) {
dispatch(disconnect(server));
},
me({ dispatch, server, channel }, ...params) {
if (params.length > 0) {
dispatch(sendMessage(`\x01ACTION ${params.join(' ')}\x01`, channel, server));
}
},
topic({ dispatch, getState, server, channel }) {
const topic = getState().channels.getIn([server, channel, 'topic']);
if (topic) {
dispatch(addMessage({
server,
to: channel,
message: topic
}));
} else {
dispatch(inform('No topic set', server, channel));
}
},
msg({ dispatch, server }, target, ...message) {
if (target && message) {
dispatch(sendMessage(message.join(' '), target, server));
}
},
say({ dispatch, server, channel }, ...message) {
if (channel && message) {
dispatch(sendMessage(message.join(' '), channel, server));
}
},
invite({ dispatch, server, channel }, user, inviteChannel) {
if (user && inviteChannel) {
dispatch(invite(user, inviteChannel, server));
} else if (user && channel) {
dispatch(invite(user, channel, server));
}
},
kick({ dispatch, server, channel }, user) {
if (user && channel) {
dispatch(kick(user, channel, server));
}
},
whois({ dispatch, server }, user) {
if (user) {
dispatch(whois(user, server));
}
},
away({ dispatch, server }, message) {
dispatch(away(message, server));
},
help({ dispatch, server, channel }) {
dispatch(inform(help, server, channel));
}
});

View File

@ -1,52 +0,0 @@
import React from 'react';
import Reflux from 'reflux';
import { Router } from 'react-router';
import TabList from './TabList.jsx';
import routeActions from '../actions/route';
import tabActions from '../actions/tab';
import PureMixin from '../mixins/pure';
export default React.createClass({
mixins: [
PureMixin,
Reflux.listenTo(routeActions.navigate, 'navigate'),
Reflux.listenTo(tabActions.hideMenu, 'hideMenu'),
Reflux.listenTo(tabActions.toggleMenu, 'toggleMenu')
],
getInitialState() {
return {
menuToggled: false
};
},
navigate(path, replace) {
const { history } = this.props;
if (!replace) {
history.pushState(null, path);
} else {
history.replaceState(null, path);
}
},
hideMenu() {
this.setState({ menuToggled: false });
},
toggleMenu() {
this.setState({ menuToggled: !this.state.menuToggled });
},
render() {
const mainClass = this.state.menuToggled ? 'main-container off-canvas' : 'main-container';
return (
<div>
<TabList menuToggled={this.state.menuToggled} />
<div className={mainClass}>
{this.props.children}
</div>
</div>
);
}
});

View File

@ -1,53 +0,0 @@
import React from 'react';
import Reflux from 'reflux';
import Router from 'react-router';
import ChatTitle from './ChatTitle.jsx';
import Search from './Search.jsx';
import MessageBox from './MessageBox.jsx';
import MessageInput from './MessageInput.jsx';
import UserList from './UserList.jsx';
import selectedTabStore from '../stores/selectedTab';
import tabActions from '../actions/tab';
import PureMixin from '../mixins/pure';
export default React.createClass({
mixins: [
PureMixin,
Router.State,
Reflux.connect(selectedTabStore, 'selectedTab')
],
componentWillMount() {
if (!window.loaded) {
const { params } = this.props;
if (params.channel) {
tabActions.select(params.server, '#' + params.channel);
} else if (params.server) {
tabActions.select(params.server);
}
}
},
render() {
let chatClass;
const tab = this.state.selectedTab;
if (!tab.channel) {
chatClass = 'chat-server';
} else if (tab.channel[0] !== '#') {
chatClass = 'chat-private';
} else {
chatClass = 'chat-channel';
}
return (
<div className={chatClass}>
<ChatTitle />
<Search />
<MessageBox />
<MessageInput />
<UserList />
</div>
);
}
});

View File

@ -0,0 +1,56 @@
import React, { Component } from 'react';
import { List } from 'immutable';
import Autolinker from 'autolinker';
import pure from 'pure-render-decorator';
import Navicon from '../components/Navicon';
@pure
export default class ChatTitle extends Component {
handleLeaveClick = () => {
const { tab, channel, disconnect, part, closePrivateChat } = this.props;
if (tab.channel) {
part([channel.get('name')], tab.server);
} else if (tab.user) {
closePrivateChat(tab.server, tab.user);
} else {
disconnect(tab.server);
}
}
render() {
const { title, tab, channel, toggleSearch } = this.props;
const topic = Autolinker.link(channel.get('topic') || '', { keepOriginalText: true });
let leaveTitle;
if (tab.channel) {
leaveTitle = 'Leave';
} else if (tab.user) {
leaveTitle = 'Close';
} else {
leaveTitle = 'Disconnect';
}
return (
<div>
<div className="chat-title-bar">
<Navicon />
<span className="chat-title">{title}</span>
<div className="chat-topic-wrap">
<span className="chat-topic" dangerouslySetInnerHTML={{ __html: topic }}></span>
</div>
<i className="icon-search" title="Search" onClick={toggleSearch} />
<i
className="icon-logout button-leave"
title={leaveTitle}
onClick={this.handleLeaveClick}
/>
</div>
<div className="userlist-bar">
<i className="icon-user" />
<span className="chat-usercount">{channel.get('users', List()).size || null}</span>
</div>
</div>
);
}
}

View File

@ -1,89 +0,0 @@
var React = require('react');
var Reflux = require('reflux');
var Autolinker = require('autolinker');
var Navicon = require('./Navicon.jsx');
var channelStore = require('../stores/channel');
var selectedTabStore = require('../stores/selectedTab');
var serverActions = require('../actions/server');
var channelActions = require('../actions/channel');
var searchActions = require('../actions/search');
var privateChatActions = require('../actions/privateChat');
var PureMixin = require('../mixins/pure');
function buildState(tab) {
return {
selectedTab: tab,
usercount: channelStore.getUsers(tab.server, tab.channel).size,
topic: channelStore.getTopic(tab.server, tab.channel)
};
}
var ChatTitle = React.createClass({
mixins: [
PureMixin,
Reflux.listenTo(channelStore, 'channelsChanged'),
Reflux.listenTo(selectedTabStore, 'selectedTabChanged')
],
getInitialState() {
return buildState(selectedTabStore.getState());
},
channelsChanged() {
this.setState(buildState(this.state.selectedTab));
},
selectedTabChanged(tab) {
this.setState(buildState(tab));
},
handleLeaveClick() {
var tab = this.state.selectedTab;
if (!tab.channel) {
serverActions.disconnect(tab.server);
} else if (tab.channel[0] === '#') {
channelActions.part([tab.channel], tab.server);
} else {
privateChatActions.close(tab.server, tab.channel);
}
},
render() {
var tab = this.state.selectedTab;
var topic = Autolinker.link(this.state.topic || '', { keepOriginalText: true });
var leaveTitle;
if (!tab.channel) {
leaveTitle = 'Disconnect';
} else if (tab.channel[0] !== '#') {
leaveTitle = 'Close';
} else {
leaveTitle = 'Leave';
}
return (
<div>
<div className="chat-title-bar">
<Navicon />
<span className="chat-title">{tab.name}</span>
<div className="chat-topic-wrap">
<span className="chat-topic" dangerouslySetInnerHTML={{ __html: topic }}></span>
</div>
<i className="icon-search" title="Search" onClick={searchActions.toggle}></i>
<i
className="icon-logout button-leave"
title={leaveTitle}
onClick={this.handleLeaveClick}></i>
</div>
<div className="userlist-bar">
<i className="icon-user"></i>
<span className="chat-usercount">{this.state.usercount || null}</span>
</div>
</div>
);
}
});
module.exports = ChatTitle;

View File

@ -1,82 +0,0 @@
var React = require('react');
var _ = require('lodash');
var Navicon = require('./Navicon.jsx');
var serverActions = require('../actions/server');
var channelActions = require('../actions/channel');
var PureMixin = require('../mixins/pure');
var Connect = React.createClass({
mixins: [PureMixin],
getInitialState() {
return {
showOptionals: false
};
},
handleSubmit(e) {
e.preventDefault();
var address = e.target.address.value.trim();
var nick = e.target.nick.value.trim();
var channels = _.filter(_.map(e.target.channels.value.split(','), _.trim));
var opts = {
name: e.target.name.value.trim(),
tls: e.target.ssl.checked
};
if (this.state.showOptionals) {
opts.realname = e.target.realname.value.trim();
opts.username = e.target.username.value.trim();
opts.password = e.target.password.value.trim();
}
if (address.indexOf('.') > 0 && nick) {
serverActions.connect(address, nick, opts);
if (channels.length > 0) {
channelActions.join(channels, address);
}
}
},
handleShowClick: function() {
this.setState({ showOptionals: !this.state.showOptionals});
},
render: function() {
var optionals = null;
if (this.state.showOptionals) {
optionals = (
<div>
<input name="username" type="text" placeholder="Username" />
<input name="password" type="text" placeholder="Password" />
<input name="realname" type="text" placeholder="Realname" />
</div>
);
}
return (
<div className="connect">
<Navicon />
<form ref="form" className="connect-form" onSubmit={this.handleSubmit}>
<h1>Connect</h1>
<input name="name" type="text" placeholder="Name" defaultValue="Freenode" />
<input name="address" type="text" placeholder="Address" defaultValue="irc.freenode.net" />
<input name="nick" type="text" placeholder="Nick" />
<input name="channels" type="text" placeholder="Channels" />
{optionals}
<p>
<label><input name="ssl" type="checkbox" />SSL</label>
<i className="icon-ellipsis" onClick={this.handleShowClick}></i>
</p>
<input type="submit" value="Connect" />
</form>
</div>
);
}
});
module.exports = Connect;

View File

@ -0,0 +1,90 @@
import React, { Component } from 'react';
import Infinite from 'react-infinite';
import pure from 'pure-render-decorator';
import MessageHeader from './MessageHeader';
import MessageLine from './MessageLine';
@pure
export default class MessageBox extends Component {
state = {
height: window.innerHeight - 100
}
componentDidMount() {
this.updateWidth();
window.addEventListener('resize', this.handleResize);
}
componentWillUpdate() {
const el = this.refs.list.refs.scrollable;
this.autoScroll = el.scrollTop + el.offsetHeight === el.scrollHeight;
}
componentDidUpdate() {
setTimeout(this.updateWidth, 0);
if (this.autoScroll) {
const el = this.refs.list.refs.scrollable;
el.scrollTop = el.scrollHeight;
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
}
updateWidth = () => {
const { setWrapWidth } = this.props;
const { list } = this.refs;
if (list) {
const width = list.refs.scrollable.offsetWidth - 30;
if (this.width !== width) {
this.width = width;
setWrapWidth(width);
}
}
}
handleResize = () => {
this.updateWidth();
this.setState({ height: window.innerHeight - 100 });
}
render() {
const { tab, messages, select, openPrivateChat } = this.props;
const dest = tab.channel || tab.user || tab.server;
const lines = [];
messages.forEach((message, j) => {
const key = message.server + dest + j;
lines.push(
<MessageHeader
key={key}
message={message}
select={select}
openPrivateChat={openPrivateChat}
/>
);
for (let i = 1; i < message.lines.length; i++) {
lines.push(
<MessageLine key={key + '-' + i} type={message.type} line={message.lines[i]} />
);
}
});
return (
<div className="messagebox">
<Infinite
ref="list"
className="messagebox-scrollable"
containerHeight={this.state.height}
elementHeight={24}
displayBottomUpwards={false}
>
{lines}
</Infinite>
</div>
);
}
}

View File

@ -1,93 +0,0 @@
import React from 'react';
import Reflux from 'reflux';
import Infinite from 'react-infinite';
import MessageHeader from './MessageHeader.jsx';
import MessageLine from './MessageLine.jsx';
import messageLineStore from '../stores/messageLine';
import selectedTabStore from '../stores/selectedTab';
import messageActions from '../actions/message';
import PureMixin from '../mixins/pure';
export default React.createClass({
mixins: [
PureMixin,
Reflux.connect(messageLineStore, 'messages'),
Reflux.connect(selectedTabStore, 'selectedTab')
],
getInitialState() {
return {
height: window.innerHeight - 100
};
},
componentDidMount() {
this.updateWidth();
window.addEventListener('resize', this.handleResize);
},
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
},
componentWillUpdate() {
var el = this.refs.list.refs.scrollable;
this.autoScroll = el.scrollTop + el.offsetHeight === el.scrollHeight;
},
componentDidUpdate() {
setTimeout(this.updateWidth, 0);
if (this.autoScroll) {
var el = this.refs.list.refs.scrollable;
el.scrollTop = el.scrollHeight;
}
},
handleResize() {
this.updateWidth();
this.setState({ height: window.innerHeight - 100 });
},
updateWidth() {
const { list } = this.refs;
if (list) {
const width = list.refs.scrollable.offsetWidth - 30;
if (this.width !== width) {
this.width = width;
messageActions.setWrapWidth(width);
}
}
},
render() {
const tab = this.state.selectedTab;
const dest = tab.channel || tab.server;
const lines = [];
this.state.messages.forEach((message, j) => {
const key = message.server + dest + j;
lines.push(<MessageHeader key={key} message={message} />);
for (let i = 1; i < message.lines.length; i++) {
lines.push(
<MessageLine key={key + '-' + i} type={message.type} line={message.lines[i]} />
);
}
});
return (
<div className="messagebox">
<Infinite
ref="list"
className="messagebox-scrollable"
containerHeight={this.state.height}
elementHeight={24}
displayBottomUpwards={false}>
{lines}
</Infinite>
</div>
);
}
});

View File

@ -0,0 +1,46 @@
import React, { Component } from 'react';
import Autolinker from 'autolinker';
import { timestamp } from '../util';
export default class MessageHeader extends Component {
shouldComponentUpdate(nextProps) {
return nextProps.message.lines[0] !== this.props.message.lines[0];
}
handleSenderClick = () => {
const { message, openPrivateChat, select } = this.props;
openPrivateChat(message.server, message.from);
select(message.server, message.from, true);
}
render() {
const { message } = this.props;
const line = Autolinker.link(message.lines[0], { stripPrefix: false });
let sender = null;
let messageClass = 'message';
if (message.from) {
sender = (
<span>
{' '}
<span className="message-sender" onClick={this.handleSenderClick}>
{message.from}
</span>
</span>
);
}
if (message.type) {
messageClass += ' message-' + message.type;
}
return (
<p className={messageClass}>
<span className="message-time">{timestamp(message.time)}</span>
{sender}
<span dangerouslySetInnerHTML={{ __html: ' ' + line }}></span>
</p>
);
}
}

View File

@ -1,51 +0,0 @@
var React = require('react');
var Autolinker = require('autolinker');
var util = require('../util');
var privateChatActions = require('../actions/privateChat');
var tabActions = require('../actions/tab');
var MessageHeader = React.createClass({
shouldComponentUpdate(nextProps) {
return nextProps.message.lines[0] !== this.props.message.lines[0];
},
handleSenderClick() {
var message = this.props.message;
privateChatActions.open(message.server, message.from);
tabActions.select(message.server, message.from);
},
render() {
var message = this.props.message;
var sender = null;
var messageClass = 'message';
var line = Autolinker.link(message.lines[0], { keepOriginalText: true });
if (message.from) {
sender = (
<span>
{' '}
<span className="message-sender" onClick={this.handleSenderClick}>
{message.from}
</span>
</span>
);
}
if (message.type) {
messageClass += ' message-' + message.type;
}
return (
<p className={messageClass}>
<span className="message-time">{util.timestamp(message.time)}</span>
{sender}
<span dangerouslySetInnerHTML={{ __html: ' ' + line }}></span>
</p>
);
}
});
module.exports = MessageHeader;

View File

@ -0,0 +1,57 @@
import React, { Component } from 'react';
import pure from 'pure-render-decorator';
@pure
export default class MessageInput extends Component {
state = {
value: ''
}
handleKey = e => {
const { tab, runCommand, sendMessage, addInputHistory, incrementInputHistory,
decrementInputHistory, resetInputHistory } = this.props;
if (e.which === 13 && e.target.value) {
if (e.target.value[0] === '/') {
runCommand(e.target.value, tab.channel || tab.user, tab.server);
} else if (tab.channel) {
sendMessage(e.target.value, tab.channel, tab.server);
} else if (tab.user) {
sendMessage(e.target.value, tab.user, tab.server);
}
addInputHistory(e.target.value);
resetInputHistory();
this.setState({ value: '' });
} else if (e.which === 38) {
e.preventDefault();
incrementInputHistory();
} else if (e.which === 40) {
decrementInputHistory();
} else if (e.key === 'Backspace' || e.key === 'Delete') {
resetInputHistory();
} else if (e.key === 'Unidentified') {
this.setState({ value: e.target.value });
resetInputHistory();
}
}
handleChange = e => {
this.setState({ value: e.target.value });
}
render() {
return (
<div className="message-input-wrap">
<input
ref="input"
className="message-input"
type="text"
value={this.props.history || this.state.value}
onKeyDown={this.handleKey}
onChange={this.handleChange}
/>
</div>
);
}
}

View File

@ -1,66 +0,0 @@
var React = require('react');
var Reflux = require('reflux');
var inputHistoryStore = require('../stores/inputHistory');
var selectedTabStore = require('../stores/selectedTab');
var messageActions = require('../actions/message');
var inputHistoryActions = require('../actions/inputHistory');
var PureMixin = require('../mixins/pure');
var MessageInput = React.createClass({
mixins: [
PureMixin,
Reflux.connect(inputHistoryStore, 'history')
],
getInitialState() {
return {
value: ''
};
},
handleKey(e) {
if (e.which === 13 && e.target.value) {
var tab = selectedTabStore.getState();
if (e.target.value[0] === '/') {
messageActions.command(e.target.value, tab.channel, tab.server);
} else {
messageActions.send(e.target.value, tab.channel, tab.server);
}
inputHistoryActions.add(e.target.value);
inputHistoryActions.reset();
this.setState({ value: '' });
} else if (e.which === 38) {
e.preventDefault();
inputHistoryActions.increment();
} else if (e.which === 40) {
inputHistoryActions.decrement();
} else if (e.key === 'Backspace' || e.key === 'Delete') {
inputHistoryActions.reset();
} else if (e.key === 'Unidentified') {
inputHistoryActions.reset();
}
},
handleChange(e) {
this.setState({ value: e.target.value });
},
render() {
return (
<div className="message-input-wrap">
<input
ref="input"
className="message-input"
type="text"
value={this.state.history || this.state.value}
onKeyDown={this.handleKey}
onChange={this.handleChange} />
</div>
);
}
});
module.exports = MessageInput;

View File

@ -0,0 +1,25 @@
import React, { Component } from 'react';
import Autolinker from 'autolinker';
import pure from 'pure-render-decorator';
@pure
export default class MessageLine extends Component {
render() {
const line = Autolinker.link(this.props.line, { stripPrefix: false });
let messageClass = 'message';
if (this.props.type) {
messageClass += ' message-' + this.props.type;
}
const style = {
paddingLeft: window.messageIndent + 'px'
};
return (
<p className={messageClass} style={style}>
<span dangerouslySetInnerHTML={{ __html: line }}></span>
</p>
);
}
}

View File

@ -1,28 +0,0 @@
var React = require('react');
var Autolinker = require('autolinker');
var PureMixin = require('../mixins/pure');
var MessageLine = React.createClass({
mixins: [PureMixin],
render() {
var line = Autolinker.link(this.props.line, { keepOriginalText: true });
var messageClass = 'message';
var style = {
paddingLeft: window.messageIndent + 'px'
};
if (this.props.type) {
messageClass += ' message-' + this.props.type;
}
return (
<p className={messageClass} style={style}>
<span dangerouslySetInnerHTML={{ __html: line }}></span>
</p>
);
}
});
module.exports = MessageLine;

View File

@ -0,0 +1,16 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import pure from 'pure-render-decorator';
import { toggleMenu } from '../actions/tab';
@pure
class Navicon extends Component {
render() {
const { dispatch } = this.props;
return (
<i className="icon-menu navicon" onClick={() => dispatch(toggleMenu())}></i>
);
}
}
export default connect()(Navicon);

View File

@ -1,13 +0,0 @@
var React = require('react');
var tabActions = require('../actions/tab');
var Navicon = React.createClass({
render() {
return (
<i className="icon-menu navicon" onClick={tabActions.toggleMenu}></i>
);
}
});
module.exports = Navicon;

View File

@ -0,0 +1,37 @@
import React, { Component } from 'react';
import pure from 'pure-render-decorator';
import { timestamp } from '../util';
@pure
export default class Search extends Component {
componentDidUpdate(prevProps) {
if (!prevProps.search.show && this.props.search.show) {
this.refs.input.focus();
}
}
render() {
const { search, onSearch } = this.props;
const results = search.results.map(result => {
return (
<p key={result.id}>{timestamp(new Date(result.time * 1000))} {result.from} {result.content}</p>
);
});
const style = {
display: search.show ? 'block' : 'none'
};
return (
<div className="search" style={style}>
<input
ref="input"
className="search-input"
type="text"
onChange={e => onSearch(e.target.value)}
/>
<div className="search-results">{results}</div>
</div>
);
}
}

View File

@ -1,55 +0,0 @@
var React = require('react');
var Reflux = require('reflux');
var util = require('../util');
var searchStore = require('../stores/search');
var selectedTabStore = require('../stores/selectedTab');
var searchActions = require('../actions/search');
var PureMixin = require('../mixins/pure');
var Search = React.createClass({
mixins: [
PureMixin,
Reflux.connect(searchStore, 'search'),
Reflux.connect(selectedTabStore, 'selectedTab')
],
componentDidUpdate(prevProps, prevState) {
if (!prevState.search.show && this.state.search.show) {
this.refs.input.getDOMNode().focus();
}
},
handleChange(e) {
var tab = this.state.selectedTab;
if (tab.channel) {
searchActions.search(tab.server, tab.channel, e.target.value);
}
},
render() {
var style = {
display: this.state.search.show ? 'block' : 'none'
};
var results = this.state.search.results.map(result => {
return (
<p key={result.id}>{util.timestamp(new Date(result.time * 1000))} {result.from} {result.content}</p>
);
});
return (
<div className="search" style={style}>
<input
ref="input"
className="search-input"
type="text"
onChange={this.handleChange} />
<div className="search-results">{results}</div>
</div>
);
}
});
module.exports = Search;

View File

@ -0,0 +1,14 @@
import React, { Component } from 'react';
import pure from 'pure-render-decorator';
import Navicon from './Navicon';
@pure
export default class Settings extends Component {
render() {
return (
<div>
<Navicon />
</div>
);
}
}

View File

@ -1,15 +0,0 @@
var React = require('react');
var Navicon = require('./Navicon.jsx');
var Settings = React.createClass({
render() {
return (
<div>
<Navicon />
</div>
);
}
});
module.exports = Settings;

View File

@ -0,0 +1,69 @@
import React, { Component } from 'react';
import pure from 'pure-render-decorator';
import TabListItem from './TabListItem';
@pure
export default class TabList extends Component {
handleConnectClick = () => {
this.props.pushPath('/connect');
this.props.hideMenu();
}
handleSettingsClick = () => {
this.props.pushPath('/settings');
this.props.hideMenu();
}
render() {
const { channels, servers, privateChats, showMenu, select, selected } = this.props;
const className = showMenu ? 'tablist off-canvas' : 'tablist';
const tabs = [];
channels.forEach((server, address) => {
tabs.push(
<TabListItem
key={address}
server
content={servers.getIn([address, 'name'])}
selected={selected.server === address && selected.channel === null && selected.user === null}
onClick={() => select(address)}
/>
);
server.forEach((channel, name) => {
tabs.push(
<TabListItem
key={address + channel.get('name')}
content={channel.get('name')}
selected={selected.server === address && selected.channel === name}
onClick={() => select(address, channel.get('name'))}
/>
);
});
if (privateChats.has(address)) {
privateChats.get(address).forEach(nick => {
tabs.push(
<TabListItem
key={address + nick}
content={nick}
selected={selected.server === address && selected.user === nick}
onClick={() => select(address, nick, true)}
/>
);
});
}
});
return (
<div className={className}>
<button className="button-connect" onClick={this.handleConnectClick}>Connect</button>
<div className="tab-container">{tabs}</div>
<div className="side-buttons">
<i className="icon-user"></i>
<i className="icon-cog" onClick={this.handleSettingsClick}></i>
</div>
</div>
);
}
}

View File

@ -1,82 +0,0 @@
var React = require('react');
var Reflux = require('reflux');
var TabListItem = require('./TabListItem.jsx');
var channelStore = require('../stores/channel');
var privateChatStore = require('../stores/privateChat');
var serverStore = require('../stores/server');
var routeActions = require('../actions/route');
var tabActions = require('../actions/tab');
var PureMixin = require('../mixins/pure');
var TabList = React.createClass({
mixins: [
PureMixin,
Reflux.connect(serverStore, 'servers'),
Reflux.connect(channelStore, 'channels'),
Reflux.connect(privateChatStore, 'privateChats')
],
handleConnectClick() {
routeActions.navigate('connect');
tabActions.hideMenu();
},
handleSettingsClick() {
routeActions.navigate('settings');
tabActions.hideMenu();
},
render() {
var className = this.props.menuToggled ? 'tablist off-canvas' : 'tablist';
var tabs = [];
this.state.channels.forEach((server, address) => {
tabs.push(
<TabListItem
key={address}
server={address}
channel={null}
name={this.state.servers.getIn([address, 'name'])}>
</TabListItem>
);
server.forEach((channel, name) => {
tabs.push(
<TabListItem
key={address + name}
server={address}
channel={name}
name={name}>
</TabListItem>
);
});
if (this.state.privateChats.has(address)) {
this.state.privateChats.get(address).forEach(nick => {
tabs.push(
<TabListItem
key={address + nick}
server={address}
channel={nick}
name={nick}>
</TabListItem>
);
});
}
});
return (
<div className={className}>
<button className="button-connect" onClick={this.handleConnectClick}>Connect</button>
<div className="tab-container">{tabs}</div>
<div className="side-buttons">
<i className="icon-user"></i>
<i className="icon-cog" onClick={this.handleSettingsClick}></i>
</div>
</div>
);
}
});
module.exports = TabList;

View File

@ -0,0 +1,21 @@
import React, { Component } from 'react';
import pure from 'pure-render-decorator';
@pure
export default class TabListItem extends Component {
render() {
const classes = [];
if (this.props.server) {
classes.push('tab-server');
}
if (this.props.selected) {
classes.push('selected');
}
return (
<p className={classes.join(' ')} onClick={this.props.onClick}>{this.props.content}</p>
);
}
}

View File

@ -1,36 +0,0 @@
var React = require('react');
var Reflux = require('reflux');
var selectedTabStore = require('../stores/selectedTab');
var tabActions = require('../actions/tab');
var PureMixin = require('../mixins/pure');
var TabListItem = React.createClass({
mixins: [
PureMixin,
Reflux.connect(selectedTabStore, 'tab')
],
handleClick() {
tabActions.select(this.props.server, this.props.channel);
},
render() {
var classes = [];
if (!this.props.channel) {
classes.push('tab-server');
}
if (this.props.server === this.state.tab.server &&
this.props.channel === this.state.tab.channel) {
classes.push('selected');
}
return (
<p className={classes.join(' ')} onClick={this.handleClick}>{this.props.name}</p>
);
}
});
module.exports = TabListItem;

View File

@ -0,0 +1,53 @@
import React, { Component } from 'react';
import Infinite from 'react-infinite';
import pure from 'pure-render-decorator';
import UserListItem from './UserListItem';
@pure
export default class UserList extends Component {
state = {
height: window.innerHeight - 100
}
componentDidMount() {
window.addEventListener('resize', this.handleResize);
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
}
handleResize = () => {
this.setState({ height: window.innerHeight - 100 });
}
render() {
const { tab, openPrivateChat, select } = this.props;
const users = [];
const style = {};
if (!tab.channel) {
style.display = 'none';
} else {
this.props.users.forEach(user => {
users.push(
<UserListItem
key={user.nick}
user={user}
tab={tab}
openPrivateChat={openPrivateChat}
select={select}
/>
);
});
}
return (
<div className="userlist" style={style}>
<Infinite containerHeight={this.state.height} elementHeight={24}>
{users}
</Infinite>
</div>
);
}
}

View File

@ -1,83 +0,0 @@
var React = require('react');
var Reflux = require('reflux');
var Infinite = require('react-infinite');
var UserListItem = require('./UserListItem.jsx');
var channelStore = require('../stores/channel');
var selectedTabStore = require('../stores/selectedTab');
var PureMixin = require('../mixins/pure');
var UserList = React.createClass({
mixins: [
PureMixin,
Reflux.listenTo(channelStore, 'channelsChanged'),
Reflux.listenTo(selectedTabStore, 'selectedTabChanged')
],
getInitialState() {
var tab = selectedTabStore.getState();
return {
users: channelStore.getUsers(tab.server, tab.channel),
selectedTab: tab,
height: window.innerHeight - 100
};
},
componentDidMount() {
window.addEventListener('resize', this.handleResize);
},
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
},
channelsChanged() {
var tab = this.state.selectedTab;
this.setState({ users: channelStore.getUsers(tab.server, tab.channel) });
},
selectedTabChanged(tab) {
this.setState({
selectedTab: tab,
users: channelStore.getUsers(tab.server, tab.channel)
});
},
handleResize() {
this.setState({ height: window.innerHeight - 100 });
},
render() {
var tab = this.state.selectedTab;
var users = [];
var style = {};
if (!tab.channel || tab.channel[0] !== '#') {
style.display = 'none';
} else {
this.state.users.forEach(user => {
users.push(<UserListItem key={user.nick} user={user} />);
});
}
if (users.length > 1) {
return (
<div className="userlist" style={style}>
<Infinite containerHeight={this.state.height} elementHeight={24}>
{users}
</Infinite>
</div>
);
} else {
return (
<div className="userlist" style={style}>
<div>{users}</div>
</div>
);
}
}
});
module.exports = UserList;

View File

@ -0,0 +1,16 @@
import React, { Component } from 'react';
import pure from 'pure-render-decorator';
@pure
export default class UserListItem extends Component {
handleClick = () => {
const { tab, user, openPrivateChat, select } = this.props;
openPrivateChat(tab.server, user.nick);
select(tab.server, user.nick, true);
}
render() {
return <p onClick={this.handleClick}>{this.props.user.renderName}</p>;
}
}

View File

@ -1,23 +0,0 @@
var React = require('react');
var selectedTabStore = require('../stores/selectedTab');
var privateChatActions = require('../actions/privateChat');
var tabActions = require('../actions/tab');
var PureMixin = require('../mixins/pure');
var UserListItem = React.createClass({
mixins: [PureMixin],
handleClick() {
var server = selectedTabStore.getServer();
privateChatActions.open(server, this.props.user.nick);
tabActions.select(server, this.props.user.nick);
},
render() {
return <p onClick={this.handleClick}>{this.props.user.renderName}</p>;
}
});
module.exports = UserListItem;

View File

@ -0,0 +1,34 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { pushPath } from 'redux-simple-router';
import pure from 'pure-render-decorator';
import TabList from '../components/TabList';
import * as actions from '../actions/tab';
@pure
class App extends Component {
render() {
const { showMenu, children } = this.props;
const mainClass = showMenu ? 'main-container off-canvas' : 'main-container';
return (
<div>
<TabList {...this.props} />
<div className={mainClass}>
{children}
</div>
</div>
);
}
}
function mapStateToProps(state) {
return {
servers: state.servers,
channels: state.channels,
privateChats: state.privateChats,
showMenu: state.showMenu,
selected: state.tab.selected
};
}
export default connect(mapStateToProps, { pushPath, ...actions })(App);

View File

@ -0,0 +1,154 @@
import React, { Component } from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { List, Map } from 'immutable';
import pure from 'pure-render-decorator';
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, setSelectedChannel, setSelectedUser } from '../actions/tab';
import { runCommand, sendMessage } from '../actions/message';
import { disconnect } from '../actions/server';
import * as inputHistoryActions from '../actions/inputHistory';
import { setWrapWidth, setCharWidth } from '../actions/environment';
import { stringWidth, wrapMessages } from '../util';
function updateSelected({ params, dispatch }) {
if (params.channel) {
dispatch(setSelectedChannel(params.server, params.channel));
} else if (params.user) {
dispatch(setSelectedUser(params.server, params.user));
} else if (params.server) {
dispatch(setSelectedChannel(params.server));
}
}
function updateCharWidth() {
const charWidth = stringWidth(' ', '16px Droid Sans Mono');
window.messageIndent = 6 * charWidth;
return setCharWidth(charWidth);
}
@pure
class Chat extends Component {
componentWillMount() {
const { dispatch } = this.props;
dispatch(updateCharWidth());
setTimeout(() => dispatch(updateCharWidth()), 1000);
}
componentDidMount() {
updateSelected(this.props);
}
componentWillReceiveProps(nextProps) {
if (nextProps.params.server !== this.props.params.server ||
nextProps.params.channel !== this.props.params.channel ||
nextProps.params.user !== this.props.params.user) {
updateSelected(nextProps);
}
}
render() {
const { tab, channel, search, history, dispatch } = this.props;
let chatClass;
if (tab.channel) {
chatClass = 'chat-channel';
} else if (tab.user) {
chatClass = 'chat-private';
} else {
chatClass = 'chat-server';
}
return (
<div className={chatClass}>
<ChatTitle {...this.props } />
<Search
search={search}
onSearch={phrase => tab.channel &&
dispatch(searchMessages(tab.server, tab.channel, phrase))}
/>
<MessageBox {...this.props } />
<MessageInput
tab={tab}
channel={channel}
runCommand={this.props.runCommand}
sendMessage={this.props.sendMessage}
history={history}
{...bindActionCreators(inputHistoryActions, dispatch)}
/>
<UserList {...this.props} />
</div>
);
}
}
const tabSelector = state => state.tab.selected;
const messageSelector = state => state.messages;
const selectedMessagesSelector = createSelector(
tabSelector,
messageSelector,
(tab, messages) => messages.getIn([tab.server, tab.channel || tab.user || tab.server], List())
);
const wrapWidthSelector = state => state.environment.get('wrapWidth');
const charWidthSelector = state => state.environment.get('charWidth');
const wrappedMessagesSelector = createSelector(
selectedMessagesSelector,
wrapWidthSelector,
charWidthSelector,
(messages, width, charWidth) => wrapMessages(messages, width, charWidth, 6 * charWidth)
);
function mapStateToProps(state) {
const tab = state.tab.selected;
const channel = state.channels.getIn([tab.server, tab.channel], Map());
let title;
if (tab.channel) {
title = channel.get('name');
} else if (tab.user) {
title = tab.user;
} else {
title = state.servers.getIn([tab.server, 'name']);
}
return {
title,
search: state.search,
users: channel.get('users', List()),
history: state.input.index === -1 ? null : state.input.history.get(state.input.index),
messages: wrappedMessagesSelector(state),
channel,
tab
};
}
function mapDispatchToProps(dispatch) {
return {
dispatch,
...bindActionCreators({
select,
toggleSearch,
searchMessages,
runCommand,
sendMessage,
part,
disconnect,
openPrivateChat,
closePrivateChat,
setWrapWidth
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(Chat);

View File

@ -0,0 +1,81 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import pure from 'pure-render-decorator';
import Navicon from '../components/Navicon';
import * as serverActions from '../actions/server';
import { join } from '../actions/channel';
import { select } from '../actions/tab';
@pure
class Connect extends Component {
state = {
showOptionals: false
}
handleSubmit = (e) => {
e.preventDefault();
const { dispatch } = this.props;
const address = e.target.address.value.trim();
const nick = e.target.nick.value.trim();
const channels = e.target.channels.value.split(',').map(s => s.trim()).filter(s => s);
const opts = {
name: e.target.name.value.trim(),
tls: e.target.ssl.checked
};
if (this.state.showOptionals) {
opts.realname = e.target.realname.value.trim();
opts.username = e.target.username.value.trim();
opts.password = e.target.password.value.trim();
}
if (address.indexOf('.') > 0 && nick) {
dispatch(serverActions.connect(address, nick, opts));
dispatch(select(address));
if (channels.length > 0) {
dispatch(join(channels, address));
}
}
}
handleShowClick = () => {
this.setState({ showOptionals: !this.state.showOptionals });
}
render() {
let optionals = null;
if (this.state.showOptionals) {
optionals = (
<div>
<input name="username" type="text" placeholder="Username" />
<input name="password" type="text" placeholder="Password" />
<input name="realname" type="text" placeholder="Realname" />
</div>
);
}
return (
<div className="connect">
<Navicon />
<form ref="form" className="connect-form" onSubmit={this.handleSubmit}>
<h1>Connect</h1>
<input name="name" type="text" placeholder="Name" defaultValue="Freenode" />
<input name="address" type="text" placeholder="Address" defaultValue="irc.freenode.net" />
<input name="nick" type="text" placeholder="Nick" />
<input name="channels" type="text" placeholder="Channels" />
{optionals}
<p>
<label><input name="ssl" type="checkbox" />SSL</label>
<i className="icon-ellipsis" onClick={this.handleShowClick}></i>
</p>
<input type="submit" value="Connect" />
</form>
</div>
);
}
}
export default connect()(Connect);

View File

@ -0,0 +1,10 @@
import React from 'react';
import { createDevTools } from 'redux-devtools';
import DockMonitor from 'redux-devtools-dock-monitor';
import LogMonitor from 'redux-devtools-log-monitor';
export default createDevTools(
<DockMonitor toggleVisibilityKey="ctrl-h" changePositionKey="ctrl-q">
<LogMonitor theme="tomorrow" />
</DockMonitor>
);

View File

@ -0,0 +1,20 @@
import React, { Component } from 'react';
import { Router } from 'react-router';
import { Provider } from 'react-redux';
import pure from 'pure-render-decorator';
import DevTools from './DevTools';
@pure
export default class Root extends Component {
render() {
const { store, routes, history } = this.props;
return (
<Provider store={store}>
<div>
<Router routes={routes} history={history} />
<DevTools />
</div>
</Provider>
);
}
}

View File

@ -0,0 +1,5 @@
if (__DEV__) {
module.exports = require('./Root.dev');
} else {
module.exports = require('./Root.prod');
}

View File

@ -0,0 +1,16 @@
import React, { Component } from 'react';
import { Router } from 'react-router';
import { Provider } from 'react-redux';
import pure from 'pure-render-decorator';
@pure
export default class Root extends Component {
render() {
const { store, routes, history } = this.props;
return (
<Provider store={store}>
<Router routes={routes} history={history} />
</Provider>
);
}
}

31
client/src/js/index.js Normal file
View File

@ -0,0 +1,31 @@
import React from 'react';
import { render } from 'react-dom';
import { syncReduxAndRouter, replacePath } from 'redux-simple-router';
import createBrowserHistory from 'history/lib/createBrowserHistory';
import configureStore from './store';
import createRoutes from './routes';
import Socket from './util/Socket';
import handleSocket from './socket';
import { createUUID } from './util';
import Root from './containers/Root';
const socket = __DEV__ ?
new Socket(`${window.location.hostname}:1337`) :
new Socket(window.location.host);
const store = configureStore(socket);
const routes = createRoutes();
const history = createBrowserHistory();
syncReduxAndRouter(history, store);
handleSocket(socket, store);
let uuid = localStorage.uuid;
if (!uuid) {
store.dispatch(replacePath('/connect'));
localStorage.uuid = uuid = createUUID();
}
socket.on('connect', () => socket.send('uuid', uuid));
render(<Root store={store} routes={routes} history={history} />, document.getElementById('root'));

View File

@ -1,98 +0,0 @@
var _ = require('lodash');
var socket = require('./socket');
var selectedTabStore = require('./stores/selectedTab');
var channelActions = require('./actions/channel');
var messageActions = require('./actions/message');
var serverActions = require('./actions/server');
var routeActions = require('./actions/route');
var searchActions = require('./actions/search');
function withReason(message, reason) {
return message + (reason ? ' (' + reason + ')' : '');
}
socket.on('join', function(data) {
channelActions.addUser(data.user, data.server, data.channels[0]);
messageActions.inform(data.user + ' joined the channel', data.server, data.channels[0]);
});
socket.on('part', function(data) {
channelActions.removeUser(data.user, data.server, data.channels[0]);
messageActions.inform(withReason(data.user + ' left the channel', data.reason), data.server, data.channels[0]);
});
socket.on('quit', function(data) {
messageActions.broadcast(withReason(data.user + ' quit', data.reason), data.server, data.user);
channelActions.removeUserAll(data.user, data.server);
});
socket.on('nick', function(data) {
messageActions.broadcast(data.old + ' changed nick to ' + data.new, data.server, data.old);
channelActions.renameUser(data.old, data.new, data.server);
});
socket.on('message', function(data) {
messageActions.add(data);
});
socket.on('pm', function(data) {
messageActions.add(data);
});
socket.on('motd', function(data) {
messageActions.addAll(_.map(data.content, line => {
return {
server: data.server,
to: data.server,
message: line
};
}));
});
socket.on('users', function(data) {
channelActions.setUsers(data.users, data.server, data.channel);
});
socket.on('topic', function(data) {
channelActions.setTopic(data.topic, data.server, data.channel);
});
socket.on('mode', function(data) {
channelActions.setMode(data);
});
socket.on('whois', function(data) {
var tab = selectedTabStore.getState();
messageActions.inform([
'Nick: ' + data.nick,
'Username: ' + data.username,
'Realname: ' + data.realname,
'Host: ' + data.host,
'Server: ' + data.server,
'Channels: ' + data.channels
], tab.server, tab.channel);
});
socket.on('servers', function(data) {
window.loaded = true;
if (data === null) {
routeActions.navigate('connect', true);
}
serverActions.load(data);
});
socket.on('channels', function(data) {
channelActions.load(data);
});
socket.on('search', function(data) {
searchActions.searchDone(data.results);
});
serverActions.connect.listen(function(server) {
messageActions.inform('Connecting...', server);
});

View File

@ -0,0 +1,20 @@
export default function createCommandMiddleware(type, handlers) {
return store => next => action => {
if (action.type === type) {
const words = action.command.slice(1).split(' ');
const command = words[0];
const params = words.slice(1);
if (handlers.hasOwnProperty(command)) {
handlers[command]({
dispatch: store.dispatch,
getState: store.getState,
server: action.server,
channel: action.channel
}, ...params);
}
}
return next(action);
};
}

View File

@ -0,0 +1,9 @@
export default function createSocketMiddleware(socket) {
return () => next => action => {
if (action.socket) {
socket.send(action.socket.type, action.socket.data);
}
return next(action);
};
}

View File

@ -1,17 +0,0 @@
var shallowEqual = require('react-pure-render/shallowEqual');
module.exports = {
shouldComponentUpdate(nextProps, nextState) {
if (this.context.router) {
var changed = this.pureComponentLastPath !== this.context.router.getCurrentPath();
this.pureComponentLastPath = this.context.router.getCurrentPath();
if (changed) {
return true;
}
}
return !shallowEqual(this.props, nextProps) ||
!shallowEqual(this.state, nextState);
}
};

View File

@ -0,0 +1,191 @@
import { Map, List, Record } from 'immutable';
import createReducer from '../util/createReducer';
import * as actions from '../actions';
const User = Record({
nick: null,
renderName: null,
mode: ''
});
function updateRenderName(user) {
let name = user.nick;
if (user.mode.indexOf('o') !== -1) {
name = '@' + name;
} else if (user.mode.indexOf('v') !== -1) {
name = '+' + name;
}
return user.set('renderName', name);
}
function createUser(nick, mode) {
return updateRenderName(new User({
nick,
renderName: nick,
mode: mode || ''
}));
}
function loadUser(nick) {
let mode;
if (nick[0] === '@') {
mode = 'o';
} else if (nick[0] === '+') {
mode = 'v';
}
if (mode) {
return createUser(nick.slice(1), mode);
}
return createUser(nick, mode);
}
function compareUsers(a, b) {
a = a.renderName.toLowerCase();
b = b.renderName.toLowerCase();
if (a[0] === '@' && b[0] !== '@') {
return -1;
}
if (b[0] === '@' && a[0] !== '@') {
return 1;
}
if (a[0] === '+' && b[0] !== '+') {
return -1;
}
if (b[0] === '+' && a[0] !== '+') {
return 1;
}
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}
export default createReducer(Map(), {
[actions.PART](state, action) {
const { channels, server } = action;
return state.withMutations(s => {
channels.forEach(channel => s.deleteIn([server, channel]));
});
},
[actions.SOCKET_JOIN](state, action) {
const { server, channels, user } = action;
const channel = channels[0];
return state
.setIn([server, channel, 'name'], channels[0])
.updateIn([server, channel, 'users'], List(), users => {
return users.push(createUser(user)).sort(compareUsers);
});
},
[actions.SOCKET_PART](state, action) {
const { server, channels, user } = action;
const channel = channels[0];
if (state.hasIn([server, channel])) {
return state.updateIn([server, channel, 'users'], users => users.filter(u => u.nick !== user));
}
return state;
},
[actions.SOCKET_QUIT](state, action) {
const { server, user } = action;
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, action) {
const { server, channels } = action;
return state.withMutations(s => {
channels.forEach(channel => {
s.updateIn([server, channel, 'users'], users => {
const i = users.findIndex(user => user.nick === action.old);
return users.update(i, user => {
return updateRenderName(user.set('nick', action.new));
}).sort(compareUsers);
});
});
});
},
[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) {
const { server, channel, topic } = action;
return state.setIn([server, channel, 'topic'], topic);
},
[actions.SOCKET_MODE](state, action) {
const { server, channel, user, remove, add } = action;
const i = state.getIn([server, channel, 'users']).findIndex(u => u.nick === user);
return state
.updateIn([server, channel, 'users', i], u => {
let mode = u.mode;
let j = remove.length;
while (j--) {
mode = mode.replace(remove[j], '');
}
return updateRenderName(u.set('mode', mode + add));
})
.updateIn([server, channel, 'users'], users => users.sort(compareUsers));
},
[actions.SOCKET_CHANNELS](state, action) {
if (!action.data) {
return state;
}
return state.withMutations(s => {
action.data.forEach(channel => {
s.setIn([channel.server, channel.name], Map({
users: List(),
topic: channel.topic,
name: channel.name
}));
});
});
},
[actions.SOCKET_SERVERS](state, action) {
if (!action.data) {
return state;
}
return state.withMutations(s => {
action.data.forEach(server => {
if (!state.has(server.address)) {
s.set(server.address, Map());
}
});
});
},
[actions.CONNECT](state, action) {
const { server } = action;
if (!state.has(server)) {
return state.set(server, Map());
}
return state;
},
[actions.DISCONNECT](state, action) {
return state.delete(action.server);
}
});

View File

@ -0,0 +1,9 @@
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);
}
});

View File

@ -0,0 +1,24 @@
import { combineReducers } from 'redux';
import { routeReducer } from 'redux-simple-router';
import channels from './channels';
import environment from './environment';
import input from './input';
import messages from './messages';
import privateChats from './privateChats';
import search from './search';
import servers from './servers';
import showMenu from './showMenu';
import tab from './tab';
export default combineReducers({
routing: routeReducer,
channels,
environment,
input,
messages,
privateChats,
search,
servers,
showMenu,
tab
});

View File

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

View File

@ -0,0 +1,68 @@
import { List, Map, Record } from 'immutable';
import createReducer from '../util/createReducer';
import * as actions from '../actions';
const Message = Record({
id: null,
server: null,
from: null,
to: null,
message: '',
time: null,
type: null,
lines: []
});
function addMessage(state, message) {
let dest = message.to || message.from;
if (message.from && message.from.indexOf('.') !== -1) {
dest = message.server;
}
if (message.message.indexOf('\x01ACTION') === 0) {
const from = message.from;
message.from = null;
message.type = 'action';
message.message = from + message.message.slice(7);
}
return state.updateIn([message.server, dest], List(), list => list.push(new Message(message)));
}
export default createReducer(Map(), {
[actions.SEND_MESSAGE](state, action) {
return addMessage(state, action);
},
[actions.ADD_MESSAGE](state, action) {
return addMessage(state, action.message);
},
[actions.ADD_MESSAGES](state, action) {
return state.withMutations(s =>
action.messages.forEach(message =>
addMessage(s, message)
)
);
},
/*
[actions.SOCKET_MESSAGE](state, action) {
return addMessage(state, action);
},
[actions.SOCKET_PM](state, action) {
return addMessage(state, action);
},
*/
[actions.DISCONNECT](state, action) {
return state.delete(action.server);
},
[actions.PART](state, action) {
return state.withMutations(s =>
action.channels.forEach(channel =>
s.deleteIn([action.server, channel])
)
);
}
});

View File

@ -0,0 +1,29 @@
import { Set, Map } from 'immutable';
import createReducer from '../util/createReducer';
import * as actions from '../actions';
function open(state, server, nick) {
return state.update(server, Set(), chats => chats.add(nick));
}
export default createReducer(Map(), {
[actions.OPEN_PRIVATE_CHAT](state, action) {
return open(state, action.server, action.nick);
},
[actions.CLOSE_PRIVATE_CHAT](state, action) {
return state.update(action.server, chats => chats.delete(action.nick));
},
[actions.SOCKET_PM](state, action) {
if (action.from.indexOf('.') === -1) {
return open(state, action.server, action.from);
}
return state;
},
[actions.DISCONNECT](state, action) {
return state.delete(action.server);
}
});

View File

@ -0,0 +1,18 @@
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

@ -0,0 +1,46 @@
import { Map, Record } from 'immutable';
import createReducer from '../util/createReducer';
import * as actions from '../actions';
const Server = Record({
nick: null,
name: null
});
export default createReducer(Map(), {
[actions.CONNECT](state, action) {
let { server } = action;
const { nick, options } = action;
const i = server.indexOf(':');
if (i > 0) {
server = server.slice(0, i);
}
return state.set(server, new Server({
nick,
name: options.name || server
}));
},
[actions.DISCONNECT](state, action) {
return state.delete(action.server);
},
[actions.SET_NICK](state, action) {
const { server, nick } = action;
return state.update(server, s => s.set('nick', nick));
},
[actions.SOCKET_SERVERS](state, action) {
if (!action.data) {
return state;
}
return state.withMutations(s => {
action.data.forEach(server => {
s.set(server.address, new Server(server));
});
});
}
});

View File

@ -0,0 +1,12 @@
import createReducer from '../util/createReducer';
import * as actions from '../actions';
export default createReducer(false, {
[actions.TOGGLE_MENU](state) {
return !state;
},
[actions.HIDE_MENU]() {
return false;
}
});

View File

@ -0,0 +1,48 @@
import { Record, List } from 'immutable';
import { UPDATE_PATH } from 'redux-simple-router';
import createReducer from '../util/createReducer';
import * as actions from '../actions';
const Tab = Record({
server: null,
channel: null,
user: null
});
const State = Record({
selected: new Tab(),
history: List()
});
export default createReducer(new State(), {
[actions.SELECT_TAB](state, action) {
const tab = new Tab(action);
return state
.set('selected', tab)
.update('history', history => history.push(tab));
},
[actions.PART](state, action) {
return state.set('history', state.history.filter(tab =>
!(tab.server === action.server && tab.channel === action.channels[0])
));
},
[actions.CLOSE_PRIVATE_CHAT](state, action) {
return state.set('history', state.history.filter(tab =>
!(tab.server === action.server && tab.user === action.nick)
));
},
[actions.DISCONNECT](state, action) {
return state.set('history', state.history.filter(tab => tab.server !== action.server));
},
[UPDATE_PATH](state, action) {
if (action.payload.path.indexOf('.') === -1 && state.selected.server) {
return state.set('selected', new Tab());
}
return state;
}
});

19
client/src/js/routes.js Normal file
View File

@ -0,0 +1,19 @@
import React from 'react';
import { Route, IndexRoute } from 'react-router';
import App from './containers/App';
import Connect from './containers/Connect';
import Chat from './containers/Chat';
import Settings from './components/Settings';
export default function createRoutes() {
return (
<Route path="/" component={App}>
<Route path="connect" component={Connect} />
<Route path="settings" component={Settings} />
<Route path="/:server" component={Chat} />
<Route path="/:server/:channel" component={Chat} />
<Route path="/:server/pm/:user" component={Chat} />
<IndexRoute component={Settings} />
</Route>
);
}

View File

@ -1,85 +1,81 @@
var EventEmitter = require('events').EventEmitter;
import { replacePath } from 'redux-simple-router';
import { broadcast, inform, addMessage, addMessages } from './actions/message';
import { select } from './actions/tab';
import { normalizeChannel } from './util';
var Backoff = require('backo');
class Socket extends EventEmitter {
constructor() {
super();
this.connectTimeout = 20000;
this.pingTimeout = 30000;
this.backoff = new Backoff({
min: 1000,
max: 5000,
jitter: 0.25
});
this.connect();
}
connect() {
this.ws = new WebSocket('ws://' + window.location.host + '/ws');
this.timeoutConnect = setTimeout(() => {
this.ws.close();
this.retry();
}, this.connectTimeout);
this.ws.onopen = () => {
clearTimeout(this.timeoutConnect);
this.backoff.reset();
this.emit('connect');
this.setTimeoutPing();
};
this.ws.onclose = () => {
clearTimeout(this.timeoutConnect);
clearTimeout(this.timeoutPing);
if (!this.closing) {
this.emit('disconnect');
this.retry();
}
this.closing = false;
};
this.ws.onerror = () => {
clearTimeout(this.timeoutConnect);
clearTimeout(this.timeoutPing);
this.closing = true;
this.ws.close();
this.retry();
};
this.ws.onmessage = (e) => {
this.setTimeoutPing();
var msg = JSON.parse(e.data);
if (msg.type === 'ping') {
this.send('pong');
}
this.emit(msg.type, msg.data);
};
}
retry() {
setTimeout(() => this.connect(), this.backoff.duration());
}
send(type, data) {
this.ws.send(JSON.stringify({ type, data }));
}
setTimeoutPing() {
clearTimeout(this.timeoutPing);
this.timeoutPing = setTimeout(() => {
this.emit('disconnect');
this.closing = true;
this.ws.close();
this.connect();
}, this.pingTimeout);
}
function withReason(message, reason) {
return message + (reason ? ` (${reason})` : '');
}
module.exports = new Socket();
export default function handleSocket(socket, { dispatch, getState }) {
socket.onAny(data => {
const type = `SOCKET_${socket.event.toUpperCase()}`;
if (Array.isArray(data)) {
dispatch({ type, data });
} else {
dispatch({ type, ...data });
}
});
socket.on('message', message => dispatch(addMessage(message)));
socket.on('pm', message => dispatch(addMessage(message)));
socket.on('join', data => {
const state = getState();
const { server, channel } = state.tab.selected;
const { nick } = state.servers.get(server);
const [joinedChannel] = data.channels;
if (channel &&
server === data.server &&
nick === data.user &&
channel !== joinedChannel &&
normalizeChannel(channel) === normalizeChannel(joinedChannel)) {
dispatch(select(server, joinedChannel));
}
});
socket.on('servers', data => {
if (!data) {
dispatch(replacePath('/connect'));
}
});
socket.on('join', ({ user, server, channels }) =>
dispatch(inform(`${user} joined the channel`, server, channels[0]))
);
socket.on('part', ({ user, server, channels, reason }) =>
dispatch(inform(withReason(`${user} left the channel`, reason), server, channels[0]))
);
socket.on('quit', ({ user, server, reason, channels }) =>
dispatch(broadcast(withReason(`${user} quit`, reason), server, channels))
);
socket.on('nick', data =>
dispatch(broadcast(`${data.old} changed nick to ${data.new}`, data.server, data.channels))
);
socket.on('motd', ({ content, server }) =>
dispatch(addMessages(content.map(line => {
return {
server,
to: server,
message: line
};
})))
);
socket.on('whois', data => {
const tab = getState().tab.selected;
dispatch(inform([
`Nick: ${data.nick}`,
`Username: ${data.username}`,
`Realname: ${data.realname}`,
`Host: ${data.host}`,
`Server: ${data.server}`,
`Channels: ${data.channels}`
], tab.server, tab.channel));
});
}

View File

@ -0,0 +1,5 @@
if (__DEV__) {
module.exports = require('./store.dev');
} else {
module.exports = require('./store.prod');
}

View File

@ -0,0 +1,27 @@
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import reducer from '../reducers';
import createSocketMiddleware from '../middleware/socket';
import commands from '../commands';
import DevTools from '../containers/DevTools';
export default function configureStore(socket, initialState) {
const finalCreateStore = compose(
applyMiddleware(
thunk,
createSocketMiddleware(socket),
commands
),
DevTools.instrument()
)(createStore);
const store = finalCreateStore(reducer, initialState);
if (module.hot) {
module.hot.accept('../reducers', () => {
store.replaceReducer(require('../reducers').default);
});
}
return store;
}

View File

@ -0,0 +1,15 @@
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import reducer from '../reducers';
import createSocketMiddleware from '../middleware/socket';
import commands from '../commands';
export default function configureStore(socket, initialState) {
const finalCreateStore = applyMiddleware(
thunk,
createSocketMiddleware(socket),
commands
)(createStore);
return finalCreateStore(reducer, initialState);
}

View File

@ -1,204 +0,0 @@
var Reflux = require('reflux');
var Immutable = require('immutable');
var _ = require('lodash');
var actions = require('../actions/channel');
var serverActions = require('../actions/server');
var channels = Immutable.Map();
var empty = Immutable.List();
var User = Immutable.Record({
nick: null,
renderName: null,
mode: ''
});
function updateRenderName(user) {
var name = user.nick;
if (user.mode.indexOf('o') !== -1) {
name = '@' + name;
} else if (user.mode.indexOf('v') !== -1) {
name = '+' + name;
}
return user.set('renderName', name);
}
function createUser(nick, mode) {
return updateRenderName(new User({
nick: nick,
renderName: nick,
mode: mode || ''
}));
}
function loadUser(nick) {
var mode;
if (nick[0] === '@') {
mode = 'o';
} else if (nick[0] === '+') {
mode = 'v';
}
if (mode) {
nick = nick.slice(1);
}
return createUser(nick, mode);
}
function compareUsers(a, b) {
a = a.renderName.toLowerCase();
b = b.renderName.toLowerCase();
if (a[0] === '@' && b[0] !== '@') {
return -1;
}
if (b[0] === '@' && a[0] !== '@') {
return 1;
}
if (a[0] === '+' && b[0] !== '+') {
return -1;
}
if (b[0] === '+' && a[0] !== '+') {
return 1;
}
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}
function getState() {
return channels;
}
var channelStore = Reflux.createStore({
init() {
this.listenToMany(actions);
this.listenTo(serverActions.connect, 'addServer');
this.listenTo(serverActions.disconnect, 'removeServer');
this.listenTo(serverActions.load, 'loadServers');
},
part(partChannels, server) {
_.each(partChannels, function(channel) {
channels = channels.deleteIn([server, channel]);
});
this.trigger(channels);
},
addUser(user, server, channel) {
channels = channels.updateIn([server, channel, 'users'], empty, users => {
return users.push(createUser(user)).sort(compareUsers);
});
this.trigger(channels);
},
removeUser(user, server, channel) {
if (channels.hasIn([server, channel])) {
channels = channels.updateIn([server, channel, 'users'], users => users.filter(u => u.nick !== user));
this.trigger(channels);
}
},
removeUserAll(user, server) {
channels.get(server).forEach((v, k) => {
channels = channels.updateIn([server, k, 'users'], users => users.filter(u => u.nick !== user));
});
this.trigger(channels);
},
renameUser(oldNick, newNick, server) {
channels.get(server).forEach((v, k) => {
channels = channels.updateIn([server, k, 'users'], users => {
var i = users.findIndex(user => user.nick === oldNick);
return users.update(i, user => {
return updateRenderName(user.set('nick', newNick));
}).sort(compareUsers);
});
});
this.trigger(channels);
},
setUsers(users, server, channel) {
users = _.map(users, user => loadUser(user)).sort(compareUsers);
channels = channels.setIn([server, channel, 'users'], Immutable.List(users));
this.trigger(channels);
},
setTopic(topic, server, channel) {
channels = channels.setIn([server, channel, 'topic'], topic);
this.trigger(channels);
},
setMode(mode) {
var i = channels.getIn([mode.server, mode.channel, 'users']).findIndex(u => u.nick === mode.user);
channels = channels.updateIn([mode.server, mode.channel, 'users', i], user => {
_.each(mode.remove, remove => {
user = user.set('mode', user.mode.replace(remove, ''));
});
user = user.set('mode', user.mode + mode.add);
return updateRenderName(user);
});
channels = channels.updateIn([mode.server, mode.channel, 'users'], users => users.sort(compareUsers));
this.trigger(channels);
},
load(storedChannels) {
_.each(storedChannels, function(channel) {
channels = channels.setIn([channel.server, channel.name], Immutable.Map({
users: Immutable.List(),
topic: channel.topic
}));
});
this.trigger(channels);
},
addServer(server) {
if (!channels.has(server)) {
channels = channels.set(server, Immutable.Map());
this.trigger(channels);
}
},
removeServer(server) {
channels = channels.delete(server);
this.trigger(channels);
},
loadServers(storedServers) {
_.each(storedServers, function(server) {
if (!channels.has(server.address)) {
channels = channels.set(server.address, Immutable.Map());
}
});
this.trigger(channels);
},
getChannels(server) {
return channels.get(server);
},
getUsers(server, channel) {
return channels.getIn([server, channel, 'users']) || empty;
},
getTopic(server, channel) {
return channels.getIn([server, channel, 'topic']);
},
getInitialState: getState,
getState
});
module.exports = channelStore;

View File

@ -1,64 +0,0 @@
var Reflux = require('reflux');
var actions = require('../actions/inputHistory');
var HISTORY_MAX_LENGTH = 128;
var history = [];
var index = -1;
var stored = localStorage.inputHistory;
if (stored) {
history = JSON.parse(stored);
}
function getState() {
if (index !== -1) {
return history[index];
}
return null;
}
var inputHistoryStore = Reflux.createStore({
init() {
this.listenToMany(actions);
},
add(line) {
if (line.trim() && line !== history[0]) {
history.unshift(line);
if (history.length > HISTORY_MAX_LENGTH) {
history.pop();
}
localStorage.inputHistory = JSON.stringify(history);
}
},
reset() {
if (index !== -1) {
index = -1;
this.trigger(history[index]);
}
},
increment() {
if (index !== history.length - 1) {
index++;
this.trigger(history[index]);
}
},
decrement() {
if (index !== -1) {
index--;
this.trigger(history[index]);
}
},
getInitialState: getState,
getState
});
module.exports = inputHistoryStore;

View File

@ -1,139 +0,0 @@
var Reflux = require('reflux');
var Immutable = require('immutable');
var _ = require('lodash');
var serverStore = require('./server');
var channelStore = require('./channel');
var actions = require('../actions/message');
var serverActions = require('../actions/server');
var channelActions = require('../actions/channel');
var messages = Immutable.Map();
var empty = Immutable.List();
var Message = Immutable.Record({
id: null,
server: null,
from: null,
to: null,
message: '',
time: null,
type: null,
lines: []
});
function addMessage(message, dest, mutable) {
message.time = new Date();
if (message.message.indexOf('\x01ACTION') === 0) {
var from = message.from;
message.from = null;
message.type = 'action';
message.message = from + message.message.slice(7);
}
if (mutable) {
mutable.updateIn([message.server, dest], empty, list => list.push(new Message(message)));
} else {
messages = messages.updateIn([message.server, dest], empty, list => list.push(new Message(message)));
}
}
function getDest(message) {
var dest = message.to || message.from;
if (message.from && message.from.indexOf('.') !== -1) {
dest = message.server;
}
return dest;
}
var messageStore = Reflux.createStore({
init() {
this.listenToMany(actions);
this.listenTo(serverActions.disconnect, 'disconnect');
this.listenTo(channelActions.part, 'part');
},
send(message, to, server) {
addMessage({
server: server,
from: serverStore.getNick(server),
to: to,
message: message
}, to);
this.trigger(messages);
},
add(message) {
addMessage(message, getDest(message));
this.trigger(messages);
},
addAll(newMessages) {
messages = messages.withMutations(mutable => {
_.each(newMessages, message => {
addMessage(message, getDest(message), mutable);
});
});
this.trigger(messages);
},
broadcast(message, server, user) {
channelStore.getChannels(server).forEach((channel, channelName) => {
if (!user || (user && channel.get('users').find(u => u.nick === user))) {
addMessage({
server: server,
to: channelName,
message: message,
type: 'info'
}, channelName);
}
});
this.trigger(messages);
},
inform(message, server, channel) {
if (_.isArray(message)) {
_.each(message, (msg) => {
addMessage({
server: server,
to: channel,
message: msg,
type: 'info'
}, channel || server);
});
} else {
addMessage({
server: server,
to: channel,
message: message,
type: 'info'
}, channel || server);
}
this.trigger(messages);
},
disconnect(server) {
messages = messages.delete(server);
this.trigger(messages);
},
part(channels, server) {
_.each(channels, function(channel) {
messages = messages.deleteIn([server, channel]);
});
this.trigger(messages);
},
getMessages(server, dest) {
return messages.getIn([server, dest]) || empty;
},
getState() {
return messages;
}
});
module.exports = messageStore;

View File

@ -1,69 +0,0 @@
var Reflux = require('reflux');
var util = require('../util');
var messageStore = require('./message');
var selectedTabStore = require('./selectedTab');
var messageActions = require('../actions/message');
var tab = selectedTabStore.getState();
var width = window.innerWidth;
var messages;
var prev;
function updateCharWidth() {
window.charWidth = util.stringWidth(' ', '16px Droid Sans Mono');
window.messageIndent = 6 * window.charWidth;
}
function wrap() {
var next = messageStore.getMessages(tab.server, tab.channel || tab.server);
if (next !== prev) {
prev = next;
messages = util.wrapMessages(next, width, window.charWidth, window.messageIndent);
return true;
}
return false;
}
function getState() {
return messages;
}
var messageLineStore = Reflux.createStore({
init() {
updateCharWidth();
wrap();
// Temporary hack incase this runs before the font has loaded
setTimeout(updateCharWidth, 1000);
this.listenTo(messageActions.setWrapWidth, 'setWrapWidth');
this.listenTo(messageStore, 'messagesChanged');
this.listenTo(selectedTabStore, 'selectedTabChanged');
},
setWrapWidth(w) {
width = w;
messages = util.wrapMessages(messages, width, window.charWidth, window.messageIndent);
this.trigger(messages);
},
messagesChanged() {
if (wrap()) {
this.trigger(messages);
}
},
selectedTabChanged(selectedTab) {
tab = selectedTab;
if (wrap()) {
this.trigger(messages);
}
},
getInitialState: getState,
getState
});
module.exports = messageLineStore;

View File

@ -1,47 +0,0 @@
var Reflux = require('reflux');
var Immutable = require('immutable');
var actions = require('../actions/privateChat');
var messageActions = require('../actions/message');
var serverActions = require('../actions/server');
var privateChats = Immutable.Map();
var empty = Immutable.List();
function getState() {
return privateChats;
}
var privateChatStore = Reflux.createStore({
init() {
this.listenToMany(actions);
this.listenTo(messageActions.add, 'messageAdded');
this.listenTo(serverActions.disconnect, 'disconnect');
},
open(server, nick) {
privateChats = privateChats.update(server, empty, chats => chats.push(nick));
this.trigger(privateChats);
},
close(server, nick) {
privateChats = privateChats.update(server, chats => chats.delete(chats.indexOf(nick)));
this.trigger(privateChats);
},
messageAdded(message) {
if (!message.to && message.from.indexOf('.') === -1) {
this.open(message.server, message.from);
}
},
disconnect(server) {
privateChats = privateChats.delete(server);
this.trigger(privateChats);
},
getInitialState: getState,
getState
});
module.exports = privateChatStore;

View File

@ -1,36 +0,0 @@
var Reflux = require('reflux');
var Immutable = require('immutable');
var actions = require('../actions/search');
var Search = Immutable.Record({
show: false,
results: Immutable.List()
});
var search = new Search();
function getState() {
return search;
}
var searchStore = Reflux.createStore({
init() {
this.listenToMany(actions);
},
searchDone(results) {
search = search.set('results', Immutable.List(results));
this.trigger(search);
},
toggle() {
search = search.set('show', !search.show);
this.trigger(search);
},
getInitialState: getState,
getState
});
module.exports = searchStore;

View File

@ -1,180 +0,0 @@
var Reflux = require('reflux');
var Immutable = require('immutable');
var _ = require('lodash');
var serverStore = require('./server');
var actions = require('../actions/tab');
var channelActions = require('../actions/channel');
var serverActions = require('../actions/server');
var routeActions = require('../actions/route');
var privateChatActions = require('../actions/privateChat');
var Tab = Immutable.Record({
server: null,
channel: null,
name: null
});
var selectedTab = new Tab();
var history = [];
function selectPrevTab() {
history.pop();
if (history.length > 0) {
selectedTab = history[history.length - 1];
return true;
}
return false;
}
function updateChannelName(name) {
selectedTab = selectedTab.set('channel', name).set('name', name);
history[history.length - 1] = selectedTab;
}
function getState() {
return selectedTab;
}
var selectedTabStore = Reflux.createStore({
init() {
this.listenToMany(actions);
this.listenTo(channelActions.part, 'part');
this.listenTo(privateChatActions.close, 'close');
this.listenTo(serverActions.disconnect, 'disconnect');
this.listenTo(channelActions.addUser, 'userAdded');
this.listenTo(channelActions.load, 'loadChannels');
this.listenTo(serverActions.load, 'loadServers');
this.listenTo(routeActions.navigate, 'navigate');
},
select(server, channel = null) {
selectedTab = new Tab({
server,
channel,
name: channel || serverStore.getName(server)
});
history.push(selectedTab);
this.trigger(selectedTab);
},
part(channels, server) {
if (server === selectedTab.server &&
channels.indexOf(selectedTab.channel) !== -1) {
if (!selectPrevTab()) {
selectedTab = selectedTab
.set('channel', null)
.set('name', serverStore.getName(server));
}
this.trigger(selectedTab);
}
},
close(server, nick) {
if (server === selectedTab.server &&
nick === selectedTab.channel) {
if (!selectPrevTab()) {
selectedTab = selectedTab
.set('channel', null)
.set('name', serverStore.getName(server));
}
this.trigger(selectedTab);
}
},
disconnect(server) {
if (server === selectedTab.server) {
_.remove(history, { server: server });
if (!selectPrevTab()) {
selectedTab = new Tab();
}
this.trigger(selectedTab);
}
},
userAdded(user, server, channel) {
if (selectedTab.channel &&
server === selectedTab.server &&
user === serverStore.getNick(server) &&
channel.toLowerCase().indexOf(selectedTab.channel.toLowerCase()) !== -1) {
// Update the selected channel incase the casing is different
updateChannelName(channel);
this.trigger(selectedTab);
}
},
loadChannels(channels) {
_.each(channels, (channel) => {
if (channel.server === selectedTab.server &&
channel.name !== selectedTab.channel &&
channel.name.indexOf(selectedTab.channel) !== -1) {
// Handle double hashtag channel names, only a single hashtag
// gets added to the channel in the URL on page load
updateChannelName(channel.name);
this.trigger(selectedTab);
return false;
}
});
},
loadServers(servers) {
var server = _.find(servers, { address: selectedTab.server });
if (server && !selectedTab.channel) {
selectedTab = selectedTab.set('name', server.name);
history[history.length - 1] = selectedTab;
this.trigger(selectedTab);
}
},
navigate(route) {
if (route.indexOf('.') === -1 && selectedTab.server) {
selectedTab = new Tab();
this.trigger(selectedTab);
}
},
getServer() {
return selectedTab.server;
},
getChannel() {
return selectedTab.channel;
},
getInitialState: getState,
getState
});
selectedTabStore.listen(tab => {
var channel = tab.channel;
actions.hideMenu();
if (tab.server) {
if (channel) {
while (channel[0] === '#') {
channel = channel.slice(1);
}
routeActions.navigate('/' + tab.server + '/' + channel);
} else {
routeActions.navigate('/' + tab.server);
}
} else if (serverStore.getState().size === 0) {
routeActions.navigate('connect');
}
localStorage.selectedTab = JSON.stringify(tab);
});
module.exports = selectedTabStore;

View File

@ -1,67 +0,0 @@
var Reflux = require('reflux');
var Immutable = require('immutable');
var _ = require('lodash');
var actions = require('../actions/server');
var tabActions = require('../actions/tab');
var servers = Immutable.Map();
var Server = Immutable.Record({
nick: null,
name: null
});
function getState() {
return servers;
}
var serverStore = Reflux.createStore({
init() {
this.listenToMany(actions);
},
connect(server, nick, opts) {
var i = server.indexOf(':');
if (i > 0) {
server = server.slice(0, i);
}
servers = servers.set(server, new Server({
nick: nick,
name: opts.name || server
}));
this.trigger(servers);
tabActions.select(server);
},
disconnect(server) {
servers = servers.delete(server);
this.trigger(servers);
},
setNick(nick, server) {
servers = servers.update(server, s => s.set('nick', nick));
this.trigger(servers);
},
load(storedServers) {
_.each(storedServers, function(server) {
servers = servers.set(server.address, new Server(server));
});
this.trigger(servers);
},
getNick(server) {
return servers.getIn([server, 'nick']);
},
getName(server) {
return servers.getIn([server, 'name']);
},
getInitialState: getState,
getState
});
module.exports = serverStore;

View File

@ -1,112 +0,0 @@
var _ = require('lodash');
exports.UUID = function() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
};
exports.timestamp = function(date) {
date = date || new Date();
var h = _.padLeft(date.getHours(), 2, '0');
var m = _.padLeft(date.getMinutes(), 2, '0');
return h + ':' + m;
};
exports.wrapMessages = function(messages, width, charWidth, indent = 0) {
return messages.withMutations(m => {
for (var j = 0, llen = messages.size; j < llen; j++) {
var message = messages.get(j);
var lineWidth = (6 + (message.from ? message.from.length + 1 : 0)) * charWidth;
if (lineWidth + message.message.length * charWidth < width) {
m.setIn([j, 'lines'], [message.message]);
continue;
}
var words = message.message.split(' ');
var line = '';
var wrapped = [];
var wordCount = 0;
var hasWrapped = false;
// Add empty line if first word after timestamp + sender wraps
if (words.length > 0 && message.from && lineWidth + words[0].length * charWidth >= width) {
wrapped.push(line);
lineWidth = 0;
}
for (var i = 0, wlen = words.length; i < wlen; i++) {
var word = words[i];
if (hasWrapped) {
hasWrapped = false;
lineWidth += indent;
}
lineWidth += word.length * charWidth;
wordCount++;
if (lineWidth >= width) {
if (wordCount !== 1) {
wrapped.push(line);
if (i !== wlen - 1) {
line = word + ' ';
lineWidth = (word.length + 1) * charWidth;
wordCount = 1;
} else {
wrapped.push(word);
}
} else {
wrapped.push(word);
lineWidth = 0;
wordCount = 0;
}
hasWrapped = true;
} else if (i !== wlen - 1) {
line += word + ' ';
lineWidth += charWidth;
} else {
line += word;
wrapped.push(line);
}
}
m.setIn([j, 'lines'], wrapped);
}
});
};
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
exports.stringWidth = function(str, font) {
ctx.font = font;
return ctx.measureText(str).width;
};
exports.scrollbarWidth = function() {
var outer = document.createElement('div');
outer.style.visibility = 'hidden';
outer.style.width = '100px';
document.body.appendChild(outer);
var widthNoScroll = outer.offsetWidth;
outer.style.overflow = 'scroll';
var inner = document.createElement('div');
inner.style.width = '100%';
outer.appendChild(inner);
var widthWithScroll = inner.offsetWidth;
outer.parentNode.removeChild(outer);
return widthNoScroll - widthWithScroll;
};

View File

@ -0,0 +1,83 @@
import EventEmitter2 from 'eventemitter2';
import Backoff from 'backo';
export default class Socket extends EventEmitter2 {
constructor(address) {
super();
this.address = address;
this.connectTimeout = 20000;
this.pingTimeout = 30000;
this.backoff = new Backoff({
min: 1000,
max: 5000,
jitter: 0.25
});
this.connect();
}
connect() {
this.ws = new WebSocket(`ws://${this.address}/ws`);
this.timeoutConnect = setTimeout(() => {
this.ws.close();
this.retry();
}, this.connectTimeout);
this.ws.onopen = () => {
clearTimeout(this.timeoutConnect);
this.backoff.reset();
this.emit('connect');
this.setTimeoutPing();
};
this.ws.onclose = () => {
clearTimeout(this.timeoutConnect);
clearTimeout(this.timeoutPing);
if (!this.closing) {
this.emit('disconnect');
this.retry();
}
this.closing = false;
};
this.ws.onerror = () => {
clearTimeout(this.timeoutConnect);
clearTimeout(this.timeoutPing);
this.closing = true;
this.ws.close();
this.retry();
};
this.ws.onmessage = (e) => {
this.setTimeoutPing();
const msg = JSON.parse(e.data);
if (msg.type === 'ping') {
this.send('pong');
}
this.emit(msg.type, msg.data);
};
}
retry() {
setTimeout(() => this.connect(), this.backoff.duration());
}
send(type, data) {
this.ws.send(JSON.stringify({ type, data }));
}
setTimeoutPing() {
clearTimeout(this.timeoutPing);
this.timeoutPing = setTimeout(() => {
this.emit('disconnect');
this.closing = true;
this.ws.close();
this.connect();
}, this.pingTimeout);
}
}

View File

@ -0,0 +1,8 @@
export default function createReducer(initialState, handlers) {
return function reducer(state = initialState, action) {
if (handlers.hasOwnProperty(action.type)) {
return handlers[action.type](state, action);
}
return state;
};
}

View File

@ -0,0 +1,54 @@
import { padLeft } from 'lodash';
export wrapMessages from './wrapMessages';
export function normalizeChannel(channel) {
if (channel.indexOf('#') !== 0) {
return channel;
}
return channel.split('#').join('').toLowerCase();
}
export function createUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
export function timestamp(date = new Date()) {
const h = padLeft(date.getHours(), 2, '0');
const m = padLeft(date.getMinutes(), 2, '0');
return h + ':' + m;
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
export function stringWidth(str, font) {
ctx.font = font;
return ctx.measureText(str).width;
}
export function scrollbarWidth() {
const outer = document.createElement('div');
outer.style.visibility = 'hidden';
outer.style.width = '100px';
document.body.appendChild(outer);
const widthNoScroll = outer.offsetWidth;
outer.style.overflow = 'scroll';
const inner = document.createElement('div');
inner.style.width = '100%';
outer.appendChild(inner);
const widthWithScroll = inner.offsetWidth;
outer.parentNode.removeChild(outer);
return widthNoScroll - widthWithScroll;
}

View File

@ -0,0 +1,65 @@
export default function wrapMessages(messages, width, charWidth, indent = 0) {
return messages.withMutations(m => {
for (let j = 0, llen = messages.size; j < llen; j++) {
const message = messages.get(j);
let lineWidth = (6 + (message.from ? message.from.length + 1 : 0)) * charWidth;
if (lineWidth + message.message.length * charWidth < width) {
m.setIn([j, 'lines'], [message.message]);
continue;
}
const words = message.message.split(' ');
const wrapped = [];
let line = '';
let wordCount = 0;
let hasWrapped = false;
// Add empty line if first word after timestamp + sender wraps
if (words.length > 0 && message.from && lineWidth + words[0].length * charWidth >= width) {
wrapped.push(line);
lineWidth = 0;
}
for (let i = 0, wlen = words.length; i < wlen; i++) {
const word = words[i];
if (hasWrapped) {
hasWrapped = false;
lineWidth += indent;
}
lineWidth += word.length * charWidth;
wordCount++;
if (lineWidth >= width) {
if (wordCount !== 1) {
wrapped.push(line);
if (i !== wlen - 1) {
line = word + ' ';
lineWidth = (word.length + 1) * charWidth;
wordCount = 1;
} else {
wrapped.push(word);
}
} else {
wrapped.push(word);
lineWidth = 0;
wordCount = 0;
}
hasWrapped = true;
} else if (i !== wlen - 1) {
line += word + ' ';
lineWidth += charWidth;
} else {
line += word;
wrapped.push(line);
}
}
m.setIn([j, 'lines'], wrapped);
}
});
}

View File

@ -0,0 +1,30 @@
var path = require('path');
var webpack = require('webpack');
module.exports = {
devtool: 'eval',
entry: [
'webpack-hot-middleware/client',
'./src/js/index'
],
output: {
path: path.join(__dirname, 'dist'),
filename: 'bundle.js',
publicPath: '/'
},
module: {
preLoaders: [
{ test: /\.js$/, loader: 'eslint', exclude: /node_modules/ }
],
loaders: [
{ test: /\.js$/, loader: 'babel', exclude: /node_modules/ }
]
},
plugins: [
new webpack.DefinePlugin({
__DEV__: true,
}),
new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin()
]
};

View File

@ -0,0 +1,38 @@
var path = require('path');
var webpack = require('webpack');
module.exports = {
entry: [
'./src/js/index'
],
output: {
path: path.join(__dirname, 'dist'),
filename: 'bundle.js',
publicPath: '/'
},
module: {
preLoaders: [
{ test: /\.js$/, loader: 'eslint', exclude: /node_modules/ }
],
loaders: [
{ test: /\.js$/, loader: 'babel', exclude: /node_modules/ }
]
},
plugins: [
new webpack.DefinePlugin({
__DEV__: false,
'process.env': {
NODE_ENV: JSON.stringify('production')
}
}),
new webpack.optimize.DedupePlugin(),
new webpack.optimize.OccurenceOrderPlugin(),
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false
}
})
]
};

View File

@ -52,6 +52,7 @@ func (i *ircHandler) nick(msg *irc.Message) {
Server: i.client.Host,
Old: msg.Nick,
New: msg.Trailing,
Channels: channelStore.FindUserChannels(msg.Nick, i.client.Host),
})
channelStore.RenameUser(msg.Nick, msg.Trailing, i.client.Host)
@ -129,6 +130,7 @@ func (i *ircHandler) quit(msg *irc.Message) {
Server: i.client.Host,
User: msg.Nick,
Reason: msg.Trailing,
Channels: channelStore.FindUserChannels(msg.Nick, i.client.Host),
})
channelStore.RemoveUserAll(msg.Nick, i.client.Host)

View File

@ -30,6 +30,7 @@ type Nick struct {
Server string `json:"server"`
Old string `json:"old"`
New string `json:"new"`
Channels []string `json:"channels"`
}
type Join struct {
@ -55,6 +56,7 @@ type Quit struct {
Server string `json:"server"`
User string `json:"user"`
Reason string `json:"reason,omitempty"`
Channels []string `json:"channels"`
}
type Chat struct {

View File

@ -95,6 +95,23 @@ func (c *ChannelStore) SetMode(server, channel, user, add, remove string) {
c.userLock.Unlock()
}
func (c *ChannelStore) FindUserChannels(user, server string) []string {
var channels []string
c.userLock.Lock()
for channel, users := range c.users[server] {
for _, nick := range users {
if user == nick {
channels = append(channels, channel)
break
}
}
}
c.userLock.Unlock()
return channels
}
func (c *ChannelStore) GetTopic(server, channel string) string {
c.topicLock.Lock()
defer c.topicLock.Unlock()

View File

@ -57,3 +57,12 @@ func TestTopic(t *testing.T) {
channelStore.SetTopic("the topic", "srv", "#chan")
assert.Equal(t, "the topic", channelStore.GetTopic("srv", "#chan"))
}
func TestFindUserChannels(t *testing.T) {
channelStore := NewChannelStore()
channelStore.AddUser("user", "srv", "#chan1")
channelStore.AddUser("user", "srv", "#chan2")
channelStore.AddUser("user2", "srv", "#chan3")
channelStore.AddUser("user", "srv2", "#chan4")
assert.Equal(t, []string{"#chan1", "#chan2"}, channelStore.FindUserChannels("user", "srv"))
}