IRC output gets queued until RPL_WELCOME, added tcp timeouts and error handling, store selected tab in localStorage, more design work, upgraded to lodash 3.0.0

This commit is contained in:
khlieng 2015-01-30 00:38:51 +01:00
parent 5c6c43e017
commit 3c02b00303
18 changed files with 268 additions and 53 deletions

View File

@ -19,7 +19,7 @@
"reactify": "~0.17.1" "reactify": "~0.17.1"
}, },
"dependencies": { "dependencies": {
"lodash": "~2.4.1", "lodash": "3.0.0",
"reflux": "~0.2.2", "reflux": "~0.2.2",
"react-router": "~0.11.6", "react-router": "~0.11.6",
"react": "~0.12.2" "react": "~0.12.2"

View File

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>IRC</title> <title>IRC</title>
<link href='http://fonts.googleapis.com/css?family=Inconsolata' rel='stylesheet' type='text/css'> <link href="//fonts.googleapis.com/css?family=Montserrat|Roboto" rel="stylesheet">
<link href="style.css" rel="stylesheet"> <link href="style.css" rel="stylesheet">
</head> </head>
<body> <body>

View File

@ -8,11 +8,12 @@ var serverActions = Reflux.createActions([
'load' 'load'
]); ]);
serverActions.connect.preEmit = function(server, nick, username) { serverActions.connect.preEmit = function(server, nick, username, tls) {
socket.send('connect', { socket.send('connect', {
server: server, server: server,
nick: nick, nick: nick,
username: username username: username,
tls: tls || false
}); });
}; };

View File

@ -14,8 +14,14 @@ socket.on('connect', function() {
socket.send('uuid', uuid); socket.send('uuid', uuid);
serverActions.connect('irc.freenode.net', nick, 'username'); serverActions.connect('irc.freenode.net', nick, 'username');
serverActions.connect('irc.quakenet.org', nick, 'username');
channelActions.join(['#stuff'], 'irc.freenode.net'); channelActions.join(['#stuff'], 'irc.freenode.net');
tabActions.select('irc.freenode.net'); channelActions.join(['#herp'], 'irc.quakenet.org');
});
socket.on('error', function(error) {
console.log(error.server + ': ' + error.message);
}); });
React.render(<App />, document.body); React.render(<App />, document.body);

View File

