Merge remote-tracking branch 'origin/notif_sync' into unread_sync

This commit is contained in:
David Baker 2016-01-07 11:44:50 +00:00
commit ba51c68844
81 changed files with 1788 additions and 778 deletions

2
header
View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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()); return days[date.getDay()] + " " + pad(date.getHours()) + ':' + pad(date.getMinutes());
} }
else if (now.getFullYear() === date.getFullYear()) { 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 { 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());
} }
} }
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -48,8 +48,15 @@ var sanitizeHtmlParams = {
}, },
}; };
module.exports = { class Highlighter {
_applyHighlights: function(safeSnippet, highlights, html, k) { constructor(html, highlightClass, onHighlightClick) {
this.html = html;
this.highlightClass = highlightClass;
this.onHighlightClick = onHighlightClick;
this._key = 0;
}
applyHighlights(safeSnippet, highlights) {
var lastOffset = 0; var lastOffset = 0;
var offset; var offset;
var nodes = []; var nodes = [];
@ -61,77 +68,97 @@ module.exports = {
// If and when this happens, we'll probably have to split his method in two between // If and when this happens, we'll probably have to split his method in two between
// HTML and plain-text highlighting. // HTML and plain-text highlighting.
var safeHighlight = html ? sanitizeHtml(highlights[0], sanitizeHtmlParams) : highlights[0]; var safeHighlight = this.html ? sanitizeHtml(highlights[0], sanitizeHtmlParams) : highlights[0];
while ((offset = safeSnippet.indexOf(safeHighlight, lastOffset)) >= 0) { while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
// handle preamble // handle preamble
if (offset > lastOffset) { if (offset > lastOffset) {
nodes = nodes.concat(this._applySubHighlightsInRange(safeSnippet, lastOffset, offset, highlights, html, k)); var subSnippet = safeSnippet.substring(lastOffset, offset);
k += nodes.length; nodes = nodes.concat(this._applySubHighlights(subSnippet, highlights));
} }
// do highlight // do highlight
if (html) { nodes.push(this._createSpan(safeHighlight, true));
nodes.push(<span key={ k++ } dangerouslySetInnerHTML={{ __html: safeHighlight }} className="mx_MessageTile_searchHighlight" />);
}
else {
nodes.push(<span key={ k++ } className="mx_MessageTile_searchHighlight">{ safeHighlight }</span>);
}
lastOffset = offset + safeHighlight.length; lastOffset = offset + safeHighlight.length;
} }
// handle postamble // handle postamble
if (lastOffset != safeSnippet.length) { if (lastOffset != safeSnippet.length) {
nodes = nodes.concat(this._applySubHighlightsInRange(safeSnippet, lastOffset, undefined, highlights, html, k)); var subSnippet = safeSnippet.substring(lastOffset, undefined);
k += nodes.length; nodes = nodes.concat(this._applySubHighlights(subSnippet, highlights));
} }
return nodes; return nodes;
}, }
_applySubHighlightsInRange: function(safeSnippet, lastOffset, offset, highlights, html, k) { _applySubHighlights(safeSnippet, highlights) {
var nodes = [];
if (highlights[1]) { if (highlights[1]) {
// recurse into this range to check for the next set of highlight matches // 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 ); return this.applyHighlights(safeSnippet, highlights.slice(1));
nodes = nodes.concat(subnodes);
k += subnodes.length;
} }
else { else {
// no more highlights to be found, just return the unhighlighted string // no more highlights to be found, just return the unhighlighted string
if (html) { return [this._createSpan(safeSnippet, false)];
nodes.push(<span key={ k++ } dangerouslySetInnerHTML={{ __html: safeSnippet.substring(lastOffset, offset) }} />);
}
else {
nodes.push(<span key={ k++ }>{ safeSnippet.substring(lastOffset, offset) }</span>);
}
} }
return nodes; }
},
bodyToHtml: function(content, highlights) { /* create a <span> node to hold the given content
var originalBody = content.body; *
var body; * spanBody: content of the span. If html, must have been sanitised
var k = 0; * highlight: true to highlight as a search match
*/
_createSpan(spanBody, highlight) {
var spanProps = {
key: this._key++,
};
if (highlights && highlights.length > 0) { if (highlight) {
var bodyList = []; spanProps.onClick = this.onHighlightClick;
spanProps.className = this.highlightClass;
}
if (content.format === "org.matrix.custom.html") { if (this.html) {
var safeBody = sanitizeHtml(content.formatted_body, sanitizeHtmlParams); return (<span {...spanProps} dangerouslySetInnerHTML={{ __html: spanBody }} />);
bodyList = this._applyHighlights(safeBody, highlights, true, k);
}
else {
bodyList = this._applyHighlights(originalBody, highlights, true, k);
}
body = bodyList;
} }
else { else {
if (content.format === "org.matrix.custom.html") { return (<span {...spanProps}>{ spanBody }</span>);
var safeBody = sanitizeHtml(content.formatted_body, sanitizeHtmlParams); }
}
}
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 = <span className="markdown-body" dangerouslySetInnerHTML={{ __html: safeBody }} />; body = <span className="markdown-body" dangerouslySetInnerHTML={{ __html: safeBody }} />;
} }
else { else {
body = originalBody; body = safeBody;
} }
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 // 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( var foundRoom = MatrixTools.getRoomForAlias(
MatrixClientPeg.get().getRooms(), MatrixClientPeg.get().getRooms(),
room_alias room_alias
); );
if (foundRoom) { // we've already joined this room, view it.
dis.dispatch({ if (foundRoom) { // we've already joined this room, view it if it's not archived.
action: 'view_room', var me = foundRoom.getMember(MatrixClientPeg.get().credentials.userId);
room_id: foundRoom.roomId if (me && me.membership !== "leave") {
}); dis.dispatch({
return success(); action: 'view_room',
} room_id: foundRoom.roomId
else { });
// attempt to join this alias. return success();
return success( }
MatrixClientPeg.get().joinRoom(room_alias).then(
function(room) {
dis.dispatch({
action: 'view_room',
room_id: room.roomId
});
})
);
} }
// 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 <room_alias>"); return reject("Usage: /join <room_alias>");

307
src/TabComplete.js Normal file
View file

@ -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 <textarea> was set!");
return;
}
if (ev.keyCode !== KEY_TAB) {
// pressing any key (except shift, windows, cmd (OSX) and ctrl/alt combinations)
// aborts the current tab completion
if (this.completing && ev.keyCode !== KEY_SHIFT &&
!ev.metaKey && !ev.ctrlKey && !ev.altKey && ev.keyCode !== KEY_WINDOWS) {
// they're resuming typing; reset tab complete state vars.
this.stopTabCompleting();
}
// explicitly pressing any key except tab removes passive mode. Tab doesn't remove
// passive mode because handleTabPress needs to know when passive mode is toggling
// off so it can resync the textarea/peek list. If tab did remove passive mode then
// handleTabPress would never be able to tell when passive mode toggled off.
this.inPassiveMode = false;
// pressing any key at all (except tab) restarts the automatic tab-complete timer
if (this.opts.autoEnterTabComplete) {
clearTimeout(this.enterTabCompleteTimerId);
this.enterTabCompleteTimerId = setTimeout(() => {
if (!this.completing) {
this.handleTabPress(true, false);
}
}, DELAY_TIME_MS);
}
return;
}
// tab key has been pressed at this point
this.handleTabPress(false, ev.shiftKey)
// prevent the default TAB operation (typically focus shifting)
ev.preventDefault();
}
/**
* Set the textarea to the next value in the matched list.
* @param {Number} offset Offset to apply *before* setting the next value.
*/
nextMatchedEntry(offset) {
if (this.matchedList.length === 0) {
return;
}
// work out the new index, wrapping if necessary.
this.currentIndex += offset;
if (this.currentIndex >= this.matchedList.length) {
this.currentIndex = 0;
}
else if (this.currentIndex < 0) {
this.currentIndex = this.matchedList.length - 1;
}
var isTransitioningToOriginalText = (
// impossible to transition if they've never hit tab
!this.inPassiveMode && this.currentIndex === 0
);
if (!this.inPassiveMode) {
// set textarea to this new value
this.textArea.value = this._replaceWith(
this.matchedList[this.currentIndex].text,
this.currentIndex !== 0 // don't suffix the original text!
);
}
// visual display to the user that we looped - TODO: This should be configurable
if (isTransitioningToOriginalText) {
this.textArea.style["background-color"] = "#faa";
setTimeout(() => { // yay for lexical 'this'!
this.textArea.style["background-color"] = "";
}, 150);
if (!this.opts.allowLooping) {
this.stopTabCompleting();
}
}
else {
this.textArea.style["background-color"] = ""; // cancel blinks TODO: required?
}
}
_replaceWith(newVal, includeSuffix) {
// The regex to replace the input matches a character of whitespace AND
// the partial word. If we just use string.replace() with the regex it will
// replace the partial word AND the character of whitespace. We want to
// preserve whatever that character is (\n, \t, etc) so find out what it is now.
var boundaryChar;
var res = MATCH_REGEX.exec(this.originalText);
if (res) {
boundaryChar = res[1]; // the first captured group
}
if (boundaryChar === undefined) {
console.warn("Failed to find boundary char on text: '%s'", this.originalText);
boundaryChar = "";
}
var replacementText = (
boundaryChar + newVal + (
includeSuffix ?
(this.isFirstWord ? this.opts.startingWordSuffix : this.opts.wordSuffix) :
""
)
);
return this.originalText.replace(MATCH_REGEX, function() {
return replacementText; // function form to avoid `$` special-casing
});
}
_calculateCompletions() {
this.originalText = this.textArea.value; // cache starting text
// grab the partial word from the text which we'll be tab-completing
var res = MATCH_REGEX.exec(this.originalText);
if (!res) {
this.matchedList = [];
return;
}
// ES6 destructuring; ignore first element (the complete match)
var [ , boundaryGroup, partialGroup] = res;
this.isFirstWord = partialGroup.length === this.originalText.length;
this.matchedList = [
new Entry(partialGroup) // first entry is always the original partial
];
// find matching entries in the set of entries given to us
this.list.forEach((entry) => {
if (entry.text.toLowerCase().indexOf(partialGroup.toLowerCase()) === 0) {
this.matchedList.push(entry);
}
});
// console.log("_calculateCompletions => %s", JSON.stringify(this.matchedList));
}
_notifyStateChange() {
if (this.opts.onStateChange) {
this.opts.onStateChange(this.completing);
}
}
};
module.exports = TabComplete;

101
src/TabCompleteEntries.js Normal file
View file

@ -0,0 +1,101 @@
/*
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 React = require("react");
var sdk = require("./index");
class Entry {
constructor(text) {
this.text = text;
}
/**
* @return {string} The text to display in this entry.
*/
getText() {
return this.text;
}
/**
* @return {ReactClass} Raw JSX
*/
getImageJsx() {
return null;
}
/**
* @return {?string} The unique key= prop for React dedupe
*/
getKey() {
return null;
}
/**
* Called when this entry is clicked.
*/
onClick() {
// NOP
}
}
class MemberEntry extends Entry {
constructor(member) {
super(member.name || member.userId);
this.member = member;
}
getImageJsx() {
var MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
return (
<MemberAvatar member={this.member} width={24} height={24} />
);
}
getKey() {
return this.member.userId;
}
}
MemberEntry.fromMemberList = function(members) {
return members.sort(function(a, b) {
var userA = a.user;
var userB = b.user;
if (userA && !userB) {
return -1; // a comes first
}
else if (!userA && userB) {
return 1; // b comes first
}
else if (!userA && !userB) {
return 0; // don't care
}
else { // both User objects exist
if (userA.lastActiveAgo < userB.lastActiveAgo) {
return -1; // a comes first
}
else if (userA.lastActiveAgo > userB.lastActiveAgo) {
return 1; // b comes first
}
else {
return 0; // same last active ago
}
}
}).map(function(m) {
return new MemberEntry(m);
});
}
module.exports.Entry = Entry;
module.exports.MemberEntry = MemberEntry;

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

67
src/UserSettingsStore.js Normal file
View file

