├── client ├── error.html ├── index.html ├── presence.css ├── util.js └── client.js ├── README.md └── server.js /client/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | $title$ 6 | 7 |

$title$

8 |

$detail$

9 | 10 | 11 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | $chan$ 6 | 7 | 8 | 9 | 10 | 11 |
load another dayload another week
12 |
Loading...
13 |
▶▶
14 | 15 |
16 |
[×]
17 |
18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Presence 2 | 3 | ## A different way to read IRC 4 | 5 | Presence is an IRC logger and client that is optimized for my specific 6 | use of IRC — scan logs for a few specific channels, respond to direct 7 | messages and mentions, and only occasionally participate in 8 | discussion. It probably won't work for you if you're a heavy IRC user. 9 | 10 | Presence consists of a server process that logs a single IRC channel, 11 | and acts as a proxy for a client, which is implemented as a web page. 12 | 13 | The client provides the features of a minimal IRC client, minus any 14 | multi-channel functionality. It remembers the furthest it was ever 15 | scrolled down, and will pop up scrolled to that position when opened 16 | again (even from a different browser or device). 17 | 18 | At the bottom of the client's output is an input element through which 19 | a user can write in the channel, with supports a minimal set of 20 | commands: `/msg`, `/me`, `/whois`, and `/names`, and auto-completion 21 | for commands and user names with `ctrl-space`. 22 | 23 | The title of the tab will show the number of unread direct messages 24 | and mentions for your user. 25 | 26 | To run, start `serve.js` passing at least these three parameters: 27 | 28 | node serve.js irc.myserver.org myusername thechannel 29 | 30 | The web server port will default to 8080. You can pass a `--port` 31 | argument to use something else. Presence doesn't do any 32 | authentication, and allows anyone who accesses the page to write on 33 | your behalf, so you'll probably want to run it through a reverse-proxy 34 | which does some kind of authenticating. 35 | -------------------------------------------------------------------------------- /client/presence.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: ubuntu, sans-serif; 3 | margin: 0 2em 0 7em; 4 | max-width: 60em; 5 | } 6 | 7 | h1 { 8 | margin-top: 0; 9 | font-size: 20pt; 10 | } 11 | 12 | div#output > div { 13 | overflow: visible; 14 | padding: 0 .5em; 15 | white-space: pre-wrap; 16 | } 17 | 18 | div#output > div.priv { 19 | background: #bbb; 20 | } 21 | 22 | div.name { 23 | position: absolute; 24 | width: 6.5em; 25 | overflow: hidden; 26 | left: 0em; 27 | text-align: right; 28 | z-index: 5; 29 | } 30 | 31 | div.date { 32 | position: absolute; 33 | right: 3.5em; 34 | font-size: 70%; 35 | color: #888; 36 | margin-top: -1.7em; 37 | } 38 | 39 | span.mention { 40 | color: #34c2c9; 41 | font-weight: bold; 42 | } 43 | 44 | textarea#input { 45 | border: none; 46 | border-left: 2px solid silver; 47 | outline: none; 48 | width: 100%; 49 | font-family: inherit; 50 | font-size: inherit; 51 | padding: 0 .5em; 52 | margin-top: 3px; 53 | } 54 | 55 | div#loadmore { 56 | color: #888; 57 | padding: 0 .5em; 58 | text-align: right; 59 | } 60 | div#loadmore > span { 61 | cursor: pointer; 62 | } 63 | 64 | .loading { 65 | background: #eee; 66 | } 67 | 68 | div#statuswrap { 69 | overflow: hidden; 70 | height: 0px; 71 | -moz-transition: height .5s; 72 | -webkit-transition: height .5s; 73 | -ms-transition: height .5s; 74 | -o-transition: height .5s; 75 | transition: height .5s; 76 | position: relative; 77 | border-left: 2px solid transparent; 78 | padding: 0 .5em; 79 | } 80 | 81 | div#statusclose { 82 | position: absolute; 83 | right: 3px; 84 | top: 3px; 85 | font-family: monospace; 86 | font-weight: bold; 87 | cursor: pointer; 88 | } 89 | 90 | div#status { 91 | color: #666; 92 | } 93 | 94 | div.names, div.completions { 95 | -webkit-column-width: 15em; 96 | -moz-column-width: 15em; 97 | -ie-column-width: 15em; 98 | column-width: 15em; 99 | } 100 | 101 | div.names > div { 102 | cursor: pointer; 103 | color: #44c; 104 | } 105 | -------------------------------------------------------------------------------- /client/util.js: -------------------------------------------------------------------------------- 1 | var Util = { 2 | // Events 3 | // Standardize a few unportable event properties. 4 | normalizeEvent: function normalizeEvent(event) { 5 | if (!event.stopPropagation) { 6 | event.stopPropagation = function() {this.cancelBubble = true;}; 7 | event.preventDefault = function() {this.returnValue = false;}; 8 | } 9 | if (!event.stop) { 10 | event.stop = function() { 11 | this.stopPropagation(); 12 | this.preventDefault(); 13 | }; 14 | } 15 | if (event.pageX == undefined && event.clientX) { 16 | event.pageX = event.clientX + (document.scrollLeft || document.body.scrollLeft); 17 | event.pageY = event.clientY + (document.scrollTop || document.body.scrollTop); 18 | } 19 | 20 | if (!event.target && event.srcElement) 21 | event.target = event.srcElement; 22 | 23 | if (event.type == "keypress") { 24 | if (event.charCode === 0 || event.charCode == undefined) 25 | event.code = event.keyCode; 26 | else 27 | event.code = event.charCode; 28 | event.character = String.fromCharCode(event.code); 29 | } 30 | return event; 31 | }, 32 | 33 | // Portably register event handlers. 34 | connect: function connect(node, type, handler) { 35 | function wrapHandler(event) { 36 | handler(Util.normalizeEvent(event || window.event)); 37 | } 38 | if (typeof node.addEventListener == "function") { 39 | node.addEventListener(type, wrapHandler, false); 40 | return function() { node.removeEventListener(type, wrapHandler, false); }; 41 | } 42 | else { 43 | node.attachEvent("on" + type, wrapHandler); 44 | return function() { node.detachEvent("on" + type, wrapHandler); }; 45 | } 46 | }, 47 | disconnect: function disconnect(handler) { 48 | handler(); 49 | }, 50 | 51 | // Collections 52 | forEach: function forEach(collection, action) { 53 | var l = collection.length; 54 | for (var i = 0; i < l; ++i) 55 | action(collection[i], i); 56 | }, 57 | map: function map(func, collection) { 58 | var l = collection.length, result = []; 59 | for (var i = 0; i < l; ++i) 60 | result.push(func(collection[i])); 61 | return result; 62 | }, 63 | filter: function filter(pred, collection) { 64 | var result = [], l = collection.length; 65 | for (var i = 0; i < l; ++i) { 66 | var cur = collection[i]; 67 | if (pred(cur)) result.push(cur); 68 | } 69 | return result; 70 | }, 71 | forEachIn: function forEachIn(object, action) { 72 | for (var property in object) { 73 | if (Object.prototype.hasOwnProperty.call(object, property)) 74 | action(property, object[property]); 75 | } 76 | }, 77 | 78 | // Objects 79 | fill: function fill(dest, source) { 80 | Util.forEachIn(source, function(name, val) {dest[name] = val;}); 81 | }, 82 | 83 | // XHR 84 | makeXHR: (function() { 85 | var tries = [function() {return new XMLHttpRequest();}, 86 | function() {return new ActiveXObject('Msxml2.XMLHTTP');}, 87 | function() {return new ActiveXObject('Microsoft.XMLHTTP');}]; 88 | var make = function() {throw new Error("XMLHttpRequest not supported by browser.");}; 89 | for (var i = 0; i < tries.length; ++i) { 90 | try { 91 | tries[i](); 92 | make = tries[i]; 93 | break; 94 | } 95 | catch (e) {} 96 | } 97 | return make; 98 | })(), 99 | httpRequest: function httpRequest(url, args, success, failure) { 100 | var xhr = Util.makeXHR(); 101 | args = args || {}; 102 | if (args.query) { 103 | var query = Util.queryString(args.query); 104 | if (query.length) url += "?" + query; 105 | } 106 | 107 | xhr.open(args.method || "GET", url, true); 108 | if (args.accept) xhr.setRequestHeader("Accept", args.accept); 109 | if (args.contentType) xhr.setRequestHeader("Content-Type", args.contentType); 110 | if (args.headers) { 111 | Util.forEachIn(args.headers, function(name, val) { 112 | xhr.setRequestHeader(name, val); 113 | }); 114 | } 115 | xhr.onreadystatechange = function() { 116 | if (xhr.readyState == 4) { 117 | var ok = null; 118 | try {ok = (xhr.status == 200 || xhr.status == 204);} 119 | catch(e) {failure((e && typeof(e) == "object" && e.message) || String(e), xhr);} 120 | 121 | if (ok == true) { 122 | success(xhr.responseText); 123 | } 124 | else if (ok == false) { 125 | var text = "No response"; 126 | try {text = xhr.responseText;} catch(e){} 127 | if (/ \n" + 10 | " [--port ]\n" + 11 | " [--realname ]\n" + 12 | " [--password ]\n" + 13 | " [--outputdir ]"); 14 | process.exit(1); 15 | } 16 | 17 | if (process.argv.length < 5) help(); 18 | 19 | var server = process.argv[2]; 20 | var nick = process.argv[3]; 21 | var channel = "#" + process.argv[4]; 22 | var port = 8080; 23 | var realName = "Presence bot"; 24 | var outputDir = "./"; 25 | var password = null; 26 | 27 | var debug = true; 28 | var timeWidth = 10; 29 | 30 | for (var i = 5; i < process.argv.length; ++i) { 31 | var arg = process.argv[i], more = i + 1 < process.argv.length; 32 | if (arg == "--port" && more) { 33 | port = Number(process.argv[++i]); 34 | } else if (arg == "--realname" && more) { 35 | realName = process.argv[++i]; 36 | } else if (arg == "--password" && more) { 37 | password = process.argv[++i]; 38 | } else if (arg == "--outputdir" && more) { 39 | outputDir = process.argv[++i]; 40 | if (outputDir.charAt(outputDir.length - 1) != "/") outputDir += "/"; 41 | } else help(); 42 | } 43 | 44 | var logFile = outputDir + "log_" + channel.slice(1) + ".txt", 45 | bookmarkFile = outputDir + "bookmark_" + channel.slice(1) + ".txt"; 46 | 47 | var output = fs.createWriteStream(logFile, {flags: "a"}); 48 | 49 | var ircClient, ircClientOK = false; 50 | function openIRC(backoff) { 51 | backoff = backoff || 1; 52 | console.log("Connecting to " + server); 53 | var client = ircClient = new irc.Client(server, nick, { 54 | realName: realName, 55 | channels: [channel] 56 | }); 57 | 58 | client.addListener("registered", function(message) { 59 | backoff = 1; 60 | console.log("Connected to " + server + (message ? ": " + message : "")); 61 | ircClientOK = true; 62 | if (password != null) 63 | ircClient.send("PRIVMSG", "NickServ", "IDENTIFY " + password); 64 | }); 65 | client.addListener("pm", function(from, text) { 66 | logLine(">", from + ": " + text); 67 | }); 68 | client.addListener("message" + channel, function(from, text) { 69 | logLine("_", from + ": " + text); 70 | }); 71 | client.addListener("error", function(message) { 72 | if (message && message.command == "err_nosuchnick") { 73 | notifyWaiting("whois " + message.args[1], ""); 74 | return; 75 | } 76 | ircClientOK = false; 77 | console.log("Error from " + server + (message ? ": " + message.command : "")); 78 | try { client.disconnect(); } catch(e) {} 79 | setTimeout(openIRC.bind(null, Math.max(30, backoff) * 2), backoff) 80 | }); 81 | client.addListener("names", function(channel, nicks) { 82 | notifyWaiting("names", Object.keys(nicks).join(" ")); 83 | }); 84 | client.addListener("join" + channel, function(nick) { 85 | logLine("+", nick + ": joined"); 86 | }); 87 | client.addListener("part" + channel, function(nick) { 88 | logLine("-", nick + ": parted"); 89 | }); 90 | client.addListener("quit", function(nick, reason, channels) { 91 | if (channels.indexOf(channel) > -1) logLine("-", nick + ": " + reason); 92 | }); 93 | client.addListener("kick" + channel, function(nick, by, reason) { 94 | logLine("-", nick + ": " + reason); 95 | }); 96 | client.addListener("kill", function(nick, reason, channels) { 97 | if (channels.indexOf(channel) > -1) logLine("-", nick + ": " + reason); 98 | }); 99 | client.addListener("notice", function(nick, to, text) { 100 | logLine("n", (nick || "") + ": " + text); 101 | }); 102 | client.addListener("nick", function(oldnick, newnick) { 103 | logLine("x", oldnick + ": " + newnick); 104 | }); 105 | client.addListener("whois", function(info) { 106 | if (info) notifyWaiting("whois " + info.nick, JSON.stringify(info)); 107 | }); 108 | } 109 | openIRC(); 110 | 111 | function time() { 112 | return Math.floor((new Date).getTime() / 1000); 113 | } 114 | function timeAt(str, pos) { 115 | return Number(str.slice(pos, pos + timeWidth)); 116 | } 117 | 118 | var recentActivity = [], recentActivityStart = time(); 119 | var maxActivityLen = 200; 120 | 121 | var bookmark = 0, savingBookmark = false; 122 | fs.readFile(bookmarkFile, function(err, bm) { if (!err) bookmark = Number(bm); }); 123 | function setBookmark(val) { 124 | bookmark = val; 125 | if (!savingBookmark) { 126 | savingBookmark = true; 127 | setTimeout(function() { 128 | savingBookmark = false; 129 | fs.writeFile(bookmarkFile, String(bookmark)); 130 | }, 5000); 131 | } 132 | } 133 | 134 | function logLine(tag, str) { 135 | var line = time() + " " + tag + " " + str + "\n"; 136 | output.write(line); 137 | recentActivity.push(line); 138 | notifyWaiting("history", line); 139 | if (recentActivity.length > maxActivityLen) { 140 | recentActivity.splice(0, maxActivityLen >> 1); 141 | recentActivityStart = timeAt(recentActivity[0], 0); 142 | } 143 | } 144 | 145 | // Do a binary search through the log files to lift out the part that 146 | // we're interested in. This gets a bit clunky with asynchronous I/O. 147 | function getHistoryOnDisk(from, to, c) { 148 | var fd = fs.openSync(logFile, "r"), len = fs.fstatSync(fd).size; 149 | var buf = new Buffer(256); 150 | 151 | function findLine(at, c) { 152 | if (at == len) return c(at); 153 | var bytes = Math.min(256, len - at); 154 | fs.read(fd, buf, 0, bytes, at, function() { 155 | var str = buf.toString("utf8", 0, bytes); 156 | var lineStart = str.indexOf("\n"); 157 | if (lineStart == -1) findLine(at + 256, c); 158 | else if (lineStart > 255 - timeWidth) findLine(at + lineStart, c); 159 | else c(at + lineStart, timeAt(str, lineStart + 1)); 160 | }); 161 | } 162 | 163 | function findPos(time, startAt, c) { 164 | var lo = startAt, hi = len; 165 | (function step() { 166 | if (hi - lo < 256) 167 | fs.read(fd, buf, 0, hi - lo, lo, function() { 168 | var str = buf.toString("utf8", 0, hi - lo), i = 0; 169 | for (;;) { 170 | var time_here = timeAt(str, i); 171 | if (time_here >= time) break; 172 | last = i; 173 | var next = str.indexOf("\n", i + 1); 174 | if (next == -1) break; 175 | i = next + 1; 176 | } 177 | c(lo + i); 178 | }); 179 | else findLine((lo + hi) >> 1, function(pos, time_at_pos) { 180 | if (time_at_pos < time) lo = pos; 181 | else if (pos == hi) hi = ((lo + hi) >> 1) - 1; 182 | else hi = pos; 183 | step(); 184 | }); 185 | }()); 186 | } 187 | 188 | function getRange(start, end) { 189 | if (start == end) { fs.closeSync(fd); return c(""); } 190 | buf = new Buffer(end - start); 191 | fs.read(fd, buf, 0, end - start, start, function() { 192 | fs.closeSync(fd); 193 | c(buf.toString()); 194 | }); 195 | } 196 | 197 | findPos(from, 0, function(start) { 198 | if (!to) getRange(start, len); 199 | else findPos(to, start, function(end) { 200 | getRange(start, end); 201 | }); 202 | }); 203 | } 204 | 205 | function getHistoryCached(from, to) { 206 | var result = "", start = 0, end = recentActivity.length; 207 | for (var i = end - 1; i >= 0; --i) { 208 | var t = timeAt(recentActivity[i], 0); 209 | if (to && t >= to) end = i - 1; 210 | if (t < from) { start = i + 1; break; } 211 | } 212 | for (var i = start; i < end; ++i) 213 | result += recentActivity[i]; 214 | return result; 215 | } 216 | 217 | function getHistory(from, to, c) { 218 | if (from > recentActivityStart) c(getHistoryCached(from, to)); 219 | else getHistoryOnDisk(from, to, c); 220 | } 221 | 222 | // HTTP server 223 | 224 | // Requests waiting for data 225 | var waiting = []; 226 | 227 | function addWaiting(type, resp) { 228 | waiting.push({since: (new Date).getTime(), type: type, resp: resp}); 229 | } 230 | function notifyWaiting(type, value) { 231 | for (var i = 0; i < waiting.length; ++i) { 232 | if (waiting[i].type == type) { 233 | sendText(waiting[i].resp, value); 234 | waiting.splice(i--, 1); 235 | } 236 | } 237 | } 238 | 239 | setInterval(function() { 240 | var cutOff = (new Date).getTime() - 40000; 241 | for (var i = 0; i < waiting.length; ++i) { 242 | if (waiting[i].since < cutOff) { 243 | sendText(waiting[i].resp, ""); 244 | waiting.splice(i--, 1); 245 | } 246 | } 247 | }, 10000); 248 | 249 | function getData(obj, c) { 250 | var received = []; 251 | obj.setEncoding("utf8"); 252 | obj.addListener("data", function(chunk) {received.push(chunk);}); 253 | obj.addListener("end", function() {c(received.join(""));}); 254 | } 255 | 256 | var clientFile = {}, mimes = {"html": "text/html", "js": "application/javascript", "css": "text/css"}; 257 | fs.readdirSync(myPath + "client").forEach(function(file) { 258 | clientFile[file] = {mime: mimes[file.split(".").pop()] || "text/plain", 259 | data: fs.readFileSync(myPath + "client/" + file, "utf8")}; 260 | }); 261 | 262 | function htmlEsc(text) { 263 | var HTMLspecial = {"<": "<", "&": "&", "\"": """}; 264 | return String(text).replace(/[<&\"]/g, function(ch) {return HTMLspecial[ch];}); 265 | } 266 | 267 | function instantiate(file, values) { 268 | var str = clientFile[file].data; 269 | for (key in values) 270 | str = str.replace(new RegExp("\\$" + key + "\\$", "g"), htmlEsc(values[key])); 271 | return str; 272 | } 273 | 274 | function err(resp, code, title, detail) { 275 | resp.writeHead(code, {"Content-Type": "text/html"}); 276 | resp.write(instantiate("error.html", {title: title, detail: detail})); 277 | resp.end(); 278 | } 279 | 280 | function sendText(resp, text) { 281 | resp.writeHead(200, {"Content-Type": "text/plain"}); 282 | resp.write(text); 283 | resp.end(); 284 | } 285 | 286 | http.createServer(function(req, resp) { 287 | var u = url.parse(req.url, true), m; 288 | var path = u.pathname.slice(1); 289 | if (req.method == "GET" && path == "") { 290 | resp.writeHead(200, {"Content-Type": "text/html"}); 291 | resp.write(instantiate("index.html", {nick: nick, chan: channel})); 292 | resp.end(); 293 | } else if (req.method == "POST" && (m = path.match(/^send\/([^\/]+)(?:\/(.*))?$/))) { 294 | if (!ircClientOK) return err(resp, 500, "No IRC connection"); 295 | var command = decodeURIComponent(m[1]); 296 | var args = m[2] ? m[2].split("/").map(decodeURIComponent) : []; 297 | getData(req, function(body) { 298 | body = body.replace(/[\n\r]/g, ""); 299 | if (command == "PRIVMSG") { 300 | if (args[0] == channel) 301 | logLine("_", nick + ": " + body); 302 | else 303 | logLine("<", args[0] + ": " + body); 304 | } 305 | args.unshift(command); 306 | args.push(body); 307 | ircClient.send.apply(ircClient, args); 308 | resp.writeHead(204, {}); 309 | resp.end(); 310 | }); 311 | } else if (req.method == "GET" && path == "names") { 312 | if (!ircClientOK) return err(resp, 500, "No IRC connection"); 313 | ircClient.send("NAMES", channel); 314 | addWaiting("names", resp); 315 | } else if (req.method == "GET" && (m = path.match(/^whois\/(.*)$/))) { 316 | if (!ircClientOK) return err(resp, 500, "No IRC connection"); 317 | var name = decodeURIComponent(m[1]); 318 | ircClient.send("WHOIS", m[1]); 319 | addWaiting("whois " + name, resp); 320 | } else if (req.method == "GET" && path == "history") { 321 | var from = Number(u.query.from), to = u.query.to ? Number(u.query.to) : null; 322 | if (!from || isNaN(from) || isNaN(to)) { 323 | err(resp, 400, "Missing parameter", "The 'from' and 'to' parameter must be provided and hold numeric values"); 324 | return; 325 | } 326 | getHistory(from, to, function(history) { 327 | if (u.query.skip && !isNaN(Number(u.query.skip))) { 328 | var pos = 0; 329 | for (var i = Number(u.query.skip); i > 0; --i) { 330 | var nl = history.indexOf("\n", pos); 331 | if (nl == -1) { pos = history.length; break; } 332 | pos = nl + 1; 333 | } 334 | history = history.slice(pos); 335 | } 336 | if (history || to) sendText(resp, history); 337 | else addWaiting("history", resp); 338 | }); 339 | } else if (req.method == "GET" && path == "bookmark") { 340 | sendText(resp, String(bookmark)); 341 | } else if (req.method == "PUT" && path == "bookmark") { 342 | getData(req, function(body) { 343 | var val = Number(body); 344 | if (!val || isNaN(val)) return err(resp, 400, "Not a valid bookmark"); 345 | if (val > bookmark || u.query.hasOwnProperty("force")) setBookmark(val); 346 | resp.writeHead(204, {}); 347 | resp.end(); 348 | }); 349 | } else if (req.method == "GET" && clientFile.hasOwnProperty(path)) { 350 | var info = clientFile[path]; 351 | resp.writeHead(200, {"Content-Type": info.mime}); 352 | resp.write(debug ? fs.readFileSync(myPath + "client/" + path) : info.data); 353 | resp.end(); 354 | } else { 355 | err(resp, 404, "Not found", u.pathname + " does not support " + req.method + " requests"); 356 | } 357 | }).listen(port, "localhost"); 358 | -------------------------------------------------------------------------------- /client/client.js: -------------------------------------------------------------------------------- 1 | var input, output; 2 | 3 | // Initialization 4 | window.onload = function() { 5 | input = document.getElementById("input"); 6 | output = document.getElementById("output"); 7 | 8 | connect(input, "keydown", function(e) { 9 | if (closeStatusOnInput) setStatus(""); 10 | if (curState.unread.length) { curState.unread = []; updateTitle(); } 11 | if (e.keyCode == 13 && !e.shiftKey) { 12 | var val = input.value; 13 | if (!val) return; 14 | input.value = ""; 15 | var cmd = val.match(/^\/(\w+)\b\s*(.*)$/); 16 | if (cmd && commands.hasOwnProperty(cmd[1])) { 17 | commands[cmd[1]](cmd[2]); 18 | } else { 19 | forEach(val.split(/\r?\n/g), function(line) { 20 | sendCommand("PRIVMSG", [channel], line); 21 | }); 22 | } 23 | e.preventDefault(); 24 | } else if (e.keyCode == 32 && e.ctrlKey) { 25 | e.preventDefault(); 26 | var val = input.value; 27 | if (!val && curState.lastDirect) { 28 | var str = "/msg " + curState.lastDirect + " "; 29 | input.value = str; 30 | input.setSelectionRange(str.length, str.length); 31 | } else { 32 | var cur = input.selectionStart, start = cur; 33 | while (start && /\w/.test(val.charAt(start - 1))) --start; 34 | var completions = [], frag = val.slice(start, cur); 35 | if (start && val.charAt(start - 1) == "/") { 36 | --start; 37 | forEachIn(commands, function(key) { 38 | if (key.slice(0, frag.length) == frag) completions.push("/" + key); 39 | }); 40 | } else { 41 | var appendCol = start ? "" : ":"; 42 | forEachIn(curState.names, function(key) { 43 | if (curState.names[key] && key.slice(0, frag.length) == frag) 44 | completions.push(key + appendCol); 45 | }); 46 | } 47 | if (completions.length == 1) { 48 | complete(start, completions[0]); 49 | } else if (completions.length > 1) { 50 | var html = "
"; 51 | forEach(completions, function(c) { html += "
" + htmlEsc(c) + "
"; }) 52 | setStatus(html + "
", true); 53 | } 54 | } 55 | } 56 | }); 57 | connect(document.getElementById("statusclose"), "click", function(e) {setStatus("");}); 58 | connect(document.body, "click", function(e) { 59 | var pclass = e.target.parentNode && e.target.parentNode.className; 60 | if (pclass == "names") whoIs(e.target.innerText); 61 | else if (e.target.className == "name") whoIs(e.target.innerText); 62 | else if (pclass == "completions") 63 | complete(Number(e.target.parentNode.getAttribute("data-start")), e.target.innerText); 64 | }); 65 | connect(window, "focus", function() { winFocused = true; }) 66 | connect(window, "blur", function() { winFocused = false; }) 67 | connect(document.getElementById("loadday"), "click", function(){loadMore(1);}); 68 | connect(document.getElementById("loadweek"), "click", function(){loadMore(7);}); 69 | connect(document.body, "mouseover", function(e) { 70 | if (e.target.parentNode == output) { 71 | var n = e.target.appendChild(document.createElement("div")); 72 | n.className = "date"; 73 | n.innerHTML = renderTime(timeFor(e.target.logLine)); 74 | } 75 | }); 76 | connect(document.body, "mouseout", function(e) { 77 | if (e.target.parentNode == output && e.target.lastChild.className == "date") 78 | e.target.removeChild(e.target.lastChild); 79 | }); 80 | connect(window, "scroll", scrolled); 81 | 82 | fetchData(); 83 | }; 84 | 85 | function complete(start, text) { 86 | var end = input.selectionStart, val = input.value; 87 | input.value = val.slice(0, start) + text + " " + val.slice(end); 88 | var cur = start + text.length + 1; 89 | input.setSelectionRange(cur, cur); 90 | } 91 | 92 | var commands = { 93 | "msg": function(line) { 94 | var m = line.match(/^\s*(\S+)\s+(.+)$/); 95 | if (m) sendCommand("PRIVMSG", [m[1]], m[2]); 96 | }, 97 | "me": function(line) { 98 | sendCommand("PRIVMSG", [channel], "\01ACTION " + line + "\01"); 99 | }, 100 | "whois": whoIs, 101 | "names": function() { 102 | var html = "
"; 103 | forEachIn(curState.names, function(name, present) { 104 | if (present) html += "
" + htmlEsc(name) + "
"; 105 | }); 106 | setStatus(html + "
"); 107 | } 108 | }; 109 | var winFocused = true; 110 | 111 | function whoIs(name) { 112 | startSend(); 113 | getWhoIs(name.match(/^\s*(.*?)\s*$/)[1], function(info) { 114 | var nm = htmlEsc(name); 115 | stopSend(); 116 | if (info == "") return setStatus("No such nick: " + nm + ""); 117 | info = JSON.parse(info); 118 | var html = ""; 119 | if (info.realname) html += nm + " is " + htmlEsc(info.realname) + "
"; 120 | if (info.channels && info.channels.length) 121 | html += nm + " is on channels " + htmlEsc(info.channels.map(function (s) { 122 | return s.replace(/@/g, ""); }).join(", ")) + "
"; 123 | if (info.host) html += "host: " + htmlEsc(info.host) + "
"; 124 | if (info.server) html += "server: " + htmlEsc(info.server) + "
"; 125 | setStatus(html); 126 | }, function(msg) { 127 | stopSend(); 128 | setStatus("Failed to get whois info: " + htmlEsc(msg) + ""); 129 | }); 130 | } 131 | 132 | function timeFor(str) { 133 | return Number(str.slice(0, 10)); 134 | } 135 | function renderTime(time) { 136 | var d = new Date(time * 1000); 137 | return d.getFullYear() + "/" + (d.getMonth() + 1) + "/" + d.getDate() + " " + 138 | d.getHours() + ":" + (d.getMinutes() < 10 ? "0" : "") + d.getMinutes(); 139 | } 140 | 141 | var sendDepth = 0; 142 | function startSend() { 143 | if (!sendDepth) input.className = "loading"; 144 | sendDepth++; 145 | } 146 | function stopSend() { 147 | sendDepth--; 148 | if (!sendDepth) input.className = ""; 149 | } 150 | 151 | // API wrappers 152 | 153 | function sendCommand(cmd, args, body, backOff) { 154 | var url = document.location.href + "send/" + encodeURIComponent(cmd); 155 | for (var i = 0; i < args.length; ++i) 156 | url += "/" + encodeURIComponent(args[i]); 157 | startSend(); 158 | httpRequest(url, {body: body, method: "POST"}, stopSend, function(msg) { 159 | console.log("Sending failed: " + msg); 160 | var time = Math.min((backOff || 2) * 2, 30); 161 | setTimeout(function() {sendCommand(cmd, args, time);}, time * 1000); 162 | }); 163 | } 164 | 165 | function getHistory(from, to, skip, c, err) { 166 | httpRequest(document.location.href + "history?from=" + from + 167 | (to ? "&to=" + to : "") + (skip ? "&skip=" + skip : ""), 168 | {}, c, err); 169 | } 170 | 171 | function getNames(c, err) { 172 | httpRequest(document.location.href + "names", {}, c, err); 173 | } 174 | 175 | function getWhoIs(name, c, err) { 176 | httpRequest(document.location.href + "whois/" + encodeURIComponent(name), {}, c, err); 177 | } 178 | 179 | function getBookmark(c, err) { 180 | httpRequest(document.location.href + "bookmark", {}, c, err); 181 | } 182 | function setBookmark(val, force, c, err) { 183 | httpRequest(document.location.href + "bookmark" + (force ? "?force" : ""), 184 | {method: "PUT", body: String(val)}, c, err); 185 | } 186 | 187 | var knownHistory = [], knownUpto, knownFrom; 188 | 189 | function fetchData() { 190 | var yesterday = Math.floor((new Date).getTime() / 1000) - 3600 * 24; 191 | function failed(msg) { 192 | document.body.innerHTML = "Failed to connect to Presence server (" + msg + ")"; 193 | } 194 | getBookmark(function(bookmark) { 195 | var btime = Number(bookmark), from = Math.min(yesterday, btime); 196 | getHistory(from || 1, null, null, function(history) { 197 | knownHistory = history.split("\n"); 198 | knownHistory.pop(); 199 | if (knownHistory.length) 200 | knownUpto = timeFor(knownHistory[knownHistory.length - 1]); 201 | else knownUpto = from; 202 | knownFrom = from; 203 | repaint(); 204 | 205 | if (output.firstChild) 206 | for (var cur = output.firstChild; cur; cur = cur.nextSibling) 207 | if (timeFor(cur.logLine) >= btime) break; 208 | if (output.lastChild) window.scrollTo(0, maxScroll = (cur || output.lastChild).offsetTop - 10); 209 | 210 | getNames(function(names) { 211 | curState.names = {}; 212 | forEach(names.split(" "), function(name) {curState.names[name] = true;}); 213 | poll(); 214 | }, function() {poll();}); 215 | }, failed); 216 | }, failed); 217 | } 218 | 219 | function loadMore(days) { 220 | var elt = document.getElementById("loadmore"); 221 | elt.className = "loading"; 222 | var from = knownFrom - 3600 * 24 * days; 223 | getHistory(from, knownFrom, null, function(history) { 224 | elt.className = ""; 225 | var lines = history.split("\n"); 226 | lines.pop(); 227 | var tempState = {prevName: null, names: {}}, nodes = []; 228 | for (var i = 0, e = lines.length; i < e; ++i) { 229 | var node = processLine(tempState, lines[i]); 230 | if (node) nodes.push(node); 231 | } 232 | var height = bodyHeight(); 233 | while (nodes.length) output.insertBefore(nodes.pop(), output.firstChild); 234 | window.scrollBy(0, bodyHeight() - height); 235 | lines.unshift(0); 236 | lines.unshift(0); 237 | knownHistory.splice.apply(knownHistory, lines); 238 | knownFrom = from; 239 | }, function() { elt.className = ""; }); 240 | } 241 | 242 | var closeStatusOnInput = false; 243 | function setStatus(html, closeOnInput) { 244 | var atBottom = isScrolledToBottom(), status = document.getElementById("status"); 245 | closeStatusOnInput = closeOnInput; 246 | status.innerHTML = html; 247 | document.getElementById("statuswrap").style.height = status.offsetHeight + "px"; 248 | if (atBottom && html) var tick = 0, scroll = setInterval(function() { 249 | window.scrollTo(0, document.body.scrollHeight); 250 | if (++tick == 11) clearInterval(scroll); 251 | }, 100); 252 | } 253 | 254 | function buildColor(hue, sat, light) { 255 | function hex(off) { 256 | var col = Math.cos((hue + off) * 2 * Math.PI) / 2 + .5; 257 | var t = ((.5 * (1 - sat)) + (col * sat)) * light; 258 | var s = Math.floor(Math.min(t, 1) * 255).toString(16); 259 | if (s.length == 1) return "0" + s; 260 | return s; 261 | } 262 | return "#" + hex(0) + hex(.33) + hex(.67); 263 | } 264 | 265 | var colors = {}, selfColor = "#34c2c9"; 266 | function getColor(name) { 267 | if (name == nick) return selfColor; 268 | while (name && !/[a-z]/.test(name.charAt(0))) name = name.slice(1); 269 | while (name && !/[a-z]/.test(name.charAt(name.length - 1))) name = name.slice(0, name.length - 1); 270 | var cached = colors[name]; 271 | if (cached) return cached; 272 | 273 | // Crude string hash 274 | var h = 2984119; 275 | for (var i = 0, e = name.length; i < e; ++i) 276 | h = (h * 761) ^ name.charCodeAt(i); 277 | h = Math.abs(h); 278 | 279 | // Crude hash -> pretty color trick 280 | var hue = (h % 100) / 100; 281 | var sat = .5 + ((h >> 3) % 100) / 200; 282 | var light = .8 + (h % 15 - 5) / 10; 283 | var col = buildColor(hue, sat, light); 284 | colors[name] = col; 285 | return col; 286 | } 287 | 288 | var scratchDiv = document.createElement("div"); 289 | function htmlEsc(s) { 290 | scratchDiv.textContent = s; 291 | return scratchDiv.innerHTML; 292 | } 293 | 294 | var curState = {prevName: null, names: {}, unread: []}; 295 | 296 | function processLine(state, line) { 297 | var type = line.charAt(11), col = line.indexOf(":", 13); 298 | var name = line.slice(13, col), msg = line.slice(col + 2); 299 | 300 | function buildOutput(from, priv, direct, msg) { 301 | var newName = state.prevName != from; 302 | var html = "
"; 304 | if (newName) { 305 | state.prevName = from; 306 | html += "
" + htmlEsc(from) + "
"; 307 | } 308 | var act = msg.match(/^\01ACTION (.*)\01$/); 309 | var msgHTML = act ? "" + htmlEsc(act[1]) + "" : htmlEsc(msg); 310 | msgHTML = msgHTML.replace(new RegExp("\\b" + nick + "\\b", "gi"), function(match) { 311 | direct = true; 312 | return "" + match + ""; 313 | }); 314 | msgHTML = msgHTML.replace(/\b((?:[a-z][\w-]+:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}\.|[a-z0-9.\-]+\.[a-z]{2,4}\/)(?:[^\s()<>]+)+[^\s`!()\[\]{};:'".,<>?])\b/g, function(url) { 315 | return "" + url + ""; 316 | }); 317 | html += msgHTML + "
"; 318 | scratchDiv.innerHTML = html; 319 | var node = scratchDiv.firstChild; 320 | node.logLine = line; 321 | if (direct && state.unread) { 322 | state.unread.push(node); 323 | if (state == curState) updateTitle(); 324 | } 325 | return node; 326 | } 327 | 328 | if (type == "_" || type == ">") { 329 | if (type == ">") curState.lastDirect = name; 330 | return buildOutput(name, type == ">", type == ">", msg); 331 | } else if (type == "<") { 332 | return buildOutput("⇝" + name, true, false, msg); 333 | } else if (type == "+") { 334 | state.names[name] = true; 335 | } else if (type == "-") { 336 | state.names[name] = false; 337 | } else if (type == "x") { 338 | state.names[name] = false; 339 | state.names[msg] = true; 340 | } 341 | } 342 | 343 | function repaint() { 344 | output.innerHTML = ""; 345 | for (var i = 0, e = knownHistory.length; i < e; ++i) { 346 | var node = processLine(curState, knownHistory[i]); 347 | if (node) output.appendChild(node); 348 | } 349 | } 350 | 351 | function updateTitle() { 352 | var msgs = curState.unread.length; 353 | document.title = channel + (msgs ? " (" + msgs + ")" : ""); 354 | } 355 | 356 | function scrollTop() { 357 | return window.pageYOffset || document.body.scrollTop || document.documentElement.scrollTop; 358 | } 359 | function winHeight() { 360 | return window.innerHeight || document.documentElement.clientHeight; 361 | } 362 | function bodyHeight() { 363 | return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight); 364 | } 365 | function isScrolledToBottom() { 366 | return scrollTop() + winHeight() >= bodyHeight() - 2; 367 | } 368 | 369 | function timeAtScrollPos(at) { 370 | var lo = 0, hi = output.childNodes.length; 371 | var node = output.firstChild, pos = at + 12; 372 | if (!node) return 0; 373 | if (pos >= output.offsetTop + output.offsetHeight) { 374 | node = output.lastChild; 375 | } else if (pos > output.offsetTop) { 376 | while (true) { 377 | var mid = (lo + hi) >> 1; 378 | node = output.childNodes[mid]; 379 | var top = node.offsetTop, bot = top + node.offsetHeight; 380 | if (top > pos) hi = mid; 381 | else if (bot >= pos || lo == mid) break; 382 | else lo = mid; 383 | } 384 | } 385 | return timeFor(node.logLine); 386 | } 387 | var maxScroll = 0, sendingScroll = false; 388 | function scrolled() { 389 | var scroll = scrollTop(); 390 | // Clear seen messages from unread list 391 | var endVis = scroll + winHeight() - 12; 392 | while (curState.unread.length) { 393 | var msg = curState.unread[0]; 394 | if (msg.offsetTop + msg.offsetHeight < endVis) { 395 | curState.unread.shift(); 396 | updateTitle(); 397 | } else break; 398 | } 399 | if (scroll > maxScroll + 50) { 400 | maxScroll = scroll; 401 | if (!sendingScroll) { 402 | sendingScroll = true; 403 | setTimeout(function send() { 404 | var time = timeAtScrollPos(maxScroll); 405 | setBookmark(time, false, function() {sendingScroll = false;}, function() { 406 | setTimeout(send, 10000); 407 | }); 408 | }, 1000); 409 | } 410 | } 411 | } 412 | 413 | function addLines(lines) { 414 | var atBottom = isScrolledToBottom(); 415 | if (!lines) return; 416 | lines = lines.split("\n"); 417 | lines.pop(); 418 | knownUpto = timeFor(lines[lines.length - 1]); 419 | for (var i = 0; i < lines.length; ++i) { 420 | var node = processLine(curState, lines[i]); 421 | if (node) output.appendChild(node); 422 | knownHistory.push(lines[i]); 423 | } 424 | if (atBottom && winFocused) window.scrollTo(0, document.body.scrollHeight); 425 | } 426 | 427 | var pollGeneration = 0, lastPoll; 428 | function poll() { poll_(pollGeneration, 2); } 429 | function poll_(generation, backOff) { 430 | lastPoll = new Date().getTime(); 431 | var skip = 0; 432 | while (skip < knownHistory.length && 433 | timeFor(knownHistory[knownHistory.length - 1 - skip]) == knownUpto) 434 | ++skip; 435 | getHistory(knownUpto, null, skip, function(lines) { 436 | if (pollGeneration != generation) return; 437 | addLines(lines); 438 | poll_(generation, 2); 439 | }, function(msg) { 440 | if (pollGeneration != generation) return; 441 | console.log("Polling failed: " + msg); 442 | var time = Math.min(backOff * 2, 30); 443 | setTimeout(function() {poll_(generation, time);}, time * 1000); 444 | }); 445 | } 446 | 447 | // Try to notice when the computer has gone to sleep and resumed 448 | // again, which tends to kill long-polling requests, or some other 449 | // circumstance has messed with our polling. 450 | setInterval(function() { 451 | var now = new Date().getTime(); 452 | if (now - lastPoll > 90000) { 453 | console.log("Resetting polling"); 454 | ++pollGeneration; 455 | poll(); 456 | } 457 | }, 60000); 458 | --------------------------------------------------------------------------------