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

pull/21833/head
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)
=========================================================================================================
[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",
"version": "0.6.4-r1",
"version": "0.6.5",
"description": "SDK for matrix.org using React",
"author": "matrix.org",
"repository": {
@ -37,10 +37,10 @@
"fuse.js": "^2.2.0",
"glob": "^5.0.14",
"highlight.js": "^8.9.1",
"linkifyjs": "^2.0.0-beta.4",
"linkifyjs": "2.0.0-beta.4",
"lodash": "^4.13.1",
"marked": "^0.3.5",
"matrix-js-sdk": "0.5.5",
"matrix-js-sdk": "0.5.6",
"optimist": "^0.6.1",
"q": "^1.4.1",
"react": "^15.2.1",

View File

@ -49,7 +49,7 @@ export function unicodeToImage(str) {
alt = (emojione.unicodeAlt) ? emojione.convert(unicode.toUpperCase()) : 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;
}
});
@ -85,12 +85,28 @@ var sanitizeHtmlParams = {
transformTags: { // custom to matrix
// add blank targets to all hyperlinks except vector URLs
'a': function(tagName, attribs) {
var m = attribs.href ? attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN) : null;
if (m) {
delete attribs.target;
}
else {
attribs.target = '_blank';
if (attribs.href) {
attribs.target = '_blank'; // by default
var m;
// FIXME: horrible duplication with linkify-matrix
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/
return { tagName: tagName, attribs : attribs };
@ -271,7 +287,7 @@ module.exports = {
emojifyText: function(text) {
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 React from 'react';
export default class AutocompleteProvider {
constructor(commandRegex?: RegExp, fuseOpts?: any) {
@ -51,4 +52,9 @@ export default class AutocompleteProvider {
getName(): string {
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() {
return 'Commands';
return '*️⃣ Commands';
}
static getInstance(): CommandProvider {
@ -83,4 +83,10 @@ export default class CommandProvider extends AutocompleteProvider {
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 ReactDOM from 'react-dom';
import classNames from 'classnames';
export function TextualCompletion({
title,
subtitle,
description,
}: {
title: ?string,
subtitle: ?string,
description: ?string
}) {
return (
<div style={{width: '100%'}}>
<span>{title}</span>
<em>{subtitle}</em>
<span style={{color: 'gray', float: 'right'}}>{description}</span>
</div>
);
/* These were earlier stateless functional components but had to be converted
since we need to use refs/findDOMNode to access the underlying DOM node to focus the correct completion,
something that is not entirely possible with stateless functional components. One could
presumably wrap them in a <div> before rendering but I think this is the better way to do it.
*/
export class TextualCompletion extends React.Component {
render() {
const {
title,
subtitle,
description,
className,
...restProps,
} = 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() {
return 'Results from DuckDuckGo';
return '🔍 Results from DuckDuckGo';
}
static getInstance(): DuckDuckGoProvider {
@ -87,4 +87,10 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
}
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 {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione';
import Fuse from 'fuse.js';
import sdk from '../index';
import {PillCompletion} from './Components';
const EMOJI_REGEX = /:\w*:?/g;
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
@ -16,28 +18,28 @@ export default class EmojiProvider extends AutocompleteProvider {
}
getCompletions(query: string, selection: {start: number, end: number}) {
const EmojiText = sdk.getComponent('views.elements.EmojiText');
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection);
if (command) {
completions = this.fuse.search(command[0]).map(result => {
let shortname = EMOJI_SHORTNAMES[result];
let imageHTML = shortnameToImage(shortname);
const shortname = EMOJI_SHORTNAMES[result];
const unicode = shortnameToUnicode(shortname);
return {
completion: shortnameToUnicode(shortname),
completion: unicode,
component: (
<div className="mx_Autocomplete_Completion">
<span style={{maxWidth: '1em'}} dangerouslySetInnerHTML={{__html: imageHTML}}></span>&nbsp;&nbsp;{shortname}
</div>
<PillCompletion title={shortname} initialComponent={<EmojiText style={{maxWidth: '1em'}}>{unicode}</EmojiText>} />
),
range,
};
}).slice(0, 4);
}).slice(0, 8);
}
return Q.when(completions);
}
getName() {
return 'Emoji';
return '😃 Emoji';
}
static getInstance() {
@ -45,4 +47,10 @@ export default class EmojiProvider extends AutocompleteProvider {
instance = new EmojiProvider();
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 MatrixClientPeg from '../MatrixClientPeg';
import Fuse from 'fuse.js';
import {TextualCompletion} from './Components';
import {PillCompletion} from './Components';
import {getDisplayAliasForRoom} from '../MatrixTools';
import sdk from '../index';
const ROOM_REGEX = /(?=#)([^\s]*)/g;
@ -21,6 +22,8 @@ export default class RoomProvider extends AutocompleteProvider {
}
getCompletions(query: string, selection: {start: number, end: number}) {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
let client = MatrixClientPeg.get();
let completions = [];
const {command, range} = this.getCurrentCommand(query, selection);
@ -39,7 +42,7 @@ export default class RoomProvider extends AutocompleteProvider {
return {
completion: displayAlias,
component: (
<TextualCompletion title={room.name} description={displayAlias} />
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={room.room} />} title={room.name} description={displayAlias} />
),
range,
};
@ -49,7 +52,7 @@ export default class RoomProvider extends AutocompleteProvider {
}
getName() {
return 'Rooms';
return '💬 Rooms';
}
static getInstance() {
@ -59,4 +62,10 @@ export default class RoomProvider extends AutocompleteProvider {
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 Q from 'q';
import Fuse from 'fuse.js';
import {TextualCompletion} from './Components';
import {PillCompletion} from './Components';
import sdk from '../index';
const USER_REGEX = /@[^\s]*/g;
@ -20,6 +21,8 @@ export default class UserProvider extends AutocompleteProvider {
}
getCompletions(query: string, selection: {start: number, end: number}) {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection);
if (command) {
@ -29,7 +32,8 @@ export default class UserProvider extends AutocompleteProvider {
return {
completion: user.userId,
component: (
<TextualCompletion
<PillCompletion
initialComponent={<MemberAvatar member={user} width={24} height={24}/>}
title={displayName}
description={user.userId} />
),
@ -41,7 +45,7 @@ export default class UserProvider extends AutocompleteProvider {
}
getName() {
return 'Users';
return '👥 Users';
}
setUserList(users) {
@ -54,4 +58,10 @@ export default class UserProvider extends AutocompleteProvider {
}
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",
CreateRoom: "create_room",
RoomDirectory: "room_directory",
UserView: "user_view",
},
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
// what ID an alias points at.
currentRoomId: null,
// If we're trying to just view a user ID (i.e. /user URL), this is it
viewUserId: null,
logged_in: false,
collapse_lhs: false,
collapse_rhs: false,
@ -94,6 +99,9 @@ module.exports = React.createClass({
width: 10000,
sideOpacity: 1.0,
middleOpacity: 1.0,
version: null,
newVersion: null,
};
return s;
},
@ -736,6 +744,18 @@ module.exports = React.createClass({
} else {
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 {
console.info("Ignoring showScreen for '%s'", screen);
@ -756,15 +776,13 @@ module.exports = React.createClass({
onUserClick: function(event, userId) {
event.preventDefault();
/*
var MemberInfo = sdk.getComponent('rooms.MemberInfo');
var member = new Matrix.RoomMember(null, userId);
ContextualMenu.createMenu(MemberInfo, {
member: member,
right: window.innerWidth - event.pageX,
top: event.pageY
});
*/
// var MemberInfo = sdk.getComponent('rooms.MemberInfo');
// var member = new Matrix.RoomMember(null, userId);
// ContextualMenu.createMenu(MemberInfo, {
// member: member,
// right: window.innerWidth - event.pageX,
// top: event.pageY
// });
var member = new Matrix.RoomMember(null, userId);
if (!member) { return; }
@ -856,6 +874,7 @@ module.exports = React.createClass({
onVersion: function(current, latest) {
this.setState({
version: current,
newVersion: latest,
hasNewVersion: current !== latest
});
},
@ -988,11 +1007,15 @@ module.exports = React.createClass({
page_element = <RoomDirectory />
right_panel = <RightPanel collapsed={this.state.collapse_rhs} opacity={this.state.sideOpacity}/>
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;
if (this.state.hasNewVersion) {
topBar = <NewVersionBar />;
topBar = <NewVersionBar version={this.state.version} newVersion={this.state.newVersion} />;
}
else if (MatrixClientPeg.get().isGuest()) {
topBar = <GuestWarningBar />;

View File

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

View File

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

View File

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

View File

@ -24,12 +24,6 @@ import sdk from '../../../index';
module.exports = React.createClass({
displayName: 'TextualEvent',
statics: {
needsSenderProfile: function() {
return false;
}
},
render: function() {
const EmojiText = sdk.getComponent('elements.EmojiText');
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 ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
import flatMap from 'lodash/flatMap';
import sdk from '../../../index';
import {getCompletions} from '../../../autocomplete/Autocompleter';
@ -100,11 +101,27 @@ export default class Autocomplete extends React.Component {
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() {
const EmojiText = sdk.getComponent('views.elements.EmojiText');
let position = 0;
let renderedCompletions = this.state.completions.map((completionResult, 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,
});
let componentPosition = position;
@ -116,40 +133,27 @@ export default class Autocomplete extends React.Component {
this.onConfirm();
};
return (
<div key={i}
className={className}
onMouseOver={onMouseOver}
onClick={onClick}>
{completion.component}
</div>
);
return React.cloneElement(completion.component, {
key: i,
ref: `completion${i}`,
className,
onMouseOver,
onClick,
});
});
return completions.length > 0 ? (
<div key={i} className="mx_Autocomplete_ProviderSection">
<span className="mx_Autocomplete_provider_name">{completionResult.provider.getName()}</span>
<ReactCSSTransitionGroup
component="div"
transitionName="autocomplete"
transitionEnterTimeout={300}
transitionLeaveTimeout={300}>
{completions}
</ReactCSSTransitionGroup>
<EmojiText element="div" className="mx_Autocomplete_provider_name">{completionResult.provider.getName()}</EmojiText>
{completionResult.provider.renderCompletions(completions)}
</div>
) : null;
});
return (
<div className="mx_Autocomplete">
<ReactCSSTransitionGroup
component="div"
transitionName="autocomplete"
transitionEnterTimeout={300}
transitionLeaveTimeout={300}>
{renderedCompletions}
</ReactCSSTransitionGroup>
<div className="mx_Autocomplete" ref={(e) => this.container = e}>
{renderedCompletions}
</div>
);
}

View File

@ -62,7 +62,7 @@ var MAX_READ_AVATARS = 5;
// '----------------------------------------------------------'
module.exports = React.createClass({
displayName: 'Event',
displayName: 'EventTile',
statics: {
haveTileForEvent: function(e) {
@ -368,7 +368,7 @@ module.exports = React.createClass({
// room, or emote messages
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
// before trying to instantiate us
if (!EventTileType) {
@ -395,25 +395,44 @@ module.exports = React.createClass({
<MessageTimestamp ts={this.props.mxEvent.getTs()} />
</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 avatar, sender;
if (!this.props.continuation && !isInfoMessage) {
if (this.props.mxEvent.sender) {
avatar = (
let avatarSize;
let needsSenderProfile;
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">
<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>
);
}
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 = (

View File

@ -531,7 +531,7 @@ module.exports = React.createClass({
},
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;
var httpUrl = MatrixClientPeg.get().mxcUrlToHttp(avatarUrl);

View File

@ -23,6 +23,7 @@ var Modal = require('../../../Modal');
var ObjectUtils = require("../../../ObjectUtils");
var dis = require("../../../dispatcher");
var ScalarAuthClient = require("../../../ScalarAuthClient");
var ScalarMessaging = require('../../../ScalarMessaging');
var UserSettingsStore = require('../../../UserSettingsStore');
// 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() {
ScalarMessaging.startListening();
MatrixClientPeg.get().getRoomDirectoryVisibility(
this.props.room.roomId
).done((result) => {
@ -93,6 +95,8 @@ module.exports = React.createClass({
},
componentWillUnmount: function() {
ScalarMessaging.stopListening();
dis.dispatch({
action: 'ui_opacity',
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() {
if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) {
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 user_levels
@ -627,6 +671,8 @@ module.exports = React.createClass({
return (
<div className="mx_RoomSettings">
{ leaveButton }
{ tagsSection }
<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>
} else {
deleteButton = (
<div className="textButton"
<div className="mx_textButton"
onClick={this._onDeleteClick}>
Delete
</div>

View File

@ -95,6 +95,7 @@ function matrixLinkify(linkify) {
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.onAliasClick = function(e, roomAlias) { e.preventDefault(); };
@ -102,11 +103,14 @@ var escapeRegExp = function(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
};
// we only recognise URLs which match our current URL as being the same app
// as if someone explicitly links to vector.im/develop and we're on vector.im/beta
// they may well be trying to get us to explicitly go to develop.
// FIXME: intercept matrix.to URLs as well.
matrixLinkify.VECTOR_URL_PATTERN = "^(https?:\/\/)?" + escapeRegExp(window.location.host + window.location.pathname);
// Recognise URLs from both our local vector and official vector as vector.
// anyone else really should be using matrix.to.
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 = {
events: function (href, type) {
@ -131,8 +135,25 @@ matrixLinkify.options = {
case 'roomalias':
return '#/room/' + href;
case 'userid':
return '#';
return '#/user/' + href;
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;
}
},
@ -143,7 +164,9 @@ matrixLinkify.options = {
target: function(href, type) {
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;
}
else {

View File

@ -40,11 +40,12 @@ describe('TimelinePanel', function() {
var timeline;
var parentDiv;
function mkMessage() {
function mkMessage(opts) {
return test_utils.mkMessage(
{
event: true, room: ROOM_ID, user: USER_ID,
ts: Date.now(),
... opts,
});
}
@ -87,7 +88,7 @@ describe('TimelinePanel', function() {
// this is https://github.com/vector-im/vector-web/issues/1367
// enough events to allow us to scroll back
var N_EVENTS = 20;
var N_EVENTS = 30;
for (var i = 0; i < N_EVENTS; i++) {
timeline.addEvent(mkMessage());
}
@ -207,10 +208,11 @@ describe('TimelinePanel', function() {
});
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
this.timeout(N_EVENTS * 30);
this.timeout(N_EVENTS * 50);
// client.getRoom is called a /lot/ in this test, so replace
// sinon's spy with a fast noop.
@ -218,13 +220,15 @@ describe('TimelinePanel', function() {
// fill the timeline with lots of events
for (var i = 0; i < N_EVENTS; i++) {
timeline.addEvent(mkMessage());
timeline.addEvent(mkMessage({msg: "Event "+i}));
}
console.log("added events to timeline");
var scrollDefer;
var panel = ReactDOM.render(
<TimelinePanel room={room} onScroll={() => {scrollDefer.resolve()}} />,
<TimelinePanel room={room} onScroll={() => {scrollDefer.resolve()}}
timelineCap={TIMELINE_CAP}
/>,
parentDiv
);
console.log("TimelinePanel rendered");
@ -256,14 +260,18 @@ describe('TimelinePanel', function() {
console.log("back paginating...");
setScrollTop(0);
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) {
// need to go further
return backPaginate();
}
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
return backPaginate();
}).then(() => {
// hopefully, we got to the start of the timeline
expect(messagePanel.props.backPaginating).toBe(false);
expect(messagePanel.props.suppressFirstDateSeparator).toBe(false);
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
// direction.
setScrollTop(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(() => {
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");
}).done(done, done);
});