diff --git a/index.html b/index.html index d4df8b6..af653d8 100644 --- a/index.html +++ b/index.html @@ -24,6 +24,13 @@ + + + + + + + diff --git a/js/connection.js b/js/connection.js new file mode 100644 index 0000000..2945d4a --- /dev/null +++ b/js/connection.js @@ -0,0 +1,280 @@ +var weechat = angular.module('weechat'); + +weechat.factory('connection', + ['$rootScope', '$log', 'handlers', 'models', 'ngWebsockets', function($rootScope, + $log, + handlers, + models, + ngWebsockets) { + + protocol = new weeChat.Protocol(); + + // Takes care of the connection and websocket hooks + + var connect = function (host, port, passwd, ssl, noCompression) { + var proto = ssl ? 'wss' : 'ws'; + // If host is an IPv6 literal wrap it in brackets + if (host.indexOf(":") !== -1) { + host = "[" + host + "]"; + } + var url = proto + "://" + host + ":" + port + "/weechat"; + $log.debug('Connecting to URL: ', url); + + var onopen = function () { + + + // Helper methods for initialization commands + var _initializeConnection = function(passwd) { + // This is not the proper way to do this. + // WeeChat does not send a confirmation for the init. + // Until it does, We need to "assume" that formatInit + // will be received before formatInfo + ngWebsockets.send( + weeChat.Protocol.formatInit({ + password: passwd, + compression: noCompression ? 'off' : 'zlib' + }) + ); + + return ngWebsockets.send( + weeChat.Protocol.formatInfo({ + name: 'version' + }) + ); + }; + + var _requestHotlist = function() { + return ngWebsockets.send( + weeChat.Protocol.formatHdata({ + path: "hotlist:gui_hotlist(*)", + keys: [] + }) + ); + }; + + var _requestBufferInfos = function() { + return ngWebsockets.send( + weeChat.Protocol.formatHdata({ + path: 'buffer:gui_buffers(*)', + keys: ['local_variables,notify,number,full_name,short_name,title'] + }) + ); + }; + + var _requestSync = function() { + return ngWebsockets.send( + weeChat.Protocol.formatSync({}) + ); + }; + + + // First command asks for the password and issues + // a version command. If it fails, it means the we + // did not provide the proper password. + _initializeConnection(passwd).then( + function() { + // Connection is successful + // Send all the other commands required for initialization + _requestBufferInfos().then(function(bufinfo) { + //XXX move to handlers? + var bufferInfos = bufinfo.objects[0].content; + // buffers objects + for (var i = 0; i < bufferInfos.length ; i++) { + var buffer = new models.Buffer(bufferInfos[i]); + models.addBuffer(buffer); + // Switch to first buffer on startup + if (i === 0) { + models.setActiveBuffer(buffer.id); + } + } + }); + + _requestHotlist().then(function(hotlist) { + handlers.handleHotlistInfo(hotlist); + }); + + _requestSync(); + $log.info("Connected to relay"); + $rootScope.connected = true; + }, + function() { + // Connection got closed, lets check if we ever was connected successfully + if (!$rootScope.waseverconnected) { + $rootScope.passwordError = true; + } + } + ); + + }; + + var onmessage = function() { + // If we recieve a message from WeeChat it means that + // password was OK. Store that result and check for it + // in the failure handler. + $rootScope.waseverconnected = true; + }; + + + var onclose = function (evt) { + /* + * Handles websocket disconnection + */ + $log.info("Disconnected from relay"); + failCallbacks('disconnection'); + $rootScope.connected = false; + $rootScope.$emit('relayDisconnect'); + if (ssl && evt.code === 1006) { + // A password error doesn't trigger onerror, but certificate issues do. Check time of last error. + if (typeof $rootScope.lastError !== "undefined" && (Date.now() - $rootScope.lastError) < 1000) { + // abnormal disconnect by client, most likely ssl error + $rootScope.sslError = true; + } + } + $rootScope.$apply(); + }; + + var onerror = function (evt) { + /* + * Handles cases when connection issues come from + * the relay. + */ + $log.error("Relay error", evt); + $rootScope.lastError = Date.now(); + + if (evt.type === "error" && this.readyState !== 1) { + failCallbacks('error'); + $rootScope.errorMessage = true; + } + }; + + protocol.setId = function(id, message) { + return '(' + id + ') ' + message; + }; + + + try { + ngWebsockets.connect(url, + protocol, + { + 'binaryType': "arraybuffer", + 'onopen': onopen, + 'onclose': onclose, + 'onmessage': onmessage, + 'onerror': onerror + }); + } catch(e) { + $log.debug("Websocket caught DOMException:", e); + $rootScope.lastError = Date.now(); + $rootScope.errorMessage = true; + $rootScope.securityError = true; + $rootScope.$emit('relayDisconnect'); + } + + }; + + var disconnect = function() { + ngWebsockets.send(weeChat.Protocol.formatQuit()); + }; + + /* + * Format and send a weechat message + * + * @returns the angular promise + */ + var sendMessage = function(message) { + ngWebsockets.send(weeChat.Protocol.formatInput({ + buffer: models.getActiveBuffer().fullName, + data: message + })); + }; + + var sendCoreCommand = function(command) { + ngWebsockets.send(weeChat.Protocol.formatInput({ + buffer: 'core.weechat', + data: command + })); + }; + + + var requestNicklist = function(bufferId, callback) { + bufferId = bufferId || null; + ngWebsockets.send( + weeChat.Protocol.formatNicklist({ + buffer: bufferId + }) + ).then(function(nicklist) { + handlers.handleNicklist(nicklist); + if (callback !== undefined) { + callback(); + } + }); + }; + + + var fetchMoreLines = function(numLines) { + $log.debug('Fetching ', numLines, ' lines'); + var buffer = models.getActiveBuffer(); + if (numLines === undefined) { + // Math.max(undefined, *) = NaN -> need a number here + numLines = 0; + } + // Calculate number of lines to fetch, at least as many as the parameter + numLines = Math.max(numLines, buffer.requestedLines * 2); + + // Indicator that we are loading lines, hides "load more lines" link + $rootScope.loadingLines = true; + // Send hdata request to fetch lines for this particular buffer + return ngWebsockets.send( + weeChat.Protocol.formatHdata({ + // "0x" is important, otherwise it won't work + path: "buffer:0x" + buffer.id + "/own_lines/last_line(-" + numLines + ")/data", + keys: [] + }) + ).then(function(lineinfo) { +//XXX move to handlers? + // delete old lines and add new ones + var oldLength = buffer.lines.length; + // Whether to set the readmarker to the middle position + // Don't do that if we didn't get any more lines than we already had + var setReadmarker = (buffer.lastSeen >= 0) && (oldLength !== buffer.lines.length); + buffer.lines.length = 0; + // We need to set the number of requested lines to 0 here, because parsing a line + // increments it. This is needed to also count newly arriving lines while we're + // already connected. + buffer.requestedLines = 0; + // Count number of lines recieved + var linesReceivedCount = lineinfo.objects[0].content.length; + + // Parse the lines + handlers.handleLineInfo(lineinfo, true); + + if (setReadmarker) { + // Read marker was somewhere in the old lines - we don't need it any more, + // set it to the boundary between old and new. This way, we stay at the exact + // same position in the text through the scrollWithBuffer below + buffer.lastSeen = buffer.lines.length - oldLength - 1; + } else { + // We are currently fetching at least some unread lines, so we need to keep + // the read marker position correct + buffer.lastSeen -= oldLength; + } + // We requested more lines than we got, no more lines. + if (linesReceivedCount < numLines) { + buffer.allLinesFetched = true; + } + $rootScope.loadingLines = false; + // Scroll read marker to the center of the screen + $rootScope.scrollWithBuffer(true); + }); + }; + + + return { + connect: connect, + disconnect: disconnect, + sendMessage: sendMessage, + sendCoreCommand: sendCoreCommand, + fetchMoreLines: fetchMoreLines, + requestNicklist: requestNicklist + }; +}]); diff --git a/js/filters.js b/js/filters.js new file mode 100644 index 0000000..418b91c --- /dev/null +++ b/js/filters.js @@ -0,0 +1,53 @@ +var weechat = angular.module('weechat'); + +weechat.filter('toArray', function () { + 'use strict'; + + return function (obj) { + if (!(obj instanceof Object)) { + return obj; + } + + return Object.keys(obj).map(function (key) { + return Object.defineProperty(obj[key], '$key', { value: key }); + }); + }; +}); + +weechat.filter('irclinky', ['$filter', function($filter) { + 'use strict'; + return function(text, target) { + if (!text) { + return text; + } + + var linkiedText = $filter('linky')(text, target); + + // This regex in no way matches all IRC channel names (they could begin with a +, an &, or an exclamation + // mark followed by 5 alphanumeric characters, and are bounded in length by 50). + // However, it matches all *common* IRC channels while trying to minimise false positives. "#1" is much + // more likely to be "number 1" than "IRC channel #1". + // Thus, we only match channels beginning with a # and having at least one letter in them. + var channelRegex = /(^|[\s,.:;?!"'()+@-])(#+[a-z0-9-_]*[a-z][a-z0-9-_]*)/gmi; + // This is SUPER nasty, but ng-click does not work inside a filter, as the markup has to be $compiled first, which is not possible in filter afaik. + // Therefore, get the scope, fire the method, and $apply. Yuck. I sincerely hope someone finds a better way of doing this. + linkiedText = linkiedText.replace(channelRegex, '$1$2'); + return linkiedText; + }; +}]); + +weechat.filter('inlinecolour', ['$sce', function($sce) { + 'use strict'; + + return function(text) { + if (!text) { + return text; + } + + // only match 6-digit colour codes, 3-digit ones have too many false positives (issue numbers, etc) + var hexColourRegex = /(^|[^&])\#([0-9a-f]{6})($|[^\w'"])/gmi; + var substitute = '$1#$2
$3'; + + return $sce.trustAsHtml(text.replace(hexColourRegex, substitute)); + }; +}]); diff --git a/js/glowingbear.js b/js/glowingbear.js index 6ed213c..ec8fbb1 100644 --- a/js/glowingbear.js +++ b/js/glowingbear.js @@ -1,559 +1,6 @@ var weechat = angular.module('weechat', ['ngRoute', 'localStorage', 'weechatModels', 'plugins', 'ngSanitize', 'ngWebsockets', 'ngTouch']); -weechat.filter('toArray', function () { - 'use strict'; - - return function (obj) { - if (!(obj instanceof Object)) { - return obj; - } - - return Object.keys(obj).map(function (key) { - return Object.defineProperty(obj[key], '$key', { value: key }); - }); - }; -}); - -// Helper to change style of a class -var changeClassStyle = function(classSelector, attr, value) { - _.each(document.getElementsByClassName(classSelector), function(e) { - e.style[attr] = value; - }); -}; -// Helper to get style from a class -var getClassStyle = function(classSelector, attr) { - _.each(document.getElementsByClassName(classSelector), function(e) { - return e.style[attr]; - }); -}; - -weechat.filter('irclinky', ['$filter', function($filter) { - 'use strict'; - return function(text, target) { - if (!text) { - return text; - } - - var linkiedText = $filter('linky')(text, target); - - // This regex in no way matches all IRC channel names (they could begin with a +, an &, or an exclamation - // mark followed by 5 alphanumeric characters, and are bounded in length by 50). - // However, it matches all *common* IRC channels while trying to minimise false positives. "#1" is much - // more likely to be "number 1" than "IRC channel #1". - // Thus, we only match channels beginning with a # and having at least one letter in them. - var channelRegex = /(^|[\s,.:;?!"'()+@-])(#+[a-z0-9-_]*[a-z][a-z0-9-_]*)/gmi; - // This is SUPER nasty, but ng-click does not work inside a filter, as the markup has to be $compiled first, which is not possible in filter afaik. - // Therefore, get the scope, fire the method, and $apply. Yuck. I sincerely hope someone finds a better way of doing this. - linkiedText = linkiedText.replace(channelRegex, '$1$2'); - return linkiedText; - }; -}]); - -weechat.filter('inlinecolour', ['$sce', function($sce) { - 'use strict'; - - return function(text) { - if (!text) { - return text; - } - - // only match 6-digit colour codes, 3-digit ones have too many false positives (issue numbers, etc) - var hexColourRegex = /(^|[^&])\#([0-9a-f]{6})($|[^\w'"])/gmi; - var substitute = '$1#$2
$3'; - - return $sce.trustAsHtml(text.replace(hexColourRegex, substitute)); - }; -}]); - -weechat.factory('handlers', ['$rootScope', '$log', 'models', 'plugins', function($rootScope, $log, models, plugins) { - - var handleBufferClosing = function(message) { - var bufferMessage = message.objects[0].content[0]; - var bufferId = bufferMessage.pointers[0]; - models.closeBuffer(bufferId); - }; - - var handleLine = function(line, manually) { - var message = new models.BufferLine(line); - var buffer = models.getBuffer(message.buffer); - buffer.requestedLines++; - // Only react to line if its displayed - if (message.displayed) { - message = plugins.PluginManager.contentForMessage(message); - buffer.addLine(message); - - if (manually) { - buffer.lastSeen++; - } - - if (buffer.active && !manually) { - $rootScope.scrollWithBuffer(); - } - - if (!manually && (!buffer.active || !$rootScope.isWindowFocused())) { - if (buffer.notify > 1 && _.contains(message.tags, 'notify_message') && !_.contains(message.tags, 'notify_none')) { - buffer.unread++; - $rootScope.$emit('notificationChanged'); - } - - if ((buffer.notify !== 0 && message.highlight) || _.contains(message.tags, 'notify_private')) { - buffer.notification++; - $rootScope.createHighlight(buffer, message); - $rootScope.$emit('notificationChanged'); - } - } - } - }; - - var handleBufferLineAdded = function(message) { - message.objects[0].content.forEach(function(l) { - handleLine(l, false); - }); - }; - - var handleBufferOpened = function(message) { - var bufferMessage = message.objects[0].content[0]; - var buffer = new models.Buffer(bufferMessage); - models.addBuffer(buffer); - /* Until we can decide if user asked for this buffer to be opened - * or not we will let user click opened buffers. - models.setActiveBuffer(buffer.id); - */ - }; - - var handleBufferTitleChanged = function(message) { - var obj = message.objects[0].content[0]; - var buffer = obj.pointers[0]; - var old = models.getBuffer(buffer); - old.fullName = obj.full_name; - old.title = obj.title; - old.number = obj.number; - }; - - var handleBufferRenamed = function(message) { - var obj = message.objects[0].content[0]; - var buffer = obj.pointers[0]; - var old = models.getBuffer(buffer); - old.fullName = obj.full_name; - old.shortName = obj.short_name; - }; - - var handleBufferLocalvarChanged = function(message) { - var obj = message.objects[0].content[0]; - var buffer = obj.pointers[0]; - var old = models.getBuffer(buffer); - - var localvars = obj.local_variables; - if (old !== undefined && localvars !== undefined) { - // Update indendation status - old.indent = (['channel', 'private'].indexOf(localvars.type) >= 0); - } - }; - - /* - * Handle answers to (lineinfo) messages - * - * (lineinfo) messages are specified by this client. It is request after bufinfo completes - */ - var handleLineInfo = function(message, manually) { - var lines = message.objects[0].content.reverse(); - if (manually === undefined) { - manually = true; - } - lines.forEach(function(l) { - handleLine(l, manually); - }); - }; - - /* - * Handle answers to hotlist request - */ - var handleHotlistInfo = function(message) { - if (message.objects.length === 0) { - return; - } - var hotlist = message.objects[0].content; - hotlist.forEach(function(l) { - var buffer = models.getBuffer(l.buffer); - // 1 is message - buffer.unread += l.count[1]; - // 2 is private - buffer.notification += l.count[2]; - // 3 is highlight - buffer.notification += l.count[3]; - /* Since there is unread messages, we can guess - * what the last read line is and update it accordingly - */ - var unreadSum = _.reduce(l.count, function(memo, num) { return memo + num; }, 0); - buffer.lastSeen = buffer.lines.length - 1 - unreadSum; - }); - }; - - /* - * Handle nicklist event - */ - var handleNicklist = function(message) { - var nicklist = message.objects[0].content; - var group = 'root'; - nicklist.forEach(function(n) { - var buffer = models.getBuffer(n.pointers[0]); - if (n.group === 1) { - var g = new models.NickGroup(n); - group = g.name; - buffer.nicklist[group] = g; - } else { - var nick = new models.Nick(n); - buffer.addNick(group, nick); - } - }); - }; - /* - * Handle nicklist diff event - */ - var handleNicklistDiff = function(message) { - var nicklist = message.objects[0].content; - var group; - nicklist.forEach(function(n) { - var buffer = models.getBuffer(n.pointers[0]); - var d = n._diff; - if (n.group === 1) { - group = n.name; - if (group === undefined) { - var g = new models.NickGroup(n); - buffer.nicklist[group] = g; - group = g.name; - } - } else { - var nick = new models.Nick(n); - if (d === 43) { // + - buffer.addNick(group, nick); - } else if (d === 45) { // - - buffer.delNick(group, nick); - } else if (d === 42) { // * - buffer.updateNick(group, nick); - } - } - }); - }; - - var eventHandlers = { - _buffer_closing: handleBufferClosing, - _buffer_line_added: handleBufferLineAdded, - _buffer_localvar_added: handleBufferLocalvarChanged, - _buffer_localvar_removed: handleBufferLocalvarChanged, - _buffer_opened: handleBufferOpened, - _buffer_title_changed: handleBufferTitleChanged, - _buffer_renamed: handleBufferRenamed, - _nicklist: handleNicklist, - _nicklist_diff: handleNicklistDiff - }; - - $rootScope.$on('onMessage', function(event, message) { - if (_.has(eventHandlers, message.id)) { - eventHandlers[message.id](message); - } else { - $log.debug('Unhandled event received: ' + message.id); - } - }); - - var handleEvent = function(event) { - if (_.has(eventHandlers, event.id)) { - eventHandlers[event.id](event); - } - }; - - return { - handleEvent: handleEvent, - handleLineInfo: handleLineInfo, - handleHotlistInfo: handleHotlistInfo, - handleNicklist: handleNicklist - }; - -}]); - -weechat.factory('connection', - ['$rootScope', - '$log', - 'handlers', - 'models', - 'ngWebsockets', -function($rootScope, - $log, - handlers, - models, - ngWebsockets) { - - protocol = new weeChat.Protocol(); - - // Takes care of the connection and websocket hooks - - var connect = function (host, port, passwd, ssl, noCompression) { - var proto = ssl ? 'wss' : 'ws'; - // If host is an IPv6 literal wrap it in brackets - if (host.indexOf(":") !== -1) { - host = "[" + host + "]"; - } - var url = proto + "://" + host + ":" + port + "/weechat"; - $log.debug('Connecting to URL: ', url); - - var onopen = function () { - - - // Helper methods for initialization commands - var _initializeConnection = function(passwd) { - // This is not the proper way to do this. - // WeeChat does not send a confirmation for the init. - // Until it does, We need to "assume" that formatInit - // will be received before formatInfo - ngWebsockets.send( - weeChat.Protocol.formatInit({ - password: passwd, - compression: noCompression ? 'off' : 'zlib' - }) - ); - - return ngWebsockets.send( - weeChat.Protocol.formatInfo({ - name: 'version' - }) - ); - }; - - var _requestHotlist = function() { - return ngWebsockets.send( - weeChat.Protocol.formatHdata({ - path: "hotlist:gui_hotlist(*)", - keys: [] - }) - ); - }; - - var _requestBufferInfos = function() { - return ngWebsockets.send( - weeChat.Protocol.formatHdata({ - path: 'buffer:gui_buffers(*)', - keys: ['local_variables,notify,number,full_name,short_name,title'] - }) - ); - }; - - var _requestSync = function() { - return ngWebsockets.send( - weeChat.Protocol.formatSync({}) - ); - }; - - - // First command asks for the password and issues - // a version command. If it fails, it means the we - // did not provide the proper password. - _initializeConnection(passwd).then( - function() { - // Connection is successful - // Send all the other commands required for initialization - _requestBufferInfos().then(function(bufinfo) { - var bufferInfos = bufinfo.objects[0].content; - // buffers objects - for (var i = 0; i < bufferInfos.length ; i++) { - var buffer = new models.Buffer(bufferInfos[i]); - models.addBuffer(buffer); - // Switch to first buffer on startup - if (i === 0) { - models.setActiveBuffer(buffer.id); - } - } - }); - - _requestHotlist().then(function(hotlist) { - handlers.handleHotlistInfo(hotlist); - }); - - _requestSync(); - $log.info("Connected to relay"); - $rootScope.connected = true; - }, - function() { - // Connection got closed, lets check if we ever was connected successfully - if (!$rootScope.waseverconnected) { - $rootScope.passwordError = true; - } - } - ); - - }; - - var onmessage = function() { - // If we recieve a message from WeeChat it means that - // password was OK. Store that result and check for it - // in the failure handler. - $rootScope.waseverconnected = true; - }; - - - var onclose = function (evt) { - /* - * Handles websocket disconnection - */ - $log.info("Disconnected from relay"); - failCallbacks('disconnection'); - $rootScope.connected = false; - $rootScope.$emit('relayDisconnect'); - if (ssl && evt.code === 1006) { - // A password error doesn't trigger onerror, but certificate issues do. Check time of last error. - if (typeof $rootScope.lastError !== "undefined" && (Date.now() - $rootScope.lastError) < 1000) { - // abnormal disconnect by client, most likely ssl error - $rootScope.sslError = true; - } - } - $rootScope.$apply(); - }; - - var onerror = function (evt) { - /* - * Handles cases when connection issues come from - * the relay. - */ - $log.error("Relay error", evt); - $rootScope.lastError = Date.now(); - - if (evt.type === "error" && this.readyState !== 1) { - failCallbacks('error'); - $rootScope.errorMessage = true; - } - }; - - protocol.setId = function(id, message) { - return '(' + id + ') ' + message; - }; - - - try { - ngWebsockets.connect(url, - protocol, - { - 'binaryType': "arraybuffer", - 'onopen': onopen, - 'onclose': onclose, - 'onmessage': onmessage, - 'onerror': onerror - }); - } catch(e) { - $log.debug("Websocket caught DOMException:", e); - $rootScope.lastError = Date.now(); - $rootScope.errorMessage = true; - $rootScope.securityError = true; - $rootScope.$emit('relayDisconnect'); - } - - }; - - var disconnect = function() { - ngWebsockets.send(weeChat.Protocol.formatQuit()); - }; - - /* - * Format and send a weechat message - * - * @returns the angular promise - */ - var sendMessage = function(message) { - ngWebsockets.send(weeChat.Protocol.formatInput({ - buffer: models.getActiveBuffer().fullName, - data: message - })); - }; - - var sendCoreCommand = function(command) { - ngWebsockets.send(weeChat.Protocol.formatInput({ - buffer: 'core.weechat', - data: command - })); - }; - - - var requestNicklist = function(bufferId, callback) { - bufferId = bufferId || null; - ngWebsockets.send( - weeChat.Protocol.formatNicklist({ - buffer: bufferId - }) - ).then(function(nicklist) { - handlers.handleNicklist(nicklist); - if (callback !== undefined) { - callback(); - } - }); - }; - - - var fetchMoreLines = function(numLines) { - $log.debug('Fetching ', numLines, ' lines'); - var buffer = models.getActiveBuffer(); - if (numLines === undefined) { - // Math.max(undefined, *) = NaN -> need a number here - numLines = 0; - } - // Calculate number of lines to fetch, at least as many as the parameter - numLines = Math.max(numLines, buffer.requestedLines * 2); - - // Indicator that we are loading lines, hides "load more lines" link - $rootScope.loadingLines = true; - // Send hdata request to fetch lines for this particular buffer - return ngWebsockets.send( - weeChat.Protocol.formatHdata({ - // "0x" is important, otherwise it won't work - path: "buffer:0x" + buffer.id + "/own_lines/last_line(-" + numLines + ")/data", - keys: [] - }) - ).then(function(lineinfo) { - // delete old lines and add new ones - var oldLength = buffer.lines.length; - // Whether to set the readmarker to the middle position - // Don't do that if we didn't get any more lines than we already had - var setReadmarker = (buffer.lastSeen >= 0) && (oldLength !== buffer.lines.length); - buffer.lines.length = 0; - // We need to set the number of requested lines to 0 here, because parsing a line - // increments it. This is needed to also count newly arriving lines while we're - // already connected. - buffer.requestedLines = 0; - // Count number of lines recieved - var linesReceivedCount = lineinfo.objects[0].content.length; - - // Parse the lines - handlers.handleLineInfo(lineinfo, true); - - if (setReadmarker) { - // Read marker was somewhere in the old lines - we don't need it any more, - // set it to the boundary between old and new. This way, we stay at the exact - // same position in the text through the scrollWithBuffer below - buffer.lastSeen = buffer.lines.length - oldLength - 1; - } else { - // We are currently fetching at least some unread lines, so we need to keep - // the read marker position correct - buffer.lastSeen -= oldLength; - } - // We requested more lines than we got, no more lines. - if (linesReceivedCount < numLines) { - buffer.allLinesFetched = true; - } - $rootScope.loadingLines = false; - // Scroll read marker to the center of the screen - $rootScope.scrollWithBuffer(true); - }); - }; - - - return { - connect: connect, - disconnect: disconnect, - sendMessage: sendMessage, - sendCoreCommand: sendCoreCommand, - fetchMoreLines: fetchMoreLines, - requestNicklist: requestNicklist - }; -}]); - -weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', '$log', 'models', 'connection', function ($rootScope, $scope, $store, $timeout, $log, models, connection) { +weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', '$log', 'models', 'connection', 'notifications', 'utils', function ($rootScope, $scope, $store, $timeout, $log, models, connection, notifications, utils) { // From: http://stackoverflow.com/a/18539624 by StackOverflow user "plantian" $rootScope.countWatchers = function () { @@ -573,38 +20,6 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', $log.debug(watchers); }; - - $rootScope.isMobileUi = function() { - // TODO don't base detection solely on screen width - // You are right. In the meantime I am renaming isMobileDevice to isMobileUi - var mobile_cutoff = 968; - return (document.body.clientWidth < mobile_cutoff); - }; - - - // Ask for permission to display desktop notifications - $scope.requestNotificationPermission = function() { - // Firefox - if (window.Notification) { - Notification.requestPermission(function(status) { - $log.info('Notification permission status: ', status); - if (Notification.permission !== status) { - Notification.permission = status; - } - }); - } - - // Webkit - if (window.webkitNotifications !== undefined) { - var havePermission = window.webkitNotifications.checkPermission(); - if (havePermission !== 0) { // 0 is PERMISSION_ALLOWED - $log.info('Notification permission status: ', havePermission === 0); - window.webkitNotifications.requestPermission(); - } - } - }; - - $scope.isinstalled = (function() { // Check for firefox & app installed if (navigator.mozApps !== undefined) { @@ -684,58 +99,6 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', } - // Reduce buffers with "+" operation over a key. Mostly useful for unread/notification counts. - $rootScope.unreadCount = function(type) { - if (!type) { - type = "unread"; - } - - // Do this the old-fashioned way with iterating over the keys, as underscore proved to be error-prone - var keys = Object.keys(models.model.buffers); - var count = 0; - for (var key in keys) { - count += models.model.buffers[keys[key]][type]; - } - - return count; - }; - - $rootScope.updateTitle = function() { - var notifications = $rootScope.unreadCount('notification'); - if (notifications > 0) { - // New notifications deserve an exclamation mark - $rootScope.notificationStatus = '(' + notifications + ') '; - } else { - $rootScope.notificationStatus = ''; - } - - var activeBuffer = models.getActiveBuffer(); - if (activeBuffer) { - $rootScope.pageTitle = activeBuffer.shortName + ' | ' + activeBuffer.title; - } - }; - - $scope.updateFavico = function() { - var notifications = $rootScope.unreadCount('notification'); - if (notifications > 0) { - $scope.favico.badge(notifications, { - bgColor: '#d00', - textColor: '#fff' - }); - } else { - var unread = $rootScope.unreadCount('unread'); - if (unread === 0) { - $scope.favico.reset(); - } else { - $scope.favico.badge(unread, { - bgColor: '#5CB85C', - textColor: '#ff0' - }); - } - } - }; - - $rootScope.$on('activeBufferChanged', function(event, unreadSum) { var ab = models.getActiveBuffer(); @@ -782,7 +145,7 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', } ); } - $rootScope.updateTitle(ab); + notifications.updateTitle(ab); $rootScope.scrollWithBuffer(true); @@ -797,7 +160,7 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', // Clear search term on buffer change $scope.search = ''; - if (!$rootScope.isMobileUi()) { + if (!utils.isMobileUi()) { // This needs to happen asynchronously to prevent the enter key handler // of the input bar to be triggered on buffer switch via the search. // Otherwise its current contents would be sent to the new buffer @@ -807,13 +170,13 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', } }); - $scope.favico = new Favico({animation: 'none'}); + $rootScope.favico = new Favico({animation: 'none'}); $rootScope.$on('notificationChanged', function() { - $rootScope.updateTitle(); + notifications.updateTitle(); - if ($scope.useFavico && $scope.favico) { - $scope.updateFavico(); + if ($scope.useFavico && $rootScope.favico) { + notifications.updateFavico(); } }); @@ -856,7 +219,7 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', $rootScope.wasMobileUi = false; - if ($rootScope.isMobileUi()) { + if (utils.isMobileUi()) { nonicklist = true; noembed = true; $rootScope.wasMobileUi = true; @@ -883,14 +246,14 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', // Save setting for playing sound on notification $store.bind($scope, "soundnotification", false); // Save setting for font family - $store.bind($scope, "fontfamily", getClassStyle('favorite-font', 'fontFamily')); + $store.bind($scope, "fontfamily", utils.getClassStyle('favorite-font', 'fontFamily')); // Save setting for font size - $store.bind($scope, "fontsize", getClassStyle('favorite-font', 'fontSize')); + $store.bind($scope, "fontsize", utils.getClassStyle('favorite-font', 'fontSize')); // Save setting for readline keybindings $store.bind($scope, "readlineBindings", false); if (!$scope.fontfamily) { - if ($rootScope.isMobileUi()) { + if (utils.isMobileUi()) { $scope.fontfamily = 'sans-serif'; } else { $scope.fontfamily = "Inconsolata, Consolas, Monaco, Ubuntu Mono, monospace"; @@ -907,7 +270,7 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', $scope.showSidebar = function() { document.getElementById('sidebar').setAttribute('data-state', 'visible'); document.getElementById('content').setAttribute('sidebar-state', 'visible'); - if ($rootScope.isMobileUi()) { + if (utils.isMobileUi()) { // de-focus the input bar when opening the sidebar on mobile, so that the keyboard goes down _.each(document.getElementsByTagName('textarea'), function(elem) { elem.blur(); @@ -916,7 +279,7 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', }; $rootScope.hideSidebar = function() { - if ($rootScope.isMobileUi()) { + if (utils.isMobileUi()) { document.getElementById('sidebar').setAttribute('data-state', 'hidden'); document.getElementById('content').setAttribute('sidebar-state', 'hidden'); } @@ -930,7 +293,7 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', // toggle sidebar (if on mobile) $scope.toggleSidebar = function() { - if ($rootScope.isMobileUi()) { + if (utils.isMobileUi()) { if ($scope.isSidebarVisible()) { $scope.hideSidebar(); } else { @@ -941,7 +304,7 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', // Open and close panels while on mobile devices through swiping $scope.openNick = function() { - if ($rootScope.isMobileUi()) { + if (utils.isMobileUi()) { if ($scope.nonicklist) { $scope.nonicklist = false; } @@ -949,7 +312,7 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', }; $scope.closeNick = function() { - if ($rootScope.isMobileUi()) { + if (utils.isMobileUi()) { if (!$scope.nonicklist) { $scope.nonicklist = true; } @@ -971,19 +334,19 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', return; } if ($scope.useFavico) { - $scope.updateFavico(); + notifications.updateFavico(); } else { - $scope.favico.reset(); + $rootScope.favico.reset(); } }); // Update font family when changed $scope.$watch('fontfamily', function() { - changeClassStyle('favorite-font', 'fontFamily', $scope.fontfamily); + utils.changeClassStyle('favorite-font', 'fontFamily', $scope.fontfamily); }); // Update font size when changed $scope.$watch('fontsize', function() { - changeClassStyle('favorite-font', 'fontSize', $scope.fontsize); + utils.changeClassStyle('favorite-font', 'fontSize', $scope.fontsize); }); // Crude scoping hack. The keypress listener does not live in the same scope as // the checkbox, so we need to transfer this between scopes here. @@ -994,7 +357,7 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', $scope.setActiveBuffer = function(bufferId, key) { // If we are on mobile we need to collapse the menu on sidebar clicks // We use 968 px as the cutoff, which should match the value in glowingbear.css - if ($rootScope.isMobileUi()) { + if (utils.isMobileUi()) { $scope.hideSidebar(); } return models.setActiveBuffer(bufferId, key); @@ -1014,6 +377,7 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', }; +//XXX this does not belong here (or does it?) // Calculate number of lines to fetch $scope.calculateNumLines = function() { var bufferlineElements = document.querySelectorAll(".bufferline"); @@ -1035,10 +399,10 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', if ($rootScope.connected) { // Show the sidebar if switching away from mobile view, hide it when switching to mobile // Wrap in a condition so we save ourselves the $apply if nothing changes (50ms or more) - if ($scope.wasMobileUi && !$scope.isMobileUi()) { + if ($scope.wasMobileUi && !utils.isMobileUi()) { $scope.showSidebar(); } - $scope.wasMobileUi = $scope.isMobileUi(); + $scope.wasMobileUi = utils.isMobileUi(); $scope.calculateNumLines(); // if we're scrolled to the bottom, scroll down to the same position after the resize @@ -1093,7 +457,7 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', $scope.connect = function() { - $scope.requestNotificationPermission(); + notifications.requestNotificationPermission(); $rootScope.sslError = false; $rootScope.securityError = false; $rootScope.errorMessage = false; @@ -1104,6 +468,8 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', $scope.connectbutton = 'Connect'; connection.disconnect(); }; + +//XXX this is a bit out of place here, either move up to the rest of the firefox install code or remove $scope.install = function() { if (navigator.mozApps !== undefined) { // Find absolute url with trailing '/' or '/index.html' removed @@ -1158,62 +524,7 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', } }; - /* Function gets called from bufferLineAdded code if user should be notified */ - $rootScope.createHighlight = function(buffer, message) { - var title = ''; - var body = ''; - var numNotifications = buffer.notification; - - if (['#', '&', '+', '!'].indexOf(buffer.shortName.charAt(0)) < 0) { - if (numNotifications > 1) { - title = numNotifications.toString() + ' private messages from '; - } else { - title = 'Private message from '; - } - body = message.text; - } else { - if (numNotifications > 1) { - title = numNotifications.toString() + ' highlights in '; - } else { - title = 'Highlight in '; - } - var prefix = ''; - for (var i = 0; i < message.prefix.length; i++) { - prefix += message.prefix[i].text; - } - body = '<' + prefix + '> ' + message.text; - } - title += buffer.shortName; - title += buffer.fullName.replace(/irc.([^\.]+)\..+/, " ($1)"); - - var notification = new Notification(title, { - body: body, - icon: 'assets/img/favicon.png' - }); - - // Cancel notification automatically - var timeout = 15*1000; - notification.onshow = function() { - setTimeout(function() { - notification.close(); - }, timeout); - }; - - // Click takes the user to the buffer - notification.onclick = function() { - models.setActiveBuffer(buffer.id); - window.focus(); - notification.close(); - }; - - if ($scope.soundnotification) { - // TODO fill in a sound file - var audioFile = "assets/audio/sonar"; - var soundHTML = ''; - document.getElementById("soundNotification").innerHTML = soundHTML; - } - }; - +//XXX what do we do with this? $scope.hasUnread = function(buffer) { // if search is set, return every buffer if ($scope.search && $scope.search !== "") { @@ -1253,6 +564,7 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', return true; }; +//XXX not sure whether this belongs here $rootScope.switchToActivityBuffer = function() { // Find next buffer with activity and switch to it var sortedBuffers = _.sortBy($scope.getBuffers(), 'number'); @@ -1304,11 +616,10 @@ weechat.controller('WeechatCtrl', ['$rootScope', '$scope', '$store', '$timeout', // Chrome requires us to set this or it will not show the dialog event.returnValue = "You have an active connection to your WeeChat relay. Please disconnect using the button in the top-right corner or by double-tapping the Escape key."; } - $scope.favico.reset(); + $rootScope.favico.reset(); }; -}] -); +}]); weechat.config(['$routeProvider', function($routeProvider) { @@ -1318,365 +629,3 @@ weechat.config(['$routeProvider', }); } ]); - - -weechat.directive('plugin', ['$rootScope', function($rootScope) { - /* - * Plugin directive - * Shows additional plugin content - */ - return { - templateUrl: 'directives/plugin.html', - - scope: { - plugin: '=data' - }, - - controller: ["$scope", function($scope) { - - $scope.displayedContent = ""; - - $scope.plugin.visible = $rootScope.auto_display_embedded_content; - - $scope.hideContent = function() { - $scope.plugin.visible = false; - }; - - $scope.showContent = function() { - /* - * Shows the plugin content. - * displayedContent is bound to the DOM. - * Actual plugin content is only fetched when - * content is shown. - */ - - // If the plugin is asynchronous / lazy, execute it now and store - // the result. This ensures that the callback is executed only once - if ($scope.plugin.content instanceof Function) { - $scope.plugin.content = $scope.plugin.content(); - } - $scope.displayedContent = $scope.plugin.content; - $scope.plugin.visible = true; - - // Scroll embed content into view - var scroll = function() { - var embed = document.querySelector(".embed_" + $scope.plugin.$$hashKey); - if (embed) { - embed.scrollIntoViewIfNeeded(); - } - }; - setTimeout(scroll, 100); - }; - - if ($scope.plugin.visible) { - $scope.showContent(); - } - }] - }; -}]); - - -weechat.directive('inputBar', function() { - - return { - - templateUrl: 'directives/input.html', - - scope: { - inputId: '@inputId' - }, - - controller: ['$rootScope', '$scope', '$element', '$log', 'connection', 'models', - function($rootScope, - $scope, - $element, - $log, - connection, - models) { - - /* - * Returns the input element - */ - $scope.getInputNode = function() { - return document.querySelector('textarea#' + $scope.inputId); - }; - - $scope.hideSidebar = function() { - $rootScope.hideSidebar(); - }; - - $scope.completeNick = function() { - // input DOM node - var inputNode = $scope.getInputNode(); - - // get current caret position - var caretPos = inputNode.selectionStart; - - // get current active buffer - var activeBuffer = models.getActiveBuffer(); - - // Empty input makes $scope.command undefined -- use empty string instead - var input = $scope.command || ''; - - // complete nick - var nickComp = IrcUtils.completeNick(input, caretPos, $scope.iterCandidate, - activeBuffer.getNicklistByTime(), ':'); - - // remember iteration candidate - $scope.iterCandidate = nickComp.iterCandidate; - - // update current input - $scope.command = nickComp.text; - - // update current caret position - setTimeout(function() { - inputNode.focus(); - inputNode.setSelectionRange(nickComp.caretPos, nickComp.caretPos); - }, 0); - }; - - - // Send the message to the websocket - $scope.sendMessage = function() { - var ab = models.getActiveBuffer(); - - // It's undefined early in the lifecycle of the program. - // Don't send empty commands - if($scope.command !== undefined && $scope.command !== '') { - - // log to buffer history - ab.addToHistory($scope.command); - - // Split the command into multiple commands based on line breaks - _.each($scope.command.split(/\r?\n/), function(line) { - connection.sendMessage(line); - }); - - // Check for /clear command - if ($scope.command === '/buffer clear' || $scope.command === '/c') { - $log.debug('Clearing lines'); - ab.clear(); - } - - // Empty the input after it's sent - $scope.command = ''; - } - - $scope.getInputNode().focus(); - }; - - $rootScope.addMention = function(prefix) { - // Extract nick from bufferline prefix - var nick = prefix[prefix.length - 1].text; - - var newValue = $scope.command || ''; // can be undefined, in that case, use the empty string - var addColon = newValue.length === 0; - if (newValue.length > 0) { - // Try to determine if it's a sequence of nicks - var trimmedValue = newValue.trim(); - if (trimmedValue.charAt(trimmedValue.length - 1) === ':') { - // get last word - var lastSpace = trimmedValue.lastIndexOf(' ') + 1; - var lastWord = trimmedValue.slice(lastSpace, trimmedValue.length - 1); - var nicklist = models.getActiveBuffer().getNicklistByTime(); - // check against nicklist to see if it's a list of highlights - for (var index in nicklist) { - if (nicklist[index].name === lastWord) { - // It's another highlight! - newValue = newValue.slice(0, newValue.lastIndexOf(':')) + ' '; - addColon = true; - break; - } - } - } - - // Add a space before the nick if there isn't one already - // Last char might have changed above, so re-check - if (newValue.charAt(newValue.length - 1) !== ' ') { - newValue += ' '; - } - } - // Add highlight to nicklist - newValue += nick; - if (addColon) { - newValue += ': '; - } - $scope.command = newValue; - $scope.getInputNode().focus(); - }; - - - // Handle key presses in the input bar - $rootScope.handleKeyPress = function($event) { - // don't do anything if not connected - if (!$rootScope.connected) { - return true; - } - - var inputNode = $scope.getInputNode(); - - // Support different browser quirks - var code = $event.keyCode ? $event.keyCode : $event.charCode; - - // any other key than Tab resets nick completion iteration - var tmpIterCandidate = $scope.iterCandidate; - $scope.iterCandidate = null; - - // Left Alt+[0-9] -> jump to buffer - if ($event.altKey && !$event.ctrlKey && (code > 47 && code < 58)) { - if (code === 48) { - code = 58; - } - - var bufferNumber = code - 48 - 1 ; - // Map the buffers to only their numbers and IDs so we don't have to - // copy the entire (possibly very large) buffer object, and then sort - // the buffers according to their WeeChat number - var sortedBuffers = _.map(models.getBuffers(), function(buffer) { - return [buffer.number, buffer.id]; - }).sort(function(left, right) { - // By default, Array.prototype.sort() sorts alphabetically. - // Pass an ordering function to sort by first element. - return left[0] - right[0]; - }); - var activeBufferId = sortedBuffers[bufferNumber]; - if (activeBufferId) { - models.setActiveBuffer(activeBufferId[1]); - $event.preventDefault(); - } - } - - // Tab -> nick completion - if (code === 9 && !$event.altKey && !$event.ctrlKey) { - $event.preventDefault(); - $scope.iterCandidate = tmpIterCandidate; - $scope.completeNick(); - return true; - } - - // Left Alt+n -> toggle nicklist - if ($event.altKey && !$event.ctrlKey && code === 78) { - $event.preventDefault(); - $rootScope.toggleNicklist(); - return true; - } - - // Alt+A -> switch to buffer with activity - if ($event.altKey && (code === 97 || code === 65)) { - $event.preventDefault(); - $rootScope.switchToActivityBuffer(); - return true; - } - - // Alt+L -> focus on input bar - if ($event.altKey && (code === 76 || code === 108)) { - $event.preventDefault(); - inputNode.focus(); - inputNode.setSelectionRange($scope.command.length, $scope.command.length); - return true; - } - - // Alt+< -> switch to previous buffer - if ($event.altKey && (code === 60 || code === 226)) { - var previousBuffer = models.getPreviousBuffer(); - if (previousBuffer) { - models.setActiveBuffer(previousBuffer.id); - $event.preventDefault(); - return true; - } - } - - // Double-tap Escape -> disconnect - if (code === 27) { - $event.preventDefault(); - - // Check if a modal is visible. If so, close it instead of disconnecting - var modals = document.querySelectorAll('.gb-modal'); - for (var modalId = 0; modalId < modals.length; modalId++) { - if (modals[modalId].getAttribute('data-state') === 'visible') { - modals[modalId].setAttribute('data-state', 'hidden'); - return true; - } - } - - if (typeof $scope.lastEscape !== "undefined" && (Date.now() - $scope.lastEscape) <= 500) { - // Double-tap - connection.disconnect(); - } - $scope.lastEscape = Date.now(); - return true; - } - - // Alt+G -> focus on buffer filter input - if ($event.altKey && (code === 103 || code === 71)) { - $event.preventDefault(); - document.getElementById('bufferFilter').focus(); - return true; - } - - // Arrow up -> go up in history - if ($event.type === "keydown" && code === 38) { - $scope.command = models.getActiveBuffer().getHistoryUp($scope.command); - // Set cursor to last position. Need 0ms timeout because browser sets cursor - // position to the beginning after this key handler returns. - setTimeout(function() { - if ($scope.command) { - inputNode.setSelectionRange($scope.command.length, $scope.command.length); - } - }, 0); - return true; - } - - // Arrow down -> go down in history - if ($event.type === "keydown" && code === 40) { - $scope.command = models.getActiveBuffer().getHistoryDown($scope.command); - // We don't need to set the cursor to the rightmost position here, the browser does that for us - return true; - } - - // Enter to submit, shift-enter for newline - if (code == 13 && !$event.shiftKey && document.activeElement === inputNode) { - $event.preventDefault(); - $scope.sendMessage(); - return true; - } - // Some readline keybindings - if ($rootScope.readlineBindings && $event.ctrlKey && !$event.altKey && !$event.shiftKey && document.activeElement === inputNode) { - // get current caret position - var caretPos = inputNode.selectionStart; - // Ctrl-a - if (code == 65) { - inputNode.setSelectionRange(0, 0); - // Ctrl-e - } else if (code == 69) { - inputNode.setSelectionRange($scope.command.length, $scope.command.length); - // Ctrl-u - } else if (code == 85) { - $scope.command = $scope.command.slice(caretPos); - setTimeout(function() { - inputNode.setSelectionRange(0, 0); - }); - // Ctrl-k - } else if (code == 75) { - $scope.command = $scope.command.slice(0, caretPos); - setTimeout(function() { - inputNode.setSelectionRange($scope.command.length, $scope.command.length); - }); - // Ctrl-w - } else if (code == 87) { - var trimmedValue = $scope.command.slice(0, caretPos); - var lastSpace = trimmedValue.lastIndexOf(' ') + 1; - $scope.command = $scope.command.slice(0, lastSpace) + $scope.command.slice(caretPos, $scope.command.length); - setTimeout(function() { - inputNode.setSelectionRange(lastSpace, lastSpace); - }); - } else { - return false; - } - $event.preventDefault(); - return true; - } - }; - }] - }; -}); diff --git a/js/handlers.js b/js/handlers.js new file mode 100644 index 0000000..4bd7247 --- /dev/null +++ b/js/handlers.js @@ -0,0 +1,207 @@ +var weechat = angular.module('weechat'); + +weechat.factory('handlers', ['$rootScope', '$log', 'models', 'plugins', 'notifications', function($rootScope, $log, models, plugins, notifications) { + + var handleBufferClosing = function(message) { + var bufferMessage = message.objects[0].content[0]; + var bufferId = bufferMessage.pointers[0]; + models.closeBuffer(bufferId); + }; + + var handleLine = function(line, manually) { + var message = new models.BufferLine(line); + var buffer = models.getBuffer(message.buffer); + buffer.requestedLines++; + // Only react to line if its displayed + if (message.displayed) { + message = plugins.PluginManager.contentForMessage(message); + buffer.addLine(message); + + if (manually) { + buffer.lastSeen++; + } + + if (buffer.active && !manually) { + $rootScope.scrollWithBuffer(); + } + + if (!manually && (!buffer.active || !$rootScope.isWindowFocused())) { + if (buffer.notify > 1 && _.contains(message.tags, 'notify_message') && !_.contains(message.tags, 'notify_none')) { + buffer.unread++; + $rootScope.$emit('notificationChanged'); + } + + if ((buffer.notify !== 0 && message.highlight) || _.contains(message.tags, 'notify_private')) { + buffer.notification++; + notifications.createHighlight(buffer, message); + $rootScope.$emit('notificationChanged'); + } + } + } + }; + + var handleBufferLineAdded = function(message) { + message.objects[0].content.forEach(function(l) { + handleLine(l, false); + }); + }; + + var handleBufferOpened = function(message) { + var bufferMessage = message.objects[0].content[0]; + var buffer = new models.Buffer(bufferMessage); + models.addBuffer(buffer); + /* Until we can decide if user asked for this buffer to be opened + * or not we will let user click opened buffers. + models.setActiveBuffer(buffer.id); + */ + }; + + var handleBufferTitleChanged = function(message) { + var obj = message.objects[0].content[0]; + var buffer = obj.pointers[0]; + var old = models.getBuffer(buffer); + old.fullName = obj.full_name; + old.title = obj.title; + old.number = obj.number; + }; + + var handleBufferRenamed = function(message) { + var obj = message.objects[0].content[0]; + var buffer = obj.pointers[0]; + var old = models.getBuffer(buffer); + old.fullName = obj.full_name; + old.shortName = obj.short_name; + }; + + var handleBufferLocalvarChanged = function(message) { + var obj = message.objects[0].content[0]; + var buffer = obj.pointers[0]; + var old = models.getBuffer(buffer); + + var localvars = obj.local_variables; + if (old !== undefined && localvars !== undefined) { + // Update indendation status + old.indent = (['channel', 'private'].indexOf(localvars.type) >= 0); + } + }; + + /* + * Handle answers to (lineinfo) messages + * + * (lineinfo) messages are specified by this client. It is request after bufinfo completes + */ + var handleLineInfo = function(message, manually) { + var lines = message.objects[0].content.reverse(); + if (manually === undefined) { + manually = true; + } + lines.forEach(function(l) { + handleLine(l, manually); + }); + }; + + /* + * Handle answers to hotlist request + */ + var handleHotlistInfo = function(message) { + if (message.objects.length === 0) { + return; + } + var hotlist = message.objects[0].content; + hotlist.forEach(function(l) { + var buffer = models.getBuffer(l.buffer); + // 1 is message + buffer.unread += l.count[1]; + // 2 is private + buffer.notification += l.count[2]; + // 3 is highlight + buffer.notification += l.count[3]; + /* Since there is unread messages, we can guess + * what the last read line is and update it accordingly + */ + var unreadSum = _.reduce(l.count, function(memo, num) { return memo + num; }, 0); + buffer.lastSeen = buffer.lines.length - 1 - unreadSum; + }); + }; + + /* + * Handle nicklist event + */ + var handleNicklist = function(message) { + var nicklist = message.objects[0].content; + var group = 'root'; + nicklist.forEach(function(n) { + var buffer = models.getBuffer(n.pointers[0]); + if (n.group === 1) { + var g = new models.NickGroup(n); + group = g.name; + buffer.nicklist[group] = g; + } else { + var nick = new models.Nick(n); + buffer.addNick(group, nick); + } + }); + }; + /* + * Handle nicklist diff event + */ + var handleNicklistDiff = function(message) { + var nicklist = message.objects[0].content; + var group; + nicklist.forEach(function(n) { + var buffer = models.getBuffer(n.pointers[0]); + var d = n._diff; + if (n.group === 1) { + group = n.name; + if (group === undefined) { + var g = new models.NickGroup(n); + buffer.nicklist[group] = g; + group = g.name; + } + } else { + var nick = new models.Nick(n); + if (d === 43) { // + + buffer.addNick(group, nick); + } else if (d === 45) { // - + buffer.delNick(group, nick); + } else if (d === 42) { // * + buffer.updateNick(group, nick); + } + } + }); + }; + + var eventHandlers = { + _buffer_closing: handleBufferClosing, + _buffer_line_added: handleBufferLineAdded, + _buffer_localvar_added: handleBufferLocalvarChanged, + _buffer_localvar_removed: handleBufferLocalvarChanged, + _buffer_opened: handleBufferOpened, + _buffer_title_changed: handleBufferTitleChanged, + _buffer_renamed: handleBufferRenamed, + _nicklist: handleNicklist, + _nicklist_diff: handleNicklistDiff + }; + + $rootScope.$on('onMessage', function(event, message) { + if (_.has(eventHandlers, message.id)) { + eventHandlers[message.id](message); + } else { + $log.debug('Unhandled event received: ' + message.id); + } + }); + + var handleEvent = function(event) { + if (_.has(eventHandlers, event.id)) { + eventHandlers[event.id](event); + } + }; + + return { + handleEvent: handleEvent, + handleLineInfo: handleLineInfo, + handleHotlistInfo: handleHotlistInfo, + handleNicklist: handleNicklist + }; + +}]); diff --git a/js/inputbar.js b/js/inputbar.js new file mode 100644 index 0000000..8f3ddd2 --- /dev/null +++ b/js/inputbar.js @@ -0,0 +1,307 @@ +var weechat = angular.module('weechat'); + +weechat.directive('inputBar', function() { + + return { + + templateUrl: 'directives/input.html', + + scope: { + inputId: '@inputId' + }, + + controller: ['$rootScope', '$scope', '$element', '$log', 'connection', 'models', function($rootScope, + $scope, + $element, //XXX do we need this? don't seem to be using it + $log, + connection, //XXX we should eliminate this dependency and use signals instead + models) { + + /* + * Returns the input element + */ + $scope.getInputNode = function() { + return document.querySelector('textarea#' + $scope.inputId); + }; + + $scope.hideSidebar = function() { + $rootScope.hideSidebar(); + }; + + $scope.completeNick = function() { + // input DOM node + var inputNode = $scope.getInputNode(); + + // get current caret position + var caretPos = inputNode.selectionStart; + + // get current active buffer + var activeBuffer = models.getActiveBuffer(); + + // Empty input makes $scope.command undefined -- use empty string instead + var input = $scope.command || ''; + + // complete nick + var nickComp = IrcUtils.completeNick(input, caretPos, $scope.iterCandidate, + activeBuffer.getNicklistByTime(), ':'); + + // remember iteration candidate + $scope.iterCandidate = nickComp.iterCandidate; + + // update current input + $scope.command = nickComp.text; + + // update current caret position + setTimeout(function() { + inputNode.focus(); + inputNode.setSelectionRange(nickComp.caretPos, nickComp.caretPos); + }, 0); + }; + + + // Send the message to the websocket + $scope.sendMessage = function() { + //XXX Use a signal here + var ab = models.getActiveBuffer(); + + // It's undefined early in the lifecycle of the program. + // Don't send empty commands + if($scope.command !== undefined && $scope.command !== '') { + + // log to buffer history + ab.addToHistory($scope.command); + + // Split the command into multiple commands based on line breaks + _.each($scope.command.split(/\r?\n/), function(line) { + connection.sendMessage(line); + }); + + // Check for /clear command + if ($scope.command === '/buffer clear' || $scope.command === '/c') { + $log.debug('Clearing lines'); + ab.clear(); + } + + // Empty the input after it's sent + $scope.command = ''; + } + + $scope.getInputNode().focus(); + }; + + //XXX THIS DOES NOT BELONG HERE! + $rootScope.addMention = function(prefix) { + // Extract nick from bufferline prefix + var nick = prefix[prefix.length - 1].text; + + var newValue = $scope.command || ''; // can be undefined, in that case, use the empty string + var addColon = newValue.length === 0; + if (newValue.length > 0) { + // Try to determine if it's a sequence of nicks + var trimmedValue = newValue.trim(); + if (trimmedValue.charAt(trimmedValue.length - 1) === ':') { + // get last word + var lastSpace = trimmedValue.lastIndexOf(' ') + 1; + var lastWord = trimmedValue.slice(lastSpace, trimmedValue.length - 1); + var nicklist = models.getActiveBuffer().getNicklistByTime(); + // check against nicklist to see if it's a list of highlights + for (var index in nicklist) { + if (nicklist[index].name === lastWord) { + // It's another highlight! + newValue = newValue.slice(0, newValue.lastIndexOf(':')) + ' '; + addColon = true; + break; + } + } + } + + // Add a space before the nick if there isn't one already + // Last char might have changed above, so re-check + if (newValue.charAt(newValue.length - 1) !== ' ') { + newValue += ' '; + } + } + // Add highlight to nicklist + newValue += nick; + if (addColon) { + newValue += ': '; + } + $scope.command = newValue; + $scope.getInputNode().focus(); + }; + + + // Handle key presses in the input bar + $rootScope.handleKeyPress = function($event) { + // don't do anything if not connected + if (!$rootScope.connected) { + return true; + } + + var inputNode = $scope.getInputNode(); + + // Support different browser quirks + var code = $event.keyCode ? $event.keyCode : $event.charCode; + + // any other key than Tab resets nick completion iteration + var tmpIterCandidate = $scope.iterCandidate; + $scope.iterCandidate = null; + + // Left Alt+[0-9] -> jump to buffer + if ($event.altKey && !$event.ctrlKey && (code > 47 && code < 58)) { + if (code === 48) { + code = 58; + } + + var bufferNumber = code - 48 - 1 ; + // Map the buffers to only their numbers and IDs so we don't have to + // copy the entire (possibly very large) buffer object, and then sort + // the buffers according to their WeeChat number + var sortedBuffers = _.map(models.getBuffers(), function(buffer) { + return [buffer.number, buffer.id]; + }).sort(function(left, right) { + // By default, Array.prototype.sort() sorts alphabetically. + // Pass an ordering function to sort by first element. + return left[0] - right[0]; + }); + var activeBufferId = sortedBuffers[bufferNumber]; + if (activeBufferId) { + models.setActiveBuffer(activeBufferId[1]); + $event.preventDefault(); + } + } + + // Tab -> nick completion + if (code === 9 && !$event.altKey && !$event.ctrlKey) { + $event.preventDefault(); + $scope.iterCandidate = tmpIterCandidate; + $scope.completeNick(); + return true; + } + + // Left Alt+n -> toggle nicklist + if ($event.altKey && !$event.ctrlKey && code === 78) { + $event.preventDefault(); + $rootScope.toggleNicklist(); + return true; + } + + // Alt+A -> switch to buffer with activity + if ($event.altKey && (code === 97 || code === 65)) { + $event.preventDefault(); + $rootScope.switchToActivityBuffer(); + return true; + } + + // Alt+L -> focus on input bar + if ($event.altKey && (code === 76 || code === 108)) { + $event.preventDefault(); + inputNode.focus(); + inputNode.setSelectionRange($scope.command.length, $scope.command.length); + return true; + } + + // Alt+< -> switch to previous buffer + if ($event.altKey && (code === 60 || code === 226)) { + var previousBuffer = models.getPreviousBuffer(); + if (previousBuffer) { + models.setActiveBuffer(previousBuffer.id); + $event.preventDefault(); + return true; + } + } + + // Double-tap Escape -> disconnect + if (code === 27) { + $event.preventDefault(); + + // Check if a modal is visible. If so, close it instead of disconnecting + var modals = document.querySelectorAll('.gb-modal'); + for (var modalId = 0; modalId < modals.length; modalId++) { + if (modals[modalId].getAttribute('data-state') === 'visible') { + modals[modalId].setAttribute('data-state', 'hidden'); + return true; + } + } + + if (typeof $scope.lastEscape !== "undefined" && (Date.now() - $scope.lastEscape) <= 500) { + // Double-tap + connection.disconnect(); + } + $scope.lastEscape = Date.now(); + return true; + } + + // Alt+G -> focus on buffer filter input + if ($event.altKey && (code === 103 || code === 71)) { + $event.preventDefault(); + document.getElementById('bufferFilter').focus(); + return true; + } + + // Arrow up -> go up in history + if ($event.type === "keydown" && code === 38) { + $scope.command = models.getActiveBuffer().getHistoryUp($scope.command); + // Set cursor to last position. Need 0ms timeout because browser sets cursor + // position to the beginning after this key handler returns. + setTimeout(function() { + if ($scope.command) { + inputNode.setSelectionRange($scope.command.length, $scope.command.length); + } + }, 0); + return true; + } + + // Arrow down -> go down in history + if ($event.type === "keydown" && code === 40) { + $scope.command = models.getActiveBuffer().getHistoryDown($scope.command); + // We don't need to set the cursor to the rightmost position here, the browser does that for us + return true; + } + + // Enter to submit, shift-enter for newline + if (code == 13 && !$event.shiftKey && document.activeElement === inputNode) { + $event.preventDefault(); + $scope.sendMessage(); + return true; + } + // Some readline keybindings + if ($rootScope.readlineBindings && $event.ctrlKey && !$event.altKey && !$event.shiftKey && document.activeElement === inputNode) { + // get current caret position + var caretPos = inputNode.selectionStart; + // Ctrl-a + if (code == 65) { + inputNode.setSelectionRange(0, 0); + // Ctrl-e + } else if (code == 69) { + inputNode.setSelectionRange($scope.command.length, $scope.command.length); + // Ctrl-u + } else if (code == 85) { + $scope.command = $scope.command.slice(caretPos); + setTimeout(function() { + inputNode.setSelectionRange(0, 0); + }); + // Ctrl-k + } else if (code == 75) { + $scope.command = $scope.command.slice(0, caretPos); + setTimeout(function() { + inputNode.setSelectionRange($scope.command.length, $scope.command.length); + }); + // Ctrl-w + } else if (code == 87) { + var trimmedValue = $scope.command.slice(0, caretPos); + var lastSpace = trimmedValue.lastIndexOf(' ') + 1; + $scope.command = $scope.command.slice(0, lastSpace) + $scope.command.slice(caretPos, $scope.command.length); + setTimeout(function() { + inputNode.setSelectionRange(lastSpace, lastSpace); + }); + } else { + return false; + } + $event.preventDefault(); + return true; + } + }; + }] + }; +}); diff --git a/js/notifications.js b/js/notifications.js new file mode 100644 index 0000000..fbeba09 --- /dev/null +++ b/js/notifications.js @@ -0,0 +1,141 @@ +var weechat = angular.module('weechat'); + +weechat.factory('notifications', ['$rootScope', '$log', 'models', function($rootScope, $log, models) { + // Ask for permission to display desktop notifications + var requestNotificationPermission = function() { + // Firefox + if (window.Notification) { + Notification.requestPermission(function(status) { + $log.info('Notification permission status: ', status); + if (Notification.permission !== status) { + Notification.permission = status; + } + }); + } + + // Webkit + if (window.webkitNotifications !== undefined) { + var havePermission = window.webkitNotifications.checkPermission(); + if (havePermission !== 0) { // 0 is PERMISSION_ALLOWED + $log.info('Notification permission status: ', havePermission === 0); + window.webkitNotifications.requestPermission(); + } + } + }; + + + // Reduce buffers with "+" operation over a key. Mostly useful for unread/notification counts. + var unreadCount = function(type) { + if (!type) { + type = "unread"; + } + + // Do this the old-fashioned way with iterating over the keys, as underscore proved to be error-prone + var keys = Object.keys(models.model.buffers); + var count = 0; + for (var key in keys) { + count += models.model.buffers[keys[key]][type]; + } + + return count; + }; + + + var updateTitle = function() { + var notifications = unreadCount('notification'); + if (notifications > 0) { + // New notifications deserve an exclamation mark + $rootScope.notificationStatus = '(' + notifications + ') '; + } else { + $rootScope.notificationStatus = ''; + } + + var activeBuffer = models.getActiveBuffer(); + if (activeBuffer) { + $rootScope.pageTitle = activeBuffer.shortName + ' | ' + activeBuffer.title; + } + }; + + var updateFavico = function() { + var notifications = unreadCount('notification'); + if (notifications > 0) { + $rootScope.favico.badge(notifications, { + bgColor: '#d00', + textColor: '#fff' + }); + } else { + var unread = unreadCount('unread'); + if (unread === 0) { + $rootScope.favico.reset(); + } else { + $rootScope.favico.badge(unread, { + bgColor: '#5CB85C', + textColor: '#ff0' + }); + } + } + }; + + /* Function gets called from bufferLineAdded code if user should be notified */ + var createHighlight = function(buffer, message) { + var title = ''; + var body = ''; + var numNotifications = buffer.notification; + + if (['#', '&', '+', '!'].indexOf(buffer.shortName.charAt(0)) < 0) { + if (numNotifications > 1) { + title = numNotifications.toString() + ' private messages from '; + } else { + title = 'Private message from '; + } + body = message.text; + } else { + if (numNotifications > 1) { + title = numNotifications.toString() + ' highlights in '; + } else { + title = 'Highlight in '; + } + var prefix = ''; + for (var i = 0; i < message.prefix.length; i++) { + prefix += message.prefix[i].text; + } + body = '<' + prefix + '> ' + message.text; + } + title += buffer.shortName; + title += buffer.fullName.replace(/irc.([^\.]+)\..+/, " ($1)"); + + var notification = new Notification(title, { + body: body, + icon: 'assets/img/favicon.png' + }); + + // Cancel notification automatically + var timeout = 15*1000; + notification.onshow = function() { + setTimeout(function() { + notification.close(); + }, timeout); + }; + + // Click takes the user to the buffer + notification.onclick = function() { + models.setActiveBuffer(buffer.id); + window.focus(); + notification.close(); + }; + + if ($rootScope.soundnotification) { + // TODO fill in a sound file + var audioFile = "assets/audio/sonar"; + var soundHTML = ''; + document.getElementById("soundNotification").innerHTML = soundHTML; + } + }; + + return { + requestNotificationPermission: requestNotificationPermission, + updateTitle: updateTitle, + updateFavico: updateFavico, + createHighlight: createHighlight, + }; +}]); diff --git a/js/plugin-directive.js b/js/plugin-directive.js new file mode 100644 index 0000000..62ea463 --- /dev/null +++ b/js/plugin-directive.js @@ -0,0 +1,56 @@ +var weechat = angular.module('weechat'); + +weechat.directive('plugin', ['$rootScope', function($rootScope) { + /* + * Plugin directive + * Shows additional plugin content + */ + return { + templateUrl: 'directives/plugin.html', + + scope: { + plugin: '=data' + }, + + controller: ['$scope', function($scope) { + + $scope.displayedContent = ""; + + $scope.plugin.visible = $rootScope.auto_display_embedded_content; + + $scope.hideContent = function() { + $scope.plugin.visible = false; + }; + + $scope.showContent = function() { + /* + * Shows the plugin content. + * displayedContent is bound to the DOM. + * Actual plugin content is only fetched when + * content is shown. + */ + + // If the plugin is asynchronous / lazy, execute it now and store + // the result. This ensures that the callback is executed only once + if ($scope.plugin.content instanceof Function) { + $scope.plugin.content = $scope.plugin.content(); + } + $scope.displayedContent = $scope.plugin.content; + $scope.plugin.visible = true; + + // Scroll embed content into view + var scroll = function() { + var embed = document.querySelector(".embed_" + $scope.plugin.$$hashKey); + if (embed) { + embed.scrollIntoViewIfNeeded(); + } + }; + setTimeout(scroll, 100); + }; + + if ($scope.plugin.visible) { + $scope.showContent(); + } + }] + }; +}]); diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 0000000..5a10c7a --- /dev/null +++ b/js/utils.js @@ -0,0 +1,29 @@ +var weechat = angular.module('weechat'); + +weechat.factory('utils', function() { + // Helper to change style of a class + var changeClassStyle = function(classSelector, attr, value) { + _.each(document.getElementsByClassName(classSelector), function(e) { + e.style[attr] = value; + }); + }; + // Helper to get style from a class + var getClassStyle = function(classSelector, attr) { + _.each(document.getElementsByClassName(classSelector), function(e) { + return e.style[attr]; + }); + }; + + var isMobileUi = function() { + // TODO don't base detection solely on screen width + // You are right. In the meantime I am renaming isMobileDevice to isMobileUi + var mobile_cutoff = 968; + return (document.body.clientWidth < mobile_cutoff); + }; + + return { + changeClassStyle: changeClassStyle, + getClassStyle: getClassStyle, + isMobileUi: isMobileUi + }; +}); diff --git a/package.json b/package.json index 4fe93af..7913fa2 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "scripts": { "postinstall": "bower install", - "minify": " uglifyjs js/localstorage.js js/weechat.js js/irc-utils.js js/glowingbear.js js/websockets.js js/models.js js/plugins.js -c -m --screw-ie8 -o min.js --source-map min.map", + "minify": " uglifyjs js/localstorage.js js/weechat.js js/irc-utils.js js/glowingbear.js js/utils.js js/notifications.js js/filters.js js/handlers.js js/connection.js js/inputbar.js js/plugin-directive.js js/websockets.js js/models.js js/plugins.js -c -m --screw-ie8 -o min.js --source-map min.map", "prestart": "npm install", "start": "http-server -a localhost -p 8000",