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,9 +19,9 @@
"reactify": "~0.17.1"
},
"dependencies": {
"lodash": "~2.4.1",
"lodash": "3.0.0",
"reflux": "~0.2.2",
"react-router": "~0.11.6",
"react": "~0.12.2"
}
}
}

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<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">
</head>
<body>

View File

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

View File

@ -14,8 +14,14 @@ socket.on('connect', function() {
socket.send('uuid', uuid);
serverActions.connect('irc.freenode.net', nick, 'username');
serverActions.connect('irc.quakenet.org', nick, 'username');
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);

View File

@ -20,18 +20,15 @@ var ChatTitle = React.createClass({
render: function() {
var tab = this.state.selectedTab;
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];
if (channel) {
title = tab.channel
title += ' [';
title += channel.users.length;
title += ']';
if (channel.topic) {
title += ': ' + channel.topic;
}
usercount = channel.users.length;
topic = channel.topic || '';
}
} else {
title = tab.server;
@ -39,7 +36,11 @@ var ChatTitle = React.createClass({
return (
<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>
);
}

View File

@ -2,6 +2,7 @@ var React = require('react');
var Reflux = require('reflux');
var _ = require('lodash');
var util = require('../util');
var messageStore = require('../stores/message');
var selectedTabStore = require('../stores/selectedTab');
@ -38,12 +39,20 @@ var MessageBox = React.createClass({
if (this.state.messages[tab.server] && dest) {
messages = _.map(this.state.messages[tab.server][dest], function(message) {
var messageClass = 'message';
switch (message.type) {
case 'info':
messageClass += ' message-info';
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() {
var self = this;
var tabClass;
var selected = this.state.selectedTab;
var tabs = _.map(this.state.channels, function(server, address) {
var channels = _.map(server, function(channel, name) {
return <p onClick={tabActions.select.bind(null, address, name)}>{name}</p>;
var channels = _.map(server, function(channel, name) {
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 (
<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 tab = this.state.selectedTab;
if (tab.channel) {
if (tab.channel && this.state.channels[tab.server]) {
var channel = this.state.channels[tab.server][tab.channel];
if (channel) {
users = _.map(channel.users, function(user) {

View File

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

View File

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

View File

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

View File

@ -1,6 +1,17 @@
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;
};

View File

@ -5,17 +5,24 @@
}
body {
font-family: Inconsolata, sans-serif;
font-family: Roboto, sans-serif;
background: #f0f0f0;
}
input {
font: 16px Inconsolata, sans-serif;
font: 16px Roboto, sans-serif;
border: none;
border-top: 1px solid #DDD;
outline: none;
}
button {
font: 16px Montserrat, sans-serif;
border: none;
outline: none;
cursor: pointer;
}
p {
line-height: 1.5;
}
@ -26,20 +33,64 @@ p {
top: 0;
bottom: 0;
width: 200px;
padding: 15px;
overflow: auto;
background: #272626;
background: #222;
color: #FFF;
font-family: Montserrat, sans-serif;
}
.tablist p {
padding: 3px 15px;
cursor: pointer;
}
.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 {
font-family: Montserrat, sans-serif;
position: fixed;
left: 200px;
top: 0;
@ -47,14 +98,34 @@ p {
height: 50px;
padding: 0 15px;
line-height: 50px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid #DDD;
}
.chat-title-bar div {
position: absolute;
left: 0;
right: 100px;
white-space: nowrap;
overflow: hidden;
}
.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 {
@ -69,11 +140,25 @@ p {
}
.message {
padding-left: 50px;
text-indent: -50px;
}
.message span {
margin-right: 10px;
}
.message-info {
color: #666;
color: #999;
}
.message-time {
color: #999;
}
.message-sender {
color: #6BB758;
font: 16px Montserrat, sans-serif;
}
.message-input-wrap {
@ -100,4 +185,5 @@ p {
padding: 15px;
overflow: auto;
border-left: 1px solid #DDD;
overflow-x: hidden;
}

54
irc.go
View File

@ -6,6 +6,8 @@ import (
"fmt"
"net"
"strings"
"sync"
"time"
)
const (
@ -54,6 +56,8 @@ type Message struct {
type IRC struct {
conn net.Conn
reader *bufio.Reader
out chan string
ready sync.WaitGroup
Messages chan *Message
Server string
@ -71,10 +75,11 @@ func NewIRC(nick, username string) *IRC {
Username: username,
Realname: nick,
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 {
i.Host = address
@ -88,13 +93,24 @@ func (i *IRC) Connect(address string) {
}
i.Server = address
dialer := &net.Dialer{Timeout: 5 * time.Second}
if i.TLS {
if i.TLSConfig == nil {
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 {
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)
@ -102,19 +118,23 @@ func (i *IRC) Connect(address string) {
i.Nick(i.nick)
i.User(i.Username, i.Realname)
i.ready.Add(1)
go i.send()
go i.recv()
return nil
}
func (i *IRC) Pass(password string) {
i.Write("PASS " + password)
i.write("PASS " + password)
}
func (i *IRC) Nick(nick string) {
i.Write("NICK " + nick)
i.write("NICK " + nick)
}
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) {
@ -147,13 +167,28 @@ func (i *IRC) Quit() {
}
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{}) {
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...)
}
func (i *IRC) send() {
i.ready.Wait()
for message := range i.out {
fmt.Fprint(i.conn, message)
}
}
func (i *IRC) recv() {
defer i.conn.Close()
for {
@ -167,7 +202,10 @@ func (i *IRC) recv() {
switch msg.Command {
case PING:
i.Write("PONG :" + msg.Trailing)
i.write("PONG :" + msg.Trailing)
case RPL_WELCOME:
i.ready.Done()
}
i.Messages <- msg

View File

@ -16,6 +16,7 @@ type WSResponse struct {
type Connect struct {
Server string `json:"server"`
TLS bool `json:"tls"`
Nick string `json:"nick"`
Username string `json:"username"`
}
@ -63,3 +64,8 @@ type MOTD struct {
Title string `json:"title"`
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
}
func (s *Session) sendError(err error, server string) {
s.sendJSON("error", Error{
Server: server,
Message: err.Error(),
})
}
func (s *Session) write() {
for res := range s.out {
s.wsLock.Lock()
for _, ws := range s.ws {
ws.In <- res
ws.Out <- res
}
s.wsLock.Unlock()
}

View File

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

View File

@ -74,18 +74,22 @@ func handleWS(ws *websocket.Conn) {
log.Println(addr, "connecting to", data.Server)
irc := NewIRC(data.Nick, data.Username)
irc.TLS = true
irc.Connect(data.Server)
irc.TLS = data.TLS
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{
Address: irc.Host,
Nick: data.Nick,
Username: data.Username,
})
session.user.AddServer(storage.Server{
Address: irc.Host,
Nick: data.Nick,
Username: data.Username,
})
}
} else {
log.Println(addr, "already connected to", data.Server)
}