Merge branch 'develop' into wmwragg/direct-chat-sublist

This commit is contained in:
wmwragg 2016-08-30 11:19:54 +01:00
commit 769e7d3b2e
24 changed files with 707 additions and 130 deletions

3
.travis.yml Normal file
View file

@ -0,0 +1,3 @@
language: node_js
node_js:
- node # Latest stable version of nodejs.

View file

@ -1,3 +1,57 @@
Changes in [0.6.5](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.6.5) (2016-08-28)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.6.4-r1...v0.6.5)
* re-add leave button in RoomSettings
* add /user URLs
* recognise matrix.to links and other vector links
* fix linkify dependency
* fix avatar clicking in MemberInfo
* fix emojione sizing
[\#431](https://github.com/matrix-org/matrix-react-sdk/pull/431)
* Fix NPE when we don't know the sender of an event
[\#430](https://github.com/matrix-org/matrix-react-sdk/pull/430)
* Update annoying TimelinePanel test
[\#429](https://github.com/matrix-org/matrix-react-sdk/pull/429)
* add fancy changelog dialog
[\#416](https://github.com/matrix-org/matrix-react-sdk/pull/416)
* Send bot options with leading underscore on the state key
[\#428](https://github.com/matrix-org/matrix-react-sdk/pull/428)
* Update autocomplete design and scroll it correctly
[\#419](https://github.com/matrix-org/matrix-react-sdk/pull/419)
* Add ability to query and set bot options
[\#427](https://github.com/matrix-org/matrix-react-sdk/pull/427)
* Add .travis.yml
[\#425](https://github.com/matrix-org/matrix-react-sdk/pull/425)
* Added event/info message avatars back in
[\#426](https://github.com/matrix-org/matrix-react-sdk/pull/426)
* Add postMessage API required for integration provisioning
[\#423](https://github.com/matrix-org/matrix-react-sdk/pull/423)
* Fix TimelinePanel test
[\#424](https://github.com/matrix-org/matrix-react-sdk/pull/424)
* Wmwragg/chat message presentation
[\#422](https://github.com/matrix-org/matrix-react-sdk/pull/422)
* Only try to delete room rule if it exists
[\#421](https://github.com/matrix-org/matrix-react-sdk/pull/421)
* Make the notification slider work
[\#420](https://github.com/matrix-org/matrix-react-sdk/pull/420)
* Don't download E2E devices if feature disabled
[\#418](https://github.com/matrix-org/matrix-react-sdk/pull/418)
* strip (IRC) suffix from tabcomplete entries
[\#417](https://github.com/matrix-org/matrix-react-sdk/pull/417)
* ignore local busy
[\#415](https://github.com/matrix-org/matrix-react-sdk/pull/415)
* defaultDeviceDisplayName should be a prop
[\#414](https://github.com/matrix-org/matrix-react-sdk/pull/414)
* Use server-generated deviceId
[\#410](https://github.com/matrix-org/matrix-react-sdk/pull/410)
* Set initial_device_display_name on login and register
[\#413](https://github.com/matrix-org/matrix-react-sdk/pull/413)
* Add device_id to devices display
[\#409](https://github.com/matrix-org/matrix-react-sdk/pull/409)
* Don't use MatrixClientPeg for temporary clients
[\#408](https://github.com/matrix-org/matrix-react-sdk/pull/408)
Changes in [0.6.4-r1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.6.4-r1) (2016-08-12) Changes in [0.6.4-r1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.6.4-r1) (2016-08-12)
========================================================================================================= =========================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.6.4...v0.6.4-r1) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.6.4...v0.6.4-r1)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "0.6.4-r1", "version": "0.6.5",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -37,10 +37,10 @@
"fuse.js": "^2.2.0", "fuse.js": "^2.2.0",
"glob": "^5.0.14", "glob": "^5.0.14",
"highlight.js": "^8.9.1", "highlight.js": "^8.9.1",
"linkifyjs": "^2.0.0-beta.4", "linkifyjs": "2.0.0-beta.4",
"lodash": "^4.13.1", "lodash": "^4.13.1",
"marked": "^0.3.5", "marked": "^0.3.5",
"matrix-js-sdk": "0.5.5", "matrix-js-sdk": "0.5.6",
"optimist": "^0.6.1", "optimist": "^0.6.1",
"q": "^1.4.1", "q": "^1.4.1",
"react": "^15.2.1", "react": "^15.2.1",

View file

@ -49,7 +49,7 @@ export function unicodeToImage(str) {
alt = (emojione.unicodeAlt) ? emojione.convert(unicode.toUpperCase()) : mappedUnicode[unicode]; alt = (emojione.unicodeAlt) ? emojione.convert(unicode.toUpperCase()) : mappedUnicode[unicode];
const title = mappedUnicode[unicode]; const title = mappedUnicode[unicode];
replaceWith = `<img class="emojione" title="${title}" alt="${alt}" src="${emojione.imagePathSVG}${unicode}.svg${emojione.cacheBustParam}"/>`; replaceWith = `<img class="mx_emojione" title="${title}" alt="${alt}" src="${emojione.imagePathSVG}${unicode}.svg${emojione.cacheBustParam}"/>`;
return replaceWith; return replaceWith;
} }
}); });
@ -85,12 +85,28 @@ var sanitizeHtmlParams = {
transformTags: { // custom to matrix transformTags: { // custom to matrix
// add blank targets to all hyperlinks except vector URLs // add blank targets to all hyperlinks except vector URLs
'a': function(tagName, attribs) { 'a': function(tagName, attribs) {
var m = attribs.href ? attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN) : null; if (attribs.href) {
if (m) { attribs.target = '_blank'; // by default
delete attribs.target;
} var m;
else { // FIXME: horrible duplication with linkify-matrix
attribs.target = '_blank'; m = attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN);
if (m) {
attribs.href = m[1];
delete attribs.target;
}
m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN);
if (m) {
var entity = m[1];
if (entity[0] === '@') {
attribs.href = '#/user/' + entity;
}
else if (entity[0] === '#' || entity[0] === '!') {
attribs.href = '#/room/' + entity;
}
delete attribs.target;
}
} }
attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/
return { tagName: tagName, attribs : attribs }; return { tagName: tagName, attribs : attribs };
@ -271,7 +287,7 @@ module.exports = {
emojifyText: function(text) { emojifyText: function(text) {
return { return {
__html: emojione.unicodeToImage(escape(text)), __html: unicodeToImage(escape(text)),
}; };
}, },
}; };

273
src/ScalarMessaging.js Normal file
View file

@ -0,0 +1,273 @@
/*
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.
*/
/*
Listens for incoming postMessage requests from the integrations UI URL. The following API is exposed:
{
action: "invite" | "membership_state" | "bot_options" | "set_bot_options",
room_id: $ROOM_ID,
user_id: $USER_ID
// additional request fields
}
The complete request object is returned to the caller with an additional "response" key like so:
{
action: "invite" | "membership_state" | "bot_options" | "set_bot_options",
room_id: $ROOM_ID,
user_id: $USER_ID,
// additional request fields
response: { ... }
}
The "action" determines the format of the request and response. All actions can return an error response.
An error response is a "response" object which consists of a sole "error" key to indicate an error.
They look like:
{
error: {
message: "Unable to invite user into room.",
_error: <Original Error Object>
}
}
The "message" key should be a human-friendly string.
ACTIONS
=======
All actions can return an error response instead of the response outlined below.
invite
------
Invites a user into a room.
Request:
- room_id is the room to invite the user into.
- user_id is the user ID to invite.
- No additional fields.
Response:
{
success: true
}
Example:
{
action: "invite",
room_id: "!foo:bar",
user_id: "@invitee:bar",
response: {
success: true
}
}
set_bot_options
---------------
Set the m.room.bot.options state event for a bot user.
Request:
- room_id is the room to send the state event into.
- user_id is the user ID of the bot who you're setting options for.
- "content" is an object consisting of the content you wish to set.
Response:
{
success: true
}
Example:
{
action: "set_bot_options",
room_id: "!foo:bar",
user_id: "@bot:bar",
content: {
default_option: "alpha"
},
response: {
success: true
}
}
membership_state AND bot_options
--------------------------------
Get the content of the "m.room.member" or "m.room.bot.options" state event respectively.
NB: Whilst this API is basically equivalent to getStateEvent, we specifically do not
want external entities to be able to query any state event for any room, hence the
restrictive API outlined here.
Request:
- room_id is the room which has the state event.
- user_id is the state_key parameter which in both cases is a user ID (the member or the bot).
- No additional fields.
Response:
- The event content. If there is no state event, the "response" key should be null.
Example:
{
action: "membership_state",
room_id: "!foo:bar",
user_id: "@somemember:bar",
response: {
membership: "join",
displayname: "Bob",
avatar_url: null
}
}
*/
const SdkConfig = require('./SdkConfig');
const MatrixClientPeg = require("./MatrixClientPeg");
function sendResponse(event, res) {
const data = JSON.parse(JSON.stringify(event.data));
data.response = res;
event.source.postMessage(data, event.origin);
}
function sendError(event, msg, nestedError) {
console.error("Action:" + event.data.action + " failed with message: " + msg);
const data = JSON.parse(JSON.stringify(event.data));
data.response = {
error: {
message: msg,
},
};
if (nestedError) {
data.response.error._error = nestedError;
}
event.source.postMessage(data, event.origin);
}
function inviteUser(event, roomId, userId) {
console.log(`Received request to invite ${userId} into room ${roomId}`);
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, "You need to be logged in.");
return;
}
const room = client.getRoom(roomId);
if (room) {
// if they are already invited we can resolve immediately.
const member = room.getMember(userId);
if (member && member.membership === "invite") {
sendResponse(event, {
success: true,
});
return;
}
}
client.invite(roomId, userId).done(function() {
sendResponse(event, {
success: true,
});
}, function(err) {
sendError(event, "You need to be able to invite users to do that.", err);
});
}
function setBotOptions(event, roomId, userId) {
console.log(`Received request to set options for bot ${userId} in room ${roomId}`);
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, "You need to be logged in.");
return;
}
client.sendStateEvent(roomId, "m.room.bot.options", event.data.content, "_" + userId).done(() => {
sendResponse(event, {
success: true,
});
}, (err) => {
sendError(event, err.message ? err.message : "Failed to send request.", err);
});
}
function getMembershipState(event, roomId, userId) {
console.log(`membership_state of ${userId} in room ${roomId} requested.`);
returnStateEvent(event, roomId, "m.room.member", userId);
}
function botOptions(event, roomId, userId) {
console.log(`bot_options of ${userId} in room ${roomId} requested.`);
returnStateEvent(event, roomId, "m.room.bot.options", "_" + userId);
}
function returnStateEvent(event, roomId, eventType, stateKey) {
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, "You need to be logged in.");
return;
}
const room = client.getRoom(roomId);
if (!room) {
sendError(event, "This room is not recognised.");
return;
}
const stateEvent = room.currentState.getStateEvents(eventType, stateKey);
if (!stateEvent) {
sendResponse(event, null);
return;
}
sendResponse(event, stateEvent.getContent());
}
const onMessage = function(event) {
if (!event.origin) { // stupid chrome
event.origin = event.originalEvent.origin;
}
// check it is from the integrations UI URL (remove trailing spaces)
let url = SdkConfig.get().integrations_ui_url;
if (url.endsWith("/")) {
url = url.substr(0, url.length - 1);
}
if (url !== event.origin) {
console.warn("Unauthorised postMessage received. Source URL: " + event.origin);
return;
}
const roomId = event.data.room_id;
const userId = event.data.user_id;
if (!userId) {
sendError(event, "Missing user_id in request");
return;
}
if (!roomId) {
sendError(event, "Missing room_id in request");
return;
}
switch (event.data.action) {
case "membership_state":
getMembershipState(event, roomId, userId);
break;
case "invite":
inviteUser(event, roomId, userId);
break;
case "bot_options":
botOptions(event, roomId, userId);
break;
case "set_bot_options":
setBotOptions(event, roomId, userId);
break;
default:
console.warn("Unhandled postMessage event with action '" + event.data.action +"'");
break;
}
};
module.exports = {
startListening: function() {
window.addEventListener("message", onMessage, false);
},
stopListening: function() {
window.removeEventListener("message", onMessage);
},
};

View file

@ -1,4 +1,5 @@
import Q from 'q'; import Q from 'q';
import React from 'react';
export default class AutocompleteProvider { export default class AutocompleteProvider {
constructor(commandRegex?: RegExp, fuseOpts?: any) { constructor(commandRegex?: RegExp, fuseOpts?: any) {
@ -51,4 +52,9 @@ export default class AutocompleteProvider {
getName(): string { getName(): string {
return 'Default Provider'; return 'Default Provider';
} }
renderCompletions(completions: [React.Component]): ?React.Component {
console.error('stub; should be implemented in subclasses');
return null;
}
} }

View file

@ -74,7 +74,7 @@ export default class CommandProvider extends AutocompleteProvider {
} }
getName() { getName() {
return 'Commands'; return '*️⃣ Commands';
} }
static getInstance(): CommandProvider { static getInstance(): CommandProvider {
@ -83,4 +83,10 @@ export default class CommandProvider extends AutocompleteProvider {
return instance; return instance;
} }
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_block">
{completions}
</div>;
}
} }

View file

@ -1,19 +1,62 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
export function TextualCompletion({ /* These were earlier stateless functional components but had to be converted
title, since we need to use refs/findDOMNode to access the underlying DOM node to focus the correct completion,
subtitle, something that is not entirely possible with stateless functional components. One could
description, presumably wrap them in a <div> before rendering but I think this is the better way to do it.
}: { */
title: ?string,
subtitle: ?string, export class TextualCompletion extends React.Component {
description: ?string render() {
}) { const {
return ( title,
<div style={{width: '100%'}}> subtitle,
<span>{title}</span> description,
<em>{subtitle}</em> className,
<span style={{color: 'gray', float: 'right'}}>{description}</span> ...restProps,
</div> } = this.props;
); return (
<div className={classNames('mx_Autocomplete_Completion_block', className)} {...restProps}>
<span className="mx_Autocomplete_Completion_title">{title}</span>
<span className="mx_Autocomplete_Completion_subtitle">{subtitle}</span>
<span className="mx_Autocomplete_Completion_description">{description}</span>
</div>
);
}
} }
TextualCompletion.propTypes = {
title: React.PropTypes.string,
subtitle: React.PropTypes.string,
description: React.PropTypes.string,
className: React.PropTypes.string,
};
export class PillCompletion extends React.Component {
render() {
const {
title,
subtitle,
description,
initialComponent,
className,
...restProps,
} = this.props;
return (
<div className={classNames('mx_Autocomplete_Completion_pill', className)} {...restProps}>
{initialComponent}
<span className="mx_Autocomplete_Completion_title">{title}</span>
<span className="mx_Autocomplete_Completion_subtitle">{subtitle}</span>
<span className="mx_Autocomplete_Completion_description">{description}</span>
</div>
);
}
}
PillCompletion.propTypes = {
title: React.PropTypes.string,
subtitle: React.PropTypes.string,
description: React.PropTypes.string,
initialComponent: React.PropTypes.element,
className: React.PropTypes.string,
};

View file

@ -78,7 +78,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
} }
getName() { getName() {
return 'Results from DuckDuckGo'; return '🔍 Results from DuckDuckGo';
} }
static getInstance(): DuckDuckGoProvider { static getInstance(): DuckDuckGoProvider {
@ -87,4 +87,10 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
} }
return instance; return instance;
} }
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_block">
{completions}
</div>;
}
} }

View file

@ -3,6 +3,8 @@ import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q'; import Q from 'q';
import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione'; import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import sdk from '../index';
import {PillCompletion} from './Components';
const EMOJI_REGEX = /:\w*:?/g; const EMOJI_REGEX = /:\w*:?/g;
const EMOJI_SHORTNAMES = Object.keys(emojioneList); const EMOJI_SHORTNAMES = Object.keys(emojioneList);
@ -16,28 +18,28 @@ export default class EmojiProvider extends AutocompleteProvider {
} }
getCompletions(query: string, selection: {start: number, end: number}) { getCompletions(query: string, selection: {start: number, end: number}) {
const EmojiText = sdk.getComponent('views.elements.EmojiText');
let completions = []; let completions = [];
let {command, range} = this.getCurrentCommand(query, selection); let {command, range} = this.getCurrentCommand(query, selection);
if (command) { if (command) {
completions = this.fuse.search(command[0]).map(result => { completions = this.fuse.search(command[0]).map(result => {
let shortname = EMOJI_SHORTNAMES[result]; const shortname = EMOJI_SHORTNAMES[result];
let imageHTML = shortnameToImage(shortname); const unicode = shortnameToUnicode(shortname);
return { return {
completion: shortnameToUnicode(shortname), completion: unicode,
component: ( component: (
<div className="mx_Autocomplete_Completion"> <PillCompletion title={shortname} initialComponent={<EmojiText style={{maxWidth: '1em'}}>{unicode}</EmojiText>} />
<span style={{maxWidth: '1em'}} dangerouslySetInnerHTML={{__html: imageHTML}}></span>&nbsp;&nbsp;{shortname}
</div>
), ),
range, range,
}; };
}).slice(0, 4); }).slice(0, 8);
} }
return Q.when(completions); return Q.when(completions);
} }
getName() { getName() {
return 'Emoji'; return '😃 Emoji';
} }
static getInstance() { static getInstance() {
@ -45,4 +47,10 @@ export default class EmojiProvider extends AutocompleteProvider {
instance = new EmojiProvider(); instance = new EmojiProvider();
return instance; return instance;
} }
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill">
{completions}
</div>;
}
} }

