diff --git a/README.md b/README.md index ae1cd17c9a..dfc1a6e6ec 100644 --- a/README.md +++ b/README.md @@ -3,65 +3,85 @@ matrix-react-sdk This is a react-based SDK for inserting a Matrix chat/voip client into a web page. -This package provides the logic and 'controller' parts for the UI components. This -forms one part of a complete matrix client, but it not useable in isolation. It -must be used from a 'skin'. A skin provides: - * The HTML for the UI components (in the form of React `render` methods) - * The CSS for this HTML - * The containing application +This package provides the React components needed to build a Matrix web client +using React. It is not useable in isolation, and instead must must be used from +a 'skin'. A skin provides: + * Customised implementations of presentation components. + * Custom CSS + * The containing application * Zero or more 'modules' containing non-UI functionality -Skins are modules are exported from such a package in the `lib` directory. -`lib/skins` contains one directory per-skin, named after the skin, and the -`modules` directory contains modules as their javascript files. +**WARNING: As of July 2016, the skinning abstraction is broken due to rapid +development of `matrix-react-sdk` to meet the needs of Vector, the first app +to be built on top of the SDK** (https://github.com/vector-im/vector-web). +Right now `matrix-react-sdk` depends on some functionality from `vector-web` +(e.g. CSS), and `matrix-react-sdk` contains some Vector specific behaviour +(grep for 'vector'). This layering will be fixed asap once Vector development +has stabilised, but for now we do not advise trying to create new skins for +matrix-react-sdk until the layers are clearly separated again. -A basic skin is provided in the matrix-react-skin package. This also contains -a minimal application that instantiates the basic skin making a working matrix -client. +In the interim, `vector-im/vector-web` and `matrix-org/matrix-react-sdk` should +be considered as a single project (for instance, matrix-react-sdk bugs +are currently filed against vector-im/vector-web rather than this project). -You can use matrix-react-sdk directly, but to do this you would have to provide -'views' for each UI component. To get started quickly, use matrix-react-skin. +Developer Guide +=============== -How to customise the SDK -======================== +Platform Targets: + * Chrome, Firefox and Safari. + * Edge should also work, but we're not testing it proactively. + * WebRTC features (VoIP and Video calling) are only available in Chrome & Firefox. + * Mobile Web is not currently a target platform - instead please use the native + iOS (https://github.com/matrix-org/matrix-ios-kit) and Android + (https://github.com/matrix-org/matrix-android-sdk) SDKs. -The SDK formerly used the 'atomic' design pattern as seen at http://patternlab.io to -encourage a very modular and reusable architecture, making it easy to -customise and use UI widgets independently of the rest of the SDK and your app. +All code lands on the `develop` branch - `master` is only used for stable releases. +**Please file PRs against `develop`!!** -So unfortunately at the moment this document does not describe how to customize your UI! +Please follow the standard Matrix contributor's guide: +https://github.com/matrix-org/synapse/tree/master/CONTRIBUTING.rst -###This is the old description for the atomic design pattern: +Please follow the Matrix JS/React code style as per: +https://github.com/matrix-org/matrix-react-sdk/tree/master/code_style.rst -In practice this means: +Whilst the layering separation between matrix-react-sdk and Vector is broken +(as of July 2016), code should be committed as follows: + * All new components: https://github.com/matrix-org/matrix-react-sdk/tree/master/src/components + * Vector-specific components: https://github.com/vector-im/vector-web/tree/master/src/components + * In practice, `matrix-react-sdk` is still evolving so fast that the maintenance + burden of customising and overriding these components for Vector can seriously + impede development. So right now, there should be very few (if any) customisations for Vector. + * CSS for Matrix SDK components: https://github.com/vector-im/vector-web/tree/master/src/skins/vector/css/matrix-react-sdk + * CSS for Vector-specific overrides and components: https://github.com/vector-im/vector-web/tree/master/src/skins/vector/css/vector-web - * The UI of the app is strictly split up into a hierarchy of components. - - * Each component has its own: - * View object defined as a React javascript class containing embedded - HTML expressed in React's JSX notation. - * CSS file, which defines the styling specific to that component. - - * Components are loosely grouped into the 5 levels outlined by atomic design: - * atoms: fundamental building blocks (e.g. a timestamp tag) - * molecules: "group of atoms which functions together as a unit" - (e.g. a message in a chat timeline) - * organisms: "groups of molecules (and atoms) which form a distinct section - of a UI" (e.g. a view of a chat room) - * templates: "a reusable configuration of organisms" - used to combine and - style organisms into a well-defined global look and feel - * pages: specific instances of templates. +React components in matrix-react-sdk are come in two different flavours: +'structures' and 'views'. Structures are stateful components which handle the +more complicated business logic of the app, delegating their actual presentation +rendering to stateless 'view' components. For instance, the RoomView component +that orchestrates the act of visualising the contents of a given Matrix chat room +tracks lots of state for its child components which it passes into them for visual +rendering via props. - Good separation between the components is maintained by adopting various best - practices that anyone working with the SDK needs to be be aware of and uphold: +Good separation between the components is maintained by adopting various best +practices that anyone working with the SDK needs to be be aware of and uphold: - * Views are named with upper camel case (e.g. molecules/MessageTile.js) + * Components are named with upper camel case (e.g. views/rooms/EventTile.js) - * The view's CSS file MUST have the same name (e.g. molecules/MessageTile.css) + * They are organised in a typically two-level hierarchy - first whether the + component is a view or a structure, and then a broad functional grouping + (e.g. 'rooms' here) + + * After creating a new component you must run `npm run reskindex` to regenerate + the `component-index.js` for the SDK (used in future for skinning) + + * The view's CSS file MUST have the same name (e.g. view/rooms/MessageTile.css). + CSS for matrix-react-sdk currently resides in + https://github.com/vector-im/vector-web/tree/master/src/skins/vector/css/matrix-react-sdk. * Per-view CSS is optional - it could choose to inherit all its styling from - the context of the rest of the app, although this is unusual for any but - the simplest atoms and molecules. + the context of the rest of the app, although this is unusual for any but + structural components (lacking presentation logic) and the simplest view + components. * The view MUST *only* refer to the CSS rules defined in its own CSS file. 'Stealing' styling information from other components (including parents) @@ -82,9 +102,10 @@ In practice this means: * We deliberately use vanilla CSS 3.0 to avoid adding any more magic dependencies into the mix than we already have. App developers are welcome - to use whatever floats their boat however. + to use whatever floats their boat however. In future we'll start using + css-next to pull in features like CSS variable support. - * The CSS for a component can however override the rules for child components. + * The CSS for a component can override the rules for child components. For instance, .mx_RoomList .mx_RoomTile {} would be the selector to override styles of RoomTiles when viewed in the context of a RoomList view. Overrides *must* be scoped to the View's CSS class - i.e. don't just define @@ -98,30 +119,36 @@ In practice this means: generally not cool and stop the component from being reused easily in different places. - * We don't use the atomify library itself, as React already provides most - of the modularity requirements it brings to the table. +Originally `matrix-react-sdk` followed the Atomic design pattern as per +http://patternlab.io to try to encourage a modular architecture. However, we +found that the grouping of components into atoms/molecules/organisms +made them harder to find relative to a functional split, and didn't emphasise +the distinction between 'structural' and 'view' components, so we backed away +from it. -With all this in mind, here's how you go about skinning the react SDK UI -components to embed a Matrix client into your app: +Github Issues +============= - * Create a new NPM project. Be sure to directly depend on react, (otherwise - you can end up with two copies of react). - * Create an index.js file that sets up react. Add require statements for - React and matrix-react-sdk. Load a skin using the 'loadSkin' method on the - SDK and call Render. This can be a skin provided by a separate package or - a skin in the same package. - * Add a way to build your project: we suggest copying the scripts block - from matrix-react-skin (which uses babel and webpack). You could use - different tools but remember that at least the skins and modules of - your project should end up in plain (ie. non ES6, non JSX) javascript in - the lib directory at the end of the build process, as well as any - packaging that you might do. - * Create an index.html file pulling in your compiled javascript and the - CSS bundle from the skin you use. For now, you'll also need to manually - import CSS from any skins that your skin inherts from. +All issues should be filed under https://github.com/vector-im/vector-web/issues +for now. + +OUTDATED: To Create Your Own Skin +================================= + +**This is ALL LIES currently, as skinning is currently broken - see the WARNING +section at the top of this readme.** + +Skins are modules are exported from such a package in the `lib` directory. +`lib/skins` contains one directory per-skin, named after the skin, and the +`modules` directory contains modules as their javascript files. + +A basic skin is provided in the matrix-react-skin package. This also contains +a minimal application that instantiates the basic skin making a working matrix +client. + +You can use matrix-react-sdk directly, but to do this you would have to provide +'views' for each UI component. To get started quickly, use matrix-react-skin. -To Create Your Own Skin -======================= To actually change the look of a skin, you can create a base skin (which does not use views from any other skin) or you can make a derived skin. Note that derived skins are currently experimental: for example, the CSS @@ -145,3 +172,22 @@ Now you have the basis of a skin, you need to generate a skindex.json file. The you add an npm script to run this, as in matrix-react-skin. For more specific detail on any of these steps, look at matrix-react-skin. + +Alternative instructions: + + * Create a new NPM project. Be sure to directly depend on react, (otherwise + you can end up with two copies of react). + * Create an index.js file that sets up react. Add require statements for + React and matrix-react-sdk. Load a skin using the 'loadSkin' method on the + SDK and call Render. This can be a skin provided by a separate package or + a skin in the same package. + * Add a way to build your project: we suggest copying the scripts block + from matrix-react-skin (which uses babel and webpack). You could use + different tools but remember that at least the skins and modules of + your project should end up in plain (ie. non ES6, non JSX) javascript in + the lib directory at the end of the build process, as well as any + packaging that you might do. + * Create an index.html file pulling in your compiled javascript and the + CSS bundle from the skin you use. For now, you'll also need to manually + import CSS from any skins that your skin inherts from. + diff --git a/code_style.md b/code_style.md new file mode 100644 index 0000000000..7b272e0656 --- /dev/null +++ b/code_style.md @@ -0,0 +1,162 @@ +Matrix JavaScript/ECMAScript Style Guide +======================================== + +The intention of this guide is to make Matrix's JavaScript codebase clean, +consistent with other popular JavaScript styles and consistent with the rest of +the Matrix codebase. For reference, the Matrix Python style guide can be found +at https://github.com/matrix-org/synapse/blob/master/docs/code_style.rst + +This document reflects how we would like Matrix JavaScript code to look, with +acknowledgement that a significant amount of code is written to older +standards. + +Write applications in modern ECMAScript and use a transpiler where necessary to +target older platforms. When writing library code, consider carefully whether +to write in ES5 to allow all JavaScript application to use the code directly or +writing in modern ECMAScript and using a transpile step to generate the file +that applications can then include. There are significant benefits in being +able to use modern ECMAScript, although the tooling for doing so can be awkward +for library code, especially with regard to translating source maps and line +number throgh from the original code to the final application. + +General Style +------------- +- 4 spaces to indent, for consistency with Matrix Python. +- 120 columns per line, but try to keep JavaScript code around the 80 column mark. + Inline JSX in particular can be nicer with more columns per line. +- No trailing whitespace at end of lines. +- Don't indent empty lines. +- One newline at the end of the file. +- Unix newlines, never `\r` +- Indent similar to our python code: break up long lines at logical boundaries, + more than one argument on a line is OK +- Use semicolons, for consistency with node. +- UpperCamelCase for class and type names +- lowerCamelCase for functions and variables. +- Single line ternary operators are fine. +- UPPER_CAMEL_CASE for constants +- Single quotes for strings by default, for consistency with most JavaScript styles: + + ```javascript + "bad" // Bad + 'good' // Good + ``` +- Use parentheses or `\`` instead of '\\' for line continuation where ever possible +- Open braces on the same line (consistent with Node): + + ```javascript + if (x) { + console.log("I am a fish"); // Good + } + + if (x) + { + console.log("I am a fish"); // Bad + } + ``` +- Spaces after `if`, `for`, `else` etc, no space around the condition: + + ```javascript + if (x) { + console.log("I am a fish"); // Good + } + + if(x) { + console.log("I am a fish"); // Bad + } + + if ( x ) { + console.log("I am a fish"); // Bad + } + ``` +- Declare one variable per var statement (consistent with Node). Unless they + are simple and closely related. If you put the next declaration on a new line, + treat yourself to another `var`: + + ```javascript + var key = "foo", + comparator = function(x, y) { + return x - y; + }; // Bad + + var key = "foo"; + var comparator = function(x, y) { + return x - y; + }; // Good + + var x = 0, y = 0; // Fine + + var x = 0; + var y = 0; // Also fine + ``` +- A single line `if` is fine, all others have braces. This prevents errors when adding to the code.: + + ```javascript + if (x) return true; // Fine + + if (x) { + return true; // Also fine + } + + if (x) + return true; // Not fine + ``` +- Terminate all multi-line lists, object literals, imports and ideally function calls with commas (if using a transpiler). Note that trailing function commas require explicit configuration in babel at time of writing: + + ```javascript + var mascots = [ + "Patrick", + "Shirley", + "Colin", + "Susan", + "Sir Arthur David" // Bad + ]; + + var mascots = [ + "Patrick", + "Shirley", + "Colin", + "Susan", + "Sir Arthur David", // Good + ]; + ``` +- Use `null`, `undefined` etc consistently with node: + Boolean variables and functions should always be either true or false. Don't set it to 0 unless it's supposed to be a number. + When something is intentionally missing or removed, set it to null. + If returning a boolean, type coerce: + + ```javascript + function hasThings() { + return !!length; // bad + return new Boolean(length); // REALLY bad + return Boolean(length); // good + } + ``` + Don't set things to undefined. Reserve that value to mean "not yet set to anything." + Boolean objects are verboten. +- Use JSDoc + +ECMAScript +---------- +- Use `const` unless you need a re-assignable variable. This ensures things you don't want to be re-assigned can't be. +- Be careful migrating files to newer syntax. + - Don't mix `require` and `import` in the same file. Either stick to the old style or change them all. + - Likewise, don't mix things like class properties and `MyClass.prototype.MY_CONSTANT = 42;` + - Be careful mixing arrow functions and regular functions, eg. if one function in a promise chain is an + arrow function, they probably all should be. +- Apart from that, newer ES features should be used whenever the author deems them to be appropriate. +- Flow annotations are welcome and encouraged. + +React +----- +- Use ES6 classes, although bear in mind a lot of code uses createClass. +- Pull out functions in props to the class, generally as specific event handlers: + + ```jsx + // Bad + {doStuff();}}> // Equally bad + // Better + // Best, if onFooClick would do anything other than directly calling doStuff + ``` +- Think about whether your component really needs state: are you duplicating + information in component state that could be derived from the model? diff --git a/package.json b/package.json index 13cabf32d9..0d2f6e1a5b 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ }, "scripts": { "reskindex": "reskindex -h header", - "build": "babel src -d lib --source-maps", - "start": "babel src -w -d lib --source-maps", + "build": "babel src -d lib --source-maps --stage 1", + "start": "babel src -w -d lib --source-maps --stage 1", "lint": "eslint src/", "lintall": "eslint src/ test/", "clean": "rimraf lib", @@ -42,10 +42,10 @@ "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", "q": "^1.4.1", - "react": "^15.0.1", - "react-addons-css-transition-group": "^15.1.0", - "react-dom": "^15.0.1", - "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#c3d942e", + "react": "^15.2.1", + "react-addons-css-transition-group": "^15.2.1", + "react-dom": "^15.2.1", + "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#dbf0abf", "sanitize-html": "^1.11.1", "velocity-vector": "vector-im/velocity#059e3b2", "whatwg-fetch": "^1.0.0" diff --git a/src/AddThreepid.js b/src/AddThreepid.js index 31805aad11..5593d46ff7 100644 --- a/src/AddThreepid.js +++ b/src/AddThreepid.js @@ -38,11 +38,13 @@ class AddThreepid { */ addEmailAddress(emailAddress, bind) { this.bind = bind; - return MatrixClientPeg.get().requestEmailToken(emailAddress, this.clientSecret, 1).then((res) => { + return MatrixClientPeg.get().requestAdd3pidEmailToken(emailAddress, this.clientSecret, 1).then((res) => { this.sessionId = res.sid; return res; }, function(err) { - if (err.httpStatus) { + if (err.errcode == 'M_THREEPID_IN_USE') { + err.message = "This email address is already in use"; + } else if (err.httpStatus) { err.message = err.message + ` (Status ${err.httpStatus})`; } throw err; diff --git a/src/ContentMessages.js b/src/ContentMessages.js index 56e3499eae..796c1ed58d 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -52,6 +52,36 @@ function infoForImageFile(imageFile) { return deferred.promise; } +function infoForVideoFile(videoFile) { + var deferred = q.defer(); + + // Load the file into an html element + var video = document.createElement("video"); + + var reader = new FileReader(); + reader.onload = function(e) { + video.src = e.target.result; + + // Once ready, returns its size + video.onloadedmetadata = function() { + deferred.resolve({ + w: video.videoWidth, + h: video.videoHeight + }); + }; + video.onerror = function(e) { + deferred.reject(e); + }; + }; + reader.onerror = function(e) { + deferred.reject(e); + }; + reader.readAsDataURL(videoFile); + + return deferred.promise; +} + + class ContentMessages { constructor() { this.inprogress = []; @@ -81,6 +111,12 @@ class ContentMessages { } else if (file.type.indexOf('audio/') == 0) { content.msgtype = 'm.audio'; def.resolve(); + } else if (file.type.indexOf('video/') == 0) { + content.msgtype = 'm.video'; + infoForVideoFile(file).then(function (videoInfo) { + extend(content.info, videoInfo); + def.resolve(); + }); } else { content.msgtype = 'm.file'; def.resolve(); diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 64d746f0a4..2ab635081f 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -186,7 +186,7 @@ module.exports = { * * highlights: optional list of words to highlight, ordered by longest word first * - * opts.highlightLink: optional href to add to highlights + * opts.highlightLink: optional href to add to highlighted words */ bodyToHtml: function(content, highlights, opts) { opts = opts || {}; @@ -223,8 +223,10 @@ module.exports = { let match = EMOJI_REGEX.exec(contentBodyTrimmed); let emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length; - let className = classNames('markdown-body', { - 'emoji-body': emojiBody, + const className = classNames({ + 'mx_EventTile_body': true, + 'mx_EventTile_bigEmoji': emojiBody, + 'markdown-body': isHtml, }); return ; }, diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index be925af19b..7c1c5b34d7 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -97,35 +97,20 @@ class MatrixClient { // FIXME, XXX: this all seems very convoluted :( // - // if we replace the singleton using URLs we bypass our createClientForPeg() - // global helper function... but if we replace it using - // an access_token we don't? - // // Why do we have this peg wrapper rather than just MatrixClient.get()? // Why do we name MatrixClient as MatrixClientPeg when we export it? // // -matthew replaceUsingUrls(hs_url, is_url) { - matrixClient = Matrix.createClient({ - baseUrl: hs_url, - idBaseUrl: is_url - }); - - // XXX: factor this out with the localStorage setting in replaceUsingAccessToken - if (localStorage) { - try { - localStorage.setItem("mx_hs_url", hs_url); - localStorage.setItem("mx_is_url", is_url); - } catch (e) { - console.warn("Error using local storage: can't persist HS/IS URLs!"); - } - } else { - console.warn("No local storage available: can't persist HS/IS URLs!"); - } + this.replaceClient(hs_url, is_url); } replaceUsingAccessToken(hs_url, is_url, user_id, access_token, isGuest) { + this.replaceClient(hs_url, is_url, user_id, access_token, isGuest); + } + + replaceClient(hs_url, is_url, user_id, access_token, isGuest) { if (localStorage) { try { localStorage.clear(); diff --git a/src/PasswordReset.js b/src/PasswordReset.js index bbafa0ef33..a03a565459 100644 --- a/src/PasswordReset.js +++ b/src/PasswordReset.js @@ -48,11 +48,13 @@ class PasswordReset { */ resetPassword(emailAddress, newPassword) { this.password = newPassword; - return this.client.requestEmailToken(emailAddress, this.clientSecret, 1).then((res) => { + return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, 1).then((res) => { this.sessionId = res.sid; return res; }, function(err) { - if (err.httpStatus) { + if (err.errcode == 'M_THREEPID_NOT_FOUND') { + err.message = "This email address was not found"; + } else if (err.httpStatus) { err.message = err.message + ` (Status ${err.httpStatus})`; } throw err; diff --git a/src/Signup.js b/src/Signup.js index 4518955d95..5aadd94701 100644 --- a/src/Signup.js +++ b/src/Signup.js @@ -152,7 +152,10 @@ class Register extends Signup { console.log("Active flow => %s", JSON.stringify(flow)); var flowStage = self.firstUncompletedStage(flow); if (flowStage != self.activeStage) { - return self.startStage(flowStage); + return self.startStage(flowStage).catch(function(err) { + self.setStep('START'); + throw err; + }); } } } diff --git a/src/SignupStages.js b/src/SignupStages.js index 1c5c48ddd6..2b0d163a08 100644 --- a/src/SignupStages.js +++ b/src/SignupStages.js @@ -170,7 +170,7 @@ class EmailIdentityStage extends Stage { encodeURIComponent(this.signupInstance.getServerData().session); var self = this; - return this.client.requestEmailToken( + return this.client.requestRegisterEmailToken( this.signupInstance.email, this.clientSecret, 1, // TODO: Multiple send attempts? @@ -186,8 +186,8 @@ class EmailIdentityStage extends Stage { var e = { isFatal: true }; - if (error.errcode == 'THREEPID_IN_USE') { - e.message = "Email in use"; + if (error.errcode == 'M_THREEPID_IN_USE') { + e.message = "This email address is already registered"; } else { e.message = 'Unable to contact the given identity server'; } diff --git a/src/TabComplete.js b/src/TabComplete.js index 7da8bde76b..65441c9381 100644 --- a/src/TabComplete.js +++ b/src/TabComplete.js @@ -13,7 +13,10 @@ 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; + +import { Entry, MemberEntry, CommandEntry } from './TabCompleteEntries'; +import SlashCommands from './SlashCommands'; +import MatrixClientPeg from './MatrixClientPeg'; const DELAY_TIME_MS = 1000; const KEY_TAB = 9; @@ -45,23 +48,39 @@ class TabComplete { this.isFirstWord = false; // true if you tab-complete on the first word this.enterTabCompleteTimerId = null; this.inPassiveMode = false; + + // Map tracking ordering of the room members. + // userId: integer, highest comes first. + this.memberTabOrder = {}; + + // monotonically increasing counter used for tracking ordering of members + this.memberOrderSeq = 0; } /** - * @param {Entry[]} completeList + * Call this when a a UI element representing a tab complete entry has been clicked + * @param {entry} The entry that was clicked */ - setCompletionList(completeList) { - this.list = completeList; + onEntryClick(entry) { if (this.opts.onClickCompletes) { - // assign onClick listeners for each entry to complete the text - this.list.forEach((l) => { - l.onClick = () => { - this.completeTo(l); - } - }); + this.completeTo(entry); } } + loadEntries(room) { + this._makeEntries(room); + this._initSorting(room); + this._sortEntries(); + } + + onMemberSpoke(member) { + if (this.memberTabOrder[member.userId] === undefined) { + this.list.push(new MemberEntry(member)); + } + this.memberTabOrder[member.userId] = this.memberOrderSeq++; + this._sortEntries(); + } + /** * @param {DOMElement} */ @@ -307,6 +326,54 @@ class TabComplete { this.opts.onStateChange(this.completing); } } + + _sortEntries() { + // largest comes first + const KIND_ORDER = { + command: 1, + member: 2, + }; + + this.list.sort((a, b) => { + const kindOrderDifference = KIND_ORDER[b.kind] - KIND_ORDER[a.kind]; + if (kindOrderDifference != 0) { + return kindOrderDifference; + } + + if (a.kind == 'member') { + let orderA = this.memberTabOrder[a.member.userId]; + let orderB = this.memberTabOrder[b.member.userId]; + if (orderA === undefined) orderA = -1; + if (orderB === undefined) orderB = -1; + + return orderB - orderA; + } + + // anything else we have no ordering for + return 0; + }); + } + + _makeEntries(room) { + const myUserId = MatrixClientPeg.get().credentials.userId; + + const members = room.getJoinedMembers().filter(function(member) { + if (member.userId !== myUserId) return true; + }); + + this.list = MemberEntry.fromMemberList(members).concat( + CommandEntry.fromCommands(SlashCommands.getCommandList()) + ); + } + + _initSorting(room) { + this.memberTabOrder = {}; + this.memberOrderSeq = 0; + + for (const ev of room.getLiveTimeline().getEvents()) { + this.memberTabOrder[ev.getSender()] = this.memberOrderSeq++; + } + } }; module.exports = TabComplete; diff --git a/src/TabCompleteEntries.js b/src/TabCompleteEntries.js index a23050063f..4a28103210 100644 --- a/src/TabCompleteEntries.js +++ b/src/TabCompleteEntries.js @@ -69,6 +69,7 @@ class Entry { class CommandEntry extends Entry { constructor(cmd, cmdWithArgs) { super(cmdWithArgs); + this.kind = 'command'; this.cmd = cmd; } @@ -95,6 +96,7 @@ class MemberEntry extends Entry { constructor(member) { super(member.name || member.userId); this.member = member; + this.kind = 'member'; } getImageJsx() { @@ -114,24 +116,7 @@ class MemberEntry extends Entry { } 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 - var lastActiveAgoA = userA.lastActiveAgo || Number.MAX_SAFE_INTEGER; - var lastActiveAgoB = userB.lastActiveAgo || Number.MAX_SAFE_INTEGER; - return lastActiveAgoA - lastActiveAgoB; - } - }).map(function(m) { + return members.map(function(m) { return new MemberEntry(m); }); } diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index 305994aa0e..f4eb4f0d83 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -113,6 +113,35 @@ module.exports = { }); }, + getUrlPreviewsDisabled: function() { + var event = MatrixClientPeg.get().getAccountData("org.matrix.preview_urls"); + return (event && event.getContent().disable); + }, + + setUrlPreviewsDisabled: function(disabled) { + // FIXME: handle errors + return MatrixClientPeg.get().setAccountData("org.matrix.preview_urls", { + disable: disabled + }); + }, + + getSyncedSettings: function() { + var event = MatrixClientPeg.get().getAccountData("im.vector.web.settings"); + return event ? event.getContent() : {}; + }, + + getSyncedSetting: function(type) { + var settings = this.getSyncedSettings(); + return settings[type]; + }, + + setSyncedSetting: function(type, value) { + var settings = this.getSyncedSettings(); + settings[type] = value; + // FIXME: handle errors + return MatrixClientPeg.get().setAccountData("im.vector.web.settings", settings); + }, + isFeatureEnabled: function(feature: string): boolean { return localStorage.getItem(`mx_labs_feature_${feature}`) === 'true'; }, diff --git a/src/component-index.js b/src/component-index.js index 4aa0efe21f..5fadb18b6a 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -74,6 +74,8 @@ module.exports.components['views.messages.TextualEvent'] = require('./components module.exports.components['views.messages.UnknownBody'] = require('./components/views/messages/UnknownBody'); module.exports.components['views.room_settings.AliasSettings'] = require('./components/views/room_settings/AliasSettings'); module.exports.components['views.room_settings.ColorSettings'] = require('./components/views/room_settings/ColorSettings'); +module.exports.components['views.room_settings.UrlPreviewSettings'] = require('./components/views/room_settings/UrlPreviewSettings'); +module.exports.components['views.rooms.Autocomplete'] = require('./components/views/rooms/Autocomplete'); module.exports.components['views.rooms.AuxPanel'] = require('./components/views/rooms/AuxPanel'); module.exports.components['views.rooms.EntityTile'] = require('./components/views/rooms/EntityTile'); module.exports.components['views.rooms.EventTile'] = require('./components/views/rooms/EventTile'); diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index dcaa82fc75..dc9ca08e94 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -390,7 +390,7 @@ module.exports = React.createClass({ // FIXME: controller shouldn't be loading a view :( var Loader = sdk.getComponent("elements.Spinner"); - var modal = Modal.createDialog(Loader); + var modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); d.then(function() { modal.close(); diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index c8e878118b..53efac6406 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -44,6 +44,9 @@ module.exports = React.createClass({ // ID of an event to highlight. If undefined, no event will be highlighted. highlightedEventId: React.PropTypes.string, + // Should we show URL Previews + showUrlPreview: React.PropTypes.bool, + // event after which we should show a read marker readMarkerEventId: React.PropTypes.string, @@ -365,6 +368,7 @@ module.exports = React.createClass({ onWidgetLoad={this._onWidgetLoad} readReceipts={readReceipts} readReceiptMap={this._readReceiptMap} + showUrlPreview={this.props.showUrlPreview} checkUnmounting={this._isUnmounting} eventSendStatus={mxEv.status} last={last} isSelectedEvent={highlight}/> diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 92f50dcb02..9a0d3dbbdd 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -26,9 +26,9 @@ module.exports = React.createClass({ propTypes: { // the room this statusbar is representing. room: React.PropTypes.object.isRequired, - - // a list of TabCompleteEntries.Entry objects - tabCompleteEntries: React.PropTypes.array, + + // a TabComplete object + tabComplete: React.PropTypes.object.isRequired, // the number of messages which have arrived since we've been scrolled up numUnreadMessages: React.PropTypes.number, @@ -208,11 +208,11 @@ module.exports = React.createClass({ ); } - if (this.props.tabCompleteEntries) { + if (this.props.tabComplete.isTabCompleting()) { return (
- + ); - }, + }, }); diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index dc4b21a300..accf96f349 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -31,10 +31,7 @@ var Modal = require("../../Modal"); var sdk = require('../../index'); var CallHandler = require('../../CallHandler'); var TabComplete = require("../../TabComplete"); -var MemberEntry = require("../../TabCompleteEntries").MemberEntry; -var CommandEntry = require("../../TabCompleteEntries").CommandEntry; var Resend = require("../../Resend"); -var SlashCommands = require("../../SlashCommands"); var dis = require("../../dispatcher"); var Tinter = require("../../Tinter"); var rate_limited_func = require('../../ratelimitedfunc'); @@ -141,6 +138,7 @@ module.exports = React.createClass({ MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); + MatrixClientPeg.get().on("accountData", this.onAccountData); this.tabComplete = new TabComplete({ allowLooping: false, @@ -204,6 +202,9 @@ module.exports = React.createClass({ user_is_in_room = this.state.room.hasMembershipState( MatrixClientPeg.get().credentials.userId, 'join' ); + + this._updateAutoComplete(); + this.tabComplete.loadEntries(this.state.room); } if (!user_is_in_room && this.state.roomId) { @@ -267,6 +268,7 @@ module.exports = React.createClass({ MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); + MatrixClientPeg.get().removeListener("accountData", this.onAccountData); } window.removeEventListener('resize', this.onResize); @@ -338,6 +340,10 @@ module.exports = React.createClass({ // ignore events for other rooms if (!this.state.room || room.roomId != this.state.room.roomId) return; + if (ev.getType() === "org.matrix.room.preview_urls") { + this._updatePreviewUrlVisibility(room); + } + // ignore anything but real-time updates at the end of the room: // updates from pagination will happen when the paginate completes. if (toStartOfTimeline || !data || !data.liveEvent) return; @@ -357,12 +363,21 @@ module.exports = React.createClass({ }); } } + + // update the tab complete list as it depends on who most recently spoke, + // and that has probably just changed + if (ev.sender) { + this.tabComplete.onMemberSpoke(ev.sender); + // nb. we don't need to update the new autocomplete here since + // its results are currently ordered purely by search score. + } }, // called when state.room is first initialised (either at initial load, // after a successful peek, or after we join the room). _onRoomLoaded: function(room) { this._calculatePeekRules(room); + this._updatePreviewUrlVisibility(room); }, _calculatePeekRules: function(room) { @@ -381,6 +396,42 @@ module.exports = React.createClass({ } }, + _updatePreviewUrlVisibility: function(room) { + // console.log("_updatePreviewUrlVisibility"); + + // check our per-room overrides + var roomPreviewUrls = room.getAccountData("org.matrix.room.preview_urls"); + if (roomPreviewUrls && roomPreviewUrls.getContent().disable !== undefined) { + this.setState({ + showUrlPreview: !roomPreviewUrls.getContent().disable + }); + return; + } + + // check our global disable override + var userRoomPreviewUrls = MatrixClientPeg.get().getAccountData("org.matrix.preview_urls"); + if (userRoomPreviewUrls && userRoomPreviewUrls.getContent().disable) { + this.setState({ + showUrlPreview: false + }); + return; + } + + // check the room state event + var roomStatePreviewUrls = room.currentState.getStateEvents('org.matrix.room.preview_urls', ''); + if (roomStatePreviewUrls && roomStatePreviewUrls.getContent().disable) { + this.setState({ + showUrlPreview: false + }); + return; + } + + // otherwise, we assume they're on. + this.setState({ + showUrlPreview: true + }); + }, + onRoom: function(room) { // This event is fired when the room is 'stored' by the JS SDK, which // means it's now a fully-fledged room object ready to be used, so @@ -411,14 +462,23 @@ module.exports = React.createClass({ Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); }, - onRoomAccountData: function(room, event) { - if (room.roomId == this.props.roomId) { - if (event.getType === "org.matrix.room.color_scheme") { + onAccountData: function(event) { + if (event.getType() === "org.matrix.preview_urls" && this.state.room) { + this._updatePreviewUrlVisibility(this.state.room); + } + }, + + onRoomAccountData: function(event, room) { + if (room.roomId == this.state.roomId) { + if (event.getType() === "org.matrix.room.color_scheme") { var color_scheme = event.getContent(); // XXX: we should validate the event console.log("Tinter.tint from onRoomAccountData"); Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); } + else if (event.getType() === "org.matrix.room.preview_urls") { + this._updatePreviewUrlVisibility(room); + } } }, @@ -434,7 +494,8 @@ module.exports = React.createClass({ } // a member state changed in this room, refresh the tab complete list - this._updateTabCompleteList(); + this.tabComplete.loadEntries(this.state.room); + this._updateAutoComplete(); // if we are now a member of the room, where we were not before, that // means we have finished joining a room we were previously peeking @@ -499,8 +560,6 @@ module.exports = React.createClass({ window.addEventListener('resize', this.onResize); this.onResize(); - this._updateTabCompleteList(); - // XXX: EVIL HACK to autofocus inviting on empty rooms. // We use the setTimeout to avoid racing with focus_composer. if (this.state.room && @@ -518,24 +577,6 @@ module.exports = React.createClass({ } }, - _updateTabCompleteList: function() { - var cli = MatrixClientPeg.get(); - - if (!this.state.room) { - return; - } - var members = this.state.room.getJoinedMembers().filter(function(member) { - if (member.userId !== cli.credentials.userId) return true; - }); - - UserProvider.getInstance().setUserList(members); - this.tabComplete.setCompletionList( - MemberEntry.fromMemberList(members).concat( - CommandEntry.fromCommands(SlashCommands.getCommandList()) - ) - ); - }, - componentDidUpdate: function() { if (this.refs.roomView) { var roomView = ReactDOM.findDOMNode(this.refs.roomView); @@ -1260,6 +1301,14 @@ module.exports = React.createClass({ } }, + _updateAutoComplete: function() { + const myUserId = MatrixClientPeg.get().credentials.userId; + const members = this.state.room.getJoinedMembers().filter(function(member) { + if (member.userId !== myUserId) return true; + }); + UserProvider.getInstance().setUserList(members); + }, + render: function() { var RoomHeader = sdk.getComponent('rooms.RoomHeader'); var MessageComposer = sdk.getComponent('rooms.MessageComposer'); @@ -1373,12 +1422,10 @@ module.exports = React.createClass({ statusBar = } else if (!this.state.searchResults) { var RoomStatusBar = sdk.getComponent('structures.RoomStatusBar'); - var tabEntries = this.tabComplete.isTabCompleting() ? - this.tabComplete.peek(6) : null; statusBar = ); diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index d804dfd6b9..798ee03e73 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -71,6 +71,9 @@ var TimelinePanel = React.createClass({ // half way down the viewport. eventPixelOffset: React.PropTypes.number, + // Should we show URL Previews + showUrlPreview: React.PropTypes.bool, + // callback which is called when the panel is scrolled. onScroll: React.PropTypes.func, @@ -934,6 +937,7 @@ var TimelinePanel = React.createClass({ readMarkerEventId={ this.state.readMarkerEventId } readMarkerVisible={ this.state.readMarkerVisible } suppressFirstDateSeparator={ this.state.canBackPaginate } + showUrlPreview = { this.props.showUrlPreview } ourUserId={ MatrixClientPeg.get().credentials.userId } stickyBottom={ stickyBottom } onScroll={ this.onMessageListScroll } diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 7fcb81a60c..188e140007 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -214,9 +214,10 @@ module.exports = React.createClass({ onFinished: this.onEmailDialogFinished, }); }, (err) => { + this.setState({email_add_pending: false}); Modal.createDialog(ErrorDialog, { title: "Unable to add email address", - description: err.toString() + description: err.message }); }); ReactDOM.findDOMNode(this.refs.add_threepid_input).blur(); @@ -261,6 +262,63 @@ module.exports = React.createClass({ }); }, + _renderUserInterfaceSettings: function() { + var client = MatrixClientPeg.get(); + + var settingsLabels = [ + /* + { + id: 'alwaysShowTimestamps', + label: 'Always show message timestamps', + }, + { + id: 'showTwelveHourTimestamps', + label: 'Show timestamps in 12 hour format (e.g. 2:30pm)', + }, + { + id: 'useCompactLayout', + label: 'Use compact timeline layout', + }, + { + id: 'useFixedWidthFont', + label: 'Use fixed width font', + }, + */ + ]; + + var syncedSettings = UserSettingsStore.getSyncedSettings(); + + return ( +
+

User Interface

+
+
+ UserSettingsStore.setUrlPreviewsDisabled(e.target.checked) } + /> + +
+
+ { settingsLabels.forEach( setting => { +
+ UserSettingsStore.setSyncedSetting(setting.id, e.target.checked) } + /> + +
+ })} +
+ ); + }, + _renderDeviceInfo: function() { if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) { return null; @@ -378,7 +436,7 @@ module.exports = React.createClass({ this._renderLabs = function () { let features = LABS_FEATURES.map(feature => ( -
+
diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index b3278dfcfe..cc400e30a6 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -59,7 +59,7 @@ module.exports = React.createClass({ {this.props.description}
-
diff --git a/src/components/views/dialogs/LogoutPrompt.js b/src/components/views/dialogs/LogoutPrompt.js index 67fedfe840..7c4ba18e82 100644 --- a/src/components/views/dialogs/LogoutPrompt.js +++ b/src/components/views/dialogs/LogoutPrompt.js @@ -46,7 +46,7 @@ module.exports = React.createClass({ Sign out?
- +
diff --git a/src/components/views/dialogs/NeedToRegisterDialog.js b/src/components/views/dialogs/NeedToRegisterDialog.js index d9133ee138..0080e0c643 100644 --- a/src/components/views/dialogs/NeedToRegisterDialog.js +++ b/src/components/views/dialogs/NeedToRegisterDialog.js @@ -63,7 +63,7 @@ module.exports = React.createClass({ {this.props.description}
-
- diff --git a/src/components/views/dialogs/SetDisplayNameDialog.js b/src/components/views/dialogs/SetDisplayNameDialog.js index 81ceb21696..c1041cc218 100644 --- a/src/components/views/dialogs/SetDisplayNameDialog.js +++ b/src/components/views/dialogs/SetDisplayNameDialog.js @@ -76,7 +76,7 @@ module.exports = React.createClass({ />
- +
diff --git a/src/components/views/dialogs/TextInputDialog.js b/src/components/views/dialogs/TextInputDialog.js index fed7ff079a..6245b5786f 100644 --- a/src/components/views/dialogs/TextInputDialog.js +++ b/src/components/views/dialogs/TextInputDialog.js @@ -86,7 +86,7 @@ module.exports = React.createClass({ - diff --git a/src/components/views/login/RegistrationForm.js b/src/components/views/login/RegistrationForm.js index a172d77bb4..39c1acc625 100644 --- a/src/components/views/login/RegistrationForm.js +++ b/src/components/views/login/RegistrationForm.js @@ -35,8 +35,16 @@ module.exports = React.createClass({ displayName: 'RegistrationForm', propTypes: { + // Values pre-filled in the input boxes when the component loads defaultEmail: React.PropTypes.string, defaultUsername: React.PropTypes.string, + defaultPassword: React.PropTypes.string, + + // A username that will be used if no username is entered. + // Specifying this param will also warn the user that entering + // a different username will cause a fresh account to be generated. + guestUsername: React.PropTypes.string, + showEmail: React.PropTypes.bool, minPasswordLength: React.PropTypes.number, onError: React.PropTypes.func, @@ -55,10 +63,6 @@ module.exports = React.createClass({ getInitialState: function() { return { - email: this.props.defaultEmail, - username: null, - password: null, - passwordConfirm: null, fieldValid: {} }; }, @@ -103,7 +107,7 @@ module.exports = React.createClass({ _doSubmit: function() { var promise = this.props.onRegisterClick({ - username: this.refs.username.value.trim() || this.props.defaultUsername, + username: this.refs.username.value.trim() || this.props.guestUsername, password: this.refs.password.value.trim(), email: this.refs.email.value.trim() }); @@ -144,7 +148,7 @@ module.exports = React.createClass({ break; case FIELD_USERNAME: // XXX: SPEC-1 - var username = this.refs.username.value.trim() || this.props.defaultUsername; + var username = this.refs.username.value.trim() || this.props.guestUsername; if (encodeURIComponent(username) != username) { this.markFieldValid( field_id, @@ -225,7 +229,7 @@ module.exports = React.createClass({ emailSection = ( ); @@ -237,8 +241,8 @@ module.exports = React.createClass({ } var placeholderUserName = "User name"; - if (this.props.defaultUsername) { - placeholderUserName += " (default: " + this.props.defaultUsername + ")" + if (this.props.guestUsername) { + placeholderUserName += " (default: " + this.props.guestUsername + ")" } return ( @@ -247,23 +251,23 @@ module.exports = React.createClass({ {emailSection}

- { this.props.defaultUsername ? + { this.props.guestUsername ?
Setting a user name will create a fresh account
: null } + placeholder="Password" defaultValue={this.props.defaultPassword} />
+ defaultValue={this.props.defaultPassword} />
{registerButton} diff --git a/src/components/views/messages/MVideoBody.js b/src/components/views/messages/MVideoBody.js index 6cbaf0b151..c8327a71ae 100644 --- a/src/components/views/messages/MVideoBody.js +++ b/src/components/views/messages/MVideoBody.js @@ -34,7 +34,7 @@ module.exports = React.createClass({ } if (fullWidth < thumbWidth && fullHeight < thumbHeight) { // no scaling needs to be applied - return fullHeight; + return 1; } var widthMulti = thumbWidth / fullWidth; var heightMulti = thumbHeight / fullHeight; diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index 35eafbff22..0863fe0842 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -38,6 +38,9 @@ module.exports = React.createClass({ /* link URL for the highlights */ highlightLink: React.PropTypes.string, + /* should show URL previews for this event */ + showUrlPreview: React.PropTypes.bool, + /* callback called when dynamic content in events are loaded */ onWidgetLoad: React.PropTypes.func, }, @@ -71,6 +74,7 @@ module.exports = React.createClass({ return ; }, }); diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 310da598fa..8c6cf455dc 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -39,6 +39,9 @@ module.exports = React.createClass({ /* link URL for the highlights */ highlightLink: React.PropTypes.string, + /* should show URL previews for this event */ + showUrlPreview: React.PropTypes.bool, + /* callback for when our widget has loaded */ onWidgetLoad: React.PropTypes.func, }, @@ -56,34 +59,47 @@ module.exports = React.createClass({ componentDidMount: function() { linkifyElement(this.refs.content, linkifyMatrix.options); - - var links = this.findLinks(this.refs.content.children); - if (links.length) { - this.setState({ links: links.map((link)=>{ - return link.getAttribute("href"); - })}); - - // lazy-load the hidden state of the preview widget from localstorage - if (global.localStorage) { - var hidden = global.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId()); - this.setState({ widgetHidden: hidden }); - } - } + this.calculateUrlPreview(); if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") HtmlUtils.highlightDom(ReactDOM.findDOMNode(this)); }, + componentDidUpdate: function() { + this.calculateUrlPreview(); + }, + shouldComponentUpdate: function(nextProps, nextState) { + //console.log("shouldComponentUpdate: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview); + // exploit that events are immutable :) - // ...and that .links is only ever set in componentDidMount and never changes return (nextProps.mxEvent.getId() !== this.props.mxEvent.getId() || nextProps.highlights !== this.props.highlights || nextProps.highlightLink !== this.props.highlightLink || + nextProps.showUrlPreview !== this.props.showUrlPreview || nextState.links !== this.state.links || nextState.widgetHidden !== this.state.widgetHidden); }, + calculateUrlPreview: function() { + //console.log("calculateUrlPreview: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview); + + if (this.props.showUrlPreview && !this.state.links.length) { + var links = this.findLinks(this.refs.content.children); + if (links.length) { + this.setState({ links: links.map((link)=>{ + return link.getAttribute("href"); + })}); + + // lazy-load the hidden state of the preview widget from localstorage + if (global.localStorage) { + var hidden = global.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId()); + this.setState({ widgetHidden: hidden }); + } + } + } + }, + findLinks: function(nodes) { var links = []; for (var i = 0; i < nodes.length; i++) { @@ -163,12 +179,14 @@ module.exports = React.createClass({ render: function() { var mxEvent = this.props.mxEvent; var content = mxEvent.getContent(); - var body = HtmlUtils.bodyToHtml(content, this.props.highlights, - {highlightLink: this.props.highlightLink}); + var body = HtmlUtils.bodyToHtml(content, this.props.highlights, {}); + if (this.props.highlightLink) { + body =
{ body }; + } var widgets; - if (this.state.links.length && !this.state.widgetHidden) { + if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) { var LinkPreviewWidget = sdk.getComponent('rooms.LinkPreviewWidget'); widgets = this.state.links.map((link)=>{ return Add - + : "" } diff --git a/src/components/views/room_settings/ColorSettings.js b/src/components/views/room_settings/ColorSettings.js index fff97ea817..6d147b1f63 100644 --- a/src/components/views/room_settings/ColorSettings.js +++ b/src/components/views/room_settings/ColorSettings.js @@ -57,7 +57,7 @@ module.exports = React.createClass({ data.primary_color = scheme.primary_color; data.secondary_color = scheme.secondary_color; data.index = this._getColorIndex(data); - + if (data.index === -1) { // append the unrecognised colours to our palette data.index = ROOM_COLORS.length; diff --git a/src/components/views/room_settings/UrlPreviewSettings.js b/src/components/views/room_settings/UrlPreviewSettings.js new file mode 100644 index 0000000000..e82c2ba201 --- /dev/null +++ b/src/components/views/room_settings/UrlPreviewSettings.js @@ -0,0 +1,157 @@ +/* +Copyright 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 q = require("q"); +var React = require('react'); +var MatrixClientPeg = require('../../../MatrixClientPeg'); +var sdk = require("../../../index"); +var Modal = require("../../../Modal"); +var UserSettingsStore = require('../../../UserSettingsStore'); + + +module.exports = React.createClass({ + displayName: 'UrlPreviewSettings', + + propTypes: { + room: React.PropTypes.object, + }, + + getInitialState: function() { + var cli = MatrixClientPeg.get(); + var roomState = this.props.room.currentState; + + var roomPreviewUrls = this.props.room.currentState.getStateEvents('org.matrix.room.preview_urls', ''); + var userPreviewUrls = this.props.room.getAccountData("org.matrix.room.preview_urls"); + + return { + globalDisableUrlPreview: (roomPreviewUrls && roomPreviewUrls.getContent().disable) || false, + userDisableUrlPreview: (userPreviewUrls && (userPreviewUrls.getContent().disable === true)) || false, + userEnableUrlPreview: (userPreviewUrls && (userPreviewUrls.getContent().disable === false)) || false, + }; + }, + + componentDidMount: function() { + this.originalState = Object.assign({}, this.state); + }, + + saveSettings: function() { + var promises = []; + + if (this.state.globalDisableUrlPreview !== this.originalState.globalDisableUrlPreview) { + console.log("UrlPreviewSettings: Updating room's preview_urls state event"); + promises.push( + MatrixClientPeg.get().sendStateEvent( + this.props.room.roomId, "org.matrix.room.preview_urls", { + disable: this.state.globalDisableUrlPreview + }, "" + ) + ); + } + + var content = undefined; + if (this.state.userDisableUrlPreview !== this.originalState.userDisableUrlPreview) { + console.log("UrlPreviewSettings: Disabling user's per-room preview_urls"); + content = this.state.userDisableUrlPreview ? { disable : true } : {}; + } + + if (this.state.userEnableUrlPreview !== this.originalState.userEnableUrlPreview) { + console.log("UrlPreviewSettings: Enabling user's per-room preview_urls"); + if (!content || content.disable === undefined) { + content = this.state.userEnableUrlPreview ? { disable : false } : {}; + } + } + + if (content) { + promises.push( + MatrixClientPeg.get().setRoomAccountData( + this.props.room.roomId, "org.matrix.room.preview_urls", content + ) + ); + } + + console.log("UrlPreviewSettings: saveSettings: " + JSON.stringify(promises)); + + return promises; + }, + + onGlobalDisableUrlPreviewChange: function() { + this.setState({ + globalDisableUrlPreview: this.refs.globalDisableUrlPreview.checked ? true : false, + }); + }, + + onUserEnableUrlPreviewChange: function() { + this.setState({ + userDisableUrlPreview: false, + userEnableUrlPreview: this.refs.userEnableUrlPreview.checked ? true : false, + }); + }, + + onUserDisableUrlPreviewChange: function() { + this.setState({ + userDisableUrlPreview: this.refs.userDisableUrlPreview.checked ? true : false, + userEnableUrlPreview: false, + }); + }, + + render: function() { + var self = this; + var roomState = this.props.room.currentState; + var cli = MatrixClientPeg.get(); + + var maySetRoomPreviewUrls = roomState.mayClientSendStateEvent('org.matrix.room.preview_urls', cli); + var disableRoomPreviewUrls; + if (maySetRoomPreviewUrls) { + disableRoomPreviewUrls = + + } + else { + disableRoomPreviewUrls = + + } + + return ( +
+

URL Previews

+ + + { disableRoomPreviewUrls } + + +
+ ); + + } +}); \ No newline at end of file diff --git a/src/components/views/rooms/EntityTile.js b/src/components/views/rooms/EntityTile.js index 91874ed45a..8a99b4c565 100644 --- a/src/components/views/rooms/EntityTile.js +++ b/src/components/views/rooms/EntityTile.js @@ -29,6 +29,23 @@ var PRESENCE_CLASS = { "unavailable": "mx_EntityTile_unavailable" }; + +function presenceClassForMember(presenceState, lastActiveAgo) { + // offline is split into two categories depending on whether we have + // a last_active_ago for them. + if (presenceState == 'offline') { + if (lastActiveAgo) { + return PRESENCE_CLASS['offline'] + '_beenactive'; + } else { + return PRESENCE_CLASS['offline'] + '_neveractive'; + } + } else if (presenceState) { + return PRESENCE_CLASS[presenceState]; + } else { + return PRESENCE_CLASS['offline'] + '_neveractive'; + } +} + module.exports = React.createClass({ displayName: 'EntityTile', @@ -79,7 +96,10 @@ module.exports = React.createClass({ }, render: function() { - var presenceClass = PRESENCE_CLASS[this.props.presenceState] || "mx_EntityTile_offline"; + const presenceClass = presenceClassForMember( + this.props.presenceState, this.props.presenceLastActiveAgo + ); + var mainClassName = "mx_EntityTile "; mainClassName += presenceClass + (this.props.className ? (" " + this.props.className) : ""); var nameEl; diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 70dfe8ac33..77be8226a2 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -101,6 +101,9 @@ module.exports = React.createClass({ /* link URL for the highlights */ highlightLink: React.PropTypes.string, + /* should show URL previews for this event */ + showUrlPreview: React.PropTypes.bool, + /* is this the focused event */ isSelectedEvent: React.PropTypes.bool, @@ -359,6 +362,8 @@ module.exports = React.createClass({ var SenderProfile = sdk.getComponent('messages.SenderProfile'); var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); + //console.log("EventTile showUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview); + var content = this.props.mxEvent.getContent(); var msgtype = content.msgtype; @@ -420,6 +425,7 @@ module.exports = React.createClass({
diff --git a/src/components/views/rooms/InviteMemberList.js b/src/components/views/rooms/InviteMemberList.js index 5246e2e54d..4742597229 100644 --- a/src/components/views/rooms/InviteMemberList.js +++ b/src/components/views/rooms/InviteMemberList.js @@ -37,17 +37,14 @@ module.exports = React.createClass({ }, componentWillMount: function() { - this._room = MatrixClientPeg.get().getRoom(this.props.roomId); + var cli = MatrixClientPeg.get(); + cli.on("RoomState.members", this.onRoomStateMember); + this._emailEntity = null; - // Load the complete user list for inviting new users - // TODO: Keep this list bleeding-edge up-to-date. Practically speaking, - // it will do for now not being updated as random new users join different - // rooms as this list will be reloaded every room swap. - if (this._room) { - this._userList = MatrixClientPeg.get().getUsers().filter((u) => { - return !this._room.hasMembershipState(u.userId, "join"); - }); - } + + // we have to update the list whenever membership changes + // particularly to avoid bug https://github.com/vector-im/vector-web/issues/1813 + this._updateList(); }, componentDidMount: function() { @@ -55,6 +52,28 @@ module.exports = React.createClass({ this.onSearchQueryChanged(''); }, + componentWillUnmount: function() { + var cli = MatrixClientPeg.get(); + if (cli) { + cli.removeListener("RoomState.members", this.onRoomStateMember); + } + }, + + _updateList: function() { + this._room = MatrixClientPeg.get().getRoom(this.props.roomId); + // Load the complete user list for inviting new users + if (this._room) { + this._userList = MatrixClientPeg.get().getUsers().filter((u) => { + return (!this._room.hasMembershipState(u.userId, "join") && + !this._room.hasMembershipState(u.userId, "invite")); + }); + } + }, + + onRoomStateMember: function(ev, state, member) { + this._updateList(); + }, + onInvite: function(ev) { this.props.onInvite(this._input); }, diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 0e14776e82..07a7b9398d 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -61,12 +61,16 @@ module.exports = React.createClass({ updating: 0, devicesLoading: true, devices: null, + existingOneToOneRoomId: null, } }, - componentWillMount: function() { this._cancelDeviceList = null; + + this.setState({ + existingOneToOneRoomId: this.getExistingOneToOneRoomId() + }); }, componentDidMount: function() { @@ -90,6 +94,44 @@ module.exports = React.createClass({ } }, + getExistingOneToOneRoomId: function() { + var self = this; + var rooms = MatrixClientPeg.get().getRooms(); + var userIds = [ + this.props.member.userId, + MatrixClientPeg.get().credentials.userId + ]; + var existingRoomId; + + // roomId can be null here because of a hack in MatrixChat.onUserClick where we + // abuse this to view users rather than room members. + var currentMembers; + if (this.props.member.roomId) { + var currentRoom = MatrixClientPeg.get().getRoom(this.props.member.roomId); + currentMembers = currentRoom.getJoinedMembers(); + } + + // reuse the first private 1:1 we find + existingRoomId = null; + + for (var i = 0; i < rooms.length; i++) { + // don't try to reuse public 1:1 rooms + var join_rules = rooms[i].currentState.getStateEvents("m.room.join_rules", ''); + if (join_rules && join_rules.getContent().join_rule === 'public') continue; + + var members = rooms[i].getJoinedMembers(); + if (members.length === 2 && + userIds.indexOf(members[0].userId) !== -1 && + userIds.indexOf(members[1].userId) !== -1) + { + existingRoomId = rooms[i].roomId; + break; + } + } + + return existingRoomId; + }, + onDeviceVerificationChanged: function(userId, device) { if (userId == this.props.member.userId) { // no need to re-download the whole thing; just update our copy of @@ -349,66 +391,29 @@ module.exports = React.createClass({ onChatClick: function() { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + + // TODO: keep existingOneToOneRoomId updated if we see any room member changes anywhere + + var useExistingOneToOneRoom = this.state.existingOneToOneRoomId && (this.state.existingOneToOneRoomId !== this.props.member.roomId); + // check if there are any existing rooms with just us and them (1:1) // If so, just view that room. If not, create a private room with them. - var self = this; - var rooms = MatrixClientPeg.get().getRooms(); - var userIds = [ - this.props.member.userId, - MatrixClientPeg.get().credentials.userId - ]; - var existingRoomId; - - // roomId can be null here because of a hack in MatrixChat.onUserClick where we - // abuse this to view users rather than room members. - var currentMembers; - if (this.props.member.roomId) { - var currentRoom = MatrixClientPeg.get().getRoom(this.props.member.roomId); - currentMembers = currentRoom.getJoinedMembers(); - } - // if we're currently in a 1:1 with this user, start a new chat - if (currentMembers && currentMembers.length === 2 && - userIds.indexOf(currentMembers[0].userId) !== -1 && - userIds.indexOf(currentMembers[1].userId) !== -1) - { - existingRoomId = null; - } - // otherwise reuse the first private 1:1 we find - else { - existingRoomId = null; - - for (var i = 0; i < rooms.length; i++) { - // don't try to reuse public 1:1 rooms - var join_rules = rooms[i].currentState.getStateEvents("m.room.join_rules", ''); - if (join_rules && join_rules.getContent().join_rule === 'public') continue; - - var members = rooms[i].getJoinedMembers(); - if (members.length === 2 && - userIds.indexOf(members[0].userId) !== -1 && - userIds.indexOf(members[1].userId) !== -1) - { - existingRoomId = rooms[i].roomId; - break; - } - } - } - - if (existingRoomId) { + if (this.state.existingOneToOneRoomId) { dis.dispatch({ action: 'view_room', - room_id: existingRoomId + room_id: this.state.existingOneToOneRoomId, }); this.props.onFinished(); } else { - self.setState({ updating: self.state.updating + 1 }); + this.setState({ updating: this.state.updating + 1 }); createRoom({ createOpts: { invite: [this.props.member.userId], }, - }).finally(function() { - self.props.onFinished(); - self.setState({ updating: self.state.updating - 1 }); + }).finally(() => { + this.props.onFinished(); + this.setState({ updating: this.state.updating - 1 }); }).done(); } }, @@ -553,7 +558,22 @@ module.exports = React.createClass({ if (this.props.member.userId !== MatrixClientPeg.get().credentials.userId) { // FIXME: we're referring to a vector component from react-sdk var BottomLeftMenuTile = sdk.getComponent('rooms.BottomLeftMenuTile'); - startChat = + + var label; + if (this.state.existingOneToOneRoomId) { + if (this.state.existingOneToOneRoomId == this.props.member.roomId) { + label = "Start new direct chat"; + } + else { + label = "Go to direct chat"; + } + } + else { + label = "Start direct chat"; + } + + startChat = } if (this.state.updating) { diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js index 20f60c80a8..01a952f1d7 100644 --- a/src/components/views/rooms/MemberList.js +++ b/src/components/views/rooms/MemberList.js @@ -54,7 +54,7 @@ module.exports = React.createClass({ this.memberDict = this.getMemberDict(); - state.members = this.roomMembers(INITIAL_LOAD_NUM_MEMBERS); + state.members = this.roomMembers(); return state; }, @@ -64,7 +64,10 @@ module.exports = React.createClass({ cli.on("RoomMember.name", this.onRoomMemberName); cli.on("RoomState.events", this.onRoomStateEvent); cli.on("Room", this.onRoom); // invites - cli.on("User.presence", this.onUserPresence); + // We listen for changes to the lastPresenceTs which is essentially + // listening for all presence events (we display most of not all of + // the information contained in presence events). + cli.on("User.lastPresenceTs", this.onUserLastPresenceTs); // cli.on("Room.timeline", this.onRoomTimeline); }, @@ -75,24 +78,11 @@ module.exports = React.createClass({ cli.removeListener("RoomMember.name", this.onRoomMemberName); cli.removeListener("RoomState.events", this.onRoomStateEvent); cli.removeListener("Room", this.onRoom); - cli.removeListener("User.presence", this.onUserPresence); + cli.removeListener("User.lastPresenceTs", this.onUserLastPresenceTs); // cli.removeListener("Room.timeline", this.onRoomTimeline); } }, - componentDidMount: function() { - var self = this; - - // Lazy-load in more than the first N members - setTimeout(function() { - if (!self.isMounted()) return; - // lazy load to prevent it blocking the first render - self.setState({ - members: self.roomMembers() - }); - }, 50); - }, - /* onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { // ignore anything but real-time updates at the end of the room: @@ -121,7 +111,7 @@ module.exports = React.createClass({ }, */ - onUserPresence(event, user) { + onUserLastPresenceTs(event, user) { // Attach a SINGLE listener for global presence changes then locate the // member tile and re-render it. This is more efficient than every tile // evar attaching their own listener. @@ -325,7 +315,7 @@ module.exports = React.createClass({ return all_members; }, - roomMembers: function(limit) { + roomMembers: function() { var all_members = this.memberDict || {}; var all_user_ids = Object.keys(all_members); var ConferenceHandler = CallHandler.getConferenceHandler(); @@ -334,7 +324,7 @@ module.exports = React.createClass({ var to_display = []; var count = 0; - for (var i = 0; i < all_user_ids.length && (limit === undefined || count < limit); ++i) { + for (var i = 0; i < all_user_ids.length; ++i) { var user_id = all_user_ids[i]; var m = all_members[user_id]; @@ -442,9 +432,16 @@ module.exports = React.createClass({ var memberList = self.state.members.filter(function(userId) { var m = self.memberDict[userId]; - if (query && m.name.toLowerCase().indexOf(query) === -1) { - return false; + + if (query) { + const matchesName = m.name.toLowerCase().indexOf(query) !== -1; + const matchesId = m.userId.toLowerCase().indexOf(query) !== -1; + + if (!matchesName && !matchesId) { + return false; + } } + return m.membership == membership; }).map(function(userId) { var m = self.memberDict[userId]; diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index 1f50a9241b..b12c6e4aa2 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -65,7 +65,12 @@ module.exports = React.createClass({ tags_changed: false, tags: tags, areNotifsMuted: areNotifsMuted, - isRoomPublished: false, // loaded async in componentWillMount + // isRoomPublished is loaded async in componentWillMount so when the component + // inits, the saved value will always be undefined, however getInitialState() + // is also called from the saving code so we must return the correct value here + // if we have it (although this could race if the user saves before we load whether + // the room is published or not). + isRoomPublished: this._originalIsRoomPublished, }; }, @@ -211,10 +216,13 @@ module.exports = React.createClass({ // color scheme promises.push(this.saveColor()); + // url preview settings + promises.push(this.saveUrlPreviewSettings()); + // encryption promises.push(this.saveEncryption()); - console.log("Performing %s operations", promises.length); + console.log("Performing %s operations: %s", promises.length, JSON.stringify(promises)); return q.allSettled(promises); }, @@ -228,6 +236,11 @@ module.exports = React.createClass({ return this.refs.color_settings.saveSettings(); }, + saveUrlPreviewSettings: function() { + if (!this.refs.url_preview_settings) { return q(); } + return this.refs.url_preview_settings.saveSettings(); + }, + saveEncryption: function () { if (!this.refs.encrypt) { return q(); } @@ -422,6 +435,7 @@ module.exports = React.createClass({ var AliasSettings = sdk.getComponent("room_settings.AliasSettings"); var ColorSettings = sdk.getComponent("room_settings.ColorSettings"); + var UrlPreviewSettings = sdk.getComponent("room_settings.UrlPreviewSettings"); var EditableText = sdk.getComponent('elements.EditableText'); var PowerSelector = sdk.getComponent('elements.PowerSelector'); @@ -654,6 +668,8 @@ module.exports = React.createClass({ canonicalAliasEvent={this.props.room.currentState.getStateEvents('m.room.canonical_alias', '')} aliasEvents={this.props.room.currentState.getStateEvents('m.room.aliases')} /> + +

Permissions

diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index da9f97ab65..aa83110632 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -43,7 +43,10 @@ module.exports = React.createClass({ }, getInitialState: function() { - return( { hover : false }); + return({ + hover : false, + badgeHover : false, + }); }, onClick: function() { @@ -61,6 +64,14 @@ module.exports = React.createClass({ this.setState( { hover : false }); }, + badgeOnMouseEnter: function() { + this.setState( { badgeHover : true } ); + }, + + badgeOnMouseLeave: function() { + this.setState( { badgeHover : false } ); + }, + render: function() { var myUserId = MatrixClientPeg.get().credentials.userId; var me = this.props.room.currentState.members[myUserId]; @@ -83,9 +94,25 @@ module.exports = React.createClass({ name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon var badge; - if (this.props.highlight || notificationCount > 0) { - badge =
{ notificationCount ? notificationCount : '!' }
; + var badgeContent; + var badgeClasses; + + if (this.state.badgeHover) { + badgeContent = "\u00B7\u00B7\u00B7"; + } else if (this.props.highlight || notificationCount > 0) { + badgeContent = notificationCount ? notificationCount : '!'; + } else { + badgeContent = '\u200B'; } + + if (this.props.highlight || notificationCount > 0) { + badgeClasses = "mx_RoomTile_badge"; + } else { + badgeClasses = "mx_RoomTile_badge mx_RoomTile_badge_no_unread"; + } + + badge =
{ badgeContent }
; + /* if (this.props.highlight) { badge =
!
; diff --git a/src/components/views/rooms/TabCompleteBar.js b/src/components/views/rooms/TabCompleteBar.js index ea74706f29..d00c0953f1 100644 --- a/src/components/views/rooms/TabCompleteBar.js +++ b/src/components/views/rooms/TabCompleteBar.js @@ -24,17 +24,17 @@ module.exports = React.createClass({ displayName: 'TabCompleteBar', propTypes: { - entries: React.PropTypes.array.isRequired + tabComplete: React.PropTypes.object.isRequired }, render: function() { return (
- {this.props.entries.map(function(entry, i) { + {this.props.tabComplete.peek(6).map((entry, i) => { return (
+ className={ "mx_TabCompleteBar_item " + (entry instanceof CommandEntry ? "mx_TabCompleteBar_command" : "") } + onClick={this.props.tabComplete.onEntryClick.bind(this.props.tabComplete, entry)} > {entry.getImageJsx()} {entry.getText()} diff --git a/src/createRoom.js b/src/createRoom.js index 658561e78a..51e3ff8a96 100644 --- a/src/createRoom.js +++ b/src/createRoom.js @@ -64,11 +64,15 @@ function createRoom(opts) { } ]; - var modal = Modal.createDialog(Loader); + var modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); return client.createRoom(createOpts).finally(function() { modal.close(); }).then(function(res) { + // NB createRoom doesn't block on the client seeing the echo that the + // room has been created, so we race here with the client knowing that + // the room exists, causing things like + // https://github.com/vector-im/vector-web/issues/1813 dis.dispatch({ action: 'view_room', room_id: res.room_id diff --git a/test/components/structures/TimelinePanel-test.js b/test/components/structures/TimelinePanel-test.js index 045ccd70b7..cd9d86cd64 100644 --- a/test/components/structures/TimelinePanel-test.js +++ b/test/components/structures/TimelinePanel-test.js @@ -210,7 +210,7 @@ describe('TimelinePanel', function() { var N_EVENTS = 600; // sadly, loading all those events takes a while - this.timeout(N_EVENTS * 20); + this.timeout(N_EVENTS * 30); // client.getRoom is called a /lot/ in this test, so replace // sinon's spy with a fast noop. @@ -271,6 +271,8 @@ describe('TimelinePanel', function() { // we should now be able to scroll down, and paginate in the other // direction. + console.log("scrollingDiv.scrollTop is " + scrollingDiv.scrollTop); + console.log("Going to set it to " + scrollingDiv.scrollHeight); scrollingDiv.scrollTop = scrollingDiv.scrollHeight; return awaitScroll(); }).then(() => {