├── .bowerrc ├── .gitignore ├── README.md ├── bower.json ├── lib ├── connection.js ├── debug.js ├── models.js ├── plugins.js └── static.js ├── package.json ├── plugin_cache └── README.md ├── settings ├── client.js └── server.js ├── src ├── components │ ├── app │ │ ├── messageInput.jsx │ │ ├── messages.jsx │ │ └── userList.jsx │ ├── irc.jsx │ ├── menu.jsx │ └── settings │ │ ├── general.jsx │ │ ├── highlight.jsx │ │ └── plugins.jsx ├── img │ ├── bubbles.svg │ └── subway.png ├── jade │ ├── debug.jade │ └── index.jade ├── js │ ├── app.js │ ├── boilerplate.js │ ├── debug.js │ ├── handle_irc.js │ ├── models │ │ └── models.js │ └── util.js ├── sounds │ ├── msg.mp3 │ ├── msg.ogg │ ├── new-pm.mp3 │ └── new-pm.ogg └── styl │ ├── app.styl │ ├── base.styl │ ├── buttons.styl │ ├── debug.styl │ ├── layout.styl │ ├── mainMenu.styl │ ├── message.styl │ ├── messageInput.styl │ ├── nav.styl │ ├── type.styl │ ├── userList.styl │ └── variables.styl ├── subway.js └── support ├── README.md ├── init.d └── subway └── nginx └── subway /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "src/libs", 3 | "json": "bower.json", 4 | "endpoint" : "https://bower.herokuapp.com" 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp/ 2 | node_modules/ 3 | src/libs/ 4 | plugin_cache/ 5 | *.db 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Subway 2 | ====== 3 | 4 | *Subway is not currently functional and not under active development. While the plan is to finish this project some point in the future, currently the maintainer strongly suggests taking a look at [The Lounge](https://github.com/thelounge/lounge).* 5 | 6 | Subway is a web-based IRC client with a multi-user backend and a 7 | JavaScript-heavy UI. Frontend/backend communication is done with 8 | websockets (or best available fallback where not available). 9 | The backend supports connection persistence and optional logging when the 10 | browser disconnects. 11 | 12 | Subway is built with [node.js](http://nodejs.org/), 13 | [node-irc](https://github.com/martynsmith/node-irc) 14 | and [Backbone.js](http://documentcloud.github.com/backbone/)/ReactJS and 15 | [jQuery](http://jquery.com/) on the frontend. 16 | 17 | Screenshots 18 | ------------ 19 | 20 | ![Chat](http://i.imgur.com/y56tLP9.png) 21 | ![Settings](http://i.imgur.com/hgwRzHq.png) 22 | 23 | Installation 24 | ------------ 25 | 26 | *Should be something like this, once implemented:* 27 | 28 | 1. Assuming you already have node.js, and npm, run: 29 | 30 | $ git clone https://github.com/thedjpetersen/subway.git 31 | $ cd subway 32 | 33 | 2. Install the dependencies using npm: 34 | 35 | $ npm install 36 | 37 | 3. Launch the web server 38 | 39 | $ ./subway.js 40 | 41 | 4. Point your browser at `http://localhost:3000/` 42 | 43 | 44 | Development 45 | ----------- 46 | 47 | Discussion about the client takes place on this repository's [Issues](https://github.com/thedjpetersen/subway/issues) page. 48 | Contributors are welcome and greatly appreciated. 49 | 50 | 51 | Goals 52 | ------- 53 | 54 | Its goals are twofold: 55 | 56 | 1) Become the best web-based IRC client available 57 | 58 | 2) Provide a really easy method of persistent IRC connections, available 59 | from any web browser 60 | 61 | The inspiration for Subway was trying to watch a fellow programmer try 62 | to explain how to set up screen/irssi to a non-technical person. 63 | 64 | 65 | License 66 | ------- 67 | 68 | Excepting third-party assets (licensed as specified in their respective files 69 | or directories), this project is covered by the MIT License: 70 | 71 | 72 | The MIT License (MIT) 73 | Copyright (c) 2015 David Petersen 74 | 75 | Permission is hereby granted, free of charge, to any person obtaining a copy of 76 | this software and associated documentation files (the "Software"), to deal in 77 | the Software without restriction, including without limitation the rights to 78 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 79 | of the Software, and to permit persons to whom the Software is furnished to do 80 | so, subject to the following conditions: 81 | 82 | The above copyright notice and this permission notice shall be included in all 83 | copies or substantial portions of the Software. 84 | 85 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 86 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 87 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 88 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 89 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 90 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 91 | SOFTWARE. 92 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "subway", 3 | "version": "0.0.2", 4 | "dependencies": { 5 | "jquery": "~2.1.1", 6 | "backbone": "1.1.2", 7 | "react.backbone": "0.5.0", 8 | "underscore": "1.6.0", 9 | "font-awesome": "4.2.0", 10 | "moment": "2.8.3", 11 | "socket.io-client": "1.0.6", 12 | "react": "0.12.0", 13 | "modernizr": "2.8.3" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/connection.js: -------------------------------------------------------------------------------- 1 | // Our dependency section of the connection module 2 | // ----- 3 | 4 | // Basic library dependencies 5 | var _ = require("underscore"); 6 | var irc = require("irc"); 7 | var uuid = require("node-uuid"); 8 | var bcrypt = require('bcrypt-nodejs'); 9 | 10 | var argv = require("yargs").argv; 11 | 12 | // Module for fetching plugins - the server can call this 13 | // when we want to fetch plugins that haven't been downloaded yet 14 | var get_plugin = require("./plugins").get_plugin; 15 | 16 | var client_settings = require("../settings/client"); 17 | var server_settings = require("../settings/server"); 18 | 19 | // Our client side IRC handling code and models 20 | var handle_irc = require("../src/js/handle_irc"); 21 | 22 | // Database models 23 | var db = require("./models")(); 24 | var User = db.models.User, 25 | Connection = db.models.Connection, 26 | Session = db.models.Session, 27 | Settings = db.models.Settings, 28 | Message = db.models.Message; 29 | 30 | var connections = {}; 31 | var loggers = {}; 32 | var debug; 33 | 34 | // Method for storing messages in database 35 | // will check to see if the user is assigned to 36 | // a channel to log the message 37 | var logMessage = function(server, channel, attributes, username) { 38 | // We do not need to log status messages 39 | if(channel.replace("#", "") === "status") { 40 | return; 41 | } 42 | 43 | loggers[server] = loggers[server] || {}; 44 | var target = loggers[server][channel]; 45 | 46 | // If it is a private message 47 | if(channel.indexOf("#") === -1) { 48 | var targetIs = [channel, attributes.from].sort().join("#"); 49 | target = loggers[server][targetIs]; 50 | channel = targetIs; 51 | } 52 | 53 | if (typeof target === "undefined" || !(_.contains(connections, target))) { 54 | target = username; 55 | } 56 | 57 | var save_obj = _.extend(attributes, {server: server, to: channel}); 58 | save_obj.attributes = JSON.stringify(_.omit(attributes, "message", "from", "server", "to", "type", "text")); 59 | 60 | if (target === username) { 61 | Message.create(save_obj); 62 | } 63 | }; 64 | 65 | var attach_listener = function(client, socket, backbone_models) { 66 | // Remove any extra listeners 67 | // we only want to keep the listener node-irc attaches 68 | // to the raw event 69 | if (typeof client._events.raw === "object") { 70 | client._events.raw = client._events.raw[0]; 71 | } 72 | 73 | client.on("raw", function(message) { 74 | if (typeof debug !== 'undefined') { 75 | debug.emit("raw", message); 76 | } 77 | 78 | message = _.extend(message, {client_server: client.opt.server}); 79 | 80 | if(socket.irc_conn) { 81 | handle_irc(message, socket.irc_conn, backbone_models); 82 | } 83 | socket.emit("raw", message); 84 | }); 85 | } 86 | 87 | // Method to restore a connection to a user 88 | // takes a user object and re-attaches an active connection to it 89 | var restore_connection = function(user, io, req) { 90 | var backbone_models = _.clone(require("../src/js/models/models")); 91 | 92 | // See if our user has an active connection 93 | var has_connection = _.has(connections, user.username); 94 | 95 | // Find the socket associated with the reqeust 96 | var socket = _.find(io.sockets.sockets, function(sock) { 97 | return sock.id === req.body.socketid; 98 | }); 99 | 100 | if (typeof backbone_models.irc === "undefined") { 101 | backbone_models.irc = socket.irc_conn = new backbone_models.models.App(); 102 | backbone_models.username = user.username; 103 | backbone_models.logMessage = logMessage; 104 | } 105 | 106 | if (typeof socket !== "undefined" && has_connection) { 107 | socket.clients = connections[user.username].clients; 108 | 109 | if(typeof connections[user.username].irc_conn !== "undefined") { 110 | // Set our irc connection to existing connection 111 | socket.irc_conn = connections[user.username].irc_conn; 112 | } else { 113 | // Store our new irc connection 114 | connections[user.username].irc_conn = backbone_models.irc; 115 | } 116 | 117 | var user_id = user.user_id; 118 | 119 | Connection.findOne({where: {user_id: user_id}}, function(err, connection) { 120 | if (connection && has_connection && typeof socket.irc_conn !== 'undefined') { 121 | socket.emit("restore_connection", socket.irc_conn.toJSON()); 122 | } 123 | 124 | // Reattach our raw emitter 125 | _.each(socket.clients, function(client) { 126 | if (typeof client._events["raw"] || client._events["raw"].length === 0) { 127 | attach_listener(client, socket, backbone_models); 128 | } 129 | }); 130 | }); 131 | } else { 132 | connections[user.username] = {clients: socket.clients, irc_conn: socket.irc_conn}; 133 | } 134 | 135 | socket.logged_in = true; 136 | socket.user = user; 137 | } 138 | 139 | var connection = function(io, app) { 140 | app.get("/", function(req, res) { 141 | if (req.signedCookies.sessionid) { 142 | User.findOne({where: {session_id: req.signedCookies.sessionid}}, function(err, user) { 143 | if (user !== null) { 144 | Settings.findOne({where: {user_id: user.id}}, function(err, settings) { 145 | var output_settings; 146 | if(settings) { 147 | output_settings = _.extend({}, JSON.parse(settings.settings)); 148 | } else { 149 | output_settings = client_settings; 150 | } 151 | res.render("index.ejs", {user: user, default_servers: _.has(server_settings, "default_servers"), settings: JSON.stringify(output_settings)}); 152 | }) 153 | } else { 154 | res.render("index.ejs", {user: false, default_servers: _.has(server_settings, "default_servers"), settings: JSON.stringify(client_settings)}); 155 | } 156 | }); 157 | } else { 158 | res.render("index.ejs", {user: false, default_servers: _.has(server_settings, "default_servers"), settings: JSON.stringify(client_settings)}); 159 | } 160 | }); 161 | 162 | app.post('/restore_connection/', function(req, resp) { 163 | var socket = _.find(io.sockets.sockets, function(sock) { 164 | return sock.id === req.body.socketid; 165 | }); 166 | 167 | if (req.signedCookies.sessionid) { 168 | User.findOne({where: {session_id: req.signedCookies.sessionid}}, function(err, user) { 169 | if (user) { 170 | restore_connection(user, io, req); 171 | } 172 | }); 173 | } 174 | }); 175 | 176 | app.post('/login/', function(req, resp) { 177 | var result = {}; 178 | // find the user 179 | User.findOne({where: {username: req.body.username}}, function(err, user) { 180 | // does the user exist? 181 | if (user) { 182 | // check password 183 | bcrypt.compare(req.body.password, user.password, function(err, res) { 184 | // if the password matched... 185 | if(res === true){ 186 | // See if our user has an active connection 187 | var has_connection = _.has(connections, user.username); 188 | var sessionid = uuid.v1() + uuid.v4(); 189 | 190 | user.session_id = sessionid; 191 | 192 | user.save(function() { 193 | restore_connection(user, io, req); 194 | 195 | result = {status: "success", username: req.body.username, has_connection: has_connection}; 196 | resp.cookie("sessionid", sessionid, {maxAge: 9000000, expires: new Date(Date.now()+9000000), httpOnly: true, signed: true}); 197 | resp.send(result); 198 | }); 199 | } else { 200 | result = {status: "error", error: "Wrong password"}; 201 | resp.send(result); 202 | } 203 | }); 204 | } else { 205 | result = {status: "error", error: "User not found"}; 206 | resp.send(result); 207 | } 208 | }); 209 | }); 210 | 211 | app.post('/logout/', function(req, resp) { 212 | var result = {success: true}; 213 | if (req.signedCookies.sessionid) { 214 | User.findOne({where: {session_id: req.signedCookies.sessionid}}, function(err, user) { 215 | if(user) { 216 | user.session_id = null; 217 | user.save() 218 | } 219 | }); 220 | } 221 | resp.clearCookie("sessionid"); 222 | resp.send(result); 223 | }); 224 | 225 | // Setup our debug endpoint 226 | if (typeof argv.debug !== "undefined") { 227 | debug = io.of('/debug'); 228 | require("./debug")(app, debug, connections); 229 | } 230 | 231 | io.sockets.on("connection", function (socket) { 232 | socket.clients = {}; 233 | 234 | var add_client = function(data) { 235 | var backbone_models = require("../src/js/models/models"); 236 | 237 | var connect_data = _.extend({}, { 238 | userName: "subway", 239 | channels: [], 240 | debug: true, 241 | retryCount: 1, 242 | autoRejoin: false 243 | }, data); 244 | 245 | data = _.pick(data, 'nick', 'server') 246 | 247 | var client = new irc.Client(data.server, data.nick, connect_data); 248 | 249 | client.on("abort", function() { 250 | // Remove connection from our local models 251 | if (socket.irc_conn) { 252 | socket.irc_conn.get("connections").remove(data.server); 253 | } 254 | 255 | delete client; 256 | socket.emit("connection_error", data); 257 | socket.emit("connection_removed", {connection: data.server}); 258 | 259 | // Remove connection from local models 260 | if (socket.irc_conn) { 261 | socket.irc_conn.get("connections").remove(data.server); 262 | } 263 | }); 264 | 265 | socket.clients[data.server] = client; 266 | 267 | if(socket.irc_conn) { 268 | backbone_models.username = socket.user.username; 269 | backbone_models.logMessage = logMessage; 270 | backbone_models.irc = socket.irc_conn; 271 | } 272 | 273 | attach_listener(client, socket, backbone_models); 274 | } 275 | 276 | //handle case for default servers 277 | if (server_settings.default_servers) { 278 | // iterate over each of our default servers 279 | // connecting to them 280 | _.each(server_settings.default_servers, function(server_data) { 281 | add_client(server_data); 282 | }) 283 | } 284 | 285 | 286 | socket.on("connectirc", function(data) { 287 | add_client(data); 288 | }); 289 | 290 | socket.on("remove_connection", function(data) { 291 | socket.clients[data.connection].disconnect(); 292 | delete socket.clients[data.connection]; 293 | socket.emit("connection_removed", {connection: data.connection}); 294 | 295 | // Remove connection from our local models 296 | if (socket.irc_conn) { 297 | socket.irc_conn.get("connections").remove(data.connection); 298 | } 299 | }); 300 | 301 | socket.on("disconnect", function(data) { 302 | if(socket.logged_in) { 303 | Connection.findOne({where: {user_id: socket.user.user_id}}, function(err, connection) { 304 | if(!connection) { 305 | connection = new Connection({ 306 | user_id: socket.user.user_id, 307 | connection_data: JSON.stringify(socket.irc_conn) 308 | }); 309 | } else { 310 | connection.connection_data = JSON.stringify(socket.irc_conn); 311 | } 312 | connection.save(); 313 | }); 314 | } 315 | }); 316 | 317 | // when the user closes the browser window 318 | // we iterate through their active connections and close 319 | // them 320 | socket.on("disconnect", function() { 321 | _.each(socket.clients, function(val, key, list) { 322 | // Clean up server conns 323 | if(!socket.logged_in) { 324 | val.disconnect(); 325 | delete list[key]; 326 | } 327 | }); 328 | }); 329 | 330 | /* auth/setup commands */ 331 | 332 | // user registration 333 | socket.on('register', function(data) { 334 | // make sure user doesn't already exist 335 | User.findOne({where: { username: data.username }}, function (err, fUser) { 336 | if (!err && !fUser) { 337 | // hash the password 338 | bcrypt.genSalt(10, function(err, salt) { 339 | bcrypt.hash(data.password, salt, null, function(err, hash) { 340 | // create the new user 341 | User.create({ 342 | username: data.username, 343 | password: hash 344 | }, function (err, user) { 345 | socket.emit('register_success', {username: data.username}); 346 | }); 347 | }); 348 | }); 349 | } else { 350 | socket.emit('register_error', {message: 'The username "' + data.username + '" is taken, please try another username.'}); 351 | } 352 | }); 353 | }); 354 | 355 | socket.on("say", function(data) { 356 | var client = socket.clients[data.server]; 357 | 358 | if (typeof socket.irc_conn !== "undefined") { 359 | var server = socket.irc_conn.get("connections").get(data.server); 360 | server.addMessage(data.target, {from: server.get("nick"), text: data.text, type: "PRIVMSG"}); 361 | } 362 | 363 | client.say(data.target, data.text); 364 | }); 365 | 366 | socket.on("command", function(data) { 367 | var client = socket.clients[data.server]; 368 | var args; 369 | if (data.command) { 370 | args = data.command.split(" "); 371 | } else { 372 | args = [""]; 373 | } 374 | 375 | // If the arguments don't include the channel we add it 376 | var includeChannel = function(args) { 377 | if(args.length > 1) { 378 | if (args[1].indexOf("#") !== 0) { 379 | args.splice(1,0,data.target); 380 | } 381 | } else if (args.length === 1) { 382 | args.splice(1,0,data.target); 383 | } 384 | return args; 385 | }; 386 | 387 | switch (args[0].toLowerCase()) { 388 | case "join": 389 | client.join(args[1]); 390 | break; 391 | 392 | case "leave": 393 | client.part(data.target, _.rest(args).join(" ")); 394 | break; 395 | 396 | case "me": 397 | // Send a sentence 398 | client.action(data.target, args.slice(1).join(" ")); 399 | break; 400 | 401 | case "msg": 402 | if (typeof socket.irc_conn !== "undefined") { 403 | var target_server = socket.irc_conn.get("connections") 404 | .get(client.opt.server); 405 | target_server.addChannel(args[1]); 406 | target_server.addMessage(args[1], {from: target_server.get("nick"), text: data.command.split(" ").splice(2).join(" ")}); 407 | } 408 | 409 | client.say(args[1], args.slice(2).join(" ")); 410 | break; 411 | 412 | case "part": 413 | case "kick": 414 | case "topic": 415 | args = includeChannel(args); 416 | client.send.apply(client, args); 417 | break; 418 | 419 | case "admin": 420 | client.send.apply(client, args); 421 | break; 422 | 423 | default: 424 | client.send.apply(client, args); 425 | break; 426 | } 427 | }); 428 | 429 | socket.on("raw", function(data) { 430 | var client = socket.clients[data.server]; 431 | client.send.apply(this, data.args); 432 | }); 433 | 434 | socket.on("add_plugin", function(data) { 435 | get_plugin(data.plugin, function() { 436 | socket.emit("plugin_added", {plugin: data.plugin}); 437 | }); 438 | }); 439 | 440 | socket.on("set_active", function(data) { 441 | if(typeof socket.irc_conn !== "undefined") { 442 | socket.irc_conn.set(data); 443 | } 444 | }); 445 | 446 | // Clear viewed notifications from channel 447 | socket.on("clearnotifications", function(data) { 448 | if(typeof socket.irc_conn !== "undefined") { 449 | socket.irc_conn.get("connections") 450 | .get(data.server.name).get("channels") 451 | .get(data.channel).clearNotifications(); 452 | } 453 | }); 454 | 455 | socket.on("closeChannel", function(data) { 456 | socket.irc_conn.get("connections") 457 | .get(data.server).get("channels") 458 | .remove(data.target); 459 | }); 460 | 461 | socket.on("loadHistory", function(data) { 462 | if(socket.user) { 463 | Message.all({where: {timestamp: {lte: data.timestamp}, server: data.server, to: data.channel}, order: 'id DESC', limit: 25}, function(er, messages) { 464 | socket.emit("history", {messages: messages.slice().reverse(), server: data.server, channel: data.channel}); 465 | }) 466 | } 467 | }); 468 | 469 | socket.on("saveSettings", function(data) { 470 | if(socket.user) { 471 | Settings.findOne({where: {user_id: socket.user.id}}, function(err, settings) { 472 | var new_settings = {user_id: socket.user.id, settings: JSON.stringify(data)}; 473 | if(settings) { 474 | settings.settings = new_settings.settings; 475 | settings.save(function(err) { 476 | if (err) { 477 | console.log(err); 478 | } 479 | }); 480 | } else { 481 | Settings.create(new_settings); 482 | } 483 | }); 484 | } 485 | }); 486 | 487 | }); 488 | }; 489 | 490 | module.exports = connection; 491 | -------------------------------------------------------------------------------- /lib/debug.js: -------------------------------------------------------------------------------- 1 | // Get our command line arguments 2 | var argv = require("yargs").argv; 3 | 4 | var debug = function(app, nsp, connections) { 5 | if (typeof argv.debug !== "undefined") { 6 | app.get('/debug', function(req,res) { 7 | res.render("debug.ejs"); 8 | }); 9 | 10 | nsp.on('connection', function(socket) { 11 | console.log('Debug connected'); 12 | }); 13 | } 14 | 15 | // From stackoverflow for printing objects with circular references 16 | var censor = function(censor) { 17 | var i = 0; 18 | 19 | return function(key, value) { 20 | if(i !== 0 && typeof(censor) === 'object' && typeof(value) === 'object' && censor == value) { 21 | return '[Circular]'; 22 | } 23 | 24 | if(i >= 29) { // seems to be a harded maximum of 30 serialized objects? 25 | return '[Unknown]'; 26 | } 27 | 28 | ++i; // so we know we aren't using the original object anymore 29 | 30 | return value; 31 | }; 32 | } 33 | 34 | 35 | process.on('uncaughtException', function(err) { 36 | console.log("Connections: %j", censor(connections)); 37 | }); 38 | }; 39 | 40 | module.exports = debug; 41 | -------------------------------------------------------------------------------- /lib/models.js: -------------------------------------------------------------------------------- 1 | var uuid = require('node-uuid'); 2 | var config = require("../settings/server"); 3 | var Schema = require('jugglingdb').Schema; 4 | 5 | var schema = new Schema(config.dbadapter, { 6 | database: config.dbname, 7 | username: config.dbusername, 8 | password: config.dbpassword, 9 | host: config.dbhost, 10 | port: config.dbport 11 | }); 12 | 13 | module.exports = function () { 14 | schema.define('User', { 15 | user_id: { type: String, default: function() { return uuid.v1(); } }, 16 | username: { type: String }, 17 | password: { type: String }, 18 | joined: { type: Date, default: function () { return new Date(); } }, 19 | session_id: { type: String } 20 | }); 21 | 22 | schema.define('Message', { 23 | server: { type: String }, 24 | from: { type: String }, 25 | to: { type: String }, 26 | type: { type: String }, 27 | timestamp: { type: Date, default: function () { return new Date(); } }, 28 | text: { type: String }, 29 | attributes: {type: String} 30 | }); 31 | 32 | schema.define('Connection', { 33 | user_id: { type: String }, 34 | connection_data: { type: Schema.Text } // This is a JSON object 35 | }); 36 | 37 | schema.define('Settings', { 38 | user_id: { type: String }, 39 | settings: { type: Schema.Text } // This is a JSON object 40 | }); 41 | 42 | schema.autoupdate(); 43 | 44 | return schema; 45 | }; 46 | -------------------------------------------------------------------------------- /lib/plugins.js: -------------------------------------------------------------------------------- 1 | var fs = require("fs"); 2 | var _ = require("underscore"); 3 | var client_settings = require("../settings/client"); 4 | var request = require("request"); 5 | var async = require("async"); 6 | 7 | var plugin_directory = __dirname + "/../plugin_cache/"; 8 | 9 | var self = { 10 | get_plugin: function(plugin, callback) { 11 | var gist_id = plugin.split("/")[1]; 12 | var output_directory = plugin_directory + gist_id; 13 | 14 | fs.exists(output_directory, function(exists) { 15 | // If the directory does not exist we want to create it 16 | if(!exists) { 17 | fs.mkdir(output_directory); 18 | } else { 19 | // Plugin is already fetched - lets just return 20 | if (callback) { callback.call(); } 21 | return; 22 | } 23 | 24 | var base_url = "https://gist.githubusercontent.com/" + plugin + "/raw/"; 25 | 26 | async.parallel([ 27 | function(cb) { 28 | request(base_url + "plugin.json", cb) 29 | .pipe(fs.createWriteStream(output_directory + '/plugin.json')); 30 | }, 31 | function(cb) { 32 | request(base_url + "plugin.js", cb) 33 | .pipe(fs.createWriteStream(output_directory + '/plugin.js')); 34 | }, 35 | function(cb) { 36 | request(base_url + "plugin.css", cb) 37 | .pipe(fs.createWriteStream(output_directory + '/plugin.css')); 38 | }, 39 | ], callback); 40 | 41 | }); 42 | }, 43 | 44 | initialize: function(callback) { 45 | fs.exists(plugin_directory, function(exists) { 46 | if(!exists) { 47 | fs.mkdir(plugin_directory); 48 | } 49 | 50 | fs.stat(plugin_directory + '/plugin.json', function(err, stat) { 51 | var one_week_ago = new Date().getTime()-604800000; 52 | 53 | // If the plugin registry is not created 54 | // or more than a week old we want to fetch it 55 | if (stat === undefined || stat.mtime.getTime() < one_week_ago) { 56 | // Grab plugin index 57 | result = request("https://raw.github.com/thedjpetersen/subway-plugins/master/plugins.json") 58 | .pipe(fs.createWriteStream(plugin_directory + '/plugins.json')); 59 | } 60 | }); 61 | 62 | async.each(client_settings.plugins, function(value, cb) { 63 | self.get_plugin(value, cb); 64 | }, function() { 65 | callback.call(); 66 | }); 67 | }); 68 | } 69 | }; 70 | 71 | module.exports = self; 72 | -------------------------------------------------------------------------------- /lib/static.js: -------------------------------------------------------------------------------- 1 | var suspend = require("suspend"); 2 | var grunt = require("grunt"); 3 | var glob = require("glob"); 4 | var crypto = require("crypto"); 5 | var gaze = require("gaze"); 6 | var async = require("async"); 7 | var _ = require("underscore"); 8 | var importer = require("rework-importer"); 9 | var env = process.env.IRC_ENV || "dev"; 10 | 11 | var js_files = [ 12 | "src/libs/jquery/dist/jquery.js", 13 | "src/libs/underscore/underscore.js", 14 | "src/libs/backbone/backbone.js", 15 | "src/libs/react/react.js", 16 | "src/libs/react.backbone/react.backbone.js", 17 | "src/libs/socket.io-client/socket.io.js", 18 | "src/libs/moment/moment.js", 19 | "src/libs/modernizr/modernizr.js", 20 | "src/js/util.js", 21 | "src/js/boilerplate.js", 22 | "src/components/components.js" 23 | ]; 24 | 25 | var min_stamp = (new Date()).valueOf().toString(); 26 | min_stamp = min_stamp + '_' + crypto.randomBytes(20).toString('hex') + "_bundle.min.js"; 27 | min_stamp = "tmp/bundles/" + min_stamp; 28 | 29 | var js_output = []; 30 | 31 | var initialize = function(original_callback) { 32 | 33 | async.waterfall([ 34 | function(callback) { 35 | grunt.task.init = function() {}; 36 | glob("src/js/**/*.js", {}, function(er, additional_files) { 37 | callback(er, additional_files); 38 | }); 39 | }, 40 | function(additional_files, callback) { 41 | glob("src/components/**/*.jsx", {}, function(er, component_files) { 42 | callback(er, additional_files, component_files); 43 | }); 44 | }, 45 | function(additional_files, component_files, callback) { 46 | // Our IRC react file needs to go last 47 | component_files = _.without(component_files, "src/components/irc.jsx").concat("src/components/irc.jsx"); 48 | 49 | js_files = _.union(js_files, _.without(additional_files, "src/js/app.js", "src/js/debug.js"), ["src/js/app.js"]); 50 | 51 | if(env === "dev") { 52 | js_output = js_output.concat(js_files); 53 | 54 | js_output = js_output.map(function(file) { 55 | return file.replace("src/", ""); 56 | }); 57 | } else { 58 | js_output = js_output.concat(min_stamp.replace("tmp/", "")); 59 | } 60 | 61 | grunt.initConfig({ 62 | clean: ["tmp/"], 63 | jade: { 64 | compile: { 65 | options: { 66 | data: { 67 | css_output: ["libs/font-awesome/css/font-awesome.css", "css/subway.css"], 68 | js_output: js_output 69 | }, 70 | pretty: true 71 | }, 72 | files: { 73 | "tmp/index.ejs": "src/jade/index.jade", 74 | "tmp/debug.ejs": "src/jade/debug.jade" 75 | } 76 | } 77 | }, 78 | styl: { 79 | dist: { 80 | options: { 81 | whitespace: true, 82 | configure: function (styl) { 83 | styl 84 | .use(importer({path: "src/styl", whitespace: true})); 85 | } 86 | }, 87 | files: { 88 | "tmp/css/debug.css": "src/styl/debug.styl", 89 | "tmp/css/subway.css": "src/styl/app.styl" 90 | } 91 | } 92 | }, 93 | react: { 94 | main: { 95 | files: { 96 | "tmp/components/components.js": component_files 97 | } 98 | } 99 | }, 100 | symlink: { 101 | main: { 102 | files: [ 103 | { 104 | expand: false, 105 | src: "src/libs", 106 | dest: "tmp/libs" 107 | }, 108 | { 109 | expand: false, 110 | src: "src/js", 111 | dest: "tmp/js" 112 | }, 113 | { 114 | expand: false, 115 | src: "src/sounds", 116 | dest: "tmp/sounds" 117 | }, 118 | { 119 | expand: false, 120 | src: "plugin_cache", 121 | dest: "tmp/plugin_cache" 122 | }, 123 | { 124 | expand: false, 125 | src: "src/img", 126 | dest: "tmp/img" 127 | } 128 | ] 129 | } 130 | }, 131 | uglify: { 132 | options: { 133 | report: "gzip" 134 | }, 135 | main: { 136 | files: { 137 | } 138 | } 139 | } 140 | }); 141 | 142 | grunt.config.data.uglify.main.files[min_stamp] = js_files; 143 | 144 | grunt.loadNpmTasks("grunt-styl"); 145 | grunt.loadNpmTasks("grunt-react"); 146 | grunt.loadNpmTasks("grunt-contrib-clean"); 147 | grunt.loadNpmTasks("grunt-contrib-symlink"); 148 | grunt.loadNpmTasks("grunt-contrib-jade"); 149 | grunt.loadNpmTasks("grunt-contrib-uglify"); 150 | 151 | callback(null); 152 | } 153 | ], function (err, result) { 154 | original_callback.call(); 155 | }); 156 | } 157 | 158 | module.exports = function(cb) { 159 | initialize(function() { 160 | 161 | if (env === 'dev') { 162 | grunt.tasks(["clean", "symlink", "jade", "react", "styl"], {}, cb); 163 | 164 | gaze("src/components/**/*.jsx", function(err, watcher) { 165 | this.on("all", function(event, filepath) { 166 | console.log("Change on: " + filepath); 167 | grunt.tasks(["react"], {}, function() {}); 168 | }); 169 | }); 170 | 171 | gaze("src/jade/**/*.jade", function(err, watcher) { 172 | this.on("all", function(event, filepath) { 173 | console.log("Change on: " + filepath); 174 | grunt.tasks(["jade"], {}, function() {}); 175 | }); 176 | }); 177 | 178 | gaze("src/styl/**/*.styl", function(err, watcher) { 179 | this.on("all", function(event, filepath) { 180 | console.log("Change on: " + filepath); 181 | grunt.tasks(["styl"], {}, function() {}); 182 | }); 183 | }); 184 | } else { 185 | // Include uglify command when running in prod 186 | grunt.tasks(["clean", "symlink", "jade", "react", "styl", "uglify"], {}, cb); 187 | } 188 | }); 189 | } 190 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "subway", 3 | "description": "A browser-based IRC client", 4 | "keywords": [ 5 | "irc" 6 | ], 7 | "author": "David Petersen ", 8 | "version": "0.3.5", 9 | "main": "./lib/subway.js", 10 | "bin": { 11 | "subway": "./subway.js" 12 | }, 13 | "engines": { 14 | "node": ">=0.10.2" 15 | }, 16 | "dependencies": { 17 | "async": "0.2.x", 18 | "yargs": "1.3.2", 19 | "request": "2.33.x", 20 | "express": "3.5.x", 21 | "irc": "git://github.com/thedjpetersen/node-irc.git", 22 | "socket.io": "1.0.x", 23 | "bcrypt-nodejs": "0.0.x", 24 | "jugglingdb": "0.2.x", 25 | "jugglingdb-sqlite3": "0.0.x", 26 | "node-uuid": "1.4.x", 27 | "bower": "1.3.x", 28 | "suspend": "0.4.x", 29 | "glob": "3.2.8", 30 | "gaze": "0.4.x", 31 | "backbone": "1.1.2", 32 | "underscore" : "1.6.0", 33 | "ejs": "1.0.0", 34 | "rework-importer": "git://github.com/thedjpetersen/rework-importer.git", 35 | 36 | "grunt-react": "0.10.x", 37 | "grunt-styl": "0.3.x", 38 | "grunt-contrib-cssmin": "0.7.x", 39 | "grunt-contrib-uglify": "0.3.x", 40 | "grunt-contrib-clean": "0.5.x", 41 | "grunt-contrib-symlink": "0.3.x", 42 | "grunt-contrib-jade": "0.9.x", 43 | "grunt": "0.4.x" 44 | }, 45 | "scripts": { 46 | "start": "./subway.js" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /plugin_cache/README.md: -------------------------------------------------------------------------------- 1 | Placeholder file for plugin cache 2 | -------------------------------------------------------------------------------- /settings/client.js: -------------------------------------------------------------------------------- 1 | // inspired by http://stackoverflow.com/a/5870544/324085 2 | var settings = {}; 3 | 4 | settings.highlights = [ 5 | { 6 | regex: "\\b(<%= connection.get(\"nick\") %>)\\b", 7 | color: "#0B2666", 8 | name: "mentions", 9 | notify: true 10 | } 11 | ]; 12 | 13 | settings.plugins = [ 14 | // Youtube embed 15 | "thedjpetersen/9140203", 16 | "thedjpetersen/9265479" 17 | ]; 18 | 19 | settings.time_format = "HH:mm"; 20 | 21 | // If you don't 22 | settings.enabled_types = [ 23 | "PRIVMSG", 24 | "NOTICE", 25 | "MODE", 26 | "PART", 27 | "QUIT", 28 | "KICK", 29 | "JOIN", 30 | "TOPIC", 31 | "NICK", 32 | "ACTION" 33 | ]; 34 | 35 | settings.disabled_types = []; 36 | 37 | settings.notify_pm = true; 38 | 39 | module.exports = settings; 40 | -------------------------------------------------------------------------------- /settings/server.js: -------------------------------------------------------------------------------- 1 | // Get our command line arguments 2 | var argv = require("yargs").argv; 3 | 4 | // Cache the plugin directory for a week 5 | settings = { 6 | plugin_directory_expiry: 604800, 7 | dbadapter: "sqlite3", 8 | dbname: "subway.db", 9 | dbusername: "", 10 | dbpassword: "", 11 | dbhost: "", 12 | dbport: "", 13 | 14 | // Servers to which the client is allowed to connect to, restrict all the others 15 | // server_whitelist: ["irc.freenode.net"], 16 | server_whitelist: false, 17 | 18 | /* not implemented yet */ 19 | user_access: { 20 | users_enabled: true, // show and allow logins 21 | registration_enabled: true // allow new users to register themselves 22 | }, 23 | 24 | dev: { 25 | port: argv.port || argv.p || process.env.PORT || 3000 26 | }, 27 | 28 | prod: { 29 | port: argv.port || argv.p || process.env.PORT || 14858 // Nodester port 30 | }, 31 | 32 | use_polling: argv.polling || process.env.USE_POLLING || false, // Use polling if websockets aren't supported 33 | 34 | // limit each user's connection log to this amount of messages (***not implemented yet***) 35 | max_log_size: 4096, 36 | 37 | // Default servers 38 | // list default servers which you want the users 39 | // to connect to on startup 40 | /* 41 | default_servers: [ 42 | { 43 | server: 'localhost', 44 | nick: 'guest', 45 | channels: ['#test_metro'] 46 | } 47 | ] 48 | */ 49 | }; 50 | 51 | 52 | module.exports = settings; 53 | -------------------------------------------------------------------------------- /src/components/app/messageInput.jsx: -------------------------------------------------------------------------------- 1 | app.components.message_input = function() { 2 | var MessageBox = React.createBackboneClass({ 3 | componentDidUpdate: function() { 4 | if(this.props.historyMode) { 5 | var position = $('ul li:nth-child(' + this.props.historyOffset + ')', this.getDOMNode()).position(); 6 | position = position ? position.top : 0; 7 | $(this.getDOMNode()).animate({ 8 | scrollTop: position 9 | }, 200); 10 | } 11 | }, 12 | 13 | render: function() { 14 | var _this = this; 15 | return ( 16 |
17 | 26 |
27 | ) 28 | } 29 | }); 30 | 31 | var NickBox = React.createBackboneClass({ 32 | componentDidUpdate: function() { 33 | if(this.props.tabMode) { 34 | var position = $('ul li:nth-child(' + this.props.selectedIndex + ')', this.getDOMNode()).position(); 35 | position = position ? position.top : 0; 36 | $(this.getDOMNode()).animate({ 37 | scrollTop: position 38 | }, 200); 39 | } 40 | }, 41 | 42 | handleSelect: function(ev) { 43 | this.props.selectUser($(ev.target).index()); 44 | }, 45 | 46 | render: function() { 47 | var _this = this; 48 | return ( 49 |
50 | 63 |
64 | ); 65 | } 66 | }); 67 | 68 | var MessageInput = React.createBackboneClass({ 69 | getInitialState: function() { 70 | return { 71 | nicks: [], 72 | selectedIndex: 0, 73 | history: [] 74 | }; 75 | }, 76 | 77 | selectUser: function(index) { 78 | this.state.selectedIndex = index; 79 | 80 | // We pass a pseudo event to our keyDown handler 81 | this.keyDown({ 82 | keyCode: 9, 83 | target: $('input', this.getDOMNode())[0], 84 | preventDefault: function() {} 85 | }); 86 | 87 | // This will cause the nick box to close 88 | this.setState({tabMode: false}); 89 | }, 90 | 91 | keyDown: function(ev) { 92 | var server = app.irc.getActiveServer(); 93 | var channel = app.irc.getActiveChannel(); 94 | var historyVal; 95 | 96 | if (ev.keyCode === 38) { 97 | // handle up key 98 | historyVal = channel.getNextHistory(); 99 | } 100 | 101 | if (ev.keyCode === 40) { 102 | // handle down key 103 | historyVal = channel.getPrevHistory(); 104 | } 105 | 106 | if (ev.keyCode === 38 || ev.keyCode === 40) { 107 | this.setState({ 108 | history: channel.attributes.history, 109 | historyMode: true, 110 | historyOffset: channel.attributes.history_offset 111 | }); 112 | 113 | if (historyVal !== undefined) { 114 | ev.target.value = historyVal; 115 | } else { 116 | ev.target.value = ""; 117 | } 118 | } else { 119 | this.setState({historyMode: false}) 120 | } 121 | 122 | // Handle esc key 123 | if (ev.keyCode === 27) { 124 | this.setState({historyMode: false, tabMode: false}) 125 | } 126 | 127 | // handle tabKey and autocompletion 128 | if (ev.keyCode === 9) { 129 | 130 | var sentence = ev.target.value.split(" "); 131 | ev.target.focus(); 132 | ev.preventDefault(); 133 | 134 | // Variable to keep track of 135 | if(!this.state.tabMode) { 136 | this.setState({tabMode: true}) 137 | this.partialMatch = new RegExp(sentence.pop(), "i"); 138 | this.originalSentence = _.extend([], sentence); 139 | } 140 | 141 | // Filter our channels users to the ones that start with our 142 | // partial match 143 | var _this = this; 144 | var users = channel.get("users").filter(function(user) { 145 | return (user.get("nick").search(_this.partialMatch) === 0 && 146 | user.get("nick") !== server.get("nick")); 147 | }); 148 | 149 | this.setState({nicks: users}); 150 | 151 | if (this.state.selectedIndex >= users.length) { 152 | this.state.selectedIndex = 0; 153 | } 154 | 155 | if (users.length) { 156 | //sentence.push(users[this.state.selectedIndex].get('nick')); 157 | var usr = users[this.state.selectedIndex].get('nick'); 158 | if (this.originalSentence.length === 0) { 159 | ev.target.value = this.originalSentence.join(' ') + usr + ": "; 160 | } else { 161 | ev.target.value = this.originalSentence.join(' ') + " " + usr; 162 | } 163 | } 164 | 165 | this.setState({selectedIndex: ++this.state.selectedIndex}); 166 | } else { 167 | this.setState({tabMode: false, selectedIndex: 0}) 168 | } 169 | }, 170 | 171 | handleBlur: function(ev) { 172 | this.setState({tabMode: false, historyMode: false}); 173 | }, 174 | 175 | handleInput: function(ev) { 176 | // If the user pushed enter 177 | var server = app.irc.getActiveServer(); 178 | var channel = app.irc.getActiveChannel(); 179 | var target = channel.get("name"); 180 | 181 | var input = ev.target.value; 182 | 183 | if (input === "") { 184 | return; 185 | } 186 | 187 | if (ev.keyCode === 13) { 188 | input = ev.target.value; 189 | 190 | // If the first character is a slash 191 | if (input[0] === "/" && input.indexOf("/me") !== 0) { 192 | // Stript the slash but emit the rest as a command 193 | app.io.emit("command", {server: server.get("name"), target: target, command: input.substring(1)}); 194 | if (input.indexOf("/msg") === 0 ) { 195 | var new_channel = input.split(" ")[1]; 196 | server.addChannel(new_channel); 197 | server.addMessage(new_channel, {from: server.get("nick"), text: input.split(" ").splice(2).join(" ")}); 198 | } 199 | } else if(input.indexOf("/me") === 0) { 200 | app.io.emit("command", {server: server.get("name"), target: target, command: input.substring(1)}); 201 | server.addMessage(target, {from: server.get("nick"), text: input.replace("/me", '\u0001ACTION'), type: "PRIVMSG"}); 202 | } else { 203 | app.io.emit("say", {server: server.get("name"), target: target, text: input}); 204 | server.addMessage(target, {from: server.get("nick"), text: input, type: "PRIVMSG"}); 205 | } 206 | channel.get("history").push(input); 207 | ev.target.value = ""; 208 | } 209 | 210 | }, 211 | 212 | render: function() { 213 | return ( 214 |
215 | 216 | 217 | 218 |
219 | ); 220 | } 221 | }); 222 | 223 | return MessageInput; 224 | } 225 | -------------------------------------------------------------------------------- /src/components/app/messages.jsx: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | 3 | app.components.messages = function() { 4 | var KickMessage = React.createBackboneClass({ 5 | render: function() { 6 | return ( 7 |
8 |
* {this.getModel().get("nick")} has kicked {this.getModel().get("text") + " "}**{this.getModel().get("reason")}**
9 |
10 | ); 11 | } 12 | }); 13 | 14 | var ActionMessage = React.createBackboneClass({ 15 | render: function() { 16 | return ( 17 |
18 |
* {this.getModel().get("from")}{'\u0020' + this.getModel().get("text")}
19 |
20 | ); 21 | } 22 | }); 23 | 24 | var NickMessage = React.createBackboneClass({ 25 | render: function() { 26 | return ( 27 |
28 |
{this.getModel().get("nick")} is now known as {this.getModel().get("text")}
29 |
30 | ); 31 | } 32 | }); 33 | 34 | var PartMessage = React.createBackboneClass({ 35 | render: function() { 36 | return ( 37 |
38 |
{this.getModel().get("nick")} has left ({this.getModel().get("text")})
39 |
40 | ); 41 | } 42 | }); 43 | 44 | var QuitMessage = React.createBackboneClass({ 45 | render: function() { 46 | return ( 47 |
48 |
{this.getModel().get("nick")} has quit ({this.getModel().get("text")})
49 |
50 | ); 51 | } 52 | }); 53 | 54 | var JoinMessage = React.createBackboneClass({ 55 | render: function() { 56 | return ( 57 |
58 |
{this.getModel().get("nick")} has joined
59 |
60 | ); 61 | } 62 | }); 63 | 64 | var TopicMessage = React.createBackboneClass({ 65 | render: function() { 66 | return ( 67 |
68 |
{this.getModel().get("nick")} has changed the topic to "{this.getModel().get("text")}"
69 |
70 | ); 71 | } 72 | }); 73 | 74 | var ModeMessage = React.createBackboneClass({ 75 | render: function() { 76 | return ( 77 |
78 |
Mode ["{this.getModel().get("mode")}" {this.getModel().get("text")}] by {this.getModel().get("from")}
79 |
80 | ); 81 | } 82 | }); 83 | 84 | var Message = React.createBackboneClass({ 85 | attachListeners: function(processedText) { 86 | var _this = this; 87 | processedText.listeners.map(function(listener) { 88 | if (listener) { 89 | listener.call(_this); 90 | } 91 | }); 92 | }, 93 | 94 | componentDidMount: function() { 95 | util.renderQueue.pushQueue(this); 96 | }, 97 | 98 | shouldComponentUpdate: function(changeObj) { 99 | return changeObj.model.cid !== this.getModel().cid; 100 | }, 101 | 102 | componentDidUpdate: function() { 103 | util.renderQueue.pushQueue(this); 104 | }, 105 | 106 | render: function() { 107 | return ( 108 |
109 |
110 | {this.getModel().get("from")} 111 |
112 |
113 | {this.getModel().get("text")} 114 |
115 |
116 | {this.getModel().get("timestamp") ? moment(this.getModel().get("timestamp")).format(app.settings.time_format) : ""} 117 |
118 |
119 | ); 120 | } 121 | }); 122 | 123 | var Messages = React.createBackboneClass({ 124 | checkScroll: _.throttle(function() { 125 | if(this.getDOMNode().scrollTop === 0) { 126 | this.getModel().fetched = false; 127 | this.props.fetchHistory(); 128 | } 129 | }, 300), 130 | 131 | componentWillUpdate: function() { 132 | this.model_length = this.getModel().length; 133 | this.children_length = this.getDOMNode().children.length; 134 | }, 135 | 136 | componentDidUpdate: function() { 137 | var node = this.getDOMNode(); 138 | this.shouldScrollBottom = node.scrollTop + node.offsetHeight > node.scrollHeight-200; 139 | 140 | var same_window = this.model_length === this.getModel().length; 141 | 142 | if (this.shouldScrollBottom && same_window) { 143 | var node = this.getDOMNode(); 144 | $(node).stop().animate({scrollTop: node.scrollHeight}, 750); 145 | } 146 | 147 | if (same_window && this.model_length > this.children_length + 1) { 148 | // This craziness maintains the scroll position as we load more models 149 | // when history is fetched 150 | this.getDOMNode().scrollTop = this.getDOMNode().children[this.model_length-this.children_length-1].offsetTop; 151 | } 152 | 153 | if (!same_window) { 154 | node.scrollTop = node.scrollHeight; 155 | } 156 | }, 157 | 158 | render: function() { 159 | return ( 160 |
161 | {this.getModel().map(function(message) { 162 | if (!(_.contains(app.settings.enabled_types, message.get("type")))) { 163 | return; 164 | } 165 | 166 | switch (message.get("type")) { 167 | case "PRIVMSG": 168 | return 169 | case "NOTICE": 170 | return 171 | case "MODE": 172 | return 173 | case "PART": 174 | return 175 | case "QUIT": 176 | return 177 | case "KICK": 178 | return 179 | case "JOIN": 180 | return 181 | case "TOPIC": 182 | return 183 | case "NICK": 184 | return 185 | case "ACTION": 186 | return 187 | } 188 | })} 189 |
190 | ); 191 | } 192 | }); 193 | 194 | return Messages; 195 | }; 196 | -------------------------------------------------------------------------------- /src/components/app/userList.jsx: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | 3 | app.components.user_list = function() { 4 | var User = React.createBackboneClass({ 5 | render: function() { 6 | return ( 7 |
8 | 9 | 10 | 11 | {this.getModel().get("type")}{this.getModel().get("nick")} 12 | {this.getModel().getActive()} 13 |
14 | ) 15 | } 16 | }); 17 | 18 | var UserList = React.createBackboneClass({ 19 | render: function() { 20 | return ( 21 |
22 |
23 | User List 24 |
25 |
26 | {this.getModel().sortAll().map(function(user) { 27 | return 28 | })} 29 |
30 |
31 | ); 32 | } 33 | }); 34 | 35 | return UserList; 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/irc.jsx: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | 3 | app.components.irc = function() { 4 | var Messages = app.components.messages(); 5 | var UserList = app.components.user_list(); 6 | var MessageInput = app.components.message_input(); 7 | 8 | var TitleBar = React.createBackboneClass({ 9 | render: function() { 10 | return ( 11 |
12 | {this.getModel().get("name")} 13 | {this.getModel().get("topic")} 14 |
15 | ); 16 | } 17 | }); 18 | 19 | var Chat = React.createBackboneClass({ 20 | fetchHistory: _.throttle(function() { 21 | var channel = this.getModel(); 22 | var messages = channel.get("messages"); 23 | 24 | if(app.user && channel.get("name") !== "status" && !(messages.fetched) && !(messages.all_fetched)) { 25 | var channelName = channel.get("name"); 26 | 27 | if (channelName.indexOf("#") !== 0) { 28 | channelName = [channel.get("name"), app.irc.getActiveServer().get("nick")].sort().join("#"); 29 | } 30 | 31 | var data = { 32 | server: app.irc.getActiveServer().get("name"), 33 | channel: channelName, 34 | timestamp: messages.length ? messages.first().get("timestamp") : Date.now() 35 | } 36 | 37 | app.io.emit("loadHistory", data); 38 | 39 | messages.fetched = true; 40 | } 41 | }, 1000), 42 | 43 | render: function() { 44 | this.fetchHistory(); 45 | 46 | return ( 47 |
48 | 49 | 50 | 51 |
52 | ) 53 | } 54 | }); 55 | 56 | var App = React.createBackboneClass({ 57 | getChannel: function() { 58 | var connections = this.getModel(); 59 | var server = app.irc.getActiveServer(); 60 | 61 | if (server === undefined) { return false; } 62 | 63 | var channel = app.irc.getActiveChannel(); 64 | return channel; 65 | }, 66 | 67 | render: function() { 68 | var channel = this.getChannel(); 69 | 70 | // If we don't currently have a channel 71 | if (!channel) { return
} 72 | 73 | return ( 74 |
75 | 76 | 77 |
78 | ); 79 | } 80 | }); 81 | 82 | var Connection = React.createBackboneClass({ 83 | isActive: function(chan) { 84 | var connections = this.getModel().collection; 85 | // Check to see if the channel is currently the active one 86 | return (app.irc.get("active_server") === this.getModel().get("name") && 87 | app.irc.get("active_channel") === chan.get("name")) 88 | }, 89 | 90 | setActive: function(event) { 91 | var connections = this.getModel().collection; 92 | var new_server = this.getModel().get("name"); 93 | var new_channel = $(event.target).closest("li").attr("data-channel"); 94 | 95 | if (new_channel !== app.irc.get("active_channel")) { 96 | util.renderQueue.clearQueue(); 97 | } 98 | 99 | // If we are just closing a channel 100 | if ($(event.target).hasClass("fa-times")) { 101 | if (new_server === app.irc.get("active_server") && 102 | new_channel === app.irc.get("active_channel")) 103 | app.irc.set("active_channel", "status"); 104 | if (typeof app.user !== "undefined") { 105 | app.io.emit("set_active", { 106 | active_server: new_server, 107 | active_channel: new_channel 108 | }) 109 | } 110 | } else { 111 | // Otherwise set the active server and channel 112 | app.irc.set("active_server", new_server); 113 | app.irc.set("active_channel", new_channel); 114 | 115 | // Clear notifications highlights and unreads 116 | app.irc.getActiveChannel().clearNotifications(); 117 | 118 | if (typeof app.user !== "undefined") { 119 | app.io.emit("set_active", { 120 | active_server: new_server, 121 | active_channel: new_channel 122 | }) 123 | } 124 | 125 | // If the our menu is not hidden we hide it now 126 | $(".mainMenu").addClass("hide"); 127 | } 128 | 129 | connections.trigger("sort"); 130 | }, 131 | 132 | leave: function(event) { 133 | var target_channel = $(event.target).closest("li").attr("data-channel"); 134 | 135 | if (target_channel.indexOf("#") === -1) { 136 | if(typeof app.user !== "undefined") { 137 | app.io.emit("closeChannel", {server: this.getModel().get("name"), target: target_channel}); 138 | } 139 | this.getModel().get("channels").remove(target_channel); 140 | } else { 141 | // Leave channel 142 | app.io.emit("command", {server: this.getModel().get("name"), target: target_channel, command: "leave"}); 143 | } 144 | }, 145 | 146 | render: function() { 147 | var _this = this; 148 | return ( 149 |
150 |
151 | {this.getModel().get("name")} 152 |
153 |
154 | {this.getModel().get("nick")} 155 |
156 | 180 |
181 | ); 182 | } 183 | }); 184 | 185 | var SideNav = React.createBackboneClass({ 186 | componentDidUpdate: function() { 187 | showNavigation(); 188 | }, 189 | 190 | render: function() { 191 | return ( 192 |
193 |
194 | More 195 | 196 |
197 | {this.getModel().map(function(conn) { 198 | return 199 | })} 200 |
201 | More 202 | 203 |
204 |
205 | ); 206 | } 207 | }); 208 | 209 | var showNavigation = function() { 210 | var element = $(".nav-area").get(0); 211 | // Show an indicator that there are more channels and info above 212 | // if the user scrolls from the top 213 | if (element.scrollTop !== 0) { 214 | $(".sideNavUp").css("left", "0"); 215 | } else { 216 | $(".sideNavUp").css("left", "-195px"); 217 | } 218 | 219 | // Show an indicator that there are more channels and info below 220 | if (element.scrollHeight === $(element).height() || $(element).height() + element.scrollTop === element.scrollHeight - 1 ) { 221 | $(".sideNavDown").css("left", "-195px"); 222 | } else { 223 | $(".sideNavDown").css("left", "0"); 224 | } 225 | } 226 | 227 | this.show = function() { 228 | var nav = SideNav({ 229 | model: window.app.irc.get("connections") 230 | }); 231 | React.renderComponent(nav, $(".nav-area").get(0)) 232 | 233 | $(".nav-area").scroll(showNavigation); 234 | 235 | var app = App({ 236 | model: window.app.irc.get("connections") 237 | }); 238 | React.renderComponent(app, $("main").get(0)) 239 | }; 240 | } 241 | -------------------------------------------------------------------------------- /src/components/menu.jsx: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | 3 | app.components.startMenu = function() { 4 | var General = app.components.general(); 5 | var Plugins = app.components.plugins(); 6 | var Highlights = app.components.highlight(); 7 | 8 | var Settings = React.createClass({ 9 | general: function() { 10 | this.setState({activeItem: "general"}); 11 | }, 12 | 13 | plugins: function() { 14 | this.setState({activeItem: "plugins"}); 15 | }, 16 | 17 | highlights: function() { 18 | this.setState({activeItem: "highlights"}); 19 | }, 20 | 21 | getInitialState: function() { 22 | return {activeItem: "general"}; 23 | }, 24 | 25 | render: function() { 26 | return ( 27 |
28 |
29 |
    30 |
  • General
  • 31 |
  • Plugins
  • 32 |
  • Highlights
  • 33 |
34 |
35 | {function(cxt) { 36 | switch(cxt.state.activeItem) { 37 | case "general": 38 | return 39 | case "plugins": 40 | return 41 | case "highlights": 42 | return 43 | } 44 | }(this)} 45 |
46 | ) 47 | } 48 | }); 49 | 50 | var ListConnections = React.createBackboneClass({ 51 | disconnect: function(ev) { 52 | var conn = $(ev.target).attr("data-connection"); 53 | app.io.emit("remove_connection", { 54 | connection: conn 55 | }); 56 | }, 57 | 58 | render: function() { 59 | var _this = this; 60 | 61 | return ( 62 |
63 | {function(cxt) { 64 | if(cxt.getModel().length) { 65 | return

Active Connections

; 66 | } 67 | }(this)} 68 | {this.getModel().map(function(conn) { 69 | return ( 70 |
71 | 72 | {conn.get("name")} 73 | {conn.get("nick")} 74 |
75 | ); 76 | })} 77 |
78 | ) 79 | } 80 | }); 81 | 82 | var Connect = React.createBackboneClass({ 83 | checkKey: function(ev) { 84 | if(ev.charCode === 13) { 85 | this.connect(); 86 | } 87 | }, 88 | 89 | connect: function() { 90 | if(!_.validateForm(this.refs)) { 91 | return; 92 | } 93 | 94 | var _this = this; 95 | var form_data = _.parseForm(this.refs); 96 | $(this.getDOMNode()).find("input").prop("disabled", true); 97 | app.io.emit("connectirc", form_data); 98 | 99 | app.io.on("connection_error", function(data) { 100 | _this.props.errorMessage = "Error connecting"; 101 | _this.forceUpdate(); 102 | _this.props.errorMessage = undefined; 103 | }); 104 | }, 105 | 106 | toggleOptions: function() { 107 | $(this.getDOMNode).find(".moreOptions").toggleClass("hide"); 108 | }, 109 | 110 | componentDidUpdate: function() { 111 | $(this.getDOMNode()).find("input").prop("disabled", false).val(""); 112 | }, 113 | 114 | componentWillUnmount: function() { 115 | app.io.removeAllListeners("connection_error"); 116 | }, 117 | 118 | render: function() { 119 | return ( 120 |
121 |

Connect

122 | {function(cxt) { 123 | if(cxt.props.errorMessage) { 124 | return ( 125 |
126 |

127 | 128 | {cxt.props.errorMessage} 129 |

130 |
131 | ) 132 | } 133 | }(this)} 134 |
135 |
136 | 137 |
138 |
139 | 140 |
141 |

142 | More Options 143 |

144 |
145 |
146 |
147 | 148 |
149 | 150 |
151 | 152 |
153 | 154 |
155 | 156 |
157 | 158 |
159 | 160 |
161 | 162 |
163 | 164 | 165 |
166 | 167 |
168 | 169 | 170 |
171 |
172 | Connect 173 |
174 | 175 |
176 | ) 177 | } 178 | }); 179 | 180 | var User = React.createBackboneClass({ 181 | logout: function() { 182 | var _this = this; 183 | $.post('/logout/', function(data) { 184 | if(data.success) { 185 | delete app.user; 186 | app.irc.get("connections").reset(); 187 | _this.props.login(); 188 | } 189 | }); 190 | }, 191 | 192 | render: function() { 193 | return ( 194 |
195 |

User Details

196 |

{this.getModel().get("username")}

197 | Logout 198 |
199 | ) 200 | } 201 | }); 202 | 203 | var Login = React.createClass({ 204 | checkKey: function(ev) { 205 | if(ev.charCode === 13) { 206 | this.login(); 207 | } 208 | }, 209 | 210 | redirectConnection: function() { 211 | }, 212 | 213 | login: function() { 214 | var _this = this; 215 | var form_data = _.parseForm(this.refs); 216 | 217 | form_data.socketid = app.io.io.engine.id; 218 | 219 | $.post("login/", form_data, function(data) { 220 | 221 | // Notify server of login 222 | app.io.emit("logged_in", {username: data.username}); 223 | 224 | if(data.status === "success") { 225 | app.user = new app.models.SubwayUser({ 226 | username: data.username 227 | }); 228 | 229 | _this.props.connect(); 230 | 231 | if (data.has_connection) { 232 | $(".mainMenu").toggleClass("hide") 233 | } 234 | } 235 | else if (data.status === "error") { 236 | _this.props.errorMessage = data.error; 237 | _this.forceUpdate(); 238 | _this.props.errorMessage = undefined; 239 | } 240 | }); 241 | }, 242 | 243 | render: function() { 244 | return ( 245 |
246 |

Login

247 | {function(cxt) { 248 | if(cxt.props.errorMessage) { 249 | return ( 250 |
251 |

252 | 253 | {cxt.props.errorMessage} 254 |

255 |
256 | ) 257 | } 258 | }(this)} 259 |
260 |
261 | 262 |
263 |
264 | 265 |
266 | Login 267 |
268 |
269 | ) 270 | } 271 | }) 272 | 273 | var Register = React.createClass({ 274 | register: function() { 275 | var form_data = _.parseForm(this.refs); 276 | app.io.emit("register", form_data); 277 | }, 278 | 279 | render: function() { 280 | return ( 281 |
282 |

Register

283 |
284 |
285 | 286 |
287 |
288 | 289 |
290 | Register 291 |
292 |
293 | ) 294 | } 295 | }) 296 | 297 | // We declare this in the global scope so it can be rendered from the top 298 | var Menu = React.createClass({ 299 | connect: function(event) { 300 | this.setState({activeItem: "connect"}); 301 | }, 302 | 303 | login: function(event) { 304 | this.setState({activeItem: "login"}); 305 | }, 306 | 307 | register: function(event) { 308 | this.setState({activeItem: "register"}); 309 | }, 310 | 311 | user: function(event) { 312 | this.setState({activeItem: "user"}); 313 | }, 314 | 315 | settings: function(event) { 316 | this.setState({activeItem: "settings"}); 317 | }, 318 | 319 | getInitialState: function() { 320 | return { activeItem: "connect" }; 321 | }, 322 | 323 | render: function() { 324 | return ( 325 |
326 | 359 |
360 | { function(cxt) {switch(cxt.state.activeItem) { 361 | case "connect": 362 | return 363 | case "settings": 364 | return 365 | case "user": 366 | return 367 | case "login": 368 | return 369 | case "register": 370 | return 371 | }}(this)} 372 |
373 |
374 | ); 375 | } 376 | }) 377 | 378 | this.hide = function() { 379 | $(".mainMenu").addClass("hide"); 380 | } 381 | 382 | this.show = function() { 383 | this.menu = React.renderComponent(new Menu(), $(".mainMenu").get(0)); 384 | 385 | $("nav img").click(function() { 386 | $(".mainMenu").toggleClass("hide"); 387 | }); 388 | } 389 | 390 | this.render = function() { 391 | this.menu.forceUpdate(); 392 | } 393 | } 394 | -------------------------------------------------------------------------------- /src/components/settings/general.jsx: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | 3 | app.components.general = function() { 4 | var General = React.createClass({ 5 | saveSettings: function() { 6 | if (typeof app.user !== "undefined") { 7 | app.io.emit("saveSettings", app.settings); 8 | } 9 | 10 | this.forceUpdate(); 11 | }, 12 | 13 | updateSetting: function(ev) { 14 | var setting = ev.target.getAttribute("data-setting"); 15 | app.settings[setting] = ev.target.value; 16 | 17 | this.saveSettings(); 18 | }, 19 | 20 | toggleMessageType: function(ev) { 21 | var type = $(ev.target).attr("data-type"); 22 | var enabled_types = this.props.settings.enabled_types; 23 | var disabled_types = this.props.settings.disabled_types; 24 | 25 | if(_.contains(enabled_types, type)) { 26 | this.props.settings.enabled_types = _.without(enabled_types, type); 27 | this.props.settings.disabled_types.push(type); 28 | } else { 29 | this.props.settings.disabled_types = _.without(disabled_types, type); 30 | this.props.settings.enabled_types.push(type); 31 | } 32 | 33 | this.saveSettings(); 34 | }, 35 | 36 | render: function() { 37 | var _this = this; 38 | return ( 39 |
40 | 41 |
42 | 43 | 44 |
45 | e.g. {moment().format(this.props.settings.time_format)} 46 |
47 |
48 |
49 | 50 | 65 |
66 | ) 67 | } 68 | }); 69 | 70 | return General; 71 | } 72 | -------------------------------------------------------------------------------- /src/components/settings/highlight.jsx: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | 3 | app.components.highlight = function() { 4 | var Highlight = React.createClass({ 5 | updateName: function(event) { 6 | this.props.highlight.name = event.target.value; 7 | this.forceUpdate(); 8 | }, 9 | 10 | updateRegex: function(event) { 11 | this.props.highlight.regex = event.target.value; 12 | this.forceUpdate(); 13 | }, 14 | 15 | updateColor: function(event) { 16 | this.props.highlight.color = event.target.value; 17 | $(this.getDOMNode()).find("input[name='color']").val(event.target.value); 18 | this.forceUpdate(); 19 | }, 20 | 21 | updateNotify: function(event) { 22 | this.props.highlight.notify = event.target.checked; 23 | this.forceUpdate(); 24 | }, 25 | 26 | render: function() { 27 | return ( 28 |
29 |
30 | 31 | 32 |
33 |
34 | 35 | 36 |
37 |
38 |
39 | 40 |
41 | 42 | {function(cxt) { 43 | if (Modernizr.inputtypes.color) { 44 | return 45 | } 46 | }(this)} 47 |
48 |
49 | 50 | 51 |
52 |
53 | ) 54 | } 55 | }); 56 | 57 | var Highlights = React.createClass({ 58 | getInitialState: function() { 59 | return {editMode: "normal"}; 60 | }, 61 | 62 | editNormal: function() { 63 | this.setState({ 64 | editMode: "normal" 65 | }); 66 | }, 67 | 68 | editJSON: function() { 69 | this.setState({ 70 | editMode: "JSON" 71 | }); 72 | }, 73 | 74 | updateJSON: function(ev) { 75 | try { 76 | var new_highlights = JSON.parse(ev.target.value); 77 | app.settings.highlights = new_highlights; 78 | 79 | // Remove any existing errors 80 | $(this.getDOMNode()).find("textarea").removeClass("error") 81 | 82 | app.io.emit("saveSettings", app.settings); 83 | } catch (e) { 84 | // Add error 85 | $(this.getDOMNode()).find("textarea").addClass("error") 86 | } 87 | }, 88 | 89 | addNew: function() { 90 | app.settings.highlights.push({ 91 | name: "", 92 | regex: "", 93 | color: "" 94 | }); 95 | app.io.emit("saveSettings", app.settings); 96 | this.forceUpdate(); 97 | }, 98 | 99 | render: function() { 100 | return ( 101 |
102 |
103 | {function(cxt) { 104 | if(cxt.state.editMode === "JSON") { 105 | return Easy Editor 106 | } else { 107 | return ( 108 | 109 | Edit JSON 110 | Add new 111 | 112 | ) 113 | } 114 | }(this)} 115 | 116 | Save 117 |
118 | 119 | {function(cxt) { 120 | if(cxt.state.editMode === "JSON") { 121 | return 122 | } else { 123 | return cxt.props.settings.highlights.map(function(highlight) { 124 | return ( 125 | 126 | ) 127 | }) 128 | } 129 | }(this)} 130 |
131 | ) 132 | } 133 | }); 134 | 135 | return Highlights; 136 | } 137 | -------------------------------------------------------------------------------- /src/components/settings/plugins.jsx: -------------------------------------------------------------------------------- 1 | /** @jsx React.DOM */ 2 | 3 | // Manages the plugin settings in the menu 4 | app.components.plugins = function() { 5 | // Component for invidual plugin 6 | // its private property is a boolean value `settings` 7 | // which determines whether or not to show the plugins settings 8 | // 9 | // From the parent plugins class it recieves the plugin 10 | // and the mode of the parent either "active" or "registry" 11 | // based on the mode we know what buttons to display 12 | var Plugin = React.createClass({ 13 | getInitialState: function() { 14 | return { 15 | settings: false 16 | }; 17 | }, 18 | 19 | // Flips the settings switch and causes the plugin to 20 | // re-render showing the plugins settings 21 | toggleSettings: function() { 22 | this.setState({ 23 | settings: !this.state.settings 24 | }); 25 | this.forceUpdate(); 26 | }, 27 | 28 | // This adds or removes the plugin from our list of plugins 29 | // if the plugin is not stored locally the script will also 30 | // fetch it from the server 31 | togglePlugin: function() { 32 | // Store some local variables for convenience 33 | var ap = app.settings.active_plugins; 34 | var plugin = this.props.plugin; 35 | 36 | // If we are just viewing the active plugins we want to remove 37 | // our plugin from the list 38 | // 39 | // otherwise we want to add it(fetching it from the server if necessary) 40 | if (this.props.mode === "active") { 41 | app.settings.active_plugins = _.without(ap, plugin.pluginId); 42 | this.props.toggleListener(); 43 | } else { 44 | // If it is already installed we 45 | // just want to add it to our active plugins 46 | if(app.plugins[plugin.pluginId]) { 47 | app.settings.active_plugins.push(plugin.pluginId); 48 | this.props.toggleListener(); 49 | } else { 50 | // We signal the server to fetch the plugin if it is 51 | // not already downloaded into our plugin cache 52 | // after this is complete it will send us back an 53 | // event that plugin has been fetched and cached 54 | // so we can add it to our client 55 | var _this = this; 56 | app.io.emit("add_plugin", {plugin: plugin.gist}); 57 | 58 | app.io.once("plugin_added", function(data) { 59 | util.loadPlugin(data.plugin, function() { 60 | _this.props.toggleListener(); 61 | }); 62 | }); 63 | } 64 | } 65 | }, 66 | 67 | // If the user changes the settings(by typing or otherwise) 68 | // we want to update the settings for that plugin. If the user 69 | // has entered invalid JSON we just ignore it 70 | // 71 | // TODO: add some sort of error class to the textarea to let 72 | // the user know they have entered some form of invalid JSON 73 | updateSettings: function(ev) { 74 | try { 75 | this.props.plugin.settings = JSON.parse(ev.target.value); 76 | app.io.emit("saveSettings", app.settings); 77 | } catch(e) { 78 | return; 79 | } 80 | }, 81 | 82 | // Utility method that checks if we are on active screen 83 | // and if this plugin is actively being used 84 | // used to decide whether to render all plugin buttons 85 | // and the 'active' badge 86 | isActive: function() { 87 | return this.props.mode !== "active" && _.contains(app.settings.active_plugins, this.props.plugin.pluginId); 88 | }, 89 | 90 | render: function() { 91 | return ( 92 |
93 |
94 | {this.props.mode === "active" ? Settings : ""} 95 | {!this.isActive() ? {this.props.mode === "registry" ? "Add" : "Remove"} : ""} 96 |
97 | 98 | {this.props.plugin.pluginId} 99 | by: {this.props.plugin.author} 100 | {this.isActive() ? Active : "" } 101 |