View file

@ -3,8 +3,9 @@ import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q'; import Q from 'q';
import MatrixClientPeg from '../MatrixClientPeg'; import MatrixClientPeg from '../MatrixClientPeg';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import {TextualCompletion} from './Components'; import {PillCompletion} from './Components';
import {getDisplayAliasForRoom} from '../MatrixTools'; import {getDisplayAliasForRoom} from '../MatrixTools';
import sdk from '../index';
const ROOM_REGEX = /(?=#)([^\s]*)/g; const ROOM_REGEX = /(?=#)([^\s]*)/g;
@ -21,6 +22,8 @@ export default class RoomProvider extends AutocompleteProvider {
} }
getCompletions(query: string, selection: {start: number, end: number}) { getCompletions(query: string, selection: {start: number, end: number}) {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
let client = MatrixClientPeg.get(); let client = MatrixClientPeg.get();
let completions = []; let completions = [];
const {command, range} = this.getCurrentCommand(query, selection); const {command, range} = this.getCurrentCommand(query, selection);
@ -39,7 +42,7 @@ export default class RoomProvider extends AutocompleteProvider {
return { return {
completion: displayAlias, completion: displayAlias,
component: ( component: (
<TextualCompletion title={room.name} description={displayAlias} /> <PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={room.room} />} title={room.name} description={displayAlias} />
), ),
range, range,
}; };
@ -49,7 +52,7 @@ export default class RoomProvider extends AutocompleteProvider {
} }
getName() { getName() {
return 'Rooms'; return '💬 Rooms';
} }
static getInstance() { static getInstance() {
@ -59,4 +62,10 @@ export default class RoomProvider extends AutocompleteProvider {
return instance; return instance;
} }
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill">
{completions}
</div>;
}
} }

