(function() { 'use strict'; var weechat = angular.module('weechat'); weechat.directive('inputBar', function() { return { templateUrl: 'directives/input.html', scope: { inputId: '@inputId', command: '=command' }, controller: ['$rootScope', '$scope', '$element', '$log', '$compile', 'connection', 'imgur', 'models', 'IrcUtils', 'settings', 'utils', function($rootScope, $scope, $element, //XXX do we need this? don't seem to be using it $log, $compile, connection, //XXX we should eliminate this dependency and use signals instead imgur, models, IrcUtils, settings, utils) { // Expose utils to be able to check if we're on a mobile UI $scope.utils = utils; // Emojify input. E.g. Turn :smile: into the unicode equivalent, but // don't do replacements in the middle of a word (e.g. std::io::foo) $scope.inputChanged = function() { // Cancel any command completion that was still ongoing commandCompletionInputChanged = true; var emojiRegex = /^(?:[\uD800-\uDBFF][\uDC00-\uDFFF])+$/, // *only* emoji changed = false, // whether a segment was modified inputNode = $scope.getInputNode(), caretPos = inputNode.selectionStart, position = 0; // current position in text // use capturing group in regex to include whitespace in output array var segments = $scope.command.split(/(\s+)/); for (var i = 0; i < segments.length; i ++) { if (/\s+/.test(segments[i]) || emojiRegex.test(segments[i])) { // ignore whitespace and emoji-only segments position += segments[i].length; continue; } // emojify segment var emojified = emojione.shortnameToUnicode(segments[i]); if (emojiRegex.test(emojified)) { // If result consists *only* of emoji, adjust caret // position and replace segment with emojified version caretPos = caretPos - segments[i].length + emojified.length; segments[i] = emojified; changed = true; } position += segments[i].length; } if (changed) { // Only re-assemble if something changed $scope.command = segments.join(''); setTimeout(function() { inputNode.setSelectionRange(caretPos, caretPos); }); } }; /* * Returns the input element */ $scope.getInputNode = function() { return document.querySelector('textarea#' + $scope.inputId); }; $scope.hideSidebar = function() { $rootScope.hideSidebar(); }; $scope.completeNick = function() { if ((models.version[0] == 2 && models.version[1] >= 9 || models.version[0] > 2) && $scope.command.startsWith('/') ) { // We are completing a command, another function will do // this on WeeChat 2.9 and later return; } // 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 completion_suffix = models.wconfig['weechat.completion.nick_completer']; var add_space = models.wconfig['weechat.completion.nick_add_space']; var nickComp = IrcUtils.completeNick(input, caretPos, $scope.iterCandidate, activeBuffer.getNicklistByTime(), completion_suffix, add_space); // 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); }; var previousInput; var commandCompletionList; var commandCompletionAddSpace; var commandCompletionBaseWord; var commandCompletionPosition; var commandCompletionPositionInList; var commandCompletionInputChanged; $scope.completeCommand = function(direction) { if (models.version[0] < 2 || (models.version[0] == 2 && models.version[1] < 9)) { // Command completion is only supported on WeeChat 2.9+ return; } if ( !$scope.command.startsWith('/') ) { // We are not completing a command, maybe a nick? return; } // Cancel if input changes commandCompletionInputChanged = false; // 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 || ''; // This function is for later cycling the list after we got it var cycleCompletionList = function (direction) { // Don't do anything, the input has changed before we were able to complete the command if ( commandCompletionInputChanged ) { return; } // Check if the list has elements and we have not cycled to the end yet if ( !commandCompletionList || !commandCompletionList[0] ) { return; } // If we are cycling in the other direction, go back two placed in the list if ( direction === 'backward' ) { commandCompletionPositionInList -= 2; if ( commandCompletionPositionInList < 0 ) { // We have reached the beginning of list and are going backward, so go to the end; commandCompletionPositionInList = commandCompletionList.length - 1; } } // Check we have not reached the end of the cycle list if ( commandCompletionList.length <= commandCompletionPositionInList ) { // We have reached the end of the list, start at the beginning commandCompletionPositionInList = 0; } // Cycle the list // First remove the word that's to be completed var commandBeforeReplace = $scope.command.substring(0, commandCompletionPosition - commandCompletionBaseWord.length); var commandAfterReplace = $scope.command.substring(commandCompletionPosition, $scope.command.length); var replacedWord = commandCompletionList[commandCompletionPositionInList]; var suffix = commandCompletionAddSpace ? ' ' : ''; // Fill in the new command $scope.command = commandBeforeReplace + replacedWord + suffix + commandAfterReplace; // Set the cursor position var newCursorPos = commandBeforeReplace.length + replacedWord.length + suffix.length; setTimeout(function() { inputNode.focus(); inputNode.setSelectionRange(newCursorPos, newCursorPos); }, 0); // If there is only one item in the list, we are done, no next cycle if ( commandCompletionList.length === 1) { previousInput = ''; return; } // Setup for the next cycle commandCompletionPositionInList++; commandCompletionBaseWord = replacedWord + suffix; previousInput = $scope.command + activeBuffer.id; commandCompletionPosition = newCursorPos; }; // Check if we have requested this completion info before if (input + activeBuffer.id !== previousInput) { // Remeber we requested this input for next time previousInput = input + activeBuffer.id; // Ask weechat for the completion list connection.requestCompletion(activeBuffer.id, caretPos, input).then( function(completionObject) { // Save the list of completion object, we will only request is once // and cycle through it as long as the input doesn't change commandCompletionList = completionObject.list; commandCompletionAddSpace = completionObject.add_space; commandCompletionBaseWord = completionObject.base_word; commandCompletionPosition = caretPos; commandCompletionPositionInList = 0; }).then( function () { //after we get the list we can continue with our first cycle cycleCompletionList(direction); }); } else { // Input hasn't changed so we should already have our completion list cycleCompletionList(direction); } }; $rootScope.insertAtCaret = function(toInsert) { // caret position in the input bar var inputNode = $scope.getInputNode(), caretPos = inputNode.selectionStart; var prefix = $scope.command.substring(0, caretPos), suffix = $scope.command.substring(caretPos, $scope.command.length); // Add spaces if missing if (prefix.length > 0 && prefix[prefix.length - 1] !== ' ') { prefix += ' '; } if (suffix.length > 0 && suffix[0] !== ' ') { suffix = ' '.concat(suffix); } $scope.command = prefix + toInsert + suffix; setTimeout(function() { inputNode.focus(); var pos = $scope.command.length - suffix.length; inputNode.setSelectionRange(pos, pos); // force refresh? $scope.$apply(); }, 0); }; $scope.uploadImage = function($event, files) { // Send image url after upload var sendImageUrl = function(imageUrl, deleteHash) { // Put link in input box if(imageUrl !== undefined && imageUrl !== '') { $rootScope.insertAtCaret(String(imageUrl)); } }; if(typeof files !== "undefined" && files.length > 0) { // Loop through files for (var i = 0; i < files.length; i++) { // Process image imgur.process(files[i], sendImageUrl); } } }; var deleteCallback = function (deleteHash) { // Image got sucessfully deleted. // Show toast with delete link var toastDeleted = $compile('
Successfully deleted.
')($scope)[0]; document.body.appendChild(toastDeleted); setTimeout(function() { document.body.removeChild(toastDeleted); }, 5000); // Try to remove the toast with the deletion link (it stays 15s // instead of the 5 of the deletion notification, so it could // come back beneath it, which would be confusing) var pasteToast = document.querySelector("[data-imgur-deletehash='" + deleteHash + "']"); if (!!pasteToast) { document.body.removeChild(pasteToast); } }; $scope.imgurDelete = function (deleteHash) { imgur.deleteImage( deleteHash, deleteCallback ); }; // 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) { // Ask before a /quit if (line === '/quit' || line.indexOf('/quit ') === 0) { if (!window.confirm("Are you sure you want to quit WeeChat? This will prevent you from connecting with Glowing Bear until you restart WeeChat on the command line!")) { // skip this line return; } } connection.sendMessage(line); }); // Check for /clear command if ($scope.command === '/buffer clear' || $scope.command === '/c') { $log.debug('Clearing lines'); ab.clear(); } // Check against a list of commands that opens a new // buffer and save the name of the buffer so we can // also automatically switch to the new buffer in gb var opencommands = ['/query', '/join', '/j', '/q']; var spacepos = $scope.command.indexOf(' '); var firstword = $scope.command.substr(0, spacepos); var index = opencommands.indexOf(firstword); if (index >= 0) { var queryName = $scope.command.substring(spacepos + 1); // Cache our queries so when a buffer gets opened we can open in UI models.outgoingQueries.push(queryName); } // Empty the input after it's sent $scope.command = ''; } // New style clearing requires this, old does not if (settings.hotlistsync && models.version[0] >= 1) { connection.sendHotlistClear(); } $scope.getInputNode().focus(); }; //XXX THIS DOES NOT BELONG HERE! $rootScope.addMention = function(bufferline) { if (!bufferline.showHiddenBrackets) { // the line is a notice or action or something else that doesn't belong return; } var prefix = bufferline.prefix; // Extract nick from bufferline prefix var nick = prefix[prefix.length - 1].text; // Check whether the user is still online var buffer = models.getBuffer(bufferline.buffer); var is_online = buffer.queryNicklist(nick); if (buffer.type === 'channel' && !is_online) { // show a toast that the user left var toast = document.createElement('div'); toast.className = "toast toast-short"; toast.innerHTML = nick + " has left the room"; document.body.appendChild(toast); setTimeout(function() { document.body.removeChild(toast); }, 5000); } 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; // A KeyboardEvent property representing the physical key that was pressed, ignoring the keyboard layout and ignoring whether any modifier keys were active. // Not supported in Edge or Safari at the time of writing this, but supported in Firefox and Chrome. var key = $event.code; // Safari doesn't implement DOM 3 input events yet as of 8.0.6 var altg = $event.getModifierState ? $event.getModifierState('AltGraph') : false; // Mac OSX behaves differntly for altgr, so we check for that if (altg) { // We don't handle any anything with altgr return false; } // reset quick keys display $rootScope.showQuickKeys = false; // any other key than Tab resets nick completion iteration var tmpIterCandidate = $scope.iterCandidate; $scope.iterCandidate = null; var bufferNumber; var sortedBuffers; var filteredBufferNum; var activeBufferId; // if Alt+J was pressed last... if ($rootScope.showJumpKeys) { var cleanup = function() { // cleanup helper $rootScope.showJumpKeys = false; $rootScope.jumpDecimal = undefined; $scope.$parent.search = ''; $scope.$parent.search_placeholder = 'Search'; $rootScope.refresh_filter_predicate(); }; // ... we expect two digits now if (!$event.altKey && (code > 47 && code < 58)) { // first digit if ($rootScope.jumpDecimal === undefined) { $rootScope.jumpDecimal = code - 48; $event.preventDefault(); $scope.$parent.search = $rootScope.jumpDecimal; $rootScope.refresh_filter_predicate(); // second digit, jump to correct buffer } else { bufferNumber = ($rootScope.jumpDecimal * 10) + (code - 48); $scope.$parent.setActiveBuffer(bufferNumber, '$jumpKey'); $event.preventDefault(); cleanup(); } } else { // Not a decimal digit, abort cleanup(); } } // Left Alt+[0-9] -> jump to buffer if ($event.altKey && !$event.ctrlKey && (code > 47 && code < 58) && settings.enableQuickKeys) { if (code === 48) { code = 58; } bufferNumber = code - 48 - 1 ; // quick select filtered entries if (($scope.$parent.search.length || $scope.$parent.onlyUnread) && $scope.$parent.filteredBuffers.length) { filteredBufferNum = $scope.$parent.filteredBuffers[bufferNumber]; if (filteredBufferNum !== undefined) { activeBufferId = [filteredBufferNum.number, filteredBufferNum.id]; } } else { // 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 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]; }); activeBufferId = sortedBuffers[bufferNumber]; } if (activeBufferId) { $scope.$parent.setActiveBuffer(activeBufferId[1]); $event.preventDefault(); } } // Tab -> nick completion if (code === 9 && !$event.altKey && !$event.ctrlKey && !$event.shiftKey) { $event.preventDefault(); $scope.iterCandidate = tmpIterCandidate; $scope.completeNick(); $scope.completeCommand('forward'); return true; } // Shitft-Tab -> nick completion backward (only commands) if (code === 9 && !$event.altKey && !$event.ctrlKey && $event.shiftKey) { $event.preventDefault(); $scope.completeCommand('backward'); 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+Arrow up/down -> switch to prev/next adjacent buffer if ($event.altKey && !$event.ctrlKey && (code === 38 || code === 40)) { $event.preventDefault(); var direction = code - 39; $rootScope.switchToAdjacentBuffer(direction); 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 // https://w3c.github.io/uievents-code/#code-IntlBackslash if ($event.altKey && (code === 60 || code === 226 || key === "IntlBackslash")) { 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(); if (!$scope.$parent.isSidebarVisible()) { $scope.$parent.showSidebar(); } setTimeout(function() { document.getElementById('bufferFilter').focus(); }); return true; } // Alt-h -> Toggle all as read if ($event.altKey && !$event.ctrlKey && code === 72) { var buffers = models.getBuffers(); _.each(buffers, function(buffer) { buffer.unread = 0; buffer.notification = 0; }); var servers = models.getServers(); _.each(servers, function(server) { server.unread = 0; }); connection.sendHotlistClearAll(); } // Alt+J -> Jump to buffer if ($event.altKey && (code === 106 || code === 74)) { $event.preventDefault(); // reset search state and show jump keys $scope.$parent.search = ''; $scope.$parent.search_placeholder = 'Number'; $rootScope.showJumpKeys = true; return true; } var caretPos; // Arrow up -> go up in history if ($event.type === "keydown" && code === 38 && document.activeElement === inputNode) { // In case of multiline we don't want to do this unless at the first line if ($scope.command) { caretPos = inputNode.selectionStart; if ($scope.command.slice(0, caretPos).indexOf("\n") !== -1) { return false; } } $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 && document.activeElement === inputNode) { // In case of multiline we don't want to do this if ($scope.command) { caretPos = inputNode.selectionStart; if ( $scope.command.slice(caretPos).indexOf("\n") !== -1) { return false; } } $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; } var bufferlines = document.getElementById("bufferlines"); var lines; var i; // Page up -> scroll up if ($event.type === "keydown" && code === 33 && document.activeElement === inputNode && !$event.ctrlKey && !$event.altKey && !$event.shiftKey) { if (bufferlines.scrollTop === 0) { if (!$rootScope.loadingLines) { $scope.$parent.fetchMoreLines(); } return true; } lines = bufferlines.querySelectorAll("tr"); for (i = lines.length - 1; i >= 0; i--) { if ((lines[i].offsetTop-bufferlines.scrollTop) scroll down if ($event.type === "keydown" && code === 34 && document.activeElement === inputNode && !$event.ctrlKey && !$event.altKey && !$event.shiftKey) { lines = bufferlines.querySelectorAll("tr"); for (i = 0; i < lines.length; i++) { if ((lines[i].offsetTop-bufferlines.scrollTop)>bufferlines.clientHeight/2) { lines[i].scrollIntoView(true); break; } } return true; } // Some readline keybindings if (settings.readlineBindings && $event.ctrlKey && !$event.altKey && !$event.shiftKey && document.activeElement === inputNode) { // get current caret position 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.replace(/\s+$/, '').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; } // Alt key down -> display quick key legend if ($event.type === "keydown" && code === 18 && !$event.ctrlKey && !$event.shiftKey && settings.enableQuickKeys) { $rootScope.showQuickKeys = true; } }; $rootScope.handleKeyRelease = function($event) { // Alt key up -> remove quick key legend if ($event.keyCode === 18) { if ($rootScope.quickKeysTimer !== undefined) { clearTimeout($rootScope.quickKeysTimer); } $rootScope.quickKeysTimer = setTimeout(function() { if ($rootScope.showQuickKeys) { $rootScope.showQuickKeys = false; $rootScope.$apply(); } delete $rootScope.quickKeysTimer; }, 1000); return true; } }; $scope.handleCompleteNickButton = function($event) { $event.preventDefault(); $scope.completeNick(); $scope.completeCommand('forward'); setTimeout(function() { $scope.getInputNode().focus(); }, 0); return true; }; $scope.inputPasted = function(e) { if (e.clipboardData && e.clipboardData.files && e.clipboardData.files.length) { e.stopPropagation(); e.preventDefault(); var sendImageUrl = function(imageUrl, deleteHash) { if(imageUrl !== undefined && imageUrl !== '') { $rootScope.insertAtCaret(String(imageUrl)); } // Show toast with delete link var toastImgur = $compile('
Image uploaded to Imgur. Delete?
')($scope)[0]; document.body.appendChild(toastImgur); setTimeout(function() { document.body.removeChild(toastImgur); }, 15000); // Log the delete hash to the console in case the toast was missed. console.log('An image was uploaded to imgur, delete it with $scope.imgurDelete(\'' + deleteHash + '\')'); }; for (var i = 0; i < e.clipboardData.files.length; i++) { imgur.process(e.clipboardData.files[i], sendImageUrl); } return false; } return true; }; }] }; }); })();