Switch to redux and webpack
This commit is contained in:
parent
b247287075
commit
e389454535
15
.editorconfig
Normal file
15
.editorconfig
Normal 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
|
@ -15,7 +15,7 @@ install:
|
||||
- npm install
|
||||
|
||||
script:
|
||||
- gulp -p
|
||||
- gulp build
|
||||
- cd ..
|
||||
- go vet ./...
|
||||
- go test -v -race ./...
|
||||
|
10
README.md
10
README.md
@ -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
20
client/.babelrc
Normal 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"]
|
||||
}]
|
||||
}]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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']);
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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
37
client/src/js/actions.js
Normal 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';
|
@ -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 }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
17
client/src/js/actions/environment.js
Normal file
17
client/src/js/actions/environment.js
Normal 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);
|
||||
}
|
@ -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
|
||||
};
|
||||
}
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
@ -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());
|
||||
};
|
||||
}
|
||||
|
@ -1,7 +0,0 @@
|
||||
var Reflux = require('reflux');
|
||||
|
||||
var routeActions = Reflux.createActions([
|
||||
'navigate'
|
||||
]);
|
||||
|
||||
module.exports = routeActions;
|
@ -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
|
||||
};
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
@ -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'));
|
@ -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
108
client/src/js/commands.js
Normal 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));
|
||||
}
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
||||
});
|
56
client/src/js/components/ChatTitle.js
Normal file
56
client/src/js/components/ChatTitle.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
90
client/src/js/components/MessageBox.js
Normal file
90
client/src/js/components/MessageBox.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
});
|
46
client/src/js/components/MessageHeader.js
Normal file
46
client/src/js/components/MessageHeader.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
57
client/src/js/components/MessageInput.js
Normal file
57
client/src/js/components/MessageInput.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
25
client/src/js/components/MessageLine.js
Normal file
25
client/src/js/components/MessageLine.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
16
client/src/js/components/Navicon.js
Normal file
16
client/src/js/components/Navicon.js
Normal 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);
|
@ -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;
|
37
client/src/js/components/Search.js
Normal file
37
client/src/js/components/Search.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
14
client/src/js/components/Settings.js
Normal file
14
client/src/js/components/Settings.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
69
client/src/js/components/TabList.js
Normal file
69
client/src/js/components/TabList.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
21
client/src/js/components/TabListItem.js
Normal file
21
client/src/js/components/TabListItem.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
53
client/src/js/components/UserList.js
Normal file
53
client/src/js/components/UserList.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
16
client/src/js/components/UserListItem.js
Normal file
16
client/src/js/components/UserListItem.js
Normal 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>;
|
||||
}
|
||||
}
|
@ -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;
|
34
client/src/js/containers/App.js
Normal file
34
client/src/js/containers/App.js
Normal 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);
|
154
client/src/js/containers/Chat.js
Normal file
154
client/src/js/containers/Chat.js
Normal 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);
|
81
client/src/js/containers/Connect.js
Normal file
81
client/src/js/containers/Connect.js
Normal 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);
|
10
client/src/js/containers/DevTools.js
Normal file
10
client/src/js/containers/DevTools.js
Normal 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>
|
||||
);
|
20
client/src/js/containers/Root.dev.js
Normal file
20
client/src/js/containers/Root.dev.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
5
client/src/js/containers/Root.js
Normal file
5
client/src/js/containers/Root.js
Normal file
@ -0,0 +1,5 @@
|
||||
if (__DEV__) {
|
||||
module.exports = require('./Root.dev');
|
||||
} else {
|
||||
module.exports = require('./Root.prod');
|
||||
}
|
16
client/src/js/containers/Root.prod.js
Normal file
16
client/src/js/containers/Root.prod.js
Normal 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
31
client/src/js/index.js
Normal 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'));
|
@ -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);
|
||||
});
|
20
client/src/js/middleware/command.js
Normal file
20
client/src/js/middleware/command.js
Normal 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);
|
||||
};
|
||||
}
|
9
client/src/js/middleware/socket.js
Normal file
9
client/src/js/middleware/socket.js
Normal 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);
|
||||
};
|
||||
}
|
@ -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);
|
||||
}
|
||||
};
|
191
client/src/js/reducers/channels.js
Normal file
191
client/src/js/reducers/channels.js
Normal 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);
|
||||
}
|
||||
});
|
9
client/src/js/reducers/environment.js
Normal file
9
client/src/js/reducers/environment.js
Normal 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);
|
||||
}
|
||||
});
|
24
client/src/js/reducers/index.js
Normal file
24
client/src/js/reducers/index.js
Normal 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
|
||||
});
|
45
client/src/js/reducers/input.js
Normal file
45
client/src/js/reducers/input.js
Normal 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;
|
||||
}
|
||||
});
|
68
client/src/js/reducers/messages.js
Normal file
68
client/src/js/reducers/messages.js
Normal 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])
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
29
client/src/js/reducers/privateChats.js
Normal file
29
client/src/js/reducers/privateChats.js
Normal 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);
|
||||
}
|
||||
});
|
18
client/src/js/reducers/search.js
Normal file
18
client/src/js/reducers/search.js
Normal 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);
|
||||
}
|
||||
});
|
46
client/src/js/reducers/servers.js
Normal file
46
client/src/js/reducers/servers.js
Normal 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));
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
12
client/src/js/reducers/showMenu.js
Normal file
12
client/src/js/reducers/showMenu.js
Normal 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;
|
||||
}
|
||||
});
|
48
client/src/js/reducers/tab.js
Normal file
48
client/src/js/reducers/tab.js
Normal 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
19
client/src/js/routes.js
Normal 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>
|
||||
);
|
||||
}
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
5
client/src/js/store/index.js
Normal file
5
client/src/js/store/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
if (__DEV__) {
|
||||
module.exports = require('./store.dev');
|
||||
} else {
|
||||
module.exports = require('./store.prod');
|
||||
}
|
27
client/src/js/store/store.dev.js
Normal file
27
client/src/js/store/store.dev.js
Normal 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;
|
||||
}
|
15
client/src/js/store/store.prod.js
Normal file
15
client/src/js/store/store.prod.js
Normal 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);
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
};
|
83
client/src/js/util/Socket.js
Normal file
83
client/src/js/util/Socket.js
Normal 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);
|
||||
}
|
||||
}
|
8
client/src/js/util/createReducer.js
Normal file
8
client/src/js/util/createReducer.js
Normal 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;
|
||||
};
|
||||
}
|
54
client/src/js/util/index.js
Normal file
54
client/src/js/util/index.js
Normal 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;
|
||||
}
|
65
client/src/js/util/wrapMessages.js
Normal file
65
client/src/js/util/wrapMessages.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
30
client/webpack.config.dev.js
Normal file
30
client/webpack.config.dev.js
Normal 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()
|
||||
]
|
||||
};
|
38
client/webpack.config.prod.js
Normal file
38
client/webpack.config.prod.js
Normal 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
|
||||
}
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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()
|
||||
|
@ -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"))
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user