@ -20,18 +20,15 @@ var ChatTitle = React.createClass({
render: function() { render: function() {
var tab = this.state.selectedTab; var tab = this.state.selectedTab;
var title; var title;
var topic;
var usercount;
if (tab.channel) { if (tab.channel && this.state.channels[tab.server]) {
var channel = this.state.channels[tab.server][tab.channel]; var channel = this.state.channels[tab.server][tab.channel];
if (channel) { if (channel) {
title = tab.channel title = tab.channel
title += ' ['; usercount = channel.users.length;
title += channel.users.length; topic = channel.topic || '';
title += ']';
if (channel.topic) {
title += ': ' + channel.topic;
}
} }
} else { } else {
title = tab.server; title = tab.server;
@ -39,7 +36,11 @@ var ChatTitle = React.createClass({
return ( return (
<div className="chat-title-bar"> <div className="chat-title-bar">
<span className="chat-title" title={title}>{title}</span> <div>
<span className="chat-title">{title}</span>
<span className="chat-topic" title={topic}>{topic}</span>
</div>
<span className="chat-usercount">{usercount}</span>
</div> </div>
); );
} }

View File

@ -2,6 +2,7 @@ var React = require('react');
var Reflux = require('reflux'); var Reflux = require('reflux');
var _ = require('lodash'); var _ = require('lodash');
var util = require('../util');
var messageStore = require('../stores/message'); var messageStore = require('../stores/message');
var selectedTabStore = require('../stores/selectedTab'); var selectedTabStore = require('../stores/selectedTab');
@ -38,12 +39,20 @@ var MessageBox = React.createClass({
if (this.state.messages[tab.server] && dest) { if (this.state.messages[tab.server] && dest) {
messages = _.map(this.state.messages[tab.server][dest], function(message) { messages = _.map(this.state.messages[tab.server][dest], function(message) {
var messageClass = 'message'; var messageClass = 'message';
switch (message.type) { switch (message.type) {
case 'info': case 'info':
messageClass += ' message-info'; messageClass += ' message-info';
break; break;
} }
return <p className={messageClass}>{message.from ? message.from + ': ' : null}{message.message}</p>;
return (
<p className={messageClass}>
<span className="message-time">{util.timestamp(message.time)}</span>
{ message.from ? <span className="message-sender">{message.from}</span> : null }
{message.message}
</p>
);
}); });
} }

View File

@ -21,16 +21,41 @@ var TabList = React.createClass({
render: function() { render: function() {
var self = this; var self = this;
var tabClass;
var selected = this.state.selectedTab;
var tabs = _.map(this.state.channels, function(server, address) { var tabs = _.map(this.state.channels, function(server, address) {
var channels = _.map(server, function(channel, name) { var channels = _.map(server, function(channel, name) {
return <p onClick={tabActions.select.bind(null, address, name)}>{name}</p>; if (address === selected.server &&
name === selected.channel) {
tabClass = 'selected';
} else {
tabClass = '';
}
return <p className={tabClass} onClick={tabActions.select.bind(null, address, name)}>{name}</p>;
}); });
channels.unshift(<p onClick={tabActions.select.bind(null, address, null)}>{address}</p>);
if (address === selected.server &&
selected.channel === null) {
tabClass = 'tab-server selected';
} else {
tabClass = 'tab-server';
}
channels.unshift(<p className={tabClass} onClick={tabActions.select.bind(null, address, null)}>{address}</p>);
return channels; return channels;
}); });
return ( return (
<div className="tablist">{tabs}</div> <div className="tablist">
<button className="button-connect">Add Network</button>
{tabs}
<div className="side-buttons">
<button>Settings</button>
</div>
</div>
); );
} }
}); });

View File

@ -22,7 +22,7 @@ var UserList = React.createClass({
var users = null; var users = null;
var tab = this.state.selectedTab; var tab = this.state.selectedTab;
if (tab.channel) { if (tab.channel && this.state.channels[tab.server]) {
var channel = this.state.channels[tab.server][tab.channel]; var channel = this.state.channels[tab.server][tab.channel];
if (channel) { if (channel) {
users = _.map(channel.users, function(user) { users = _.map(channel.users, function(user) {

View File

@ -2,6 +2,7 @@ var Reflux = require('reflux');
var _ = require('lodash'); var _ = require('lodash');
var actions = require('../actions/channel'); var actions = require('../actions/channel');
var serverActions = require('../actions/server');
var channels = {}; var channels = {};
@ -80,6 +81,7 @@ function sortUsers(server, channel) {
var channelStore = Reflux.createStore({ var channelStore = Reflux.createStore({
init: function() { init: function() {
this.listenToMany(actions); this.listenToMany(actions);
this.listenTo(serverActions.connect, 'addServer');
}, },
part: function(partChannels, server) { part: function(partChannels, server) {
@ -161,6 +163,13 @@ var channelStore = Reflux.createStore({
this.trigger(channels); this.trigger(channels);
}, },
addServer: function(server) {
if (!(server in channels)) {
channels[server] = {};
this.trigger(channels);
}
},
getState: function() { getState: function() {
return channels; return channels;
} }

View File

@ -1,6 +1,6 @@
var Reflux = require('reflux'); var Reflux = require('reflux');
var serverStore = require('../stores/server'); var serverStore = require('./server');
var actions = require('../actions/message'); var actions = require('../actions/message');
var messages = {}; var messages = {};
@ -26,7 +26,8 @@ var messageStore = Reflux.createStore({
server: server, server: server,
from: serverStore.getNick(server), from: serverStore.getNick(server),
to: to, to: to,
message: message message: message,
time: new Date()
}, to); }, to);
this.trigger(messages); this.trigger(messages);
@ -38,6 +39,8 @@ var messageStore = Reflux.createStore({
dest = message.server; dest = message.server;
} }
message.time = new Date();
addMessage(message, dest); addMessage(message, dest);
this.trigger(messages); this.trigger(messages);
}, },

View File

@ -5,6 +5,11 @@ var actions = require('../actions/tab');
var channelActions = require('../actions/channel'); var channelActions = require('../actions/channel');
var selectedTab = {}; var selectedTab = {};
var stored = localStorage.selectedTab;
if (stored) {
selectedTab = JSON.parse(stored);
}
var selectedTabStore = Reflux.createStore({ var selectedTabStore = Reflux.createStore({
init: function() { init: function() {
@ -36,4 +41,8 @@ var selectedTabStore = Reflux.createStore({
} }
}); });
selectedTabStore.listen(function(selected) {
localStorage.selectedTab = JSON.stringify(selected);
});
module.exports = selectedTabStore; module.exports = selectedTabStore;

View File

@ -1,6 +1,17 @@
var _ = require('lodash');
exports.UUID = function() { exports.UUID = function() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8);
return v.toString(16); 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;
};

View File

@ -5,17 +5,24 @@
} }
body { body {
font-family: Inconsolata, sans-serif; font-family: Roboto, sans-serif;
background: #f0f0f0; background: #f0f0f0;
} }
input { input {
font: 16px Inconsolata, sans-serif; font: 16px Roboto, sans-serif;
border: none; border: none;
border-top: 1px solid #DDD; border-top: 1px solid #DDD;
outline: none; outline: none;
} }
button {
font: 16px Montserrat, sans-serif;
border: none;
outline: none;
cursor: pointer;
}
p { p {
line-height: 1.5; line-height: 1.5;
} }
@ -26,20 +33,64 @@ p {
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 200px; width: 200px;
padding: 15px;
overflow: auto; overflow: auto;
background: #272626; background: #222;
color: #FFF; color: #FFF;
font-family: Montserrat, sans-serif;
} }
.tablist p { .tablist p {
padding: 3px 15px;
cursor: pointer; cursor: pointer;
} }
.tablist p:hover { .tablist p:hover {
background: #111;
}
.tablist p.selected {
padding-left: 10px;
border-left: 5px solid #6BB758;
}
.tab-server {
color: #AAA;
margin-top: 15px !important;
}
.button-connect {
width: 100%;
height: 50px;
background: #6BB758;
color: #FFF;
}
.button-connect:hover {
background: #7BBF6A;
}
.button-connect:active {
background: #6BB758;
}
.side-buttons {
position: fixed;
bottom: 0;
height: 50px;
width: 200px;
text-align: center;
}
.side-buttons button {
background: #333;
color: #FFF;
margin: 5px;
height: 40px;
width: 100px;
} }
.chat-title-bar { .chat-title-bar {
font-family: Montserrat, sans-serif;
position: fixed; position: fixed;
left: 200px; left: 200px;
top: 0; top: 0;
@ -47,14 +98,34 @@ p {
height: 50px; height: 50px;
padding: 0 15px; padding: 0 15px;
line-height: 50px; line-height: 50px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid #DDD; border-bottom: 1px solid #DDD;
} }
.chat-title-bar div {
position: absolute;
left: 0;
right: 100px;
white-space: nowrap;
overflow: hidden;
}
.chat-title { .chat-title {
font-size: 24px; margin-left: 15px;
font-size: 28px;
}
.chat-topic {
display: none;
font: 16px Roboto, sans-serif;
line-height: 50px;
vertical-align: top;
color: #222;
margin-left: 15px;
}
.chat-usercount {
font-size: 20px;
float: right;
} }
.messagebox { .messagebox {
@ -69,11 +140,25 @@ p {
} }
.message { .message {
padding-left: 50px;
text-indent: -50px;
}
.message span {
margin-right: 10px;
} }
.message-info { .message-info {
color: #666; color: #999;
}
.message-time {
color: #999;
}
.message-sender {
color: #6BB758;
font: 16px Montserrat, sans-serif;
} }
.message-input-wrap { .message-input-wrap {
@ -100,4 +185,5 @@ p {
padding: 15px; padding: 15px;
overflow: auto; overflow: auto;
border-left: 1px solid #DDD; border-left: 1px solid #DDD;
overflow-x: hidden;
} }

54
irc.go
View File

@ -6,6 +6,8 @@ import (
"fmt" "fmt"
"net" "net"
"strings" "strings"
"sync"
"time"
) )
const ( const (
@ -54,6 +56,8 @@ type Message struct {
type IRC struct { type IRC struct {
conn net.Conn conn net.Conn
reader *bufio.Reader reader *bufio.Reader
out chan string
ready sync.WaitGroup
Messages chan *Message Messages chan *Message
Server string Server string
@ -71,10 +75,11 @@ func NewIRC(nick, username string) *IRC {
Username: username, Username: username,
Realname: nick, Realname: nick,
Messages: make(chan *Message, 32), Messages: make(chan *Message, 32),
out: make(chan string, 32),
} }
} }
func (i *IRC) Connect(address string) { func (i *IRC) Connect(address string) error {
if idx := strings.Index(address, ":"); idx < 0 { if idx := strings.Index(address, ":"); idx < 0 {
i.Host = address i.Host = address
@ -88,13 +93,24 @@ func (i *IRC) Connect(address string) {
} }
i.Server = address i.Server = address
dialer := &net.Dialer{Timeout: 5 * time.Second}
if i.TLS { if i.TLS {
if i.TLSConfig == nil { if i.TLSConfig == nil {
i.TLSConfig = &tls.Config{InsecureSkipVerify: true} i.TLSConfig = &tls.Config{InsecureSkipVerify: true}
} }
i.conn, _ = tls.Dial("tcp", address, i.TLSConfig)
if conn, err := tls.DialWithDialer(dialer, "tcp", address, i.TLSConfig); err != nil {
return err
} else {
i.conn = conn
}
} else { } else {
i.conn, _ = net.Dial("tcp", address) if conn, err := dialer.Dial("tcp", address); err != nil {
return err
} else {
i.conn = conn
}
} }
i.reader = bufio.NewReader(i.conn) i.reader = bufio.NewReader(i.conn)
@ -102,19 +118,23 @@ func (i *IRC) Connect(address string) {
i.Nick(i.nick) i.Nick(i.nick)
i.User(i.Username, i.Realname) i.User(i.Username, i.Realname)
i.ready.Add(1)
go i.send()
go i.recv() go i.recv()
return nil
} }
func (i *IRC) Pass(password string) { func (i *IRC) Pass(password string) {
i.Write("PASS " + password) i.write("PASS " + password)
} }
func (i *IRC) Nick(nick string) { func (i *IRC) Nick(nick string) {
i.Write("NICK " + nick) i.write("NICK " + nick)
} }
func (i *IRC) User(username, realname string) { func (i *IRC) User(username, realname string) {
i.Writef("USER %s 0 * :%s", username, realname) i.writef("USER %s 0 * :%s", username, realname)
} }
func (i *IRC) Join(channels ...string) { func (i *IRC) Join(channels ...string) {
@ -147,13 +167,28 @@ func (i *IRC) Quit() {
} }
func (i *IRC) Write(data string) { func (i *IRC) Write(data string) {
fmt.Fprint(i.conn, data+"\r\n") i.out <- data + "\r\n"
} }
func (i *IRC) Writef(format string, a ...interface{}) { func (i *IRC) Writef(format string, a ...interface{}) {
i.out <- fmt.Sprintf(format+"\r\n", a...)
}
func (i *IRC) write(data string) {
fmt.Fprint(i.conn, data+"\r\n")
}
func (i *IRC) writef(format string, a ...interface{}) {
fmt.Fprintf(i.conn, format+"\r\n", a...) fmt.Fprintf(i.conn, format+"\r\n", a...)
} }
func (i *IRC) send() {
i.ready.Wait()
for message := range i.out {
fmt.Fprint(i.conn, message)
}
}
func (i *IRC) recv() { func (i *IRC) recv() {
defer i.conn.Close() defer i.conn.Close()
for { for {
@ -167,7 +202,10 @@ func (i *IRC) recv() {
switch msg.Command { switch msg.Command {
case PING: case PING:
i.Write("PONG :" + msg.Trailing) i.write("PONG :" + msg.Trailing)
case RPL_WELCOME:
i.ready.Done()
} }
i.Messages <- msg i.Messages <- msg

View File

@ -16,6 +16,7 @@ type WSResponse struct {
type Connect struct { type Connect struct {
Server string `json:"server"` Server string `json:"server"`
TLS bool `json:"tls"`
Nick string `json:"nick"` Nick string `json:"nick"`
Username string `json:"username"` Username string `json:"username"`
} }
@ -63,3 +64,8 @@ type MOTD struct {
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` Content string `json:"content"`
} }
type Error struct {
Server string `json:"server"`
Message string `json:"message"`
}

View File

@ -65,11 +65,18 @@ func (s *Session) sendJSON(t string, v interface{}) {
s.out <- res s.out <- res
} }
func (s *Session) sendError(err error, server string) {
s.sendJSON("error", Error{
Server: server,
Message: err.Error(),
})
}
func (s *Session) write() { func (s *Session) write() {
for res := range s.out { for res := range s.out {
s.wsLock.Lock() s.wsLock.Lock()
for _, ws := range s.ws { for _, ws := range s.ws {
ws.In <- res ws.Out <- res
} }
s.wsLock.Unlock() s.wsLock.Unlock()
} }

View File

@ -7,18 +7,18 @@ import (
type WebSocket struct { type WebSocket struct {
conn *websocket.Conn conn *websocket.Conn
In chan []byte Out chan []byte
} }
func NewWebSocket(ws *websocket.Conn) *WebSocket { func NewWebSocket(ws *websocket.Conn) *WebSocket {
return &WebSocket{ return &WebSocket{
conn: ws, conn: ws,
In: make(chan []byte, 32), Out: make(chan []byte, 32),
} }
} }
func (w *WebSocket) write() { func (w *WebSocket) write() {
for data := range w.In { for data := range w.Out {
w.conn.Write(data) w.conn.Write(data)
} }
} }

View File

@ -74,18 +74,22 @@ func handleWS(ws *websocket.Conn) {
log.Println(addr, "connecting to", data.Server) log.Println(addr, "connecting to", data.Server)
irc := NewIRC(data.Nick, data.Username) irc := NewIRC(data.Nick, data.Username)
irc.TLS = true irc.TLS = data.TLS
irc.Connect(data.Server)
session.setIRC(irc.Host, irc) if err := irc.Connect(data.Server); err != nil {
session.sendError(err, irc.Host)
log.Println(err)
} else {
session.setIRC(irc.Host, irc)
go handleMessages(irc, session) go handleMessages(irc, session)
session.user.AddServer(storage.Server{ session.user.AddServer(storage.Server{
Address: irc.Host, Address: irc.Host,
Nick: data.Nick, Nick: data.Nick,
Username: data.Username, Username: data.Username,
}) })
}
} else { } else {
log.Println(addr, "already connected to", data.Server) log.Println(addr, "already connected to", data.Server)
} }