View file

@ -2,7 +2,8 @@ import React from 'react';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q'; import Q from 'q';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import {TextualCompletion} from './Components'; import {PillCompletion} from './Components';
import sdk from '../index';
const USER_REGEX = /@[^\s]*/g; const USER_REGEX = /@[^\s]*/g;
@ -20,6 +21,8 @@ export default class UserProvider extends AutocompleteProvider {
} }
getCompletions(query: string, selection: {start: number, end: number}) { getCompletions(query: string, selection: {start: number, end: number}) {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
let completions = []; let completions = [];
let {command, range} = this.getCurrentCommand(query, selection); let {command, range} = this.getCurrentCommand(query, selection);
if (command) { if (command) {
@ -29,7 +32,8 @@ export default class UserProvider extends AutocompleteProvider {
return { return {
completion: user.userId, completion: user.userId,
component: ( component: (
<TextualCompletion <PillCompletion
initialComponent={<MemberAvatar member={user} width={24} height={24}/>}
title={displayName} title={displayName}
description={user.userId} /> description={user.userId} />
), ),
@ -41,7 +45,7 @@ export default class UserProvider extends AutocompleteProvider {
} }
getName() { getName() {
return 'Users'; return '👥 Users';
} }
setUserList(users) { setUserList(users) {
@ -54,4 +58,10 @@ export default class UserProvider extends AutocompleteProvider {
} }
return instance; return instance;
} }
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill">
{completions}
</div>;
}
} }

View file

@ -69,6 +69,7 @@ module.exports = React.createClass({
UserSettings: "user_settings", UserSettings: "user_settings",
CreateRoom: "create_room", CreateRoom: "create_room",
RoomDirectory: "room_directory", RoomDirectory: "room_directory",
UserView: "user_view",
}, },
AuxPanel: { AuxPanel: {
@ -87,6 +88,10 @@ module.exports = React.createClass({
// in the case where we view a room by ID or by RoomView when it resolves // in the case where we view a room by ID or by RoomView when it resolves
// what ID an alias points at. // what ID an alias points at.
currentRoomId: null, currentRoomId: null,
// If we're trying to just view a user ID (i.e. /user URL), this is it
viewUserId: null,
logged_in: false, logged_in: false,
collapse_lhs: false, collapse_lhs: false,
collapse_rhs: false, collapse_rhs: false,
@ -94,6 +99,9 @@ module.exports = React.createClass({
width: 10000, width: 10000,
sideOpacity: 1.0, sideOpacity: 1.0,
middleOpacity: 1.0, middleOpacity: 1.0,
version: null,
newVersion: null,
}; };
return s; return s;
}, },
@ -736,6 +744,18 @@ module.exports = React.createClass({
} else { } else {
dis.dispatch(payload); dis.dispatch(payload);
} }
} else if (screen.indexOf('user/') == 0) {
var userId = screen.substring(5);
this.setState({ viewUserId: userId });
this._setPage(this.PageTypes.UserView);
this.notifyNewScreen('user/' + userId);
var member = new Matrix.RoomMember(null, userId);
if (member) {
dis.dispatch({
action: 'view_user',
member: member,
});
}
} }
else { else {
console.info("Ignoring showScreen for '%s'", screen); console.info("Ignoring showScreen for '%s'", screen);
@ -756,15 +776,13 @@ module.exports = React.createClass({
onUserClick: function(event, userId) { onUserClick: function(event, userId) {
event.preventDefault(); event.preventDefault();
/* // var MemberInfo = sdk.getComponent('rooms.MemberInfo');
var MemberInfo = sdk.getComponent('rooms.MemberInfo'); // var member = new Matrix.RoomMember(null, userId);
var member = new Matrix.RoomMember(null, userId); // ContextualMenu.createMenu(MemberInfo, {
ContextualMenu.createMenu(MemberInfo, { // member: member,
member: member, // right: window.innerWidth - event.pageX,
right: window.innerWidth - event.pageX, // top: event.pageY
top: event.pageY // });
});
*/
var member = new Matrix.RoomMember(null, userId); var member = new Matrix.RoomMember(null, userId);
if (!member) { return; } if (!member) { return; }
@ -856,6 +874,7 @@ module.exports = React.createClass({
onVersion: function(current, latest) { onVersion: function(current, latest) {
this.setState({ this.setState({
version: current, version: current,
newVersion: latest,
hasNewVersion: current !== latest hasNewVersion: current !== latest
}); });
}, },
@ -988,11 +1007,15 @@ module.exports = React.createClass({
page_element = <RoomDirectory /> page_element = <RoomDirectory />
right_panel = <RightPanel collapsed={this.state.collapse_rhs} opacity={this.state.sideOpacity}/> right_panel = <RightPanel collapsed={this.state.collapse_rhs} opacity={this.state.sideOpacity}/>
break; break;
case this.PageTypes.UserView:
page_element = null; // deliberately null for now
right_panel = <RightPanel userId={this.state.viewUserId} collapsed={false} opacity={this.state.sideOpacity} />
break;
} }
var topBar; var topBar;
if (this.state.hasNewVersion) { if (this.state.hasNewVersion) {
topBar = <NewVersionBar />; topBar = <NewVersionBar version={this.state.version} newVersion={this.state.newVersion} />;
} }
else if (MatrixClientPeg.get().isGuest()) { else if (MatrixClientPeg.get().isGuest()) {
topBar = <GuestWarningBar />; topBar = <GuestWarningBar />;

View file

@ -31,7 +31,6 @@ var KeyCode = require('../../KeyCode');
var PAGINATE_SIZE = 20; var PAGINATE_SIZE = 20;
var INITIAL_SIZE = 20; var INITIAL_SIZE = 20;
var TIMELINE_CAP = 250; // the most events to show in a timeline
var DEBUG = false; var DEBUG = false;
@ -82,6 +81,9 @@ var TimelinePanel = React.createClass({
// opacity for dynamic UI fading effects // opacity for dynamic UI fading effects
opacity: React.PropTypes.number, opacity: React.PropTypes.number,
// maximum number of events to show in a timeline
timelineCap: React.PropTypes.number,
}, },
statics: { statics: {
@ -92,6 +94,12 @@ var TimelinePanel = React.createClass({
roomReadMarkerTsMap: {}, roomReadMarkerTsMap: {},
}, },
getDefaultProps: function() {
return {
timelineCap: 250,
};
},
getInitialState: function() { getInitialState: function() {
var initialReadMarker = var initialReadMarker =
TimelinePanel.roomReadMarkerMap[this.props.room.roomId] TimelinePanel.roomReadMarkerMap[this.props.room.roomId]
@ -684,7 +692,7 @@ var TimelinePanel = React.createClass({
_loadTimeline: function(eventId, pixelOffset, offsetBase) { _loadTimeline: function(eventId, pixelOffset, offsetBase) {
this._timelineWindow = new Matrix.TimelineWindow( this._timelineWindow = new Matrix.TimelineWindow(
MatrixClientPeg.get(), this.props.room, MatrixClientPeg.get(), this.props.room,
{windowLimit: TIMELINE_CAP}); {windowLimit: this.props.timelineCap});
var onLoaded = () => { var onLoaded = () => {
this._reloadEvents(); this._reloadEvents();

View file

@ -47,6 +47,9 @@ module.exports = React.createClass({
}, },
_getState: function(props) { _getState: function(props) {
if (!props.member) {
console.error("MemberAvatar called somehow with null member");
}
return { return {
name: props.member.name, name: props.member.name,
title: props.member.userId, title: props.member.userId,

View file

@ -22,12 +22,6 @@ var sdk = require('../../../index');
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MessageEvent', displayName: 'MessageEvent',
statics: {
needsSenderProfile: function() {
return true;
}
},
propTypes: { propTypes: {
/* the MatrixEvent to show */ /* the MatrixEvent to show */
mxEvent: React.PropTypes.object.isRequired, mxEvent: React.PropTypes.object.isRequired,

View file

@ -24,12 +24,6 @@ import sdk from '../../../index';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'TextualEvent', displayName: 'TextualEvent',
statics: {
needsSenderProfile: function() {
return false;
}
},
render: function() { render: function() {
const EmojiText = sdk.getComponent('elements.EmojiText'); const EmojiText = sdk.getComponent('elements.EmojiText');
var text = TextForEvent.textForEvent(this.props.mxEvent); var text = TextForEvent.textForEvent(this.props.mxEvent);
@ -39,4 +33,3 @@ module.exports = React.createClass({
); );
}, },
}); });

View file

@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; import ReactDOM from 'react-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import flatMap from 'lodash/flatMap'; import flatMap from 'lodash/flatMap';
import sdk from '../../../index';
import {getCompletions} from '../../../autocomplete/Autocompleter'; import {getCompletions} from '../../../autocomplete/Autocompleter';
@ -100,11 +101,27 @@ export default class Autocomplete extends React.Component {
this.setState({selectionOffset}); this.setState({selectionOffset});
} }
componentDidUpdate() {
// this is the selected completion, so scroll it into view if needed
const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`];
if (selectedCompletion && this.container) {
const domNode = ReactDOM.findDOMNode(selectedCompletion);
const offsetTop = domNode && domNode.offsetTop;
if (offsetTop > this.container.scrollTop + this.container.offsetHeight ||
offsetTop < this.container.scrollTop) {
this.container.scrollTop = offsetTop - this.container.offsetTop;
}
}
}
render() { render() {
const EmojiText = sdk.getComponent('views.elements.EmojiText');
let position = 0; let position = 0;
let renderedCompletions = this.state.completions.map((completionResult, i) => { let renderedCompletions = this.state.completions.map((completionResult, i) => {
let completions = completionResult.completions.map((completion, i) => { let completions = completionResult.completions.map((completion, i) => {
let className = classNames('mx_Autocomplete_Completion', {
const className = classNames('mx_Autocomplete_Completion', {
'selected': position === this.state.selectionOffset, 'selected': position === this.state.selectionOffset,
}); });
let componentPosition = position; let componentPosition = position;
@ -116,40 +133,27 @@ export default class Autocomplete extends React.Component {
this.onConfirm(); this.onConfirm();
}; };
return ( return React.cloneElement(completion.component, {
<div key={i} key: i,
className={className} ref: `completion${i}`,
onMouseOver={onMouseOver} className,
onClick={onClick}> onMouseOver,
{completion.component} onClick,
</div> });
);
}); });
return completions.length > 0 ? ( return completions.length > 0 ? (
<div key={i} className="mx_Autocomplete_ProviderSection"> <div key={i} className="mx_Autocomplete_ProviderSection">
<span className="mx_Autocomplete_provider_name">{completionResult.provider.getName()}</span> <EmojiText element="div" className="mx_Autocomplete_provider_name">{completionResult.provider.getName()}</EmojiText>
<ReactCSSTransitionGroup {completionResult.provider.renderCompletions(completions)}
component="div"
transitionName="autocomplete"
transitionEnterTimeout={300}
transitionLeaveTimeout={300}>
{completions}
</ReactCSSTransitionGroup>
</div> </div>
) : null; ) : null;
}); });
return ( return (
<div className="mx_Autocomplete"> <div className="mx_Autocomplete" ref={(e) => this.container = e}>
<ReactCSSTransitionGroup {renderedCompletions}
component="div"
transitionName="autocomplete"
transitionEnterTimeout={300}
transitionLeaveTimeout={300}>
{renderedCompletions}
</ReactCSSTransitionGroup>
</div> </div>
); );
} }

View file

@ -62,7 +62,7 @@ var MAX_READ_AVATARS = 5;
// '----------------------------------------------------------' // '----------------------------------------------------------'
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'Event', displayName: 'EventTile',
statics: { statics: {
haveTileForEvent: function(e) { haveTileForEvent: function(e) {
@ -368,7 +368,7 @@ module.exports = React.createClass({
// room, or emote messages // room, or emote messages
var isInfoMessage = (msgtype === 'm.emote' || eventType !== 'm.room.message'); var isInfoMessage = (msgtype === 'm.emote' || eventType !== 'm.room.message');
var EventTileType = sdk.getComponent(eventTileTypes[this.props.mxEvent.getType()]); var EventTileType = sdk.getComponent(eventTileTypes[eventType]);
// This shouldn't happen: the caller should check we support this type // This shouldn't happen: the caller should check we support this type
// before trying to instantiate us // before trying to instantiate us
if (!EventTileType) { if (!EventTileType) {
@ -395,25 +395,44 @@ module.exports = React.createClass({
<MessageTimestamp ts={this.props.mxEvent.getTs()} /> <MessageTimestamp ts={this.props.mxEvent.getTs()} />
</a> </a>
var aux = null;
if (msgtype === 'm.image') aux = "sent an image";
else if (msgtype === 'm.video') aux = "sent a video";
else if (msgtype === 'm.file') aux = "uploaded a file";
var readAvatars = this.getReadAvatars(); var readAvatars = this.getReadAvatars();
var avatar, sender; var avatar, sender;
if (!this.props.continuation && !isInfoMessage) { let avatarSize;
if (this.props.mxEvent.sender) { let needsSenderProfile;
avatar = (
if (isInfoMessage) {
// a small avatar, with no sender profile, for emotes and
// joins/parts/etc
avatarSize = 14;
needsSenderProfile = false;
} else if (this.props.continuation) {
// no avatar or sender profile for continuation messages
avatarSize = 0;
needsSenderProfile = false;
} else {
avatarSize = 30;
needsSenderProfile = true;
}
if (this.props.mxEvent.sender && avatarSize) {
avatar = (
<div className="mx_EventTile_avatar"> <div className="mx_EventTile_avatar">
<MemberAvatar member={this.props.mxEvent.sender} width={30} height={30} onClick={ this.onMemberAvatarClick } /> <MemberAvatar member={this.props.mxEvent.sender}
width={avatarSize} height={avatarSize}
onClick={ this.onMemberAvatarClick }
/>
</div> </div>
); );
} }
if (EventTileType.needsSenderProfile()) {
sender = <SenderProfile onClick={ this.onSenderProfileClick } mxEvent={this.props.mxEvent} aux={aux} />; if (needsSenderProfile) {
} let aux = null;
if (msgtype === 'm.image') aux = "sent an image";
else if (msgtype === 'm.video') aux = "sent a video";
else if (msgtype === 'm.file') aux = "uploaded a file";
sender = <SenderProfile onClick={ this.onSenderProfileClick } mxEvent={this.props.mxEvent} aux={aux} />;
} }
var editButton = ( var editButton = (

View file

@ -531,7 +531,7 @@ module.exports = React.createClass({
}, },
onMemberAvatarClick: function () { onMemberAvatarClick: function () {
var avatarUrl = this.props.member.user.avatarUrl; var avatarUrl = this.props.member.user ? this.props.member.user.avatarUrl : this.props.member.events.member.getContent().avatar_url;
if(!avatarUrl) return; if(!avatarUrl) return;
var httpUrl = MatrixClientPeg.get().mxcUrlToHttp(avatarUrl); var httpUrl = MatrixClientPeg.get().mxcUrlToHttp(avatarUrl);

View file

@ -23,6 +23,7 @@ var Modal = require('../../../Modal');
var ObjectUtils = require("../../../ObjectUtils"); var ObjectUtils = require("../../../ObjectUtils");
var dis = require("../../../dispatcher"); var dis = require("../../../dispatcher");
var ScalarAuthClient = require("../../../ScalarAuthClient"); var ScalarAuthClient = require("../../../ScalarAuthClient");
var ScalarMessaging = require('../../../ScalarMessaging');
var UserSettingsStore = require('../../../UserSettingsStore'); var UserSettingsStore = require('../../../UserSettingsStore');
// parse a string as an integer; if the input is undefined, or cannot be parsed // parse a string as an integer; if the input is undefined, or cannot be parsed
@ -70,6 +71,7 @@ module.exports = React.createClass({
}, },
componentWillMount: function() { componentWillMount: function() {
ScalarMessaging.startListening();
MatrixClientPeg.get().getRoomDirectoryVisibility( MatrixClientPeg.get().getRoomDirectoryVisibility(
this.props.room.roomId this.props.room.roomId
).done((result) => { ).done((result) => {
@ -93,6 +95,8 @@ module.exports = React.createClass({
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
ScalarMessaging.stopListening();
dis.dispatch({ dis.dispatch({
action: 'ui_opacity', action: 'ui_opacity',
sideOpacity: 1.0, sideOpacity: 1.0,
@ -422,6 +426,27 @@ module.exports = React.createClass({
}, ""); }, "");
}, },
onLeaveClick() {
dis.dispatch({
action: 'leave_room',
room_id: this.props.room.roomId,
});
},
onForgetClick() {
// FIXME: duplicated with RoomTagContextualMenu (and dead code in RoomView)
MatrixClientPeg.get().forget(this.props.room.roomId).done(function() {
dis.dispatch({ action: 'view_next_room' });
}, function(err) {
var errCode = err.errcode || "unknown error code";
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Error",
description: `Failed to forget room (${errCode})`
});
});
},
_renderEncryptionSection: function() { _renderEncryptionSection: function() {
if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) { if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) {
return null; return null;
@ -540,6 +565,25 @@ module.exports = React.createClass({
); );
} }
var leaveButton = null;
var myMember = this.props.room.getMember(user_id);
if (myMember) {
if (myMember.membership === "join") {
leaveButton = (
<div className="mx_RoomSettings_leaveButton" onClick={ this.onLeaveClick }>
Leave room
</div>
);
}
else if (myMember.membership === "leave") {
leaveButton = (
<div className="mx_RoomSettings_leaveButton" onClick={ this.onForgetClick }>
Forget room
</div>
);
}
}
// TODO: support editing custom events_levels // TODO: support editing custom events_levels
// TODO: support editing custom user_levels // TODO: support editing custom user_levels
@ -627,6 +671,8 @@ module.exports = React.createClass({
return ( return (
<div className="mx_RoomSettings"> <div className="mx_RoomSettings">
{ leaveButton }
{ tagsSection } { tagsSection }
<div className="mx_RoomSettings_toggles"> <div className="mx_RoomSettings_toggles">

View file

@ -100,7 +100,7 @@ export default class DevicesPanelEntry extends React.Component {
deleteButton = <div className="error">{this.state.deleteError}</div> deleteButton = <div className="error">{this.state.deleteError}</div>
} else { } else {
deleteButton = ( deleteButton = (
<div className="textButton" <div className="mx_textButton"
onClick={this._onDeleteClick}> onClick={this._onDeleteClick}>
Delete Delete
</div> </div>

View file

@ -95,6 +95,7 @@ function matrixLinkify(linkify) {
S_AT_NAME_COLON_DOMAIN_DOT.on(TT.TLD, S_USERID); S_AT_NAME_COLON_DOMAIN_DOT.on(TT.TLD, S_USERID);
} }
// stubs, overwritten in MatrixChat's componentDidMount
matrixLinkify.onUserClick = function(e, userId) { e.preventDefault(); }; matrixLinkify.onUserClick = function(e, userId) { e.preventDefault(); };
matrixLinkify.onAliasClick = function(e, roomAlias) { e.preventDefault(); }; matrixLinkify.onAliasClick = function(e, roomAlias) { e.preventDefault(); };
@ -102,11 +103,14 @@ var escapeRegExp = function(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}; };
// we only recognise URLs which match our current URL as being the same app // Recognise URLs from both our local vector and official vector as vector.
// as if someone explicitly links to vector.im/develop and we're on vector.im/beta // anyone else really should be using matrix.to.
// they may well be trying to get us to explicitly go to develop. matrixLinkify.VECTOR_URL_PATTERN = "^(?:https?:\/\/)?(?:"
// FIXME: intercept matrix.to URLs as well. + escapeRegExp(window.location.host + window.location.pathname) + "|"
matrixLinkify.VECTOR_URL_PATTERN = "^(https?:\/\/)?" + escapeRegExp(window.location.host + window.location.pathname); + "(?:www\\.)?vector\\.im/(?:beta|staging|develop)/"
+ ")(#.*)";
matrixLinkify.MATRIXTO_URL_PATTERN = "^(?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/((#|@|!).*)";
matrixLinkify.options = { matrixLinkify.options = {
events: function (href, type) { events: function (href, type) {
@ -131,8 +135,25 @@ matrixLinkify.options = {
case 'roomalias': case 'roomalias':
return '#/room/' + href; return '#/room/' + href;
case 'userid': case 'userid':
return '#'; return '#/user/' + href;
default: default:
var m;
// FIXME: horrible duplication with HtmlUtils' transform tags
m = href.match(matrixLinkify.VECTOR_URL_PATTERN);
if (m) {
return m[1];
}
m = href.match(matrixLinkify.MATRIXTO_URL_PATTERN);
if (m) {
var entity = m[1];
if (entity[0] === '@') {
return '#/user/' + entity;
}
else if (entity[0] === '#' || entity[0] === '!') {
return '#/room/' + entity;
}
}
return href; return href;
} }
}, },
@ -143,7 +164,9 @@ matrixLinkify.options = {
target: function(href, type) { target: function(href, type) {
if (type === 'url') { if (type === 'url') {
if (href.match(matrixLinkify.VECTOR_URL_PATTERN)) { if (href.match(matrixLinkify.VECTOR_URL_PATTERN) ||
href.match(matrixLinkify.MATRIXTO_URL_PATTERN))
{
return null; return null;
} }
else { else {

View file

@ -40,11 +40,12 @@ describe('TimelinePanel', function() {
var timeline; var timeline;
var parentDiv; var parentDiv;
function mkMessage() { function mkMessage(opts) {
return test_utils.mkMessage( return test_utils.mkMessage(
{ {
event: true, room: ROOM_ID, user: USER_ID, event: true, room: ROOM_ID, user: USER_ID,
ts: Date.now(), ts: Date.now(),
... opts,
}); });
} }
@ -87,7 +88,7 @@ describe('TimelinePanel', function() {
// this is https://github.com/vector-im/vector-web/issues/1367 // this is https://github.com/vector-im/vector-web/issues/1367
// enough events to allow us to scroll back // enough events to allow us to scroll back
var N_EVENTS = 20; var N_EVENTS = 30;
for (var i = 0; i < N_EVENTS; i++) { for (var i = 0; i < N_EVENTS; i++) {
timeline.addEvent(mkMessage()); timeline.addEvent(mkMessage());
} }
@ -207,10 +208,11 @@ describe('TimelinePanel', function() {
}); });
it("should let you scroll down again after you've scrolled up", function(done) { it("should let you scroll down again after you've scrolled up", function(done) {
var N_EVENTS = 600; var TIMELINE_CAP = 100; // needs to be more than we can fit in the div
var N_EVENTS = 120; // needs to be more than TIMELINE_CAP
// sadly, loading all those events takes a while // sadly, loading all those events takes a while
this.timeout(N_EVENTS * 30); this.timeout(N_EVENTS * 50);
// client.getRoom is called a /lot/ in this test, so replace // client.getRoom is called a /lot/ in this test, so replace
// sinon's spy with a fast noop. // sinon's spy with a fast noop.
@ -218,13 +220,15 @@ describe('TimelinePanel', function() {
// fill the timeline with lots of events // fill the timeline with lots of events
for (var i = 0; i < N_EVENTS; i++) { for (var i = 0; i < N_EVENTS; i++) {
timeline.addEvent(mkMessage()); timeline.addEvent(mkMessage({msg: "Event "+i}));
} }
console.log("added events to timeline"); console.log("added events to timeline");
var scrollDefer; var scrollDefer;
var panel = ReactDOM.render( var panel = ReactDOM.render(
<TimelinePanel room={room} onScroll={() => {scrollDefer.resolve()}} />, <TimelinePanel room={room} onScroll={() => {scrollDefer.resolve()}}
timelineCap={TIMELINE_CAP}
/>,
parentDiv parentDiv
); );
console.log("TimelinePanel rendered"); console.log("TimelinePanel rendered");
@ -256,14 +260,18 @@ describe('TimelinePanel', function() {
console.log("back paginating..."); console.log("back paginating...");
setScrollTop(0); setScrollTop(0);
return awaitScroll().then(() => { return awaitScroll().then(() => {
let eventTiles = scryEventTiles(panel);
let firstEvent = eventTiles[0].props.mxEvent;
console.log("TimelinePanel contains " + eventTiles.length +
" events; first is " +
firstEvent.getContent().body);
if(scrollingDiv.scrollTop > 0) { if(scrollingDiv.scrollTop > 0) {
// need to go further // need to go further
return backPaginate(); return backPaginate();
} }
console.log("paginated to start."); console.log("paginated to start.");
// hopefully, we got to the start of the timeline
expect(messagePanel.props.backPaginating).toBe(false);
}); });
} }
@ -276,16 +284,38 @@ describe('TimelinePanel', function() {
// back-paginate until we hit the start // back-paginate until we hit the start
return backPaginate(); return backPaginate();
}).then(() => { }).then(() => {
// hopefully, we got to the start of the timeline
expect(messagePanel.props.backPaginating).toBe(false);
expect(messagePanel.props.suppressFirstDateSeparator).toBe(false); expect(messagePanel.props.suppressFirstDateSeparator).toBe(false);
var events = scryEventTiles(panel); var events = scryEventTiles(panel);
expect(events[0].props.mxEvent).toBe(timeline.getEvents()[0]) expect(events[0].props.mxEvent).toBe(timeline.getEvents()[0]);
expect(events.length).toEqual(TIMELINE_CAP);
// we should now be able to scroll down, and paginate in the other // we should now be able to scroll down, and paginate in the other
// direction. // direction.
setScrollTop(scrollingDiv.scrollHeight); setScrollTop(scrollingDiv.scrollHeight);
scrollingDiv.scrollTop = scrollingDiv.scrollHeight; scrollingDiv.scrollTop = scrollingDiv.scrollHeight;
return awaitScroll();
// the delay() below is a heinous hack to deal with the fact that,
// without it, we may or may not get control back before the
// forward pagination completes. The delay means that it should
// have completed.
return awaitScroll().delay(0);
}).then(() => { }).then(() => {
expect(messagePanel.props.backPaginating).toBe(false);
expect(messagePanel.props.forwardPaginating).toBe(false);
expect(messagePanel.props.suppressFirstDateSeparator).toBe(true);
var events = scryEventTiles(panel);
expect(events.length).toEqual(TIMELINE_CAP);
// we don't really know what the first event tile will be, since that
// depends on how much the timelinepanel decides to paginate.
//
// just check that the first tile isn't event 0.
expect(events[0].props.mxEvent).toNotBe(timeline.getEvents()[0]);
console.log("done"); console.log("done");
}).done(done, done); }).done(done, done);
}); });