diff --git a/header b/header index fd88ee27f7..060709b82e 100644 --- a/header +++ b/header @@ -1,5 +1,5 @@ /* -Copyright 2015 OpenMarket Ltd +Copyright 2015, 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/Avatar.js b/src/Avatar.js index c919630f96..e97ed6b673 100644 --- a/src/Avatar.js +++ b/src/Avatar.js @@ -1,5 +1,5 @@ /* -Copyright 2015 OpenMarket Ltd +Copyright 2015, 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/CallHandler.js b/src/CallHandler.js index 189e99b307..e5bc80246e 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -1,5 +1,5 @@ /* -Copyright 2015 OpenMarket Ltd +Copyright 2015, 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/ContentMessages.js b/src/ContentMessages.js index 094eff18d9..82c295756b 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -1,5 +1,5 @@ /* -Copyright 2015 OpenMarket Ltd +Copyright 2015, 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/ContextualMenu.js b/src/ContextualMenu.js index a7b1849e18..e720b69eda 100644 --- a/src/ContextualMenu.js +++ b/src/ContextualMenu.js @@ -1,5 +1,5 @@ /* -Copyright 2015 OpenMarket Ltd +Copyright 2015, 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/DateUtils.js b/src/DateUtils.js index fe363586ab..208134ee83 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.js @@ -1,5 +1,5 @@ /* -Copyright 2015 OpenMarket Ltd +Copyright 2015, 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -35,10 +35,10 @@ module.exports = { return days[date.getDay()] + " " + pad(date.getHours()) + ':' + pad(date.getMinutes()); } else if (now.getFullYear() === date.getFullYear()) { - return days[date.getDay()] + ", " + months[date.getMonth()] + " " + (date.getDay()+1) + " " + pad(date.getHours()) + ':' + pad(date.getMinutes()); + return days[date.getDay()] + ", " + months[date.getMonth()] + " " + date.getDate() + " " + pad(date.getHours()) + ':' + pad(date.getMinutes()); } else { - return days[date.getDay()] + ", " + months[date.getMonth()] + " " + (date.getDay()+1) + " " + date.getFullYear() + " " + pad(date.getHours()) + ':' + pad(date.getMinutes()); + return days[date.getDay()] + ", " + months[date.getMonth()] + " " + date.getDate() + " " + date.getFullYear() + " " + pad(date.getHours()) + ':' + pad(date.getMinutes()); } } } diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 7a3cdd277b..603f595951 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -1,5 +1,5 @@ /* -Copyright 2015 OpenMarket Ltd +Copyright 2015, 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -48,8 +48,15 @@ var sanitizeHtmlParams = { }, }; -module.exports = { - _applyHighlights: function(safeSnippet, highlights, html, k) { +class Highlighter { + constructor(html, highlightClass, onHighlightClick) { + this.html = html; + this.highlightClass = highlightClass; + this.onHighlightClick = onHighlightClick; + this._key = 0; + } + + applyHighlights(safeSnippet, highlights) { var lastOffset = 0; var offset; var nodes = []; @@ -61,77 +68,97 @@ module.exports = { // If and when this happens, we'll probably have to split his method in two between // HTML and plain-text highlighting. - var safeHighlight = html ? sanitizeHtml(highlights[0], sanitizeHtmlParams) : highlights[0]; - while ((offset = safeSnippet.indexOf(safeHighlight, lastOffset)) >= 0) { + var safeHighlight = this.html ? sanitizeHtml(highlights[0], sanitizeHtmlParams) : highlights[0]; + while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) { // handle preamble if (offset > lastOffset) { - nodes = nodes.concat(this._applySubHighlightsInRange(safeSnippet, lastOffset, offset, highlights, html, k)); - k += nodes.length; + var subSnippet = safeSnippet.substring(lastOffset, offset); + nodes = nodes.concat(this._applySubHighlights(subSnippet, highlights)); } // do highlight - if (html) { - nodes.push(); - } - else { - nodes.push({ safeHighlight }); - } + nodes.push(this._createSpan(safeHighlight, true)); lastOffset = offset + safeHighlight.length; } // handle postamble if (lastOffset != safeSnippet.length) { - nodes = nodes.concat(this._applySubHighlightsInRange(safeSnippet, lastOffset, undefined, highlights, html, k)); - k += nodes.length; + var subSnippet = safeSnippet.substring(lastOffset, undefined); + nodes = nodes.concat(this._applySubHighlights(subSnippet, highlights)); } return nodes; - }, + } - _applySubHighlightsInRange: function(safeSnippet, lastOffset, offset, highlights, html, k) { - var nodes = []; + _applySubHighlights(safeSnippet, highlights) { if (highlights[1]) { // recurse into this range to check for the next set of highlight matches - var subnodes = this._applyHighlights( safeSnippet.substring(lastOffset, offset), highlights.slice(1), html, k ); - nodes = nodes.concat(subnodes); - k += subnodes.length; + return this.applyHighlights(safeSnippet, highlights.slice(1)); } else { // no more highlights to be found, just return the unhighlighted string - if (html) { - nodes.push(); - } - else { - nodes.push({ safeSnippet.substring(lastOffset, offset) }); - } + return [this._createSpan(safeSnippet, false)]; } - return nodes; - }, + } - bodyToHtml: function(content, highlights) { - var originalBody = content.body; - var body; - var k = 0; + /* create a node to hold the given content + * + * spanBody: content of the span. If html, must have been sanitised + * highlight: true to highlight as a search match + */ + _createSpan(spanBody, highlight) { + var spanProps = { + key: this._key++, + }; - if (highlights && highlights.length > 0) { - var bodyList = []; + if (highlight) { + spanProps.onClick = this.onHighlightClick; + spanProps.className = this.highlightClass; + } - if (content.format === "org.matrix.custom.html") { - var safeBody = sanitizeHtml(content.formatted_body, sanitizeHtmlParams); - bodyList = this._applyHighlights(safeBody, highlights, true, k); - } - else { - bodyList = this._applyHighlights(originalBody, highlights, true, k); - } - body = bodyList; + if (this.html) { + return (); } else { - if (content.format === "org.matrix.custom.html") { - var safeBody = sanitizeHtml(content.formatted_body, sanitizeHtmlParams); + return ({ spanBody }); + } + } +} + + +module.exports = { + /* turn a matrix event body into html + * + * content: 'content' of the MatrixEvent + * + * highlights: optional list of words to highlight + * + * opts.onHighlightClick: optional callback function to be called when a + * highlighted word is clicked + */ + bodyToHtml: function(content, highlights, opts) { + opts = opts || {}; + + var isHtml = (content.format === "org.matrix.custom.html"); + + var safeBody; + if (isHtml) { + safeBody = sanitizeHtml(content.formatted_body, sanitizeHtmlParams); + } else { + safeBody = content.body; + } + + var body; + if (highlights && highlights.length > 0) { + var highlighter = new Highlighter(isHtml, "mx_EventTile_searchHighlight", opts.onHighlightClick); + body = highlighter.applyHighlights(safeBody, highlights); + } + else { + if (isHtml) { body = ; } else { - body = originalBody; + body = safeBody; } } diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index d940b69f27..4a83ed09d9 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -1,5 +1,5 @@ /* -Copyright 2015 OpenMarket Ltd +Copyright 2015, 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/MatrixTools.js b/src/MatrixTools.js index 6c7a9ee9ba..7fded4adea 100644 --- a/src/MatrixTools.js +++ b/src/MatrixTools.js @@ -1,5 +1,5 @@ /* -Copyright 2015 OpenMarket Ltd +Copyright 2015, 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/Modal.js b/src/Modal.js index 41b1a9c0ab..d3a5404e1e 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -1,5 +1,5 @@ /* -Copyright 2015 OpenMarket Ltd +Copyright 2015, 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/Notifier.js b/src/Notifier.js index a35c3bb1ee..e52fd252fe 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -1,5 +1,5 @@ /* -Copyright 2015 OpenMarket Ltd +Copyright 2015, 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/Presence.js b/src/Presence.js index e776cca078..5c9d6945a3 100644 --- a/src/Presence.js +++ b/src/Presence.js @@ -1,5 +1,5 @@ /* -Copyright 2015 OpenMarket Ltd +Copyright 2015, 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/Resend.js b/src/Resend.js index e07e571455..ae5aaa9ea9 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -1,5 +1,5 @@ /* -Copyright 2015 OpenMarket Ltd +Copyright 2015, 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/RoomListSorter.js b/src/RoomListSorter.js index 730a0de18b..09f178dd3f 100644 --- a/src/RoomListSorter.js +++ b/src/RoomListSorter.js @@ -1,5 +1,5 @@ /* -Copyright 2015 OpenMarket Ltd +Copyright 2015, 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/Skinner.js b/src/Skinner.js index 3e71d10e2d..4482f2239c 100644 --- a/src/Skinner.js +++ b/src/Skinner.js @@ -1,5 +1,5 @@ /* -Copyright 2015 OpenMarket Ltd +Copyright 2015, 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/SlashCommands.js b/src/SlashCommands.js index e6ea7533dc..2c1f25a2d4 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -1,5 +1,5 @@ /* -Copyright 2015 OpenMarket Ltd +Copyright 2015, 2016 OpenMarket Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -99,29 +99,33 @@ var commands = { } // Try to find a room with this alias + // XXX: do we need to do this? Doesn't the JS SDK suppress duplicate attempts to join the same room? var foundRoom = MatrixTools.getRoomForAlias( MatrixClientPeg.get().getRooms(), room_alias ); - if (foundRoom) { // we've already joined this room, view it. - dis.dispatch({ - action: 'view_room', - room_id: foundRoom.roomId - }); - return success(); - } - else { - // attempt to join this alias. - return success( - MatrixClientPeg.get().joinRoom(room_alias).then( - function(room) { - dis.dispatch({ - action: 'view_room', - room_id: room.roomId - }); - }) - ); + + if (foundRoom) { // we've already joined this room, view it if it's not archived. + var me = foundRoom.getMember(MatrixClientPeg.get().credentials.userId); + if (me && me.membership !== "leave") { + dis.dispatch({ + action: 'view_room', + room_id: foundRoom.roomId + }); + return success(); + } } + + // otherwise attempt to join this alias. + return success( + MatrixClientPeg.get().joinRoom(room_alias).then( + function(room) { + dis.dispatch({ + action: 'view_room', + room_id: room.roomId + }); + }) + ); } } return reject("Usage: /join "); diff --git a/src/TabComplete.js b/src/TabComplete.js new file mode 100644 index 0000000000..6690802d5d --- /dev/null +++ b/src/TabComplete.js @@ -0,0 +1,307 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +var Entry = require("./TabCompleteEntries").Entry; + +const DELAY_TIME_MS = 1000; +const KEY_TAB = 9; +const KEY_SHIFT = 16; +const KEY_WINDOWS = 91; + +// NB: DO NOT USE \b its "words" are roman alphabet only! +// +// Capturing group containing the start +// of line or a whitespace char +// \_______________ __________Capturing group of 1 or more non-whitespace chars +// _|__ _|_ followed by the end of line +// / \/ \ +const MATCH_REGEX = /(^|\s)(\S+)$/; + +class TabComplete { + + constructor(opts) { + opts.startingWordSuffix = opts.startingWordSuffix || ""; + opts.wordSuffix = opts.wordSuffix || ""; + opts.allowLooping = opts.allowLooping || false; + opts.autoEnterTabComplete = opts.autoEnterTabComplete || false; + opts.onClickCompletes = opts.onClickCompletes || false; + this.opts = opts; + this.completing = false; + this.list = []; // full set of tab-completable things + this.matchedList = []; // subset of completable things to loop over + this.currentIndex = 0; // index in matchedList currently + this.originalText = null; // original input text when tab was first hit + this.textArea = opts.textArea; // DOMElement + this.isFirstWord = false; // true if you tab-complete on the first word + this.enterTabCompleteTimerId = null; + this.inPassiveMode = false; + } + + /** + * @param {Entry[]} completeList + */ + setCompletionList(completeList) { + this.list = completeList; + if (this.opts.onClickCompletes) { + // assign onClick listeners for each entry to complete the text + this.list.forEach((l) => { + l.onClick = () => { + this.completeTo(l.getText()); + } + }); + } + } + + /** + * @param {DOMElement} + */ + setTextArea(textArea) { + this.textArea = textArea; + } + + /** + * @return {Boolean} + */ + isTabCompleting() { + // actually have things to tab over + return this.completing && this.matchedList.length > 1; + } + + stopTabCompleting() { + this.completing = false; + this.currentIndex = 0; + this._notifyStateChange(); + } + + startTabCompleting() { + this.completing = true; + this.currentIndex = 0; + this._calculateCompletions(); + } + + /** + * Do an auto-complete with the given word. This terminates the tab-complete. + * @param {string} someVal + */ + completeTo(someVal) { + this.textArea.value = this._replaceWith(someVal, true); + this.stopTabCompleting(); + // keep focus on the text area + this.textArea.focus(); + } + + /** + * @param {Number} numAheadToPeek Return *up to* this many elements. + * @return {Entry[]} + */ + peek(numAheadToPeek) { + if (this.matchedList.length === 0) { + return []; + } + var peekList = []; + + // return the current match item and then one with an index higher, and + // so on until we've reached the requested limit. If we hit the end of + // the list of options we're done. + for (var i = 0; i < numAheadToPeek; i++) { + var nextIndex; + if (this.opts.allowLooping) { + nextIndex = (this.currentIndex + i) % this.matchedList.length; + } + else { + nextIndex = this.currentIndex + i; + if (nextIndex === this.matchedList.length) { + break; + } + } + peekList.push(this.matchedList[nextIndex]); + } + // console.log("Peek list(%s): %s", numAheadToPeek, JSON.stringify(peekList)); + return peekList; + } + + handleTabPress(passive, shiftKey) { + var wasInPassiveMode = this.inPassiveMode && !passive; + this.inPassiveMode = passive; + + if (!this.completing) { + this.startTabCompleting(); + } + + if (shiftKey) { + this.nextMatchedEntry(-1); + } + else { + // if we were in passive mode we got out of sync by incrementing the + // index to show the peek view but not set the text area. Therefore, + // we want to set the *current* index rather than the *next* index. + this.nextMatchedEntry(wasInPassiveMode ? 0 : 1); + } + this._notifyStateChange(); + } + + /** + * @param {DOMEvent} e + */ + onKeyDown(ev) { + if (!this.textArea) { + console.error("onKeyDown called before a