{this.props.plugin.description}

102 | {this.state.settings ? : "" } 103 |
104 | ) 105 | } 106 | }); 107 | 108 | var Plugins = React.createClass({ 109 | getInitialState: function() { 110 | return ({ 111 | mode: "active", 112 | searchString: "" 113 | }); 114 | }, 115 | 116 | active: function() { 117 | this.setState({ 118 | mode: "active", 119 | searchString: "" 120 | }); 121 | }, 122 | 123 | registry: function() { 124 | this.setState({ 125 | mode: "registry", 126 | searchString: "" 127 | }); 128 | }, 129 | 130 | pluginToggled: function() { 131 | this.forceUpdate(); 132 | }, 133 | 134 | search: function(ev) { 135 | this.setState({ 136 | searchString: ev.target.value 137 | }); 138 | }, 139 | 140 | // Convenience method to return list of methods 141 | // filtered by search string if necessary 142 | getPlugins: function() { 143 | var plugins; 144 | 145 | // We split our search terms into an array 146 | var search_terms = this.state.searchString.split(" "); 147 | 148 | if (this.state.mode === "active") { 149 | plugins = _.map(this.props.settings.active_plugins.sort(), function(pluginId) { 150 | var plugin = app.plugin_data[pluginId]; 151 | return plugin; 152 | }); 153 | 154 | } else { 155 | plugins = _.map(_.keys(app.plugin_registry).sort(), function(key) { 156 | var plugin = app.plugin_registry[key]; 157 | plugin.pluginId = key; 158 | return plugin; 159 | }); 160 | } 161 | 162 | // Filter base on search terms 163 | return _.filter(plugins, function(plugin) { 164 | var matched = true; 165 | // We see if any of the search terms match the id or description of 166 | // the plugin 167 | _.each(search_terms, function(st) { 168 | st = new RegExp(st, "ig"); 169 | var name = plugin.name || plugin.pluginId; 170 | 171 | if(name.match(st) === null && 172 | plugin.description.match(st) === null && 173 | plugin.author.match(st) === null) { 174 | matched = false; 175 | } 176 | }); 177 | 178 | return matched; 179 | }); 180 | }, 181 | 182 | render: function() { 183 | return ( 184 |
185 | {function(cxt) { 186 | if(cxt.state.mode ==="active") { 187 | return ( 188 |
189 |
190 | Registry 191 | 192 |
193 |
194 | {cxt.getPlugins().map(function(plugin) { 195 | return ( 196 | 197 | ) 198 | })} 199 | {cxt.props.settings.active_plugins.length === 0 ?

No active plugins

: "" } 200 |
201 |
202 | ) 203 | } else { 204 | return ( 205 |
206 |
207 | Active Plugins 208 | 209 |
210 |
211 | {cxt.getPlugins().map(function(plugin) { 212 | return ( 213 | 214 | ) 215 | })} 216 |
217 |
218 | ) 219 | } 220 | }(this)} 221 |
222 | ) 223 | } 224 | }); 225 | 226 | return Plugins; 227 | }; 228 | -------------------------------------------------------------------------------- /src/img/bubbles.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 9 | 10 | 11 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/img/subway.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedjpetersen/subway/5996c14c7faa880a6b5a79fdac606347a4034b5c/src/img/subway.png -------------------------------------------------------------------------------- /src/jade/debug.jade: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | meta(charset='utf-8') 5 | meta(http-equiv='X-UA-Compatible', content='IE=edge') 6 | title Subway IRC client 7 | meta(name='description', content='The Subway IRC client') 8 | meta(name='viewport', content='width=device-width, initial-scale=1') 9 | link(href="//fonts.googleapis.com/css?family=Open+Sans:400italic,400,300,600,700,800" rel="stylesheet" type="text/css") 10 | link(rel="stylesheet", href="/css/subway.css") 11 | link(rel="stylesheet", href="/css/debug.css") 12 | 13 | body 14 | .container 15 | main 16 | .objects 17 | .irc_log 18 | script(src="libs/underscore/underscore.js") 19 | script(src="libs/socket.io-client/socket.io.js") 20 | script(src="js/debug.js") 21 | -------------------------------------------------------------------------------- /src/jade/index.jade: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | meta(charset='utf-8') 5 | meta(http-equiv='X-UA-Compatible', content='IE=edge') 6 | title Subway IRC client 7 | meta(name='description', content='The Subway IRC client') 8 | meta(name='viewport', content='width=device-width, initial-scale=1') 9 | link(href="//fonts.googleapis.com/css?family=Open+Sans:400italic,400,300,600,700,800" rel="stylesheet" type="text/css") 10 | each file in css_output 11 | link(rel="stylesheet", href="#{file}") 12 | 13 | body 14 | .mainMenu 15 | .container 16 | nav 17 | .text-center 18 | img(src="/img/subway.png") 19 | h1 Subway 20 | br 21 | small IRC Client 22 | .nav-area 23 | main 24 | each file in js_output 25 | script(src="#{file}") 26 | | 33 | -------------------------------------------------------------------------------- /src/js/app.js: -------------------------------------------------------------------------------- 1 | // Initial state of our app 2 | app.initialized = false; 3 | 4 | app.irc = new app.models.App(); 5 | 6 | // Display startup menu 7 | // TODO if the user is already logged in we need to connect them directly 8 | // their session 9 | // Or if in the server settings they are to be connected directly to a channel 10 | // we need to immediately go into the connecting mode 11 | app.io.on("connect", function() { 12 | var menu = new app.components.startMenu(); 13 | menu.show(); 14 | 15 | if(app.user) { 16 | $.post('restore_connection/', {socketid: app.io.io.engine.id}); 17 | } 18 | 19 | // If we have default servers we want to hide the menu 20 | // and show the loading servers message 21 | if(app.default_servers && _.isUndefined(app.user)) { 22 | $(".mainMenu").toggleClass("hide"); 23 | $("main").html(app.loading_template); 24 | } 25 | 26 | util.loadPlugins(app.settings.plugins); 27 | util.highlightCss(); 28 | }); 29 | 30 | app.io.on("connection_removed", function(data) { 31 | app.irc.get("connections").remove(data.connection); 32 | }); 33 | 34 | app.io.on("register_success", function(data) { 35 | }); 36 | 37 | app.io.on("restore_connection", function(data) { 38 | app.initialized = true; 39 | 40 | app.irc.set(_.omit(data, "connections")); 41 | 42 | app.irc.set({ 43 | connections: new app.collections.Connections(data.connections) 44 | }); 45 | 46 | var conn = app.irc.get("connections"); 47 | 48 | var irc = new app.components.irc({ 49 | collection: conn 50 | }); 51 | 52 | irc.show(); 53 | 54 | if(data.connections.length > 0) { 55 | $(".mainMenu").addClass("hide"); 56 | } 57 | }); 58 | 59 | app.io.on("raw", function(message) { 60 | util.handle_irc(message, app.irc); 61 | }); 62 | 63 | app.io.on("disconnect", function(data) { 64 | console.log("disconnected"); 65 | }); 66 | 67 | app.io.on("history", function(data) { 68 | var server = app.irc.get("connections").get(data.server); 69 | var channel; 70 | if(data.channel.indexOf("#") === 0) { 71 | channel = server.get("channels").get(data.channel); 72 | } else { 73 | // For private messages 74 | channel = server.get("channels").get(data.channel.replace(server.get("nick"), "").replace("#", "")); 75 | } 76 | var messages = channel.get("messages"); 77 | messages.add(data.messages, {at: 0}); 78 | 79 | if (data.messages.length < 25) { 80 | messages.all_fetched = true; 81 | } 82 | }); 83 | -------------------------------------------------------------------------------- /src/js/boilerplate.js: -------------------------------------------------------------------------------- 1 | window.app = { 2 | models: {}, 3 | collections: {}, 4 | components: {}, 5 | plugins: {}, 6 | plugin_data: {}, 7 | plugin_registry: {}, 8 | settings: { 9 | highlights: [], 10 | time_format: "HH:MM", 11 | active_plugins: [ 12 | ] 13 | }, 14 | }; 15 | 16 | window.util = { 17 | title: document.title, 18 | 19 | // The raw argument and class argument are optional 20 | embedCss: function(css, isRaw, cssClass) { 21 | var output_css = ""; 22 | cssClass = cssClass || ""; 23 | 24 | if(isRaw) { 25 | output_css = '"; 26 | } else { 27 | output_css = ''; 28 | } 29 | $("head").append(output_css); 30 | }, 31 | 32 | embedJs: function(js, isRaw) { 33 | var output_js = ""; 34 | if(isRaw) { 35 | output_js = '"; 36 | } else { 37 | output_js = ''; 38 | } 39 | $("body").append(output_js); 40 | }, 41 | 42 | applyPlugins: function(text) { 43 | var listeners = []; 44 | // Iterate over all the plugins and apply them to the text 45 | _.each(app.settings.active_plugins, function(pluginName) { 46 | var pluginMethod = app.plugins[pluginName]; 47 | var args = pluginMethod(text); 48 | 49 | if (typeof args === "string") { 50 | text = args; 51 | } else { 52 | // Set our text the text after it is processed by the plugin 53 | text = args.text; 54 | } 55 | 56 | if (args.listener) { 57 | listeners.push(args.listener); 58 | } 59 | 60 | }); 61 | return {text: text, listeners: listeners}; 62 | }, 63 | 64 | loadPlugin: function(plugin, cb) { 65 | var gist_id = plugin.split("/")[1]; 66 | var base_url = "plugin_cache/" + gist_id + "/"; 67 | $.get(base_url + "plugin.json", function(data) { 68 | util.embedJs(base_url + "plugin.js"); 69 | util.embedCss(base_url + "plugin.css"); 70 | app.plugin_data[data.pluginId] = data; 71 | app.settings.active_plugins.push(data.pluginId); 72 | 73 | if (cb) { 74 | cb.call(this); 75 | } 76 | }); 77 | }, 78 | 79 | loadPlugins: function(plugins) { 80 | // We also want to load our plugin registry at the same time 81 | $.getJSON("plugin_cache/plugins.json", function(data) { 82 | app.plugin_registry = data; 83 | }); 84 | 85 | plugins.map(function(plugin) { 86 | util.loadPlugin(plugin); 87 | }); 88 | }, 89 | 90 | // Check for highlights and set the text 91 | // Takes a message as an argument and returns HTML 92 | highlightText: function(message) { 93 | var connection = app.irc.getActiveServer(); 94 | 95 | var text = _.escape(message.get("text")); 96 | var template = _.template("\">$&"); 97 | 98 | _.each(app.settings.highlights, function(highlight, index, highlights) { 99 | if(highlight.name === undefined) { 100 | return; 101 | } 102 | var re = new RegExp(_.template(highlight.regex, {message: message, channel: undefined, connection: connection}), "g"); 103 | text = text.replace(re, template(highlight)); 104 | }); 105 | return text; 106 | }, 107 | 108 | checkHighlights: function(message, channel, connection) { 109 | var text = message.get("text"); 110 | 111 | _.each(app.settings.highlights, function(highlight, index, highlights) { 112 | if(!highlight.name || !highlight.regex) { 113 | return; 114 | } 115 | 116 | var num = channel.get(highlight.name) || 0; 117 | var re = new RegExp(_.template(highlight.regex, {message: message, channel: undefined, connection: connection}), "g"); 118 | 119 | if (text.search(re) !== -1) { 120 | channel.set(highlight.name, ++num); 121 | 122 | if(highlight.notify) { 123 | app.irc.setNotifications(1); 124 | util.displayNotification(channel.get("name"), message.get("from") + ": " + message.get("text")); 125 | util.playSound("message"); 126 | } 127 | } 128 | 129 | if (channel.get("name") !== app.irc.get("active_channel") && app.settings.notify_pm && message.pmToMe(channel)) { 130 | util.displayNotification(channel.get("name") + " (pm)", message.get("text")); 131 | util.playSound("newPm"); 132 | } 133 | }); 134 | }, 135 | 136 | highlightCss: function() { 137 | // Remove any old css styles 138 | if ($(".highlightCss").length) { 139 | $(".highlightCss").remove(); 140 | } 141 | 142 | var template = _.template(".highlight_<%= name %> { font-weight: bold; color: <%= color %>; }\n.unread_<%= name %> { background: <%= color %>; }\n"); 143 | var output_css = ""; 144 | _.each(app.settings.highlights, function(highlight, index, highlights) { 145 | // If the highlight name is 146 | if(!highlight.name) { 147 | return; 148 | } 149 | output_css = output_css + template(highlight); 150 | }); 151 | util.embedCss(output_css, true, "highlightCss"); 152 | }, 153 | 154 | renderQueue: { 155 | queue: [], 156 | 157 | clearQueue: function() { 158 | clearInterval(this.queueInt); 159 | this.queueInt = undefined; 160 | this.queue = []; 161 | }, 162 | 163 | pushQueue: function(message) { 164 | var _this = this; 165 | 166 | this.queue.push(message); 167 | 168 | if(this.queueInt === undefined) { 169 | var x; 170 | this.queueInt = setInterval(function() { 171 | // Render ten messages and then wait 50 milliseconds 172 | // then render ten more messages 173 | // this allows us to switch channels quickly 174 | var mess = document.getElementsByClassName("messages")[0]; 175 | 176 | for(x=0; x<10; x++) { 177 | if(_this.queue.length === 0) { 178 | clearInterval(this.queueInt); 179 | break; 180 | } else { 181 | var entry = _this.queue.pop(); 182 | var processedText = entry.getModel().getText(); 183 | // we need to evaluate if the messages is scrolled to the bottom 184 | // and then stay there if we are there 185 | var is_at_bottom = mess.scrollTop + mess.offsetHeight === mess.scrollHeight; 186 | 187 | $(entry.getDOMNode()).find(".messageText").html(processedText.text); 188 | 189 | if(is_at_bottom) { 190 | mess.scrollTop = mess.scrollHeight; 191 | } 192 | 193 | entry.attachListeners(processedText); 194 | } 195 | } 196 | }, 50); 197 | } 198 | } 199 | }, 200 | 201 | // Display a desktop notification. 202 | displayNotification: function(title, body) { 203 | var icon = '/img/subway.png'; 204 | if ("Notification" in window) { 205 | if (Notification.permission === 'granted') { 206 | new Notification(title, {body: body, icon: icon}); 207 | } 208 | } 209 | }, 210 | 211 | playSound: function(type) { 212 | util.sounds && util.sounds[type].play(); 213 | }, 214 | 215 | _loadSound: function(name) { 216 | var a = new Audio(); 217 | a.src = '/sounds/' + name + '.' + this._supportedFormat(); 218 | return a; 219 | }, 220 | 221 | // Detect supported HTML5 audio format 222 | _supportedFormat: function() { 223 | var a = document.createElement('audio'); 224 | if (!a.canPlayType) return false; 225 | else if (!!(a.canPlayType('audio/ogg; codecs="vorbis"').replace(/no/, ''))) 226 | return 'ogg' 227 | else if (!!(a.canPlayType('audio/mpeg;').replace(/no/, ''))) 228 | return 'mp3' 229 | }, 230 | 231 | }; 232 | 233 | util.sounds = { 234 | newPm: util._loadSound('new-pm'), 235 | message: util._loadSound('msg') 236 | }; 237 | 238 | // Notifications. 239 | // Check if the browser supports notifications 240 | if ("Notification" in window) { 241 | // build title and body for the notification saying subway has notifications 242 | var title = 'Notifications from Subway'; 243 | var body = 'Subway will display notifications like this for this session'; 244 | 245 | // We display a notification saying that subway will use notifications. 246 | // On Chrome this is also a way of requesting permission to display notifications. 247 | if (Notification.permission !== 'denied') { 248 | // We have to bind the function to `this` to be able to access this.displayNotification 249 | Notification.requestPermission(function (permission) { 250 | if (!('permission' in Notification)) { 251 | Notification.permission = permission; 252 | } 253 | 254 | if(permission === 'granted') { 255 | util.displayNotification(title, body); 256 | } 257 | }); 258 | } else { 259 | util.displayNotification(title, body); 260 | } 261 | } 262 | 263 | // please note, that IE11 now returns true for window.chrome 264 | var isChromium = window.chrome, 265 | vendorName = window.navigator.vendor; 266 | if(isChromium !== null && vendorName === "Google Inc." && Notification.permission !== 'granted') { 267 | // is Google chrome 268 | var notif = $("
Click to show notifications
") 269 | .appendTo("body") 270 | .click(function(ev) { 271 | Notification.requestPermission(function (permission) { 272 | if (!('permission' in Notification)) { 273 | Notification.permission = permission; 274 | } 275 | }); 276 | $(this).remove(); 277 | }); 278 | 279 | setTimeout(function() { 280 | notif.slideUp(400, function(){ 281 | $(this).remove(); 282 | }); 283 | }, 5000); 284 | } 285 | 286 | app.loading_template = _.template("
Loading default channels...
")(); 287 | 288 | app.io = io(null, {port: document.location.port}); 289 | -------------------------------------------------------------------------------- /src/js/debug.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var socket = io("/debug"); 3 | 4 | socket.on("raw", function(message) { 5 | console.log(message); 6 | var irc_log = document.querySelectorAll(".irc_log")[0]; 7 | irc_log.innerHTML = irc_log.innerHTML + "
" + JSON.stringify(message) + "
"; 8 | }); 9 | })(); 10 | -------------------------------------------------------------------------------- /src/js/handle_irc.js: -------------------------------------------------------------------------------- 1 | var _ = typeof _ !== 'undefined' ? _ : require("underscore"); 2 | var util = typeof util !== 'undefined' ? util : {}; 3 | 4 | util.handle_irc = function(message, irc, app_ref) { 5 | var app = typeof window !== 'undefined' ? window.app : app_ref; 6 | var conn = irc.get("connections"); 7 | 8 | // Alias the long namespace 9 | var server = conn.get(message.client_server); 10 | 11 | // For debugging purposes 12 | if (message.rawCommand !== "PING") { 13 | console.log(message); 14 | } 15 | 16 | switch (message.rawCommand) { 17 | // We ignore PING messages - in the future 18 | // maybe we these are important for timeout purposes? 19 | case "PING": 20 | break; 21 | 22 | case "NOTICE": 23 | // If our app is not initialized we need to start it now 24 | if (!app.initialized && message.client_server) { 25 | app.initialized = true; 26 | 27 | app.irc.set({ 28 | active_server: message.client_server, 29 | active_channel: "status" 30 | }); 31 | 32 | conn.addServer(message.client_server); 33 | 34 | // We a status channel for our new connection 35 | conn.first().addChannel("status"); 36 | 37 | if (typeof module === 'undefined') { 38 | $(".mainMenu").addClass("hide"); 39 | 40 | var irc = new app.components.irc({ 41 | collection: conn 42 | }); 43 | irc.show(); 44 | } 45 | 46 | } else { 47 | if(conn.get(message.client_server) === undefined) { 48 | conn.addServer(message.client_server); 49 | server = conn.get(message.client_server); 50 | app.irc.set({ 51 | active_server: message.client_server, 52 | active_channel: "status" 53 | }); 54 | } 55 | server.addMessage("status", {from: "", text: message.args[1], type: "NOTICE"}); 56 | } 57 | break; 58 | 59 | case "PRIVMSG": 60 | // If we have a message addressed to a server 61 | if (message.args[0][0] === "#") { 62 | server.addMessage(message.args[0], {from: message.nick, text: message.args[1], type: "PRIVMSG"}); 63 | } else { 64 | // Deal with a private message 65 | server.addMessage(message.nick, {from: message.nick, text: message.args[1], type: "PRIVMSG"}); 66 | } 67 | break; 68 | 69 | case "MODE": 70 | if (message.args[0].indexOf("#") === 0) { 71 | var channel = server.get("channels").get(message.args[0]); 72 | server.addMessage(channel.get("name"), {from: message.nick, text: message.args[2], mode: message.args[1], type: "MODE"}); 73 | switch (message.args[1]) { 74 | case "+o": 75 | channel.get("users").get(message.args[2]).set("type", "@"); 76 | break; 77 | case "-o": 78 | channel.get("users").get(message.args[2]).set("type", ""); 79 | break; 80 | default: 81 | break; 82 | } 83 | } else { 84 | //user mode message 85 | var user = message.args[0]; 86 | } 87 | break; 88 | 89 | case "JOIN": 90 | // The first argument is the name of the channel 91 | if(message.nick === app.irc.getActiveNick()) { 92 | server.addChannel(message.args[0]); 93 | app.irc.set("active_channel", message.args[0]); 94 | conn.trigger("sort"); 95 | } else { 96 | server.addMessage(message.args[0], {type: "JOIN", nick: message.nick}); 97 | var channel = server.get("channels").get(message.args[0]); 98 | channel.get("users").add({nick: message.nick}); 99 | } 100 | break; 101 | 102 | case "PART": 103 | if(message.nick === server.get("nick")) { 104 | server.get("channels").remove(message.args[0]); 105 | app.irc.set("active_channel", "status"); 106 | conn.trigger("sort"); 107 | } else { 108 | var channel = server.get("channels").get(message.args[0]); 109 | server.addMessage(message.args[0], {type: "PART", nick: message.nick, text: message.args[1]}); 110 | channel.get("users").remove(message.nick); 111 | } 112 | break; 113 | 114 | case "QUIT": 115 | server.get("channels").map(function(channel) { 116 | if (channel.get("users").get(message.nick)) { 117 | server.addMessage(channel.get("name"), {type: "QUIT", nick: message.nick, text: message.args[0]}); 118 | channel.get("users").remove(message.nick); 119 | } 120 | }); 121 | break; 122 | 123 | case "KICK": 124 | if(message.args[1] === server.get("nick")) { 125 | server.get("channels").remove(message.args[0]); 126 | app.irc.set("active_channel", "status"); 127 | server.addMessage('status', {type: "KICK", nick: message.nick, text: message.args[1], reason: message.args[2]}); 128 | conn.trigger("sort"); 129 | } else { 130 | var channel = server.get("channels").get(message.args[0]); 131 | server.addMessage(message.args[0], {type: "KICK", nick: message.nick, text: message.args[1], reason: message.args[2]}); 132 | channel.get("users").remove(message.args[1]); 133 | } 134 | break; 135 | 136 | 137 | case "TOPIC": 138 | server.addMessage(message.args[0], {type: "TOPIC", nick: message.nick, text: message.args[1]}); 139 | 140 | var channel = server.get("channels").get(message.args[0]); 141 | channel.set("topic", message.args[1]); 142 | break; 143 | 144 | case "NICK": 145 | var isMe = false; 146 | // If it was us that changed our nick we want to change it here 147 | if (server.get("nick") === message.nick) { 148 | server.set("nick", message.args[0]); 149 | isMe = true; 150 | server.addMessage("status", {type: "NICK", nick: message.nick, text: message.args[0]}); 151 | } 152 | 153 | // for each channel we are in 154 | // we want to change the nick of the user that has the new nick 155 | server.get("channels").map(function(channel) { 156 | var user = channel.get("users").get(message.nick); 157 | if (channel.get("users").get(message.nick)){ 158 | user.set("nick", message.args[0]); 159 | if (!isMe) { 160 | server.addMessage(channel.get("name"), {type: "NICK", nick: message.nick, text: message.args[0]}); 161 | } 162 | } 163 | }); 164 | break; 165 | 166 | case "001": 167 | server.set({nick: _.first(message.args)}); 168 | server.addMessage("status", {text: message.args[1], type: "NOTICE"}); 169 | 170 | server.get("channels").each(function(channel) { 171 | if (channel.get("name").indexOf("#") !== -1) { 172 | server.get("channels").remove(channel.get("name")); 173 | } 174 | }); 175 | break; 176 | 177 | case "002": 178 | server.addMessage("status", {text: message.args.join(" "), type: "NOTICE"}); 179 | break; 180 | 181 | case "256": 182 | case "257": 183 | case "258": 184 | case "259": 185 | case "371": 186 | server.addMessage("status", {text: message.args[1], type: "NOTICE"}); 187 | break; 188 | 189 | case "321": 190 | // rpl_liststart 191 | // args are username/channel/usersname 192 | server._list_store = []; 193 | break; 194 | 195 | case "322": 196 | server._list_store.push({ 197 | channel: message.args[1], 198 | users: message.args[2], 199 | topic: message.args[3] 200 | }); 201 | break; 202 | 203 | case "323": 204 | server.get("list").reset(server._list_store); 205 | server._list_store = []; 206 | break; 207 | 208 | // Set the topic 209 | case "332": 210 | server.get("channels").get(message.args[1]).set("topic", message.args[2]); 211 | break; 212 | 213 | case "333": 214 | // This has the topic user and the topic creation date 215 | // args [0: user 1: channel 2: user who set topic 3: topic timestamp] 216 | break; 217 | 218 | case "353": 219 | // We have to trim for leading and trailing whitespace 220 | var usernames = message.args[3].trim().split(" "); 221 | usernames = _.map(usernames, function(u) { 222 | return {nick: u}; 223 | }); 224 | server.addUser(message.args[2], usernames); 225 | break; 226 | 227 | case "372": 228 | server.set("motd", server.get("motd") + "\n" + message.args[1]); 229 | break; 230 | 231 | case "375": 232 | server.set("motd", message.args[1]); 233 | break; 234 | 235 | case "376": 236 | server.addMessage("status", {text: server.get("motd"), specialType: "MOTD"}); 237 | break; 238 | 239 | case "433": 240 | server.addMessage("status", {text: "Error " + message.args.join(" ")}); 241 | break; 242 | 243 | case "474": 244 | server.addMessage("status", {text: message.args[1] + " " + message.args[2]}); 245 | break; 246 | 247 | default: 248 | // Generic handler for irc errors 249 | if (message.commandType === "irc_error") { 250 | server.addMessage("status", {text: message.args.join(" - ")}); 251 | } 252 | break; 253 | } 254 | } 255 | 256 | // to export our models code to work server side 257 | if (typeof module !== 'undefined' && module.exports) { 258 | module.exports = util.handle_irc; 259 | } 260 | -------------------------------------------------------------------------------- /src/js/models/models.js: -------------------------------------------------------------------------------- 1 | var Backbone = typeof Backbone !== 'undefined' ? Backbone : require("backbone"); 2 | var _ = typeof _ !== 'undefined' ? _ : require("underscore"); 3 | 4 | var app = typeof app !== 'undefined' ? app : {models: {}, collections: {}}; 5 | 6 | var isNode = false; 7 | 8 | if (typeof module !== 'undefined' && module.exports) { 9 | isNode = true; 10 | } 11 | 12 | app.models.App = Backbone.Model.extend({ 13 | initialize: function(attrs, opts) { 14 | attrs = attrs || {}; 15 | this.attributes.connections = new app.collections.Connections(attrs.connections || []); 16 | this.attributes.notifications = 0; 17 | }, 18 | 19 | getActiveServer: function() { 20 | return this.get("connections").get(this.get("active_server")); 21 | }, 22 | 23 | getActiveChannel: function() { 24 | return this.getActiveServer().get("channels").get(this.get("active_channel")); 25 | }, 26 | 27 | getActiveNick: function() { 28 | return this.getActiveServer().get("nick"); 29 | }, 30 | 31 | setNotifications: function(num) { 32 | app.irc.attributes.notifications = app.irc.attributes.notifications+num; 33 | 34 | if(app.irc.attributes.notifications < 0) { 35 | app.irc.attributes.notifications = 0; 36 | } 37 | 38 | if(app.irc.attributes.notifications > 0) { 39 | document.title = "(" + app.irc.attributes.notifications + ") " + util.title; 40 | } else { 41 | document.title = util.title; 42 | } 43 | } 44 | 45 | }); 46 | 47 | app.models.Connection = Backbone.Model.extend({ 48 | idAttribute: "name", 49 | 50 | initialize: function(attrs, opts) { 51 | this.attributes.channels = new app.collections.Channels(attrs.channels || []); 52 | this.attributes.list = new app.collections.ChannelList(attrs.list || []); 53 | 54 | // When we have an update on the model we want to bubble the change 55 | // on up to the connection 56 | this.attributes.channels.on("all", function() { 57 | this.trigger("change"); 58 | }, this); 59 | }, 60 | 61 | addChannel: function (channel) { 62 | if (this.get("channels").get(channel) === undefined) { 63 | this.get("channels").add({name: channel}); 64 | } 65 | }, 66 | 67 | addMessage: function(channelName, message) { 68 | // If the channel doesn't exist this will add it 69 | this.addChannel(channelName); 70 | var channel = this.get("channels").get(channelName); 71 | 72 | // Handle action messages 73 | if(message.text && message.text.indexOf("ACTION ") === 1 ) { 74 | message.text = message.text.substring(8); 75 | message.type = "ACTION"; 76 | } 77 | 78 | // Set message and activity 79 | var added_message = channel.get("messages").add(message); 80 | var user = channel.get("users").get(message.from); 81 | 82 | if (isNode) { 83 | app.logMessage(this.get("name"), channelName, added_message.attributes, app.username); 84 | } 85 | 86 | // If the user exists we need to set the users activty back to its 87 | // initial state 88 | if (user !== undefined) { 89 | user.resetActive(); 90 | if (channel.get("name") === app.irc.get("active_channel")) { 91 | // Redraw the server list 92 | user.collection.trigger("add"); 93 | } 94 | } 95 | 96 | // If we are not idling on the active channel we want to 97 | // increment the number of unread messages in the server 98 | if(channel.get("name") !== app.irc.getActiveChannel().get("name") && _.contains(["PRIVMSG"], added_message.get("type"))) { 99 | if (!channel.get("unread")) { 100 | channel.set("unread", 0); 101 | } 102 | 103 | var unread = channel.get("unread"); 104 | channel.set("unread", ++unread); 105 | 106 | if (typeof util !== "undefined") { 107 | util.checkHighlights(added_message, channel, this); 108 | } 109 | } 110 | }, 111 | 112 | addUser: function(channel, user) { 113 | var users = this.get("channels").get(channel).get("users"); 114 | 115 | // If the user already exists we don't want to add them 116 | if (!users.get(user)) { 117 | users.add(user); 118 | } 119 | } 120 | }); 121 | 122 | app.collections.Connections = Backbone.Collection.extend({ 123 | idAttribute: "name", 124 | 125 | model: app.models.Connection, 126 | 127 | addServer: function(server) { 128 | if (_.isEmpty(this.where({name: server}))) { 129 | this.add({name: server}); 130 | } 131 | } 132 | }); 133 | 134 | 135 | app.models.Channel = Backbone.Model.extend({ 136 | idAttribute: "name", 137 | 138 | // we don't want to return the messages with our json 139 | toJSON: function () { 140 | var ret_obj = _.extend({}, this.attributes); 141 | // Only save 25 messages 142 | ret_obj.messages.slice(0,24); 143 | return ret_obj; 144 | }, 145 | 146 | initialize: function(attrs, opts) { 147 | this.attributes.messages = new app.collections.Messages(attrs.messages || []); 148 | this.attributes.users = new app.collections.Users(attrs.users || []); 149 | this.attributes.history = []; 150 | this.attributes.history_offset = 0; 151 | }, 152 | 153 | getNextHistory: function() { 154 | var history = this.attributes.history; 155 | var offset = this.attributes.history_offset; 156 | var entry = history[offset]; 157 | 158 | if(offset === history.length) { 159 | this.attributes.history_offset = 0; 160 | } else { 161 | this.attributes.history_offset = offset+1; 162 | } 163 | return entry; 164 | }, 165 | 166 | getPrevHistory: function() { 167 | var history = this.attributes.history; 168 | var offset = this.attributes.history_offset; 169 | 170 | if(offset === 0) { 171 | this.attributes.history_offset = history.length; 172 | } else { 173 | this.attributes.history_offset = offset-1; 174 | } 175 | 176 | var entry = history[this.attributes.history_offset-1]; 177 | return entry; 178 | }, 179 | 180 | clearNotifications: function() { 181 | this.set("unread", 0); 182 | var _this = this; 183 | var count; 184 | if (typeof app.settings !== "undefined") { 185 | _.each(app.settings.highlights, function(highlight, index, highlights) { 186 | count = _this.get(highlight.name) || 0; 187 | app.irc.setNotifications(count*-1); 188 | _this.set(highlight.name, 0); 189 | }); 190 | } else { 191 | _.each(this.attributes, function(val, key) { 192 | // Set any highlights to 0 as well 193 | if (typeof val === "number" && val > 0) { 194 | _this.set(key, 0); 195 | } 196 | }); 197 | } 198 | 199 | if (typeof app.user !== 'undefined') { 200 | app.io.emit("clearnotifications", { 201 | server: app.irc.getActiveServer(), 202 | channel: this.get("name") 203 | }); 204 | } 205 | }, 206 | 207 | 208 | isPm: function() { 209 | return this.get("name").indexOf("#") === -1; 210 | } 211 | }); 212 | 213 | app.collections.Channels = Backbone.Collection.extend({ 214 | model: app.models.Channel 215 | }); 216 | 217 | app.models.Message = Backbone.Model.extend({ 218 | initialize: function(attrs, options) { 219 | var default_props = { 220 | timestamp: attrs.timestamp || Date.now() 221 | }; 222 | if (this.get("type") === undefined) { 223 | default_props.type = "PRIVMSG"; 224 | } 225 | 226 | if(this.get("attributes")) { 227 | _.extend(default_props, JSON.parse(this.get("attributes"))); 228 | } 229 | 230 | this.set(default_props); 231 | }, 232 | 233 | isMe: function() { 234 | return this.get("from") === app.irc.getActiveNick(); 235 | }, 236 | 237 | getAuthor: function() { 238 | return this.get("from"); 239 | }, 240 | 241 | getClass: function() { 242 | var classList = "message"; 243 | if (this.isMe()) { 244 | classList = classList + " isMe"; 245 | } 246 | 247 | if (this.get("specialType") === "MOTD") { 248 | classList = classList + " motd"; 249 | } 250 | return classList; 251 | }, 252 | 253 | pmToMe: function(channel) { 254 | return channel.isPm() && channel.get("name") === this.getAuthor(); 255 | }, 256 | 257 | getText: function() { 258 | // Highlight any mentions or other regexes 259 | var text = util.highlightText(this); 260 | 261 | // Apply any loaded plugins to the message 262 | text = util.applyPlugins(text); 263 | return text; 264 | } 265 | }); 266 | 267 | app.collections.Messages = Backbone.Collection.extend({ 268 | model: app.models.Message 269 | }); 270 | 271 | app.models.User = Backbone.Model.extend({ 272 | idAttribute: "nick", 273 | 274 | initialize: function(attrs, options) { 275 | // If the user is the an admin we want to indicate it with the type 276 | if (this.get("nick").indexOf("@") !== -1 || attrs.type === "@") { 277 | this.set({ 278 | nick: this.get("nick").replace("@", ""), 279 | type: "@", 280 | updated: attrs.updated || Date.now()-3600000, 281 | }); 282 | } else { 283 | this.set({ 284 | type: "", 285 | updated: attrs.updated || Date.now()-3600000, 286 | }); 287 | } 288 | }, 289 | 290 | resetActive: function() { 291 | // Get rid of any existing counters we have 292 | clearInterval(this.active_counter); 293 | 294 | // Set active to 0 295 | this.set({ 296 | last_active: 0, 297 | updated: Date.now() 298 | }); 299 | 300 | var _this = this; 301 | this.active_counter = setInterval(function() { 302 | var active = _this.get("last_active"); 303 | 304 | // If we are past an hour we set the user to idle 305 | if(active > 60) { 306 | _this.set("last_active", undefined); 307 | clearInterval(_this.active_counter); 308 | } else { 309 | _this.set("last_active", active+1); 310 | } 311 | }, 60000); 312 | }, 313 | 314 | isActive: function() { 315 | return this.get("last_active") < 60 ? "activeUser" : "notActiveUser"; 316 | }, 317 | 318 | getActive: function() { 319 | var active = this.get("last_active"); 320 | return active < 60 ? "(" + active + "m)" : ""; 321 | } 322 | }); 323 | 324 | app.collections.Users = Backbone.Collection.extend({ 325 | model: app.models.User, 326 | 327 | comparator: function(user) { 328 | return [user.get("last_active")*-1, user.get("nick")]; 329 | }, 330 | 331 | sortAll: function() { 332 | // Sort users alphabetically 333 | var users = this.sortBy(function(user) { 334 | return user.get("nick").toLowerCase(); 335 | }); 336 | 337 | // Sort users by whether or not they are an operator 338 | users = _.sortBy(users, function(user) { 339 | return user.get("type") === "@" ? -1 : 1; 340 | }); 341 | 342 | // Sort users by whether by when they were updated 343 | users = _.sortBy(users, function(user) { 344 | var active = user.get("last_active"); 345 | return active < 60 ? user.get("updated")*-1 : active; 346 | }); 347 | 348 | return users; 349 | } 350 | }); 351 | 352 | app.models.SubwayUser = Backbone.Model.extend({ 353 | }); 354 | 355 | app.collections.ChannelList = Backbone.Collection.extend({ 356 | }); 357 | 358 | // to export our models code to work server side 359 | if (isNode) { 360 | module.exports = app; 361 | } 362 | -------------------------------------------------------------------------------- /src/js/util.js: -------------------------------------------------------------------------------- 1 | // Utility method to parse references from a react form and return an object 2 | _.parseForm = function(refs) { 3 | var out = {}; 4 | var val, node; 5 | _.each(refs, function(v,k) { 6 | node = v.getDOMNode(); 7 | val = node.value.trim(); 8 | 9 | if (node.type && node.type === "checkbox") { 10 | val = node.checked; 11 | } 12 | 13 | if (val !== "") { 14 | out[k] = val; 15 | } 16 | }); 17 | return out; 18 | }; 19 | 20 | _.validateForm = function(refs) { 21 | var isValid = true; 22 | _.each(refs, function(v,k) { 23 | var domNode = $(v.getDOMNode()); 24 | var val = domNode.val(); 25 | if(val === "" && domNode.attr("required")) { 26 | domNode.addClass("required"); 27 | isValid = false ; 28 | } else { 29 | domNode.removeClass("required"); 30 | } 31 | }); 32 | 33 | return isValid; 34 | }; 35 | -------------------------------------------------------------------------------- /src/sounds/msg.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedjpetersen/subway/5996c14c7faa880a6b5a79fdac606347a4034b5c/src/sounds/msg.mp3 -------------------------------------------------------------------------------- /src/sounds/msg.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedjpetersen/subway/5996c14c7faa880a6b5a79fdac606347a4034b5c/src/sounds/msg.ogg -------------------------------------------------------------------------------- /src/sounds/new-pm.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedjpetersen/subway/5996c14c7faa880a6b5a79fdac606347a4034b5c/src/sounds/new-pm.mp3 -------------------------------------------------------------------------------- /src/sounds/new-pm.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedjpetersen/subway/5996c14c7faa880a6b5a79fdac606347a4034b5c/src/sounds/new-pm.ogg -------------------------------------------------------------------------------- /src/styl/app.styl: -------------------------------------------------------------------------------- 1 | @import url('variables.styl') 2 | @import url('base.styl') 3 | @import url('layout.styl') 4 | @import url('nav.styl') 5 | @import url('type.styl') 6 | @import url('buttons.styl') 7 | @import url('mainMenu.styl') 8 | @import url('userList.styl') 9 | @import url('message.styl') 10 | @import url('messageInput.styl') 11 | 12 | -------------------------------------------------------------------------------- /src/styl/base.styl: -------------------------------------------------------------------------------- 1 | *, *::before, *::after 2 | box-sizing: border-box 3 | 4 | html, body 5 | font-family: 'Open Sans', Helvetica, Arial, sans-serif; 6 | margin: 0 7 | padding: 0 8 | height: 100% 9 | font-size: 14px 10 | color: #333333 11 | 12 | textarea, input 13 | font-size: 0.9em 14 | background: #FFF 15 | border: 1px solid #DBDFE5 16 | border-radius: 3px 17 | padding: 10px 15px 18 | -webkit-font-smoothing: antialiased 19 | 20 | input[type="color"] 21 | height: 38px 22 | padding: 3px 5px 23 | vertical-align: bottom 24 | 25 | strong 26 | font-weight: 600 27 | 28 | .container 29 | height: 100% 30 | display: -webkit-flex 31 | display: flex 32 | 33 | main 34 | background: whiteSmoke 35 | flex: 1 36 | 37 | .hide 38 | display: none !important 39 | 40 | .pointer 41 | cursor: pointer 42 | 43 | .faux_a 44 | &:hover 45 | cusor: pointer 46 | 47 | .spacing-right 48 | margin-right: 5px 49 | 50 | textarea.error 51 | border: 1px solid #FFC5C5 52 | 53 | .badge 54 | font-size: 12px 55 | color: white 56 | padding: 5px 57 | border-radius: 4px 58 | background: #9696CE 59 | 60 | .right 61 | float: right 62 | 63 | .left 64 | float: left 65 | 66 | input.error 67 | background-color: #F3D7D7 68 | border: 1px solid #E2CBCB 69 | 70 | input[disabled] 71 | background: #DDD 72 | 73 | .alert 74 | padding: 1px 20px 75 | margin: 10px 0 76 | border-radius: 5px 77 | 78 | .alert.error 79 | background: #B97676 80 | color: white 81 | 82 | .required 83 | border: 1px solid #DFAFAF; 84 | 85 | .notificationCheck 86 | top: 0; 87 | position: absolute; 88 | background: #7B8F6C; 89 | color: #FFF; 90 | vertical-align: middle; 91 | width: 100%; 92 | z-index: 1000; 93 | text-align: center; 94 | line-height: 30px; 95 | 96 | &:hover 97 | background: #94AC82 98 | cursor: pointer 99 | 100 | .loading_default 101 | padding: 20px 102 | -------------------------------------------------------------------------------- /src/styl/buttons.styl: -------------------------------------------------------------------------------- 1 | .button 2 | display: inline-block 3 | padding: 5px 4 | background: #BBB 5 | color: white 6 | &:hover 7 | background: #CCC 8 | 9 | .button.blue 10 | background: #8D94C9 11 | &:hover 12 | background: #A3ACEE 13 | -------------------------------------------------------------------------------- /src/styl/debug.styl: -------------------------------------------------------------------------------- 1 | .irc_log 2 | width: 1000px 3 | overflow: scroll 4 | margin: 10px 5 | padding: 10px 6 | border-radius: 4px 7 | background: #FFF 8 | 9 | .irc_msg 10 | padding: 10px 0 11 | border-bottom: 1px solid #CCC 12 | -------------------------------------------------------------------------------- /src/styl/layout.styl: -------------------------------------------------------------------------------- 1 | main, .app 2 | height: 100% 3 | 4 | .app 5 | display: -webkit-flex 6 | display: flex 7 | 8 | .chat 9 | display: -webkit-flex 10 | display: flex 11 | flex-direction: column 12 | align-items: stretch 13 | flex: 1 14 | 15 | .titlebar 16 | display: -webkit-flex 17 | display: flex 18 | padding: 10px 19 | color: white 20 | background: #434950 21 | 22 | strong 23 | margin-right: 10px 24 | 25 | span 26 | flex: 1 27 | 28 | .messageInput 29 | display: -webkit-flex 30 | display: flex 31 | padding: 10px 32 | border-top: 1px solid #DDD 33 | input 34 | flex: 1 35 | margin-right: 10px 36 | 37 | .userList 38 | width: 250px 39 | -------------------------------------------------------------------------------- /src/styl/mainMenu.styl: -------------------------------------------------------------------------------- 1 | .mainMenu 2 | position: absolute 3 | top: 0 4 | left: 14em 5 | right: 0 6 | height: 100% 7 | background: #EEE 8 | z-index: 1000 9 | display: inline-block 10 | 11 | h1 12 | margin: 0 13 | 14 | input 15 | margin-bottom: 10px 16 | 17 | .fullWidth 18 | width: 100% 19 | 20 | .fa-question-circle 21 | font-size: 1.3em 22 | vertical-align: top 23 | margin-left: 10px 24 | opacity: 0.6 25 | color: #333 26 | 27 | &:hover 28 | opacity: 1.0 29 | 30 | .highlightEditor 31 | margin-top: 10px 32 | height: 200px 33 | 34 | .menuContainer 35 | display: -webkit-flex 36 | display: flex 37 | height: 100% 38 | 39 | .menuArea 40 | flex: 1 41 | display: inline-block 42 | padding: 20px 40px 43 | overflow: scroll 44 | 45 | .menuList 46 | display: inline-block 47 | background: #333 48 | list-style: none 49 | width: 170px 50 | margin: 0 51 | padding: 20px 0 52 | li 53 | padding: 15px 15px 15px 25px 54 | color: #C2C2C2 55 | 56 | &:not(.activeMenuItem):hover 57 | color: white 58 | background: #494949 59 | cursor: pointer 60 | 61 | span 62 | margin-right: 5px 63 | &:hover 64 | cursor: pointer 65 | 66 | li.activeMenuItem 67 | background: #272727 68 | color: white 69 | font-weight: 600 70 | 71 | .navBar 72 | list-style: none 73 | color: #585858 74 | border: 1px solid #CCC; 75 | display: inline-block; 76 | border-radius: 3px; 77 | padding: 0; 78 | 79 | .navActive 80 | background: #CCC 81 | 82 | li 83 | display: inline-block; 84 | margin: 0; 85 | padding: 10px; 86 | border-right: 1px solid #CCC; 87 | 88 | li:last-child 89 | border-right: none 90 | 91 | li:not(.navActive):hover 92 | cursor: pointer 93 | color: #888 94 | 95 | .menuHighlight 96 | border-top: 1px solid #CCC 97 | margin-top: 10px 98 | padding-top: 10px 99 | 100 | .menuPlugins 101 | padding: 20px 102 | background: #FFF 103 | border-radius: 4px 104 | margin-top: 10px 105 | max-height: 400px 106 | overflow: scroll 107 | 108 | .menuPlugin 109 | border-bottom: 1px solid #CCC 110 | margin-bottom: 10px 111 | 112 | &:last-child 113 | border-bottom: none 114 | 115 | &:hover .pluginButtons 116 | display: block 117 | 118 | .pluginButtons 119 | display: none 120 | float: right 121 | 122 | .activeConnections 123 | span, strong, i 124 | margin-right: 10px 125 | 126 | i 127 | opacity: 0.6 128 | &:hover 129 | opacity: 1 130 | 131 | .messageTypes 132 | background: white 133 | list-style-type: none 134 | padding: 0 135 | width: 200px 136 | 137 | li 138 | padding: 10px 15px 10px 30px 139 | 140 | &:hover 141 | background: #ACDF98 142 | color: white 143 | 144 | .active 145 | color: white 146 | background: #8CC077 147 | -------------------------------------------------------------------------------- /src/styl/message.styl: -------------------------------------------------------------------------------- 1 | .messages 2 | flex: 1 3 | overflow: auto 4 | 5 | .message 6 | display: -webkit-flex 7 | display: flex 8 | border-bottom: 1px solid #EEE 9 | padding: 5px 0 10 | & > div 11 | padding: 7px 12 | 13 | .messageAuthor 14 | width: 100px 15 | overflow: hidden 16 | text-overflow: ellipsis 17 | font-weight: 600 18 | color: #555 19 | 20 | .messageText 21 | flex: 1 22 | word-wrap: break-word 23 | text-rendering: optimizeLegibility 24 | 25 | .messageTimestamp 26 | width: 100px 27 | 28 | color: #AAA 29 | .isMe 30 | background: #ECECF5 31 | 32 | .mention 33 | color: #0B2666 34 | font-weight: bold 35 | 36 | .message > div > .fa 37 | margin-right: 5px 38 | opacity: 0.6 39 | 40 | .message:hover > div > .fa 41 | opacity: 1.0 42 | 43 | .message > div > .fa-sign-in 44 | color: #66AF2C 45 | 46 | .message > div > .fa-sign-out 47 | color: #AF2C36 48 | 49 | .message > div > .fa-sign-circle 50 | color: #2C7BAF 51 | 52 | .motd .messageText 53 | white-space: pre 54 | background: #FAFAFA 55 | border-radius: 4px 56 | 57 | -------------------------------------------------------------------------------- /src/styl/messageInput.styl: -------------------------------------------------------------------------------- 1 | .messageInput 2 | position: relative 3 | 4 | .messageBox 5 | background: white; 6 | right: 0 7 | 8 | .nickBox 9 | background: #F8F8F8 10 | border-radius: 0 5px 0 0 11 | border-right: 1px solid #DDD 12 | 13 | .nickBox, .messageBox 14 | position: absolute 15 | bottom: 63px 16 | left: 0 17 | border-top: 1px solid #DDD 18 | 19 | max-height: 250px 20 | overflow: scroll 21 | 22 | .nickBox ul, .messageBox ul 23 | list-style: none 24 | margin: 0 25 | padding: 0 26 | overflow: hidden 27 | 28 | .messageBox li.selected 29 | background: #BBBBBB 30 | color: white 31 | 32 | .nickBox li.selected 33 | background: #8D8D8D 34 | color: white 35 | 36 | .messageBox li 37 | padding: 8px 38 | border-bottom: 1px solid #EEE 39 | 40 | &:last-child 41 | border-bottom: none 42 | 43 | .nickBox li 44 | padding-right: 250px 45 | 46 | &:hover 47 | cursor: pointer 48 | color: #333 49 | background: #F1F1F1 50 | -------------------------------------------------------------------------------- /src/styl/nav.styl: -------------------------------------------------------------------------------- 1 | nav 2 | flex: 0 0 14em 3 | order: -1 4 | width: 14em 5 | background: #E7E9ED 6 | border-right: 1px solid #DDDDDD 7 | padding-top: 20px 8 | display: -webkit-flex 9 | display: flex 10 | flex-direction: column 11 | 12 | .nav-area 13 | flex: 1 14 | overflow: scroll 15 | ul 16 | padding: 0 17 | margin: 0 18 | 19 | li 20 | padding: 10px 0 10px 40px 21 | list-style-type: none 22 | &:hover 23 | cursor: pointer 24 | background: #A7DBD8 25 | i 26 | display: inline-block 27 | opacity: 0.6 28 | margin-right: 10px 29 | &:hover 30 | opacity: 1 31 | cursor: pointer 32 | &.active:hover 33 | background: #315857 34 | cursor: default 35 | 36 | i 37 | display: none 38 | float: right 39 | margin-top: 2px 40 | 41 | .active 42 | background: #315857 43 | color: white 44 | 45 | .server_name, .server_nick 46 | background: #434950 47 | padding: 10px 0 10px 40px 48 | color: white 49 | 50 | .server_name 51 | border-left: 5px solid #FA6900 52 | 53 | .server_nick 54 | border-left: 5px solid #8A9B0F 55 | 56 | .unread, .unread_highlight 57 | padding: 2px 6px 58 | color: whiteSmoke 59 | border-radius: 4px 60 | margin-left: 5px 61 | display: inline-block 62 | 63 | .sideNavUp, .sideNavDown 64 | width: 195px 65 | background: orange 66 | color: white 67 | padding: 10px 0px 10px 40px 68 | position: fixed 69 | transition: left 300ms ease-in-out 70 | left: -195px 71 | 72 | .sideNavDown 73 | bottom: 0 74 | 75 | .unread 76 | background: #980000 77 | 78 | .nav_server_name 79 | line-height: 23px; 80 | -------------------------------------------------------------------------------- /src/styl/type.styl: -------------------------------------------------------------------------------- 1 | small 2 | font-size: 0.7em 3 | color: #999999 4 | 5 | .text-center 6 | text-align: center 7 | 8 | -------------------------------------------------------------------------------- /src/styl/userList.styl: -------------------------------------------------------------------------------- 1 | .userList 2 | background: #E7E9ED 3 | display: -webkit-flex 4 | display: flex 5 | flex-direction: column 6 | 7 | .titlebar 8 | border-left: 1px solid #4E5257 9 | 10 | .usersListed 11 | overflow: scroll 12 | flex: 1 13 | border-left: 1px solid #DDD 14 | 15 | .user 16 | border-bottom: 1px solid #DDD 17 | 18 | .activeUser, .notActiveUser 19 | border-right: 1px solid #DDD 20 | display: inline-block 21 | padding: 7px 22 | margin-right: 5px 23 | background: #F5F5F5 24 | 25 | .activeUser 26 | color: #66AF2C 27 | 28 | .notActiveUser 29 | color: #F78E22 30 | 31 | .lastActive 32 | margin-left: 5px 33 | color: #888 34 | -------------------------------------------------------------------------------- /src/styl/variables.styl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thedjpetersen/subway/5996c14c7faa880a6b5a79fdac606347a4034b5c/src/styl/variables.styl -------------------------------------------------------------------------------- /subway.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* The Subway IRC client 4 | * Written By: David Petersen (thedjpetersen) 5 | * 6 | * This is a persistent web based IRC client that is targeted 7 | * to those new to IRC. 8 | */ 9 | 10 | // Basic dependencies. We use connect/http to server our content. 11 | // Bower manages our 3rd party dependcies allowing us to upgrade them 12 | // easily(just update the bower.json file). The async module manages our flow - 13 | // instead of nesting callbacks we try to follow the waterfall pattern 14 | // to have at least the appearance of synchronous code. This aids our readabilty. 15 | var express = require("express"), 16 | http = require("http"), 17 | bower = require("bower"), 18 | async = require("async"); 19 | 20 | var server_settings = require("./settings/server"); 21 | 22 | 23 | //Get current environment 24 | var env = process.env.IRC_ENV || "dev"; 25 | 26 | // These are our local lib files. The initialize function in our plugin module 27 | // fetches the different plugins(github gists) we have and downloads them to 28 | // the plugin_cache directory. 29 | // 30 | // The static module includes all of our grunt configuration. 31 | // This takes care of any code preprocessing(grunt/stylus/Jade) 32 | // as well code concatenation/minfication. 33 | // 34 | // The connection module handles any interaction between the client and the server 35 | // all incoming IRC commands are hanndled here. It was also handle IRC logging 36 | // and any other info that needs to be sent to the client(plugin info or settings) 37 | var init_plugins = require("./lib/plugins").initialize, 38 | static = require("./lib/static"), 39 | connection = require("./lib/connection"); 40 | 41 | var cwd = __dirname; 42 | 43 | // We use the async module waterfall to set through different steps to start up 44 | // the subway client. Each one needs to happen in series 45 | async.waterfall([ 46 | function(callback) { 47 | // This method fetches different plugins from the github gists 48 | // and saves them to the plugin_cache directory 49 | console.log("Fetching Subway plugins..."); 50 | // TODO 51 | // resolve Fatal error: getaddrinfo ENOTFOUND 52 | // when we don't have active internet connection 53 | init_plugins(callback); 54 | }, 55 | function(callback) { 56 | // We download all of our third party dependencies or upgrade any if 57 | // if the configuration has changed. These are all client side depdencies 58 | // like jQuery, Backbone or React. 59 | console.log("Downloading dependencies..."); 60 | bower.commands.install().on("end", function(results){ 61 | callback(null, results); 62 | }); 63 | }, 64 | function(results, callback) { 65 | // We compile any preprocessed code that we need to like React components 66 | // and Stylus stylesheets 67 | console.log("Compiling static resources..."); 68 | static(function() { 69 | callback(null); 70 | }); 71 | } 72 | ], function(err, result) { 73 | // All static content is placed in the tmp ./tmp directory 74 | // we use this directory as the root of our server 75 | var app = express() 76 | .use(express.urlencoded()) 77 | .use(express.cookieParser(server_settings.cookie_secret || "subway_secret")) 78 | .use(express.static(cwd + "/tmp")); 79 | 80 | app.configure(function() { 81 | app.set("views", __dirname + "/tmp"); 82 | }); 83 | app.engine("ejs", require("ejs").renderFile); 84 | 85 | 86 | var http = require("http").Server(app); 87 | var io = require("socket.io")(http); 88 | 89 | // We can get the port of the server from the command line 90 | // or from the server settings 91 | http.listen(server_settings[env] ? server_settings[env].port : server_settings.dev.port); 92 | 93 | // We pass off our socket.io listener to the connection module 94 | // so it can handle incoming events and emit different actions 95 | connection(io, app); 96 | }); 97 | -------------------------------------------------------------------------------- /support/README.md: -------------------------------------------------------------------------------- 1 | Support scripts 2 | =============== 3 | 4 | [Init Script](init.d/subway) 5 | ---------------------------- 6 | 7 | This is a pretty basic init script to get subway running as a service. 8 | By default it places a log file at /var/log/subway.log and a pid file 9 | in /var/run/subway.pid . 10 | 11 | 12 | [Nginx config](nginx/subway) 13 | ---------------------------- 14 | 15 | This is a simple Nginx host configuration that proxies both the normal 16 | HTTP requests as well as Websocket connections to Subway's server. 17 | Simply edit the file where necessary and throw it in your 18 | /etc/nginx/sites-enabled (or what ever it's called) folder 19 | and reload/restart your nginx (typically "service nginx reload"). 20 | -------------------------------------------------------------------------------- /support/init.d/subway: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ### BEGIN INIT INFO 3 | # Provides: subway 4 | # Required-Start: $local_fs $remote_fs $network $time $named 5 | # Required-Stop: $local_fs $remote_fs $network $time $named 6 | # Default-Start: 3 5 7 | # Default-Stop: 0 1 2 6 8 | # Short-Description: Subway Web IRC 9 | # Description: A JS based web IRC 10 | ### END INIT INFO 11 | 12 | NAME="subway" 13 | SCRIPTNAME="/etc/init.d/$NAME" 14 | PREFIX="/home/znc/$NAME" 15 | USER="znc" 16 | PID="/var/run/subway.pid" 17 | LOG="/var/log/subway.log" 18 | 19 | if [ `id -u` -ne 0 ]; then 20 | echo "The $NAME init script can only be run as root" 21 | exit 1 22 | fi 23 | 24 | SCRIPT="$PREFIX/$NAME" 25 | 26 | do_start() 27 | { 28 | su - "$USER" -c "$SCRIPT" > "$LOG" 2>&1 & 29 | echo $! > "$PID" 30 | echo "Started: `cat "$PID"`" 31 | } 32 | 33 | do_stop() 34 | { 35 | if [ -e "$PID" ] 36 | then 37 | kill -INT `cat "$PID"` 38 | rm "$PID" 39 | echo "Stopped" 40 | else 41 | echo "Not running." 42 | fi 43 | } 44 | 45 | do_status() 46 | { 47 | if [ -e "$PID" ] 48 | then 49 | if kill -0 `cat "$PID"` 50 | then 51 | echo "Running: `cat $PID`" 52 | else 53 | echo "Crashed!" 54 | fi 55 | else 56 | echo "Not running." 57 | fi 58 | } 59 | 60 | do_restart() 61 | { 62 | do_stop 63 | do_start 64 | } 65 | 66 | case "$1" in 67 | start) 68 | do_start 69 | ;; 70 | stop) 71 | do_stop 72 | ;; 73 | status) 74 | do_status 75 | ;; 76 | restart|force-reload) 77 | do_restart 78 | ;; 79 | *) 80 | echo "Usage: $SCRIPTNAME {start|stop|status|restart|force-reload}" >&2 81 | exit 3 82 | ;; 83 | esac 84 | 85 | exit 0 86 | -------------------------------------------------------------------------------- /support/nginx/subway: -------------------------------------------------------------------------------- 1 | upstream subway { 2 | server localhost:3000; 3 | } 4 | 5 | map $http_upgrade $connection_upgrade { 6 | default upgrade; 7 | '' close; 8 | } 9 | 10 | server { 11 | listen 80; 12 | # for IPv6 13 | # listen [::]:80; 14 | server_name irc.example.org; 15 | 16 | access_log /path/to/subway/subway_access.log; 17 | error_log /path/to/subway/subway_errors.log warn; 18 | 19 | location /assets { 20 | root /path/to/subway; 21 | } 22 | 23 | location / { 24 | proxy_pass http://subway; 25 | proxy_redirect off; 26 | 27 | proxy_http_version 1.1; 28 | 29 | proxy_set_header Host $host; 30 | proxy_set_header X-Real-IP $remote_addr; 31 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 32 | proxy_set_header Upgrade $http_upgrade; 33 | proxy_set_header Connection $connection_upgrade; 34 | } 35 | } 36 | --------------------------------------------------------------------------------