@ -0,0 +1,67 @@
/*
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.
*/
'use strict';
var MatrixClientPeg = require("./MatrixClientPeg");
var Notifier = require("./Notifier");
/*
* TODO: Make things use this. This is all WIP - see UserSettings.js for usage.
*/
module.exports = {
loadProfileInfo: function() {
var cli = MatrixClientPeg.get();
return cli.getProfileInfo(cli.credentials.userId);
},
saveDisplayName: function(newDisplayname) {
return MatrixClientPeg.get().setDisplayName(newDisplayname);
},
loadThreePids: function() {
return MatrixClientPeg.get().getThreePids();
},
saveThreePids: function(threePids) {
// TODO
},
getEnableNotifications: function() {
return Notifier.isEnabled();
},
setEnableNotifications: function(enable) {
if (!Notifier.supportsDesktopNotifications()) {
return;
}
Notifier.setEnabled(enable);
},
changePassword: function(old_password, new_password) {
var cli = MatrixClientPeg.get();
var authDict = {
type: 'm.login.password',
user: cli.credentials.userId,
password: old_password
};
return cli.setPassword(authDict, new_password);
},
};

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -28,6 +28,7 @@ module.exports.components['structures.login.PostRegistration'] = require('./comp
module.exports.components['structures.login.Registration'] = require('./components/structures/login/Registration'); module.exports.components['structures.login.Registration'] = require('./components/structures/login/Registration');
module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat'); module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat');
module.exports.components['structures.RoomView'] = require('./components/structures/RoomView'); module.exports.components['structures.RoomView'] = require('./components/structures/RoomView');
module.exports.components['structures.ScrollPanel'] = require('./components/structures/ScrollPanel');
module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar'); module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar');
module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings'); module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings');
module.exports.components['views.avatars.MemberAvatar'] = require('./components/views/avatars/MemberAvatar'); module.exports.components['views.avatars.MemberAvatar'] = require('./components/views/avatars/MemberAvatar');
@ -65,6 +66,8 @@ module.exports.components['views.rooms.RoomHeader'] = require('./components/view
module.exports.components['views.rooms.RoomList'] = require('./components/views/rooms/RoomList'); module.exports.components['views.rooms.RoomList'] = require('./components/views/rooms/RoomList');
module.exports.components['views.rooms.RoomSettings'] = require('./components/views/rooms/RoomSettings'); module.exports.components['views.rooms.RoomSettings'] = require('./components/views/rooms/RoomSettings');
module.exports.components['views.rooms.RoomTile'] = require('./components/views/rooms/RoomTile'); module.exports.components['views.rooms.RoomTile'] = require('./components/views/rooms/RoomTile');
module.exports.components['views.rooms.SearchResultTile'] = require('./components/views/rooms/SearchResultTile');
module.exports.components['views.rooms.TabCompleteBar'] = require('./components/views/rooms/TabCompleteBar');
module.exports.components['views.settings.ChangeAvatar'] = require('./components/views/settings/ChangeAvatar'); module.exports.components['views.settings.ChangeAvatar'] = require('./components/views/settings/ChangeAvatar');
module.exports.components['views.settings.ChangeDisplayName'] = require('./components/views/settings/ChangeDisplayName'); module.exports.components['views.settings.ChangeDisplayName'] = require('./components/views/settings/ChangeDisplayName');
module.exports.components['views.settings.ChangePassword'] = require('./components/views/settings/ChangePassword'); module.exports.components['views.settings.ChangePassword'] = require('./components/views/settings/ChangePassword');

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -251,13 +251,15 @@ module.exports = React.createClass({
var UserSelector = sdk.getComponent("elements.UserSelector"); var UserSelector = sdk.getComponent("elements.UserSelector");
var RoomHeader = sdk.getComponent("rooms.RoomHeader"); var RoomHeader = sdk.getComponent("rooms.RoomHeader");
var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, '');
return ( return (
<div className="mx_CreateRoom"> <div className="mx_CreateRoom">
<RoomHeader simpleHeader="Create room" /> <RoomHeader simpleHeader="Create room" />
<div className="mx_CreateRoom_body"> <div className="mx_CreateRoom_body">
<input type="text" ref="room_name" value={this.state.room_name} onChange={this.onNameChange} placeholder="Name"/> <br /> <input type="text" ref="room_name" value={this.state.room_name} onChange={this.onNameChange} placeholder="Name"/> <br />
<textarea className="mx_CreateRoom_description" ref="topic" value={this.state.topic} onChange={this.onTopicChange} placeholder="Topic"/> <br /> <textarea className="mx_CreateRoom_description" ref="topic" value={this.state.topic} onChange={this.onTopicChange} placeholder="Topic"/> <br />
<RoomAlias ref="alias" alias={this.state.alias} onChange={this.onAliasChanged}/> <br /> <RoomAlias ref="alias" alias={this.state.alias} homeserver={ domain } onChange={this.onAliasChanged}/> <br />
<UserSelector ref="user_selector" selected_users={this.state.invited_users} onChange={this.onInviteChanged}/> <br /> <UserSelector ref="user_selector" selected_users={this.state.invited_users} onChange={this.onInviteChanged}/> <br />
<Presets ref="presets" onChange={this.onPresetChanged} preset={this.state.preset}/> <br /> <Presets ref="presets" onChange={this.onPresetChanged} preset={this.state.preset}/> <br />
<div> <div>

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -639,12 +639,30 @@ module.exports = React.createClass({
var rooms = MatrixClientPeg.get().getRooms(); var rooms = MatrixClientPeg.get().getRooms();
for (var i = 0; i < rooms.length; ++i) { for (var i = 0; i < rooms.length; ++i) {
notifCount += rooms[i].unread_notification_count; if (rooms[i].unread_notification_count) {
notifCount += rooms[i].unread_notification_count;
}
} }
this.favicon.badge(notifCount); this.favicon.badge(notifCount);
document.title = (notifCount > 0 ? "["+notifCount+"] " : "")+"Vector"; document.title = (notifCount > 0 ? "["+notifCount+"] " : "")+"Vector";
}, },
onUserSettingsClose: function() {
// XXX: use browser history instead to find the previous room?
if (this.state.currentRoom) {
dis.dispatch({
action: 'view_room',
room_id: this.state.currentRoom,
});
}
else {
dis.dispatch({
action: 'view_indexed_room',
roomIndex: 0,
});
}
},
render: function() { render: function() {
var LeftPanel = sdk.getComponent('structures.LeftPanel'); var LeftPanel = sdk.getComponent('structures.LeftPanel');
var RoomView = sdk.getComponent('structures.RoomView'); var RoomView = sdk.getComponent('structures.RoomView');
@ -677,7 +695,7 @@ module.exports = React.createClass({
right_panel = <RightPanel roomId={this.state.currentRoom} collapsed={this.state.collapse_rhs} /> right_panel = <RightPanel roomId={this.state.currentRoom} collapsed={this.state.collapse_rhs} />
break; break;
case this.PageTypes.UserSettings: case this.PageTypes.UserSettings:
page_element = <UserSettings /> page_element = <UserSettings onClose={this.onUserSettingsClose} />
right_panel = <RightPanel collapsed={this.state.collapse_rhs}/> right_panel = <RightPanel collapsed={this.state.collapse_rhs}/>
break; break;
case this.PageTypes.CreateRoom: case this.PageTypes.CreateRoom:

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -23,7 +23,6 @@ limitations under the License.
var React = require("react"); var React = require("react");
var ReactDOM = require("react-dom"); var ReactDOM = require("react-dom");
var GeminiScrollbar = require('react-gemini-scrollbar');
var q = require("q"); var q = require("q");
var classNames = require("classnames"); var classNames = require("classnames");
var Matrix = require("matrix-js-sdk"); var Matrix = require("matrix-js-sdk");
@ -34,6 +33,8 @@ var WhoIsTyping = require("../../WhoIsTyping");
var Modal = require("../../Modal"); var Modal = require("../../Modal");
var sdk = require('../../index'); var sdk = require('../../index');
var CallHandler = require('../../CallHandler'); var CallHandler = require('../../CallHandler');
var TabComplete = require("../../TabComplete");
var MemberEntry = require("../../TabCompleteEntries").MemberEntry;
var Resend = require("../../Resend"); var Resend = require("../../Resend");
var dis = require("../../dispatcher"); var dis = require("../../dispatcher");
@ -42,6 +43,13 @@ var INITIAL_SIZE = 20;
var DEBUG_SCROLL = false; var DEBUG_SCROLL = false;
if (DEBUG_SCROLL) {
// using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console);
} else {
var debuglog = function () {};
}
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'RoomView', displayName: 'RoomView',
propTypes: { propTypes: {
@ -49,13 +57,6 @@ module.exports = React.createClass({
}, },
/* properties in RoomView objects include: /* properties in RoomView objects include:
*
* savedScrollState: the current scroll position in the backlog. Response
* from _calculateScrollState. Updated on scroll events.
*
* savedSearchScrollState: similar to savedScrollState, but specific to the
* search results (we need to preserve savedScrollState when search
* results are visible)
* *
* eventNodes: a map from event id to DOM node representing that event * eventNodes: a map from event id to DOM node representing that event
*/ */
@ -84,7 +85,18 @@ module.exports = React.createClass({
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().on("sync", this.onSyncStateChange); MatrixClientPeg.get().on("sync", this.onSyncStateChange);
this.savedScrollState = {atBottom: true}; // xchat-style tab complete, add a colon if tab
// completing at the start of the text
this.tabComplete = new TabComplete({
startingWordSuffix: ": ",
wordSuffix: " ",
allowLooping: false,
autoEnterTabComplete: true,
onClickCompletes: true,
onStateChange: (isCompleting) => {
this.forceUpdate();
}
});
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
@ -168,23 +180,6 @@ module.exports = React.createClass({
} }
}, },
// get the DOM node which has the scrollTop property we care about for our
// message panel.
//
// If the gemini scrollbar is doing its thing, this will be a div within
// the message panel (ie, the gemini container); otherwise it will be the
// message panel itself.
_getScrollNode: function() {
var panel = ReactDOM.findDOMNode(this.refs.messagePanel);
if (!panel) return null;
if (panel.classList.contains('gm-prevented')) {
return panel;
} else {
return panel.children[2]; // XXX: Fragile!
}
},
onSyncStateChange: function(state, prevState) { onSyncStateChange: function(state, prevState) {
if (state === "SYNCING" && prevState === "SYNCING") { if (state === "SYNCING" && prevState === "SYNCING") {
return; return;
@ -218,7 +213,7 @@ module.exports = React.createClass({
if (!toStartOfTimeline && if (!toStartOfTimeline &&
(ev.getSender() !== MatrixClientPeg.get().credentials.userId)) { (ev.getSender() !== MatrixClientPeg.get().credentials.userId)) {
// update unread count when scrolled up // update unread count when scrolled up
if (!this.state.searchResults && this.savedScrollState.atBottom) { if (!this.state.searchResults && this.refs.messagePanel && this.refs.messagePanel.isAtBottom()) {
currentUnread = 0; currentUnread = 0;
} }
else { else {
@ -251,6 +246,11 @@ module.exports = React.createClass({
}, },
onRoomStateMember: function(ev, state, member) { onRoomStateMember: function(ev, state, member) {
if (member.roomId === this.props.roomId) {
// a member state changed in this room, refresh the tab complete list
this._updateTabCompleteList(this.state.room);
}
if (!this.props.ConferenceHandler) { if (!this.props.ConferenceHandler) {
return; return;
} }
@ -313,6 +313,17 @@ module.exports = React.createClass({
window.addEventListener('resize', this.onResize); window.addEventListener('resize', this.onResize);
this.onResize(); this.onResize();
this._updateTabCompleteList(this.state.room);
},
_updateTabCompleteList: function(room) {
if (!room || !this.tabComplete) {
return;
}
this.tabComplete.setCompletionList(
MemberEntry.fromMemberList(room.getJoinedMembers())
);
}, },
_initialiseMessagePanel: function() { _initialiseMessagePanel: function() {
@ -326,7 +337,6 @@ module.exports = React.createClass({
this.scrollToBottom(); this.scrollToBottom();
this.sendReadReceipt(); this.sendReadReceipt();
this.fillSpace();
}, },
componentDidUpdate: function() { componentDidUpdate: function() {
@ -338,64 +348,52 @@ module.exports = React.createClass({
if (!this.refs.messagePanel.initialised) { if (!this.refs.messagePanel.initialised) {
this._initialiseMessagePanel(); this._initialiseMessagePanel();
} }
// after adding event tiles, we may need to tweak the scroll (either to
// keep at the bottom of the timeline, or to maintain the view after
// adding events to the top).
this._restoreSavedScrollState();
}, },
_paginateCompleted: function() { _paginateCompleted: function() {
if (DEBUG_SCROLL) console.log("paginate complete"); debuglog("paginate complete");
this.setState({ this.setState({
room: MatrixClientPeg.get().getRoom(this.props.roomId) room: MatrixClientPeg.get().getRoom(this.props.roomId)
}); });
// we might not have got enough results from the pagination
// request, so give fillSpace() a chance to set off another.
this.setState({paginating: false}); this.setState({paginating: false});
},
if (!this.state.searchResults) { onSearchResultsFillRequest: function(backwards) {
this.fillSpace(); if (!backwards)
return q(false);
if (this.state.searchResults.next_batch) {
debuglog("requesting more search results");
var searchPromise = MatrixClientPeg.get().backPaginateRoomEventsSearch(
this.state.searchResults);
return this._handleSearchResult(searchPromise);
} else {
debuglog("no more search results");
return q(false);
} }
}, },
// check the scroll position, and if we need to, set off a pagination // set off a pagination request.
// request. onMessageListFillRequest: function(backwards) {
fillSpace: function() { if (!backwards)
if (!this.refs.messagePanel) return; return q(false);
var messageWrapperScroll = this._getScrollNode();
if (messageWrapperScroll.scrollTop > messageWrapperScroll.clientHeight) {
return;
}
// there's less than a screenful of messages left - try to get some
// more messages.
if (this.state.searchResults) {
if (this.nextSearchBatch) {
if (DEBUG_SCROLL) console.log("requesting more search results");
this._getSearchBatch(this.state.searchTerm,
this.state.searchScope);
} else {
if (DEBUG_SCROLL) console.log("no more search results");
}
return;
}
// Either wind back the message cap (if there are enough events in the // Either wind back the message cap (if there are enough events in the
// timeline to do so), or fire off a pagination request. // timeline to do so), or fire off a pagination request.
if (this.state.messageCap < this.state.room.timeline.length) { if (this.state.messageCap < this.state.room.timeline.length) {
var cap = Math.min(this.state.messageCap + PAGINATE_SIZE, this.state.room.timeline.length); var cap = Math.min(this.state.messageCap + PAGINATE_SIZE, this.state.room.timeline.length);
if (DEBUG_SCROLL) console.log("winding back message cap to", cap); debuglog("winding back message cap to", cap);
this.setState({messageCap: cap}); this.setState({messageCap: cap});
return q(true);
} else if(this.state.room.oldState.paginationToken) { } else if(this.state.room.oldState.paginationToken) {
var cap = this.state.messageCap + PAGINATE_SIZE; var cap = this.state.messageCap + PAGINATE_SIZE;
if (DEBUG_SCROLL) console.log("starting paginate to cap", cap); debuglog("starting paginate to cap", cap);
this.setState({messageCap: cap, paginating: true}); this.setState({messageCap: cap, paginating: true});
MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE).finally(this._paginateCompleted).done(); return MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE).
finally(this._paginateCompleted).then(true);
} }
}, },
@ -431,44 +429,9 @@ module.exports = React.createClass({
}, },
onMessageListScroll: function(ev) { onMessageListScroll: function(ev) {
var sn = this._getScrollNode(); if (this.state.numUnreadMessages != 0 &&
if (DEBUG_SCROLL) console.log("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll); this.refs.messagePanel.isAtBottom()) {
this.setState({numUnreadMessages: 0});
// Sometimes we see attempts to write to scrollTop essentially being
// ignored. (Or rather, it is successfully written, but on the next
// scroll event, it's been reset again).
//
// This was observed on Chrome 47, when scrolling using the trackpad in OS
// X Yosemite. Can't reproduce on El Capitan. Our theory is that this is
// due to Chrome not being able to cope with the scroll offset being reset
// while a two-finger drag is in progress.
//
// By way of a workaround, we detect this situation and just keep
// resetting scrollTop until we see the scroll node have the right
// value.
if (this.recentEventScroll !== undefined) {
if(sn.scrollTop < this.recentEventScroll-200) {
console.log("Working around vector-im/vector-web#528");
this._restoreSavedScrollState();
return;
}
this.recentEventScroll = undefined;
}
if (this.refs.messagePanel) {
if (this.state.searchResults) {
this.savedSearchScrollState = this._calculateScrollState();
if (DEBUG_SCROLL) console.log("Saved search scroll state", this.savedSearchScrollState);
} else {
this.savedScrollState = this._calculateScrollState();
if (DEBUG_SCROLL) console.log("Saved scroll state", this.savedScrollState);
if (this.savedScrollState.atBottom && this.state.numUnreadMessages != 0) {
this.setState({numUnreadMessages: 0});
}
}
}
if (!this.state.paginating && !this.state.searchInProgress) {
this.fillSpace();
} }
}, },
@ -524,71 +487,78 @@ module.exports = React.createClass({
this.setState({ this.setState({
searchTerm: term, searchTerm: term,
searchScope: scope, searchScope: scope,
searchResults: [], searchResults: {},
searchHighlights: [], searchHighlights: [],
searchCount: null,
searchCanPaginate: null,
}); });
this.savedSearchScrollState = {atBottom: true}; // if we already have a search panel, we need to tell it to forget
this.nextSearchBatch = null; // about its scroll state.
this._getSearchBatch(term, scope); if (this.refs.searchResultsPanel) {
this.refs.searchResultsPanel.resetScrollState();
}
// make sure that we don't end up showing results from
// an aborted search by keeping a unique id.
//
// todo: should cancel any previous search requests.
this.searchId = new Date().getTime();
var filter;
if (scope === "Room") {
filter = {
// XXX: it's unintuitive that the filter for searching doesn't have the same shape as the v2 filter API :(
rooms: [
this.props.roomId
]
};
}
debuglog("sending search request");
var searchPromise = MatrixClientPeg.get().searchRoomEvents({
filter: filter,
term: term,
});
this._handleSearchResult(searchPromise).done();
}, },
// fire off a request for a batch of search results _handleSearchResult: function(searchPromise) {
_getSearchBatch: function(term, scope) { var self = this;
// keep a record of the current search id, so that if the search terms
// change before we get a response, we can ignore the results.
var localSearchId = this.searchId;
this.setState({ this.setState({
searchInProgress: true, searchInProgress: true,
}); });
// make sure that we don't end up merging results from return searchPromise.then(function(results) {
// different searches by keeping a unique id. debuglog("search complete");
// if (!self.state.searching || self.searchId != localSearchId) {
// todo: should cancel any previous search requests.
var searchId = this.searchId = new Date().getTime();
var self = this;
if (DEBUG_SCROLL) console.log("sending search request");
MatrixClientPeg.get().search({ body: this._getSearchCondition(term, scope),
next_batch: this.nextSearchBatch })
.then(function(data) {
if (DEBUG_SCROLL) console.log("search complete");
if (!self.state.searching || self.searchId != searchId) {
console.error("Discarding stale search results"); console.error("Discarding stale search results");
return; return;
} }
var results = data.search_categories.room_events; // postgres on synapse returns us precise details of the strings
// which actually got matched for highlighting.
//
// In either case, we want to highlight the literal search term
// whether it was used by the search engine or not.
// postgres on synapse returns us precise details of the var highlights = results.highlights;
// strings which actually got matched for highlighting. if (highlights.indexOf(self.state.searchTerm) < 0) {
highlights = highlights.concat(self.state.searchTerm);
// combine the highlight list with our existing list; build an object
// to avoid O(N^2) fail
var highlights = {};
results.highlights.forEach(function(hl) { highlights[hl] = 1; });
self.state.searchHighlights.forEach(function(hl) { highlights[hl] = 1; });
// turn it back into an ordered list. For overlapping highlights,
// favour longer (more specific) terms first
highlights = Object.keys(highlights).sort(function(a, b) { b.length - a.length });
// sqlite doesn't give us any highlights, so just try to highlight the literal search term
if (highlights.length == 0) {
highlights = [ term ];
} }
// append the new results to our existing results // For overlapping highlights,
var events = self.state.searchResults.concat(results.results); // favour longer (more specific) terms first
highlights = highlights.sort(function(a, b) { b.length - a.length });
self.setState({ self.setState({
searchHighlights: highlights, searchHighlights: highlights,
searchResults: events, searchResults: results,
searchCount: results.count,
searchCanPaginate: !!(results.next_batch),
}); });
self.nextSearchBatch = results.next_batch;
}, function(error) { }, function(error) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
@ -599,109 +569,84 @@ module.exports = React.createClass({
self.setState({ self.setState({
searchInProgress: false searchInProgress: false
}); });
}).done(); });
}, },
_getSearchCondition: function(term, scope) {
var filter;
if (scope === "Room") { getSearchResultTiles: function() {
filter = { var EventTile = sdk.getComponent('rooms.EventTile');
// XXX: it's unintuitive that the filter for searching doesn't have the same shape as the v2 filter API :( var SearchResultTile = sdk.getComponent('rooms.SearchResultTile');
rooms: [ var cli = MatrixClientPeg.get();
this.props.roomId
] // XXX: todo: merge overlapping results somehow?
}; // XXX: why doesn't searching on name work?
if (this.state.searchResults.results === undefined) {
// awaiting results
return [];
} }
return { var ret = [];
search_categories: {
room_events: { if (!this.state.searchResults.next_batch) {
search_term: term, if (this.state.searchResults.results.length == 0) {
filter: filter, ret.push(<li key="search-top-marker">
order_by: "recent", <h2 className="mx_RoomView_topMarker">No results</h2>
event_context: { </li>
before_limit: 1, );
after_limit: 1, } else {
include_profile: true, ret.push(<li key="search-top-marker">
} <h2 className="mx_RoomView_topMarker">No more results</h2>
} </li>
);
} }
} }
var lastRoomId;
for (var i = this.state.searchResults.results.length - 1; i >= 0; i--) {
var result = this.state.searchResults.results[i];
var mxEv = result.context.getEvent();
if (!EventTile.haveTileForEvent(mxEv)) {
// XXX: can this ever happen? It will make the result count
// not match the displayed count.
continue;
}
if (this.state.searchScope === 'All') {
var roomId = mxEv.getRoomId();
if(roomId != lastRoomId) {
var room = cli.getRoom(roomId);
// XXX: if we've left the room, we might not know about
// it. We should tell the js sdk to go and find out about
// it. But that's not an issue currently, as synapse only
// returns results for rooms we're joined to.
var roomName = room ? room.name : "Unknown room "+roomId;
ret.push(<li key={mxEv.getId() + "-room"}>
<h1>Room: { roomName }</h1>
</li>);
lastRoomId = roomId;
}
}
ret.push(<SearchResultTile key={mxEv.getId()}
searchResult={result}
searchHighlights={this.state.searchHighlights}/>);
}
return ret;
}, },
getEventTiles: function() { getEventTiles: function() {
var DateSeparator = sdk.getComponent('messages.DateSeparator'); var DateSeparator = sdk.getComponent('messages.DateSeparator');
var cli = MatrixClientPeg.get();
var ret = []; var ret = [];
var count = 0; var count = 0;
var EventTile = sdk.getComponent('rooms.EventTile'); var EventTile = sdk.getComponent('rooms.EventTile');
var self = this;
if (this.state.searchResults)
{
// XXX: todo: merge overlapping results somehow?
// XXX: why doesn't searching on name work?
var lastRoomId;
if (this.state.searchCanPaginate === false) {
if (this.state.searchResults.length == 0) {
ret.push(<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">No results</h2>
</li>
);
} else {
ret.push(<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">No more results</h2>
</li>
);
}
}
for (var i = this.state.searchResults.length - 1; i >= 0; i--) {
var result = this.state.searchResults[i];
var mxEv = new Matrix.MatrixEvent(result.result);
if (!EventTile.haveTileForEvent(mxEv)) {
// XXX: can this ever happen? It will make the result count
// not match the displayed count.
continue;
}
var eventId = mxEv.getId();
if (self.state.searchScope === 'All') {
var roomId = result.result.room_id;
if(roomId != lastRoomId) {
ret.push(<li key={eventId + "-room"}><h1>Room: { cli.getRoom(roomId).name }</h1></li>);
lastRoomId = roomId;
}
}
var ts1 = result.result.origin_server_ts;
ret.push(<li key={ts1 + "-search"}><DateSeparator ts={ts1}/></li>); // Rank: {resultList[i].rank}
if (result.context.events_before[0]) {
var mxEv2 = new Matrix.MatrixEvent(result.context.events_before[0]);
if (EventTile.haveTileForEvent(mxEv2)) {
ret.push(<li key={eventId+"-1"} data-scroll-token={eventId+"-1"}><EventTile mxEvent={mxEv2} contextual={true} /></li>);
}
}
ret.push(<li key={eventId+"+0"} data-scroll-token={eventId+"+0"}><EventTile mxEvent={mxEv} highlights={self.state.searchHighlights}/></li>);
if (result.context.events_after[0]) {
var mxEv2 = new Matrix.MatrixEvent(result.context.events_after[0]);
if (EventTile.haveTileForEvent(mxEv2)) {
ret.push(<li key={eventId+"+1"} data-scroll-token={eventId+"+1"}><EventTile mxEvent={mxEv2} contextual={true} /></li>);
}
}
}
return ret;
}
var prevEvent = null; // the last event we showed var prevEvent = null; // the last event we showed
@ -996,10 +941,9 @@ module.exports = React.createClass({
}, },
scrollToBottom: function() { scrollToBottom: function() {
var scrollNode = this._getScrollNode(); var messagePanel = this.refs.messagePanel;
if (!scrollNode) return; if (!messagePanel) return;
scrollNode.scrollTop = scrollNode.scrollHeight; messagePanel.scrollToBottom();
if (DEBUG_SCROLL) console.log("Scrolled to bottom; offset now", scrollNode.scrollTop);
}, },
// scroll the event view to put the given event at the bottom. // scroll the event view to put the given event at the bottom.
@ -1007,6 +951,9 @@ module.exports = React.createClass({
// pixel_offset gives the number of pixels between the bottom of the event // pixel_offset gives the number of pixels between the bottom of the event
// and the bottom of the container. // and the bottom of the container.
scrollToEvent: function(eventId, pixelOffset) { scrollToEvent: function(eventId, pixelOffset) {
var messagePanel = this.refs.messagePanel;
if (!messagePanel) return;
var idx = this._indexForEventId(eventId); var idx = this._indexForEventId(eventId);
if (idx === null) { if (idx === null) {
// we don't seem to have this event in our timeline. Presumably // we don't seem to have this event in our timeline. Presumably
@ -1016,7 +963,7 @@ module.exports = React.createClass({
// //
// for now, just scroll to the top of the buffer. // for now, just scroll to the top of the buffer.
console.log("Refusing to scroll to unknown event "+eventId); console.log("Refusing to scroll to unknown event "+eventId);
this._getScrollNode().scrollTop = 0; messagePanel.scrollToTop();
return; return;
} }
@ -1036,117 +983,30 @@ module.exports = React.createClass({
// the scrollTokens on our DOM nodes are the event IDs, so we can pass // the scrollTokens on our DOM nodes are the event IDs, so we can pass
// eventId directly into _scrollToToken. // eventId directly into _scrollToToken.
this._scrollToToken(eventId, pixelOffset); messagePanel.scrollToToken(eventId, pixelOffset);
},
_restoreSavedScrollState: function() {
var scrollState = this.state.searchResults ? this.savedSearchScrollState : this.savedScrollState;
if (!scrollState || scrollState.atBottom) {
this.scrollToBottom();
} else if (scrollState.lastDisplayedScrollToken) {
this._scrollToToken(scrollState.lastDisplayedScrollToken,
scrollState.pixelOffset);
}
},
_calculateScrollState: function() {
// we don't save the absolute scroll offset, because that
// would be affected by window width, zoom level, amount of scrollback,
// etc.
//
// instead we save an identifier for the last fully-visible message,
// and the number of pixels the window was scrolled below it - which
// will hopefully be near enough.
//
// Our scroll implementation is agnostic of the precise contents of the
// message list (since it needs to work with both search results and
// timelines). 'refs.messageList' is expected to be a DOM node with a
// number of children, each of which may have a 'data-scroll-token'
// attribute. It is this token which is stored as the
// 'lastDisplayedScrollToken'.
var messageWrapperScroll = this._getScrollNode();
// + 1 here to avoid fractional pixel rounding errors
var atBottom = messageWrapperScroll.scrollHeight - messageWrapperScroll.scrollTop <= messageWrapperScroll.clientHeight + 1;
var messageWrapper = this.refs.messagePanel;
var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect();
var messages = this.refs.messageList.children;
for (var i = messages.length-1; i >= 0; --i) {
var node = messages[i];
if (!node.dataset.scrollToken) continue;
var boundingRect = node.getBoundingClientRect();
if (boundingRect.bottom < wrapperRect.bottom) {
return {
atBottom: atBottom,
lastDisplayedScrollToken: node.dataset.scrollToken,
pixelOffset: wrapperRect.bottom - boundingRect.bottom,
}
}
}
// apparently the entire timeline is below the viewport. Give up.
return { atBottom: true };
},
// scroll the message list to the node with the given scrollToken. See
// notes in _calculateScrollState on how this works.
//
// pixel_offset gives the number of pixels between the bottom of the node
// and the bottom of the container.
_scrollToToken: function(scrollToken, pixelOffset) {
/* find the dom node with the right scrolltoken */
var node;
var messages = this.refs.messageList.children;
for (var i = messages.length-1; i >= 0; --i) {
var m = messages[i];
if (!m.dataset.scrollToken) continue;
if (m.dataset.scrollToken == scrollToken) {
node = m;
break;
}
}
if (!node) {
console.error("No node with scrollToken '"+scrollToken+"'");
return;
}
var scrollNode = this._getScrollNode();
var messageWrapper = this.refs.messagePanel;
var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect();
var boundingRect = node.getBoundingClientRect();
var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom;
if(scrollDelta != 0) {
scrollNode.scrollTop += scrollDelta;
// see the comments in onMessageListScroll regarding recentEventScroll
this.recentEventScroll = scrollNode.scrollTop;
}
if (DEBUG_SCROLL) {
console.log("Scrolled to token", node.dataset.scrollToken, "+", pixelOffset+":", scrollNode.scrollTop, "(delta: "+scrollDelta+")");
console.log("recentEventScroll now "+this.recentEventScroll);
}
}, },
// get the current scroll position of the room, so that it can be // get the current scroll position of the room, so that it can be
// restored when we switch back to it // restored when we switch back to it
getScrollState: function() { getScrollState: function() {
return this.savedScrollState; var messagePanel = this.refs.messagePanel;
if (!messagePanel) return null;
return messagePanel.getScrollState();
}, },
restoreScrollState: function(scrollState) { restoreScrollState: function(scrollState) {
if (!this.refs.messagePanel) return; var messagePanel = this.refs.messagePanel;
if (!messagePanel) return null;
if(scrollState.atBottom) { if(scrollState.atBottom) {
// we were at the bottom before. Ideally we'd scroll to the // we were at the bottom before. Ideally we'd scroll to the
// 'read-up-to' mark here. // 'read-up-to' mark here.
messagePanel.scrollToBottom();
} else if (scrollState.lastDisplayedScrollToken) { } else if (scrollState.lastDisplayedScrollToken) {
// we might need to backfill, so we call scrollToEvent rather than // we might need to backfill, so we call scrollToEvent rather than
// _scrollToToken here. The scrollTokens on our DOM nodes are the // scrollToToken here. The scrollTokens on our DOM nodes are the
// event IDs, so lastDisplayedScrollToken will be the event ID we need, // event IDs, so lastDisplayedScrollToken will be the event ID we need,
// and we can pass it directly into scrollToEvent. // and we can pass it directly into scrollToEvent.
this.scrollToEvent(scrollState.lastDisplayedScrollToken, this.scrollToEvent(scrollState.lastDisplayedScrollToken,
@ -1212,6 +1072,7 @@ module.exports = React.createClass({
var CallView = sdk.getComponent("voip.CallView"); var CallView = sdk.getComponent("voip.CallView");
var RoomSettings = sdk.getComponent("rooms.RoomSettings"); var RoomSettings = sdk.getComponent("rooms.RoomSettings");
var SearchBar = sdk.getComponent("rooms.SearchBar"); var SearchBar = sdk.getComponent("rooms.SearchBar");
var ScrollPanel = sdk.getComponent("structures.ScrollPanel");
if (!this.state.room) { if (!this.state.room) {
if (this.props.roomId) { if (this.props.roomId) {
@ -1298,6 +1159,21 @@ module.exports = React.createClass({
</div> </div>
); );
} }
else if (this.tabComplete.isTabCompleting()) {
var TabCompleteBar = sdk.getComponent('rooms.TabCompleteBar');
statusBar = (
<div className="mx_RoomView_tabCompleteBar">
<div className="mx_RoomView_tabCompleteImage">...</div>
<div className="mx_RoomView_tabCompleteWrapper">
<TabCompleteBar entries={this.tabComplete.peek(6)} />
<div className="mx_RoomView_tabCompleteEol">
<img src="img/eol.svg" width="22" height="16" alt="->|"/>
Auto-complete
</div>
</div>
</div>
);
}
else if (this.state.hasUnsentMessages) { else if (this.state.hasUnsentMessages) {
statusBar = ( statusBar = (
<div className="mx_RoomView_connectionLostBar"> <div className="mx_RoomView_connectionLostBar">
@ -1378,7 +1254,9 @@ module.exports = React.createClass({
); );
if (canSpeak) { if (canSpeak) {
messageComposer = messageComposer =
<MessageComposer room={this.state.room} roomView={this} uploadFile={this.uploadFile} callState={this.state.callState} /> <MessageComposer
room={this.state.room} roomView={this} uploadFile={this.uploadFile}
callState={this.state.callState} tabComplete={this.tabComplete} />
} }
// TODO: Why aren't we storing the term/scope/count in this format // TODO: Why aren't we storing the term/scope/count in this format
@ -1387,7 +1265,7 @@ module.exports = React.createClass({
searchInfo = { searchInfo = {
searchTerm : this.state.searchTerm, searchTerm : this.state.searchTerm,
searchScope : this.state.searchScope, searchScope : this.state.searchScope,
searchCount : this.state.searchCount, searchCount : this.state.searchResults.count,
}; };
} }
@ -1433,6 +1311,33 @@ module.exports = React.createClass({
</div> </div>
} }
// if we have search results, we keep the messagepanel (so that it preserves its
// scroll state), but hide it.
var searchResultsPanel;
var hideMessagePanel = false;
if (this.state.searchResults) {
searchResultsPanel = (
<ScrollPanel ref="searchResultsPanel" className="mx_RoomView_messagePanel"
onFillRequest={ this.onSearchResultsFillRequest }>
<li className={scrollheader_classes}></li>
{this.getSearchResultTiles()}
</ScrollPanel>
);
hideMessagePanel = true;
}
var messagePanel = (
<ScrollPanel ref="messagePanel" className="mx_RoomView_messagePanel"
onScroll={ this.onMessageListScroll }
onFillRequest={ this.onMessageListFillRequest }
style={ hideMessagePanel ? { display: 'none' } : {} } >
<li className={scrollheader_classes}></li>
{this.getEventTiles()}
</ScrollPanel>
);
return ( return (
<div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") }> <div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") }>
<RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo} <RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo}
@ -1453,15 +1358,8 @@ module.exports = React.createClass({
{ conferenceCallNotification } { conferenceCallNotification }
{ aux } { aux }
</div> </div>
<GeminiScrollbar autoshow={true} ref="messagePanel" className="mx_RoomView_messagePanel" onScroll={ this.onMessageListScroll }> { messagePanel }
<div className="mx_RoomView_messageListWrapper"> { searchResultsPanel }
<ol ref="messageList" className="mx_RoomView_MessageList" aria-live="polite">
<li className={scrollheader_classes}>
</li>
{this.getEventTiles()}
</ol>
</div>
</GeminiScrollbar>
<div className="mx_RoomView_statusArea"> <div className="mx_RoomView_statusArea">
<div className="mx_RoomView_statusAreaBox"> <div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line"></div> <div className="mx_RoomView_statusAreaBox_line"></div>

View file

@ -0,0 +1,376 @@
/*
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 React = require("react");
var ReactDOM = require("react-dom");
var GeminiScrollbar = require('react-gemini-scrollbar');
var q = require("q");
var DEBUG_SCROLL = false;
if (DEBUG_SCROLL) {
// using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console);
} else {
var debuglog = function () {};
}
/* This component implements an intelligent scrolling list.
*
* It wraps a list of <li> children; when items are added to the start or end
* of the list, the scroll position is updated so that the user still sees the
* same position in the list.
*
* It also provides a hook which allows parents to provide more list elements
* when we get close to the start or end of the list.
*
* We don't save the absolute scroll offset, because that would be affected by
* window width, zoom level, amount of scrollback, etc. Instead we save an
* identifier for the last fully-visible message, and the number of pixels the
* window was scrolled below it - which is hopefully be near enough.
*
* Each child element should have a 'data-scroll-token'. This token is used to
* serialise the scroll state, and returned as the 'lastDisplayedScrollToken'
* attribute by getScrollState().
*/
module.exports = React.createClass({
displayName: 'ScrollPanel',
propTypes: {
/* stickyBottom: if set to true, then once the user hits the bottom of
* the list, any new children added to the list will cause the list to
* scroll down to show the new element, rather than preserving the
* existing view.
*/
stickyBottom: React.PropTypes.bool,
/* onFillRequest(backwards): a callback which is called on scroll when
* the user nears the start (backwards = true) or end (backwards =
* false) of the list.
*
* This should return a promise; no more calls will be made until the
* promise completes.
*
* The promise should resolve to true if there is more data to be
* retrieved in this direction (in which case onFillRequest may be
* called again immediately), or false if there is no more data in this
* directon (at this time) - which will stop the pagination cycle until
* the user scrolls again.
*/
onFillRequest: React.PropTypes.func,
/* onScroll: a callback which is called whenever any scroll happens.
*/
onScroll: React.PropTypes.func,
/* className: classnames to add to the top-level div
*/
className: React.PropTypes.string,
/* style: styles to add to the top-level div
*/
style: React.PropTypes.object,
},
getDefaultProps: function() {
return {
stickyBottom: true,
onFillRequest: function(backwards) { return q(false); },
onScroll: function() {},
};
},
componentWillMount: function() {
this._pendingFillRequests = {b: null, f: null};
this.resetScrollState();
},
componentDidMount: function() {
this.checkFillState();
},
componentDidUpdate: function() {
// after adding event tiles, we may need to tweak the scroll (either to
// keep at the bottom of the timeline, or to maintain the view after
// adding events to the top).
this._restoreSavedScrollState();
// we also re-check the fill state, in case the paginate was inadequate
this.checkFillState();
},
onScroll: function(ev) {
var sn = this._getScrollNode();
debuglog("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll);
// Sometimes we see attempts to write to scrollTop essentially being
// ignored. (Or rather, it is successfully written, but on the next
// scroll event, it's been reset again).
//
// This was observed on Chrome 47, when scrolling using the trackpad in OS
// X Yosemite. Can't reproduce on El Capitan. Our theory is that this is
// due to Chrome not being able to cope with the scroll offset being reset
// while a two-finger drag is in progress.
//
// By way of a workaround, we detect this situation and just keep
// resetting scrollTop until we see the scroll node have the right
// value.
if (this.recentEventScroll !== undefined) {
if(sn.scrollTop < this.recentEventScroll-200) {
console.log("Working around vector-im/vector-web#528");
this._restoreSavedScrollState();
return;
}
this.recentEventScroll = undefined;
}
this.scrollState = this._calculateScrollState();
debuglog("Saved scroll state", this.scrollState);
this.props.onScroll(ev);
this.checkFillState();
},
// return true if the content is fully scrolled down right now; else false.
//
// Note that if the content hasn't yet been fully populated, this may
// spuriously return true even if the user wanted to be looking at earlier
// content. So don't call it in render() cycles.
isAtBottom: function() {
var sn = this._getScrollNode();
// + 1 here to avoid fractional pixel rounding errors
return sn.scrollHeight - sn.scrollTop <= sn.clientHeight + 1;
},
// check the scroll state and send out backfill requests if necessary.
checkFillState: function() {
var sn = this._getScrollNode();
// if there is less than a screenful of messages above or below the
// viewport, try to get some more messages.
//
// scrollTop is the number of pixels between the top of the content and
// the top of the viewport.
//
// scrollHeight is the total height of the content.
//
// clientHeight is the height of the viewport (excluding borders,
// margins, and scrollbars).
//
//
// .---------. - -
// | | | scrollTop |
// .-+---------+-. - - |
// | | | | | |
// | | | | | clientHeight | scrollHeight
// | | | | | |
// `-+---------+-' - |
// | | |
// | | |
// `---------' -
//
if (sn.scrollTop < sn.clientHeight) {
// need to back-fill
this._maybeFill(true);
}
if (sn.scrollTop > sn.scrollHeight - sn.clientHeight * 2) {
// need to forward-fill
this._maybeFill(false);
}
},
// check if there is already a pending fill request. If not, set one off.
_maybeFill: function(backwards) {
var dir = backwards ? 'b' : 'f';
if (this._pendingFillRequests[dir]) {
debuglog("ScrollPanel: Already a "+dir+" fill in progress - not starting another");
return;
}
debuglog("ScrollPanel: starting "+dir+" fill");
// onFillRequest can end up calling us recursively (via onScroll
// events) so make sure we set this before firing off the call. That
// does present the risk that we might not ever actually fire off the
// fill request, so wrap it in a try/catch.
this._pendingFillRequests[dir] = true;
var fillPromise;
try {
fillPromise = this.props.onFillRequest(backwards);
} catch (e) {
this._pendingFillRequests[dir] = false;
throw e;
}
q.finally(fillPromise, () => {
debuglog("ScrollPanel: "+dir+" fill complete");
this._pendingFillRequests[dir] = false;
}).then((hasMoreResults) => {
if (hasMoreResults) {
// further pagination requests have been disabled until now, so
// it's time to check the fill state again in case the pagination
// was insufficient.
this.checkFillState();
}
}).done();
},
// get the current scroll position of the room, so that it can be
// restored later
getScrollState: function() {
return this.scrollState;
},
/* reset the saved scroll state.
*
* This will cause the scroll to be reinitialised on the next update of the
* child list.
*
* This is useful if the list is being replaced, and you don't want to
* preserve scroll even if new children happen to have the same scroll
* tokens as old ones.
*/
resetScrollState: function() {
this.scrollState = null;
},
scrollToTop: function() {
this._getScrollNode().scrollTop = 0;
debuglog("Scrolled to top");
},
scrollToBottom: function() {
var scrollNode = this._getScrollNode();
scrollNode.scrollTop = scrollNode.scrollHeight;
debuglog("Scrolled to bottom; offset now", scrollNode.scrollTop);
},
// scroll the message list to the node with the given scrollToken. See
// notes in _calculateScrollState on how this works.
//
// pixel_offset gives the number of pixels between the bottom of the node
// and the bottom of the container.
scrollToToken: function(scrollToken, pixelOffset) {
/* find the dom node with the right scrolltoken */
var node;
var messages = this.refs.itemlist.children;
for (var i = messages.length-1; i >= 0; --i) {
var m = messages[i];
if (!m.dataset.scrollToken) continue;
if (m.dataset.scrollToken == scrollToken) {
node = m;
break;
}
}
if (!node) {
console.error("No node with scrollToken '"+scrollToken+"'");
return;
}
var scrollNode = this._getScrollNode();
var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
var boundingRect = node.getBoundingClientRect();
var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom;
if(scrollDelta != 0) {
scrollNode.scrollTop += scrollDelta;
// see the comments in onMessageListScroll regarding recentEventScroll
this.recentEventScroll = scrollNode.scrollTop;
}
debuglog("Scrolled to token", node.dataset.scrollToken, "+",
pixelOffset+":", scrollNode.scrollTop,
"(delta: "+scrollDelta+")");
debuglog("recentEventScroll now "+this.recentEventScroll);
},
_calculateScrollState: function() {
// Our scroll implementation is agnostic of the precise contents of the
// message list (since it needs to work with both search results and
// timelines). 'refs.messageList' is expected to be a DOM node with a
// number of children, each of which may have a 'data-scroll-token'
// attribute. It is this token which is stored as the
// 'lastDisplayedScrollToken'.
var atBottom = this.isAtBottom();
var itemlist = this.refs.itemlist;
var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
var messages = itemlist.children;
for (var i = messages.length-1; i >= 0; --i) {
var node = messages[i];
if (!node.dataset.scrollToken) continue;
var boundingRect = node.getBoundingClientRect();
if (boundingRect.bottom < wrapperRect.bottom) {
return {
atBottom: atBottom,
lastDisplayedScrollToken: node.dataset.scrollToken,
pixelOffset: wrapperRect.bottom - boundingRect.bottom,
}
}
}
// apparently the entire timeline is below the viewport. Give up.
return { atBottom: true };
},
_restoreSavedScrollState: function() {
var scrollState = this.scrollState;
if (!scrollState || (this.props.stickyBottom && scrollState.atBottom)) {
this.scrollToBottom();
} else if (scrollState.lastDisplayedScrollToken) {
this.scrollToToken(scrollState.lastDisplayedScrollToken,
scrollState.pixelOffset);
}
},
/* get the DOM node which has the scrollTop property we care about for our
* message panel.
*/
_getScrollNode: function() {
var panel = ReactDOM.findDOMNode(this.refs.geminiPanel);
// If the gemini scrollbar is doing its thing, this will be a div within
// the message panel (ie, the gemini container); otherwise it will be the
// message panel itself.
if (panel.classList.contains('gm-prevented')) {
return panel;
} else {
return panel.children[2]; // XXX: Fragile!
}
},
render: function() {
// TODO: the classnames on the div and ol could do with being updated to
// reflect the fact that we don't necessarily contain a list of messages.
// it's not obvious why we have a separate div and ol anyway.
return (<GeminiScrollbar autoshow={true} ref="geminiPanel" onScroll={ this.onScroll }
className={this.props.className} style={this.props.style}>
<div className="mx_RoomView_messageListWrapper">
<ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite">
{this.props.children}
</ol>
</div>
</GeminiScrollbar>
);
},
});

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -17,14 +17,22 @@ var React = require('react');
var sdk = require('../../index'); var sdk = require('../../index');
var MatrixClientPeg = require("../../MatrixClientPeg"); var MatrixClientPeg = require("../../MatrixClientPeg");
var Modal = require('../../Modal'); var Modal = require('../../Modal');
var dis = require("../../dispatcher");
var q = require('q'); var q = require('q');
var version = require('../../../package.json').version; var version = require('../../../package.json').version;
var UserSettingsStore = require('../../UserSettingsStore');
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'UserSettings', displayName: 'UserSettings',
Phases: {
Loading: "loading", propTypes: {
Display: "display", onClose: React.PropTypes.func
},
getDefaultProps: function() {
return {
onClose: function() {}
};
}, },
getInitialState: function() { getInitialState: function() {
@ -32,131 +40,227 @@ module.exports = React.createClass({
avatarUrl: null, avatarUrl: null,
threePids: [], threePids: [],
clientVersion: version, clientVersion: version,
phase: this.Phases.Loading, phase: "UserSettings.LOADING", // LOADING, DISPLAY
}; };
}, },
componentWillMount: function() { componentWillMount: function() {
var self = this; var self = this;
var cli = MatrixClientPeg.get(); this._refreshFromServer();
var profile_d = cli.getProfileInfo(cli.credentials.userId);
var threepid_d = cli.getThreePids();
q.all([profile_d, threepid_d]).then(
function(resps) {
self.setState({
avatarUrl: resps[0].avatar_url,
threepids: resps[1].threepids,
phase: self.Phases.Display,
});
},
function(err) { console.err(err); }
);
}, },
editAvatar: function() { componentDidMount: function() {
var url = MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl); this.dispatcherRef = dis.register(this.onAction);
var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); this._me = MatrixClientPeg.get().credentials.userId;
var avatarDialog = (
<div>
<ChangeAvatar initialAvatarUrl={url} />
<div className="mx_Dialog_buttons">
<button onClick={this.onAvatarDialogCancel}>Cancel</button>
</div>
</div>
);
this.avatarDialog = Modal.createDialogWithElement(avatarDialog);
}, },
addEmail: function() { componentWillUnmount: function() {
dis.unregister(this.dispatcherRef);
}, },
editDisplayName: function() { _refreshFromServer: function() {
this.refs.displayname.edit(); var self = this;
q.all([
UserSettingsStore.loadProfileInfo(), UserSettingsStore.loadThreePids()
]).done(function(resps) {
self.setState({
avatarUrl: resps[0].avatar_url,
threepids: resps[1].threepids,
phase: "UserSettings.DISPLAY",
});
}, function(error) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Can't load user settings",
description: error.toString()
});
});
}, },
changePassword: function() { onAction: function(payload) {
var ChangePassword = sdk.getComponent('settings.ChangePassword'); if (payload.action === "notifier_enabled") {
Modal.createDialog(ChangePassword); this.forceUpdate();
}
},
onAvatarSelected: function(ev) {
var self = this;
var changeAvatar = this.refs.changeAvatar;
if (!changeAvatar) {
console.error("No ChangeAvatar found to upload image to!");
return;
}
changeAvatar.onFileSelected(ev).done(function() {
// dunno if the avatar changed, re-check it.
self._refreshFromServer();
}, function(err) {
var errMsg = (typeof err === "string") ? err : (err.error || "");
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Error",
description: "Failed to set avatar. " + errMsg
});
});
}, },
onLogoutClicked: function(ev) { onLogoutClicked: function(ev) {
var LogoutPrompt = sdk.getComponent('dialogs.LogoutPrompt'); var LogoutPrompt = sdk.getComponent('dialogs.LogoutPrompt');
this.logoutModal = Modal.createDialog(LogoutPrompt, {onCancel: this.onLogoutPromptCancel}); this.logoutModal = Modal.createDialog(
LogoutPrompt, {onCancel: this.onLogoutPromptCancel}
);
},
onPasswordChangeError: function(err) {
var errMsg = err.error || "";
if (err.httpStatus === 403) {
errMsg = "Failed to change password. Is your password correct?";
}
else if (err.httpStatus) {
errMsg += ` (HTTP status ${err.httpStatus})`;
}
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Error",
description: errMsg
});
},
onPasswordChanged: function() {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Success",
description: `Your password was successfully changed. You will not
receive push notifications on other devices until you
log back in to them.`
});
}, },
onLogoutPromptCancel: function() { onLogoutPromptCancel: function() {
this.logoutModal.closeDialog(); this.logoutModal.closeDialog();
}, },
onAvatarDialogCancel: function() { onEnableNotificationsChange: function(event) {
this.avatarDialog.close(); UserSettingsStore.setEnableNotifications(event.target.checked);
}, },
render: function() { render: function() {
var Loader = sdk.getComponent("elements.Spinner"); switch (this.state.phase) {
if (this.state.phase === this.Phases.Loading) { case "UserSettings.LOADING":
return <Loader /> var Loader = sdk.getComponent("elements.Spinner");
return (
<Loader />
);
case "UserSettings.DISPLAY":
break; // quit the switch to return the common state
default:
throw new Error("Unknown state.phase => " + this.state.phase);
} }
else if (this.state.phase === this.Phases.Display) { // can only get here if phase is UserSettings.DISPLAY
var ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName'); var RoomHeader = sdk.getComponent('rooms.RoomHeader');
var EnableNotificationsButton = sdk.getComponent('settings.EnableNotificationsButton'); var ChangeDisplayName = sdk.getComponent("views.settings.ChangeDisplayName");
return ( var ChangePassword = sdk.getComponent("views.settings.ChangePassword");
var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
var avatarUrl = (
this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null
);
return (
<div className="mx_UserSettings"> <div className="mx_UserSettings">
<div className="mx_UserSettings_User"> <RoomHeader simpleHeader="Settings" />
<h1>User Settings</h1>
<hr/> <h2>Profile</h2>
<div className="mx_UserSettings_User_Inner">
<div className="mx_UserSettings_Avatar"> <div className="mx_UserSettings_section">
<div className="mx_UserSettings_Avatar_Text"> <div className="mx_UserSettings_profileTable">
Profile Photo <div className="mx_UserSettings_profileTableRow">
<div className="mx_UserSettings_profileLabelCell">
<label htmlFor="displayName">Display name</label>
</div> </div>
<div className="mx_UserSettings_Avatar_Edit" onClick={this.editAvatar}> <div className="mx_UserSettings_profileInputCell">
Edit <ChangeDisplayName />
</div> </div>
</div> </div>
<div className="mx_UserSettings_DisplayName"> {this.state.threepids.map(function(val, pidIndex) {
<ChangeDisplayName ref="displayname" /> var id = "email-" + val.address;
<div className="mx_UserSettings_DisplayName_Edit" onClick={this.editDisplayName}> return (
Edit <div className="mx_UserSettings_profileTableRow" key={pidIndex}>
</div> <div className="mx_UserSettings_profileLabelCell">
</div> <label htmlFor={id}>Email</label>
</div>
<div className="mx_UserSettings_profileInputCell">
<input key={val.address} id={id} value={val.address} disabled />
</div>
</div>
);
})}
</div>
<div className="mx_UserSettings_3pids"> <div className="mx_UserSettings_avatarPicker">
{this.state.threepids.map(function(val) { <ChangeAvatar ref="changeAvatar" initialAvatarUrl={avatarUrl}
return <div key={val.address}>{val.address}</div>; showUploadSection={false} className="mx_UserSettings_avatarPicker_img"/>
})} <div className="mx_UserSettings_avatarPicker_edit">
</div> <label htmlFor="avatarInput">
<img src="img/upload.svg"
<div className="mx_UserSettings_Add3pid" onClick={this.addEmail}> alt="Upload avatar" title="Upload avatar"
Add email width="19" height="24" />
</label>
<input id="avatarInput" type="file" onChange={this.onAvatarSelected}/>
</div> </div>
</div> </div>
</div> </div>
<div className="mx_UserSettings_Global"> <h2>Account</h2>
<h1>Global Settings</h1>
<hr/> <div className="mx_UserSettings_section">
<div className="mx_UserSettings_Global_Inner"> <ChangePassword
<div className="mx_UserSettings_ChangePassword" onClick={this.changePassword}> className="mx_UserSettings_accountTable"
Change Password rowClassName="mx_UserSettings_profileTableRow"
</div> rowLabelClassName="mx_UserSettings_profileLabelCell"
<div className="mx_UserSettings_ClientVersion"> rowInputClassName="mx_UserSettings_profileInputCell"
Version {this.state.clientVersion} buttonClassName="mx_UserSettings_button"
</div> onError={this.onPasswordChangeError}
<div className="mx_UserSettings_EnableNotifications"> onFinished={this.onPasswordChanged} />
<EnableNotificationsButton /> </div>
</div>
<div className="mx_UserSettings_Logout"> <div className="mx_UserSettings_logout">
<button onClick={this.onLogoutClicked}>Sign Out</button> <div className="mx_UserSettings_button" onClick={this.onLogoutClicked}>
Log out
</div>
</div>
<h2>Notifications</h2>
<div className="mx_UserSettings_section">
<div className="mx_UserSettings_notifTable">
<div className="mx_UserSettings_notifTableRow">
<div className="mx_UserSettings_notifInputCell">
<input id="enableNotifications"
ref="enableNotifications"
type="checkbox"
checked={ UserSettingsStore.getEnableNotifications() }
onChange={ this.onEnableNotificationsChange } />
</div>
<div className="mx_UserSettings_notifLabelCell">
<label htmlFor="enableNotifications">
Enable desktop notifications
</label>
</div>
</div> </div>
</div> </div>
</div> </div>
<h2>Advanced</h2>
<div className="mx_UserSettings_section">
<div className="mx_UserSettings_advanced">
Logged in as {this._me}
</div>
<div className="mx_UserSettings_advanced">
Version {this.state.clientVersion}
</div>
</div>
</div> </div>
); );
}
} }
}); });

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -56,8 +56,9 @@ module.exports = React.createClass({
if (this.props.homeserver) { if (this.props.homeserver) {
if (curr_val == "") { if (curr_val == "") {
var self = this;
setTimeout(function() { setTimeout(function() {
target.value = "#:" + this.props.homeserver; target.value = "#:" + self.props.homeserver;
target.setSelectionRange(1, 1); target.setSelectionRange(1, 1);
}, 0); }, 0);
} else { } else {

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -113,6 +113,10 @@ module.exports = React.createClass({
} }
}, },
onBlur: function() {
this.cancelEdit();
},
render: function() { render: function() {
var editable_el; var editable_el;
@ -125,7 +129,8 @@ module.exports = React.createClass({
} else if (this.state.phase == this.Phases.Edit) { } else if (this.state.phase == this.Phases.Edit) {
editable_el = ( editable_el = (
<div> <div>
<input type="text" defaultValue={this.state.value} onKeyUp={this.onKeyUp} onFocus={this.onFocus} onBlur={this.onFinish} placeholder={this.props.placeHolder} autoFocus/> <input type="text" defaultValue={this.state.value}
onKeyUp={this.onKeyUp} onFocus={this.onFocus} onBlur={this.onBlur} placeholder={this.props.placeHolder} autoFocus/>
</div> </div>
); );
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -54,8 +54,8 @@ module.exports = React.createClass({
if (httpUrl) { if (httpUrl) {
return ( return (
<span className="mx_MFileTile"> <span className="mx_MFileBody">
<div className="mx_MImageTile_download"> <div className="mx_MImageBody_download">
<a href={cli.mxcUrlToHttp(content.url)} target="_blank"> <a href={cli.mxcUrlToHttp(content.url)} target="_blank">
<img src="img/download.png" width="10" height="12"/> <img src="img/download.png" width="10" height="12"/>
Download {text} Download {text}
@ -65,7 +65,7 @@ module.exports = React.createClass({
); );
} else { } else {
var extra = text ? ': '+text : ''; var extra = text ? ': '+text : '';
return <span className="mx_MFileTile"> return <span className="mx_MFileBody">
Invalid file{extra} Invalid file{extra}
</span> </span>
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -109,14 +109,14 @@ module.exports = React.createClass({
var thumbUrl = this._getThumbUrl(); var thumbUrl = this._getThumbUrl();
if (thumbUrl) { if (thumbUrl) {
return ( return (
<span className="mx_MImageTile"> <span className="mx_MImageBody">
<a href={cli.mxcUrlToHttp(content.url)} onClick={ this.onClick }> <a href={cli.mxcUrlToHttp(content.url)} onClick={ this.onClick }>
<img className="mx_MImageTile_thumbnail" src={thumbUrl} <img className="mx_MImageBody_thumbnail" src={thumbUrl}
alt={content.body} style={imgStyle} alt={content.body} style={imgStyle}
onMouseEnter={this.onImageEnter} onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} /> onMouseLeave={this.onImageLeave} />
</a> </a>
<div className="mx_MImageTile_download"> <div className="mx_MImageBody_download">
<a href={cli.mxcUrlToHttp(content.url)} target="_blank"> <a href={cli.mxcUrlToHttp(content.url)} target="_blank">
<img src="img/download.png" width="10" height="12"/> <img src="img/download.png" width="10" height="12"/>
Download {content.body} ({ content.info && content.info.size ? filesize(content.info.size) : "Unknown size" }) Download {content.body} ({ content.info && content.info.size ? filesize(content.info.size) : "Unknown size" })
@ -126,13 +126,13 @@ module.exports = React.createClass({
); );
} else if (content.body) { } else if (content.body) {
return ( return (
<span className="mx_MImageTile"> <span className="mx_MImageBody">
Image '{content.body}' cannot be displayed. Image '{content.body}' cannot be displayed.
</span> </span>
); );
} else { } else {
return ( return (
<span className="mx_MImageTile"> <span className="mx_MImageBody">
This image cannot be displayed. This image cannot be displayed.
</span> </span>
); );

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -70,8 +70,8 @@ module.exports = React.createClass({
} }
return ( return (
<span className="mx_MVideoTile"> <span className="mx_MVideoBody">
<video className="mx_MVideoTile" src={cli.mxcUrlToHttp(content.url)} alt={content.body} <video className="mx_MVideoBody" src={cli.mxcUrlToHttp(content.url)} alt={content.body}
controls preload={preload} autoPlay="0" controls preload={preload} autoPlay="0"
height={height} width={width} poster={poster}> height={height} width={width} poster={poster}>
</video> </video>

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -47,6 +47,7 @@ module.exports = React.createClass({
TileType = tileTypes[msgtype]; TileType = tileTypes[msgtype];
} }
return <TileType mxEvent={this.props.mxEvent} highlights={this.props.highlights} />; return <TileType mxEvent={this.props.mxEvent} highlights={this.props.highlights}
onHighlightClick={this.props.onHighlightClick} />;
}, },
}); });

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -49,25 +49,26 @@ module.exports = React.createClass({
render: function() { render: function() {
var mxEvent = this.props.mxEvent; var mxEvent = this.props.mxEvent;
var content = mxEvent.getContent(); var content = mxEvent.getContent();
var body = HtmlUtils.bodyToHtml(content, this.props.highlights); var body = HtmlUtils.bodyToHtml(content, this.props.highlights,
{onHighlightClick: this.props.onHighlightClick});
switch (content.msgtype) { switch (content.msgtype) {
case "m.emote": case "m.emote":
var name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); var name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
return ( return (
<span ref="content" className="mx_MEmoteTile mx_MessageTile_content"> <span ref="content" className="mx_MEmoteBody mx_EventTile_content">
* { name } { body } * { name } { body }
</span> </span>
); );
case "m.notice": case "m.notice":
return ( return (
<span ref="content" className="mx_MNoticeTile mx_MessageTile_content"> <span ref="content" className="mx_MNoticeBody mx_EventTile_content">
{ body } { body }
</span> </span>
); );
default: // including "m.text" default: // including "m.text"
return ( return (
<span ref="content" className="mx_MTextTile mx_MessageTile_content"> <span ref="content" className="mx_MTextBody mx_EventTile_content">
{ body } { body }
</span> </span>
); );

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -34,7 +34,7 @@ module.exports = React.createClass({
if (text == null || text.length == 0) return null; if (text == null || text.length == 0) return null;
return ( return (
<div className="mx_EventAsTextTile"> <div className="mx_TextualEvent">
{TextForEvent.textForEvent(this.props.mxEvent)} {TextForEvent.textForEvent(this.props.mxEvent)}
</div> </div>
); );

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -24,7 +24,7 @@ module.exports = React.createClass({
render: function() { render: function() {
var content = this.props.mxEvent.getContent(); var content = this.props.mxEvent.getContent();
return ( return (
<span className="mx_UnknownMessageTile"> <span className="mx_UnknownBody">
{content.body} {content.body}
</span> </span>
); );

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -74,6 +74,32 @@ module.exports = React.createClass({
} }
}, },
propTypes: {
/* the MatrixEvent to show */
mxEvent: React.PropTypes.object.isRequired,
/* true if this is a continuation of the previous event (which has the
* effect of not showing another avatar/displayname
*/
continuation: React.PropTypes.bool,
/* true if this is the last event in the timeline (which has the effect
* of always showing the timestamp)
*/
last: React.PropTypes.bool,
/* true if this is search context (which has the effect of greying out
* the text
*/
contextual: React.PropTypes.bool,
/* a list of words to highlight */
highlights: React.PropTypes.array,
/* a function to be called when the highlight is clicked */
onHighlightClick: React.PropTypes.func,
},
getInitialState: function() { getInitialState: function() {
return {menu: false, allReadAvatars: false}; return {menu: false, allReadAvatars: false};
}, },
@ -134,6 +160,9 @@ module.exports = React.createClass({
for (var i = 0; i < receipts.length; ++i) { for (var i = 0; i < receipts.length; ++i) {
var member = room.getMember(receipts[i].userId); var member = room.getMember(receipts[i].userId);
if (!member) {
continue;
}
// Using react refs here would mean both getting Velociraptor to expose // Using react refs here would mean both getting Velociraptor to expose
// them and making them scoped to the whole RoomView. Not impossible, but // them and making them scoped to the whole RoomView. Not impossible, but
@ -280,7 +309,8 @@ module.exports = React.createClass({
{ avatar } { avatar }
{ sender } { sender }
<div className="mx_EventTile_line"> <div className="mx_EventTile_line">
<EventTileType mxEvent={this.props.mxEvent} highlights={this.props.highlights} /> <EventTileType mxEvent={this.props.mxEvent} highlights={this.props.highlights}
onHighlightClick={this.props.onHighlightClick} />
</div> </div>
</div> </div>
); );

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -254,7 +254,7 @@ module.exports = React.createClass({
} else { } else {
return ( return (
<form onSubmit={this.onPopulateInvite}> <form onSubmit={this.onPopulateInvite}>
<input className="mx_MemberList_invite" ref="invite" placeholder="Invite another user"/> <input className="mx_MemberList_invite" ref="invite" placeholder="Invite user (email)"/>
</form> </form>
); );
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -31,6 +31,7 @@ var MatrixClientPeg = require("../../../MatrixClientPeg");
var SlashCommands = require("../../../SlashCommands"); var SlashCommands = require("../../../SlashCommands");
var Modal = require("../../../Modal"); var Modal = require("../../../Modal");
var CallHandler = require('../../../CallHandler'); var CallHandler = require('../../../CallHandler');
var MemberEntry = require("../../../TabCompleteEntries").MemberEntry;
var sdk = require('../../../index'); var sdk = require('../../../index');
var dis = require("../../../dispatcher"); var dis = require("../../../dispatcher");
@ -64,14 +65,13 @@ function mdownToHtml(mdown) {
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MessageComposer', displayName: 'MessageComposer',
propTypes: {
tabComplete: React.PropTypes.any
},
componentWillMount: function() { componentWillMount: function() {
this.oldScrollHeight = 0; this.oldScrollHeight = 0;
this.markdownEnabled = MARKDOWN_ENABLED; this.markdownEnabled = MARKDOWN_ENABLED;
this.tabStruct = {
completing: false,
original: null,
index: 0
};
var self = this; var self = this;
this.sentHistory = { this.sentHistory = {
// The list of typed messages. Index 0 is more recent // The list of typed messages. Index 0 is more recent
@ -172,6 +172,9 @@ module.exports = React.createClass({
this.props.room.roomId this.props.room.roomId
); );
this.resizeInput(); this.resizeInput();
if (this.props.tabComplete) {
this.props.tabComplete.setTextArea(this.refs.textarea);
}
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
@ -197,13 +200,6 @@ module.exports = React.createClass({
this.sentHistory.push(input); this.sentHistory.push(input);
this.onEnter(ev); this.onEnter(ev);
} }
else if (ev.keyCode === KeyCode.TAB) {
var members = [];
if (this.props.room) {
members = this.props.room.getJoinedMembers();
}
this.onTab(ev, members);
}
else if (ev.keyCode === KeyCode.UP) { else if (ev.keyCode === KeyCode.UP) {
var input = this.refs.textarea.value; var input = this.refs.textarea.value;
var offset = this.refs.textarea.selectionStart || 0; var offset = this.refs.textarea.selectionStart || 0;
@ -222,10 +218,9 @@ module.exports = React.createClass({
this.resizeInput(); this.resizeInput();
} }
} }
else if (ev.keyCode !== KeyCode.SHIFT && this.tabStruct.completing) {
// they're resuming typing; reset tab complete state vars. if (this.props.tabComplete) {
this.tabStruct.completing = false; this.props.tabComplete.onKeyDown(ev);
this.tabStruct.index = 0;
} }
var self = this; var self = this;
@ -349,104 +344,6 @@ module.exports = React.createClass({
ev.preventDefault(); ev.preventDefault();
}, },
onTab: function(ev, sortedMembers) {
var textArea = this.refs.textarea;
if (!this.tabStruct.completing) {
this.tabStruct.completing = true;
this.tabStruct.index = 0;
// cache starting text
this.tabStruct.original = textArea.value;
}
// loop in the right direction
if (ev.shiftKey) {
this.tabStruct.index --;
if (this.tabStruct.index < 0) {
// wrap to the last search match, and fix up to a real index
// value after we've matched.
this.tabStruct.index = Number.MAX_VALUE;
}
}
else {
this.tabStruct.index++;
}
var searchIndex = 0;
var targetIndex = this.tabStruct.index;
var text = this.tabStruct.original;
var search = /@?([a-zA-Z0-9_\-:\.]+)$/.exec(text);
// console.log("Searched in '%s' - got %s", text, search);
if (targetIndex === 0) { // 0 is always the original text
textArea.value = text;
}
else if (search && search[1]) {
// console.log("search found: " + search+" from "+text);
var expansion;
// FIXME: could do better than linear search here
for (var i=0; i<sortedMembers.length; i++) {
var member = sortedMembers[i];
if (member.name && searchIndex < targetIndex) {
if (member.name.toLowerCase().indexOf(search[1].toLowerCase()) === 0) {
expansion = member.name;
searchIndex++;
}
}
}
if (searchIndex < targetIndex) { // then search raw mxids
for (var i=0; i<sortedMembers.length; i++) {
if (searchIndex >= targetIndex) {
break;
}
var userId = sortedMembers[i].userId;
// === 1 because mxids are @username
if (userId.toLowerCase().indexOf(search[1].toLowerCase()) === 1) {
expansion = userId;
searchIndex++;
}
}
}
if (searchIndex === targetIndex ||
targetIndex === Number.MAX_VALUE) {
// xchat-style tab complete, add a colon if tab
// completing at the start of the text
if (search[0].length === text.length) {
expansion += ": ";
}
else {
expansion += " ";
}
textArea.value = text.replace(
/@?([a-zA-Z0-9_\-:\.]+)$/, expansion
);
// cancel blink
textArea.style["background-color"] = "";
if (targetIndex === Number.MAX_VALUE) {
// wrap the index around to the last index found
this.tabStruct.index = searchIndex;
targetIndex = searchIndex;
}
}
else {
// console.log("wrapped!");
textArea.style["background-color"] = "#faa";
setTimeout(function() {
textArea.style["background-color"] = "";
}, 150);
textArea.value = text;
this.tabStruct.index = 0;
}
}
else {
this.tabStruct.index = 0;
}
// prevent the default TAB operation (typically focus shifting)
ev.preventDefault();
},
onTypingActivity: function() { onTypingActivity: function() {
this.isTyping = true; this.isTyping = true;
if (!this.userTypingTimer) { if (!this.userTypingTimer) {

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -73,10 +73,15 @@ module.exports = React.createClass({
var header; var header;
if (this.props.simpleHeader) { if (this.props.simpleHeader) {
var cancel;
if (this.props.onCancelClick) {
cancel = <img className="mx_RoomHeader_simpleHeaderCancel" src="img/cancel-black.png" onClick={ this.props.onCancelClick } alt="Close" width="18" height="18"/>
}
header = header =
<div className="mx_RoomHeader_wrapper"> <div className="mx_RoomHeader_wrapper">
<div className="mx_RoomHeader_simpleHeader"> <div className="mx_RoomHeader_simpleHeader">
{ this.props.simpleHeader } { this.props.simpleHeader }
{ cancel }
</div> </div>
</div> </div>
} }
@ -104,8 +109,8 @@ module.exports = React.createClass({
var searchStatus; var searchStatus;
// don't display the search count until the search completes and // don't display the search count until the search completes and
// gives us a non-null searchCount. // gives us a valid (possibly zero) searchCount.
if (this.props.searchInfo && this.props.searchInfo.searchCount !== null) { if (this.props.searchInfo && this.props.searchInfo.searchCount !== undefined && this.props.searchInfo.searchCount !== null) {
searchStatus = <div className="mx_RoomHeader_searchStatus">&nbsp;(~{ this.props.searchInfo.searchCount } results)</div>; searchStatus = <div className="mx_RoomHeader_searchStatus">&nbsp;(~{ this.props.searchInfo.searchCount } results)</div>;
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -127,8 +127,16 @@ module.exports = React.createClass({
}, },
onRoomReceipt: function(receiptEvent, room) { onRoomReceipt: function(receiptEvent, room) {
// because if we read a message it will affect notification / unread counts // because if we read a notification, it will affect notification count
this.refreshRoomList(); // only bother updating if there's a receipt from us
var receiptKeys = Object.keys(receiptEvent.getContent());
for (var i = 0; i < receiptKeys.length; ++i) {
var rcpt = receiptEvent.getContent()[receiptKeys[i]];
if (rcpt['m.read'] && rcpt['m.read'][MatrixClientPeg.get().credentials.userId]) {
this.refreshRoomList();
break;
}
}
}, },
onRoomName: function(room) { onRoomName: function(room) {

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -0,0 +1,64 @@
/*
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.
*/
'use strict';
var React = require('react');
var sdk = require('../../../index');
module.exports = React.createClass({
displayName: 'SearchResult',
propTypes: {
// a matrix-js-sdk SearchResult containing the details of this result
searchResult: React.PropTypes.object.isRequired,
// a list of strings to be highlighted in the results
searchHighlights: React.PropTypes.array,
// callback to be called when the user selects this result
onSelect: React.PropTypes.func,
},
render: function() {
var DateSeparator = sdk.getComponent('messages.DateSeparator');
var EventTile = sdk.getComponent('rooms.EventTile');
var result = this.props.searchResult;
var mxEv = result.context.getEvent();
var eventId = mxEv.getId();
var ts1 = mxEv.getTs();
var ret = [<DateSeparator key={ts1 + "-search"} ts={ts1}/>];
var timeline = result.context.getTimeline();
for (var j = 0; j < timeline.length; j++) {
var ev = timeline[j];
var highlights;
var contextual = (j != result.context.getOurEventIndex());
if (!contextual) {
highlights = this.props.searchHighlights;
}
if (EventTile.haveTileForEvent(ev)) {
ret.push(<EventTile key={eventId+"+"+j} mxEvent={ev} contextual={contextual} highlights={highlights}
onHighlightClick={this.props.onSelect}/>)
}
}
return (
<li data-scroll-token={eventId+"+"+j}>
{ret}
</li>);
},
});

View file

@ -0,0 +1,46 @@
/*
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.
*/
'use strict';
var React = require('react');
var MatrixClientPeg = require("../../../MatrixClientPeg");
module.exports = React.createClass({
displayName: 'TabCompleteBar',
propTypes: {
entries: React.PropTypes.array.isRequired
},
render: function() {
return (
<div className="mx_TabCompleteBar">
{this.props.entries.map(function(entry, i) {
return (
<div key={entry.getKey() || i + ""} className="mx_TabCompleteBar_item"
onClick={entry.onClick.bind(entry)} >
{entry.getImageJsx()}
<span className="mx_TabCompleteBar_text">
{entry.getText()}
</span>
</div>
);
})}
</div>
);
}
});

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -23,6 +23,9 @@ module.exports = React.createClass({
propTypes: { propTypes: {
initialAvatarUrl: React.PropTypes.string, initialAvatarUrl: React.PropTypes.string,
room: React.PropTypes.object, room: React.PropTypes.object,
// if false, you need to call changeAvatar.onFileSelected yourself.
showUploadSection: React.PropTypes.bool,
className: React.PropTypes.string
}, },
Phases: { Phases: {
@ -31,6 +34,13 @@ module.exports = React.createClass({
Error: "error", Error: "error",
}, },
getDefaultProps: function() {
return {
showUploadSection: true,
className: "mx_Dialog_content" // FIXME - shouldn't be this by default
};
},
getInitialState: function() { getInitialState: function() {
return { return {
avatarUrl: this.props.initialAvatarUrl, avatarUrl: this.props.initialAvatarUrl,
@ -55,7 +65,7 @@ module.exports = React.createClass({
phase: this.Phases.Uploading phase: this.Phases.Uploading
}); });
var self = this; var self = this;
MatrixClientPeg.get().uploadContent(file).then(function(url) { var httpPromise = MatrixClientPeg.get().uploadContent(file).then(function(url) {
newUrl = url; newUrl = url;
if (self.props.room) { if (self.props.room) {
return MatrixClientPeg.get().sendStateEvent( return MatrixClientPeg.get().sendStateEvent(
@ -67,7 +77,9 @@ module.exports = React.createClass({
} else { } else {
return MatrixClientPeg.get().setAvatarUrl(url); return MatrixClientPeg.get().setAvatarUrl(url);
} }
}).done(function() { });
httpPromise.done(function() {
self.setState({ self.setState({
phase: self.Phases.Display, phase: self.Phases.Display,
avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(newUrl) avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(newUrl)
@ -78,11 +90,13 @@ module.exports = React.createClass({
}); });
self.onError(error); self.onError(error);
}); });
return httpPromise;
}, },
onFileSelected: function(ev) { onFileSelected: function(ev) {
this.avatarSet = true; this.avatarSet = true;
this.setAvatarFromFile(ev.target.files[0]); return this.setAvatarFromFile(ev.target.files[0]);
}, },
onError: function(error) { onError: function(error) {
@ -106,19 +120,26 @@ module.exports = React.createClass({
avatarImg = <img src={this.state.avatarUrl} style={style} />; avatarImg = <img src={this.state.avatarUrl} style={style} />;
} }
var uploadSection;
if (this.props.showUploadSection) {
uploadSection = (
<div className={this.props.className}>
Upload new:
<input type="file" onChange={this.onFileSelected}/>
{this.state.errorText}
</div>
);
}
switch (this.state.phase) { switch (this.state.phase) {
case this.Phases.Display: case this.Phases.Display:
case this.Phases.Error: case this.Phases.Error:
return ( return (
<div> <div>
<div className="mx_Dialog_content"> <div className={this.props.className}>
{avatarImg} {avatarImg}
</div> </div>
<div className="mx_Dialog_content"> {uploadSection}
Upload new:
<input type="file" onChange={this.onFileSelected}/>
{this.state.errorText}
</div>
</div> </div>
); );
case this.Phases.Uploading: case this.Phases.Uploading:

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -98,7 +98,9 @@ module.exports = React.createClass({
} else { } else {
var EditableText = sdk.getComponent('elements.EditableText'); var EditableText = sdk.getComponent('elements.EditableText');
return ( return (
<EditableText ref="displayname_edit" initialValue={this.state.displayName} label="Click to set display name." onValueChanged={this.onValueChanged}/> <EditableText ref="displayname_edit" initialValue={this.state.displayName}
label="Click to set display name."
onValueChanged={this.onValueChanged} />
); );
} }
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -18,30 +18,47 @@ limitations under the License.
var React = require('react'); var React = require('react');
var MatrixClientPeg = require("../../../MatrixClientPeg"); var MatrixClientPeg = require("../../../MatrixClientPeg");
var sdk = require("../../../index");
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'ChangePassword', displayName: 'ChangePassword',
propTypes: { propTypes: {
onFinished: React.PropTypes.func, onFinished: React.PropTypes.func,
onError: React.PropTypes.func,
onCheckPassword: React.PropTypes.func,
rowClassName: React.PropTypes.string,
rowLabelClassName: React.PropTypes.string,
rowInputClassName: React.PropTypes.string,
buttonClassName: React.PropTypes.string
}, },
Phases: { Phases: {
Edit: "edit", Edit: "edit",
Uploading: "uploading", Uploading: "uploading",
Error: "error", Error: "error"
Success: "Success"
}, },
getDefaultProps: function() { getDefaultProps: function() {
return { return {
onFinished: function() {}, onFinished: function() {},
onError: function() {},
onCheckPassword: function(oldPass, newPass, confirmPass) {
if (newPass !== confirmPass) {
return {
error: "New passwords don't match."
};
} else if (!newPass || newPass.length === 0) {
return {
error: "Passwords can't be empty"
};
}
}
}; };
}, },
getInitialState: function() { getInitialState: function() {
return { return {
phase: this.Phases.Edit, phase: this.Phases.Edit
errorString: ''
} }
}, },
@ -55,60 +72,72 @@ module.exports = React.createClass({
}; };
this.setState({ this.setState({
phase: this.Phases.Uploading, phase: this.Phases.Uploading
errorString: '', });
})
var d = cli.setPassword(authDict, new_password);
var self = this; var self = this;
d.then(function() { cli.setPassword(authDict, new_password).then(function() {
self.setState({ self.props.onFinished();
phase: self.Phases.Success,
errorString: '',
})
}, function(err) { }, function(err) {
self.props.onError(err);
}).finally(function() {
self.setState({ self.setState({
phase: self.Phases.Error, phase: self.Phases.Edit
errorString: err.toString() });
}) }).done();
});
}, },
onClickChange: function() { onClickChange: function() {
var old_password = this.refs.old_input.value; var old_password = this.refs.old_input.value;
var new_password = this.refs.new_input.value; var new_password = this.refs.new_input.value;
var confirm_password = this.refs.confirm_input.value; var confirm_password = this.refs.confirm_input.value;
if (new_password != confirm_password) { var err = this.props.onCheckPassword(
this.setState({ old_password, new_password, confirm_password
state: this.Phases.Error, );
errorString: "Passwords don't match" if (err) {
}); this.props.onError(err);
} else if (new_password == '' || old_password == '') { }
this.setState({ else {
state: this.Phases.Error,
errorString: "Passwords can't be empty"
});
} else {
this.changePassword(old_password, new_password); this.changePassword(old_password, new_password);
} }
}, },
render: function() { render: function() {
var rowClassName = this.props.rowClassName;
var rowLabelClassName = this.props.rowLabelClassName;
var rowInputClassName = this.props.rowInputClassName
var buttonClassName = this.props.buttonClassName;
switch (this.state.phase) { switch (this.state.phase) {
case this.Phases.Edit: case this.Phases.Edit:
case this.Phases.Error:
return ( return (
<div> <div className={this.props.className}>
<div className="mx_Dialog_content"> <div className={rowClassName}>
<div>{this.state.errorString}</div> <div className={rowLabelClassName}>
<div><label>Old password <input type="password" ref="old_input"/></label></div> <label htmlFor="passwordold">Current password</label>
<div><label>New password <input type="password" ref="new_input"/></label></div> </div>
<div><label>Confirm password <input type="password" ref="confirm_input"/></label></div> <div className={rowInputClassName}>
<input id="passwordold" type="password" ref="old_input" />
</div>
</div> </div>
<div className="mx_Dialog_buttons"> <div className={rowClassName}>
<button onClick={this.onClickChange}>Change Password</button> <div className={rowLabelClassName}>
<button onClick={this.props.onFinished}>Cancel</button> <label htmlFor="password1">New password</label>
</div>
<div className={rowInputClassName}>
<input id="password1" type="password" ref="new_input" />
</div>
</div>
<div className={rowClassName}>
<div className={rowLabelClassName}>
<label htmlFor="password2">Confirm password</label>
</div>
<div className={rowInputClassName}>
<input id="password2" type="password" ref="confirm_input" />
</div>
</div>
<div className={buttonClassName} onClick={this.onClickChange}>
Change Password
</div> </div>
</div> </div>
); );
@ -119,17 +148,6 @@ module.exports = React.createClass({
<Loader /> <Loader />
</div> </div>
); );
case this.Phases.Success:
return (
<div>
<div className="mx_Dialog_content">
Success!
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.props.onFinished}>Ok</button>
</div>
</div>
)
} }
} }
}); });

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -25,8 +25,12 @@ var dis = require('../../../dispatcher');
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'VideoView', displayName: 'VideoView',
componentWillMount: function() { componentDidMount: function() {
dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
},
componentWillUnmount: function() {
dis.unregister(this.dispatcherRef);
}, },
getRemoteVideoElement: function() { getRemoteVideoElement: function() {

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.