diff --git a/src/CallHandler.js b/src/CallHandler.js index 187449924f..189e99b307 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -138,9 +138,17 @@ function _setCallListeners(call) { function _setCallState(call, roomId, status) { console.log( - "Call state in %s changed to %s (%s)", roomId, status, (call ? call.state : "-") + "Call state in %s changed to %s (%s)", roomId, status, (call ? call.call_state : "-") ); calls[roomId] = call; + + if (status === "ringing") { + play("ringAudio") + } + else if (call && call.call_state === "ringing") { + pause("ringAudio") + } + if (call) { call.call_state = status; } diff --git a/src/ContentMessages.js b/src/ContentMessages.js index eba3011917..094eff18d9 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -18,6 +18,10 @@ limitations under the License. var q = require('q'); var extend = require('./extend'); +var dis = require('./dispatcher'); +var MatrixClientPeg = require('./MatrixClientPeg'); +var sdk = require('./index'); +var Modal = require('./Modal'); function infoForImageFile(imageFile) { var deferred = q.defer(); @@ -48,39 +52,108 @@ function infoForImageFile(imageFile) { return deferred.promise; } -function sendContentToRoom(file, roomId, matrixClient) { - var content = { - body: file.name, - info: { - size: file.size, +class ContentMessages { + constructor() { + this.inprogress = []; + this.nextId = 0; + } + + sendContentToRoom(file, roomId, matrixClient) { + var content = { + body: file.name, + info: { + size: file.size, + } + }; + + // if we have a mime type for the file, add it to the message metadata + if (file.type) { + content.info.mimetype = file.type; } - }; - // if we have a mime type for the file, add it to the message metadata - if (file.type) { - content.info.mimetype = file.type; - } - - var def = q.defer(); - if (file.type.indexOf('image/') == 0) { - content.msgtype = 'm.image'; - infoForImageFile(file).then(function(imageInfo) { - extend(content.info, imageInfo); + var def = q.defer(); + if (file.type.indexOf('image/') == 0) { + content.msgtype = 'm.image'; + infoForImageFile(file).then(function(imageInfo) { + extend(content.info, imageInfo); + def.resolve(); + }); + } else { + content.msgtype = 'm.file'; def.resolve(); + } + + var upload = { + fileName: file.name, + roomId: roomId, + total: 0, + loaded: 0 + }; + this.inprogress.push(upload); + dis.dispatch({action: 'upload_started'}); + + var self = this; + return def.promise.then(function() { + upload.promise = matrixClient.uploadContent(file); + return upload.promise; + }).progress(function(ev) { + if (ev) { + upload.total = ev.total; + upload.loaded = ev.loaded; + dis.dispatch({action: 'upload_progress', upload: upload}); + } + }).then(function(url) { + dis.dispatch({action: 'upload_finished', upload: upload}); + content.url = url; + return matrixClient.sendMessage(roomId, content); + }, function(err) { + dis.dispatch({action: 'upload_failed', upload: upload}); + if (!upload.canceled) { + var desc = "The file '"+upload.fileName+"' failed to upload."; + if (err.http_status == 413) { + desc = "The file '"+upload.fileName+"' exceeds this home server's size limit for uploads"; + } + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Upload Failed", + description: desc + }); + } + }).finally(function() { + var inprogressKeys = Object.keys(self.inprogress); + for (var i = 0; i < self.inprogress.length; ++i) { + var k = inprogressKeys[i]; + if (self.inprogress[k].promise === upload.promise) { + self.inprogress.splice(k, 1); + break; + } + } }); - } else { - content.msgtype = 'm.file'; - def.resolve(); } - return def.promise.then(function() { - return matrixClient.uploadContent(file); - }).then(function(url) { - content.url = url; - return matrixClient.sendMessage(roomId, content); - }); + getCurrentUploads() { + return this.inprogress; + } + + cancelUpload(promise) { + var inprogressKeys = Object.keys(this.inprogress); + var upload; + for (var i = 0; i < this.inprogress.length; ++i) { + var k = inprogressKeys[i]; + if (this.inprogress[k].promise === promise) { + upload = this.inprogress[k]; + break; + } + } + if (upload) { + upload.canceled = true; + MatrixClientPeg.get().cancelUpload(upload.promise); + } + } } -module.exports = { - sendContentToRoom: sendContentToRoom -}; +if (global.mx_ContentMessage === undefined) { + global.mx_ContentMessage = new ContentMessages(); +} + +module.exports = global.mx_ContentMessage; diff --git a/src/DateUtils.js b/src/DateUtils.js index fe363586ab..3b11b82ccf 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.js @@ -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..95c6a46458 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -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/Notifier.js b/src/Notifier.js index 66e96fb15c..a35c3bb1ee 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -29,7 +29,7 @@ var dis = require("./dispatcher"); * } */ -module.exports = { +var Notifier = { notificationMessageForEvent: function(ev) { return TextForEvent.textForEvent(ev); @@ -98,13 +98,16 @@ module.exports = { start: function() { this.boundOnRoomTimeline = this.onRoomTimeline.bind(this); + this.boundOnSyncStateChange = this.onSyncStateChange.bind(this); MatrixClientPeg.get().on('Room.timeline', this.boundOnRoomTimeline); + MatrixClientPeg.get().on("sync", this.boundOnSyncStateChange); this.toolbarHidden = false; }, stop: function() { if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener('Room.timeline', this.boundOnRoomTimeline); + MatrixClientPeg.get().removeListener('sync', this.boundOnSyncStateChange); } }, @@ -175,8 +178,15 @@ module.exports = { return this.toolbarHidden; }, + onSyncStateChange: function(state) { + if (state === "PREPARED" || state === "SYNCING") { + this.isPrepared = true; + } + }, + onRoomTimeline: function(ev, room, toStartOfTimeline) { if (toStartOfTimeline) return; + if (!this.isPrepared) return; // don't alert for any messages initially if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) return; if (!this.isEnabled()) { @@ -190,3 +200,8 @@ module.exports = { } }; +if (!global.mxNotifier) { + global.mxNotifier = Notifier; +} + +module.exports = global.mxNotifier; \ No newline at end of file diff --git a/src/Resend.js b/src/Resend.js index b1132750b8..e07e571455 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -1,3 +1,19 @@ +/* +Copyright 2015 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 MatrixClientPeg = require('./MatrixClientPeg'); var dis = require('./dispatcher'); @@ -21,4 +37,13 @@ module.exports = { event: event }); }, + + removeFromQueue: function(event) { + MatrixClientPeg.get().getScheduler().removeEventFromQueue(event); + var room = MatrixClientPeg.get().getRoom(event.getRoomId()); + if (!room) { + return; + } + room.removeEvents([event.getId()]); + } }; \ No newline at end of file diff --git a/src/Signup.js b/src/Signup.js index 02ddaacc6d..74c4ad5f19 100644 --- a/src/Signup.js +++ b/src/Signup.js @@ -116,8 +116,17 @@ class Register extends Signup { _tryRegister(authDict) { var self = this; + + var bindEmail; + + if (this.username && this.password) { + // only need to bind_email when sending u/p - sending it at other + // times clobbers the u/p resulting in M_MISSING_PARAM (password) + bindEmail = true; + } + return MatrixClientPeg.get().register( - this.username, this.password, this.params.sessionId, authDict + this.username, this.password, this.params.sessionId, authDict, bindEmail ).then(function(result) { self.credentials = result; self.setStep("COMPLETE"); diff --git a/src/SlashCommands.js b/src/SlashCommands.js index e6ea7533dc..f5eaff9066 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -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..94be0193c6 --- /dev/null +++ b/src/TabComplete.js @@ -0,0 +1,307 @@ +/* +Copyright 2015 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