Merge branch 'develop' into dbkr/scalar

This commit is contained in:
Matthew Hodgson 2016-06-19 22:40:48 +01:00
commit 94aec10512
23 changed files with 1578 additions and 460 deletions

View file

@ -38,11 +38,13 @@ var MatrixTools = require('../../MatrixTools');
var linkifyMatrix = require("../../linkify-matrix");
var KeyCode = require('../../KeyCode');
var createRoom = require("../../createRoom");
module.exports = React.createClass({
displayName: 'MatrixChat',
propTypes: {
config: React.PropTypes.object.isRequired,
config: React.PropTypes.object,
ConferenceHandler: React.PropTypes.any,
onNewScreen: React.PropTypes.func,
registrationUrl: React.PropTypes.string,
@ -63,6 +65,13 @@ module.exports = React.createClass({
getInitialState: function() {
var s = {
// If we are viewing a room by alias, this contains the alias
currentRoomAlias: null,
// The ID of the room we're viewing. This is either populated directly
// in the case where we view a room by ID or by RoomView when it resolves
// what ID an alias points at.
currentRoomId: null,
logged_in: !!(MatrixClientPeg.get() && MatrixClientPeg.get().credentials),
collapse_lhs: false,
collapse_rhs: false,
@ -85,7 +94,8 @@ module.exports = React.createClass({
getDefaultProps: function() {
return {
startingQueryParams: {}
startingQueryParams: {},
config: {},
};
},
@ -98,10 +108,9 @@ module.exports = React.createClass({
else if (window.localStorage && window.localStorage.getItem("mx_hs_url")) {
return window.localStorage.getItem("mx_hs_url");
}
else if (this.props.config) {
return this.props.config.default_hs_url
else {
return this.props.config.default_hs_url || "https://matrix.org";
}
return "https://matrix.org";
},
getFallbackHsUrl: function() {
@ -117,10 +126,9 @@ module.exports = React.createClass({
else if (window.localStorage && window.localStorage.getItem("mx_is_url")) {
return window.localStorage.getItem("mx_is_url");
}
else if (this.props.config) {
return this.props.config.default_is_url
else {
return this.props.config.default_is_url || "https://vector.im"
}
return "https://matrix.org";
},
componentWillMount: function() {
@ -393,6 +401,10 @@ module.exports = React.createClass({
});
break;
case 'view_room':
// Takes either a room ID or room alias: if switching to a room the client is already
// known to be in (eg. user clicks on a room in the recents panel), supply the ID
// If the user is clicking on a room in the context of the alias being presented
// to them, supply the room alias. If both are supplied, the room ID will be ignored.
this._viewRoom(
payload.room_id, payload.room_alias, payload.show_settings, payload.event_id,
payload.third_party_invite, payload.oob_data
@ -406,7 +418,7 @@ module.exports = React.createClass({
);
var roomIndex = -1;
for (var i = 0; i < allRooms.length; ++i) {
if (allRooms[i].roomId == this.state.currentRoom) {
if (allRooms[i].roomId == this.state.currentRoomId) {
roomIndex = i;
break;
}
@ -424,42 +436,6 @@ module.exports = React.createClass({
this._viewRoom(allRooms[roomIndex].roomId);
}
break;
case 'view_room_alias':
if (!this.state.logged_in) {
this.starting_room_alias_payload = payload;
// Login is the default screen, so we'd do this anyway,
// but this will set the URL bar appropriately.
dis.dispatch({ action: 'start_login' });
return;
}
var foundRoom = MatrixTools.getRoomForAlias(
MatrixClientPeg.get().getRooms(), payload.room_alias
);
if (foundRoom) {
dis.dispatch({
action: 'view_room',
room_id: foundRoom.roomId,
room_alias: payload.room_alias,
event_id: payload.event_id,
third_party_invite: payload.third_party_invite,
oob_data: payload.oob_data,
});
return;
}
// resolve the alias and *then* view it
MatrixClientPeg.get().getRoomIdForAlias(payload.room_alias).done(
function(result) {
dis.dispatch({
action: 'view_room',
room_id: result.room_id,
room_alias: payload.room_alias,
event_id: payload.event_id,
third_party_invite: payload.third_party_invite,
oob_data: payload.oob_data,
});
});
break;
case 'view_user_settings':
this._setPage(this.PageTypes.UserSettings);
this.notifyNewScreen('settings');
@ -468,49 +444,7 @@ module.exports = React.createClass({
//this._setPage(this.PageTypes.CreateRoom);
//this.notifyNewScreen('new');
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog");
var Loader = sdk.getComponent("elements.Spinner");
var modal = Modal.createDialog(Loader);
if (MatrixClientPeg.get().isGuest()) {
Modal.createDialog(NeedToRegisterDialog, {
title: "Please Register",
description: "Guest users can't create new rooms. Please register to create room and start a chat."
});
return;
}
// XXX: FIXME: deduplicate this with MemberInfo's 'start chat' impl
MatrixClientPeg.get().createRoom({
preset: "private_chat",
// Allow guests by default since the room is private and they'd
// need an invite. This means clicking on a 3pid invite email can
// actually drop you right in to a chat.
initial_state: [
{
content: {
guest_access: 'can_join'
},
type: 'm.room.guest_access',
state_key: '',
visibility: 'private',
}
],
}).done(function(res) {
modal.close();
dis.dispatch({
action: 'view_room',
room_id: res.room_id,
// show_settings: true,
});
}, function(err) {
modal.close();
Modal.createDialog(ErrorDialog, {
title: "Failed to create room",
description: err.toString()
});
});
createRoom().done();
break;
case 'view_room_directory':
this._setPage(this.PageTypes.RoomDirectory);
@ -575,16 +509,19 @@ module.exports = React.createClass({
this.focusComposer = true;
var newState = {
currentRoom: roomId,
currentRoomAlias: roomAlias,
initialEventId: eventId,
highlightedEventId: eventId,
initialEventPixelOffset: undefined,
page_type: this.PageTypes.RoomView,
thirdPartyInvite: thirdPartyInvite,
roomOobData: oob_data,
currentRoomAlias: roomAlias,
};
if (!roomAlias) {
newState.currentRoomId = roomId;
}
// if we aren't given an explicit event id, look for one in the
// scrollStateMap.
if (!eventId) {
@ -604,7 +541,7 @@ module.exports = React.createClass({
var presentedId = roomAlias || roomId;
var room = MatrixClientPeg.get().getRoom(roomId);
if (room) {
var theAlias = MatrixTools.getCanonicalAliasForRoom(room);
var theAlias = MatrixTools.getDisplayAliasForRoom(room);
if (theAlias) presentedId = theAlias;
// No need to do this given RoomView triggers it itself...
@ -677,13 +614,13 @@ module.exports = React.createClass({
dis.dispatch(self.starting_room_alias_payload);
delete self.starting_room_alias_payload;
} else if (!self.state.page_type) {
if (!self.state.currentRoom) {
if (!self.state.currentRoomId) {
var firstRoom = null;
if (cli.getRooms() && cli.getRooms().length) {
firstRoom = RoomListSorter.mostRecentActivityFirst(
cli.getRooms()
)[0].roomId;
self.setState({ready: true, currentRoom: firstRoom, page_type: self.PageTypes.RoomView});
self.setState({ready: true, currentRoomId: firstRoom, page_type: self.PageTypes.RoomView});
} else {
self.setState({ready: true, page_type: self.PageTypes.RoomDirectory});
}
@ -693,10 +630,10 @@ module.exports = React.createClass({
// we notifyNewScreen now because now the room will actually be displayed,
// and (mostly) now we can get the correct alias.
var presentedId = self.state.currentRoom;
var room = MatrixClientPeg.get().getRoom(self.state.currentRoom);
var presentedId = self.state.currentRoomId;
var room = MatrixClientPeg.get().getRoom(self.state.currentRoomId);
if (room) {
var theAlias = MatrixTools.getCanonicalAliasForRoom(room);
var theAlias = MatrixTools.getDisplayAliasForRoom(room);
if (theAlias) presentedId = theAlias;
}
@ -861,22 +798,28 @@ module.exports = React.createClass({
inviterName: params.inviter_name,
};
var payload = {
action: 'view_room',
event_id: eventId,
third_party_invite: third_party_invite,
oob_data: oob_data,
};
if (roomString[0] == '#') {
dis.dispatch({
action: 'view_room_alias',
room_alias: roomString,
event_id: eventId,
third_party_invite: third_party_invite,
oob_data: oob_data,
});
payload.room_alias = roomString;
} else {
dis.dispatch({
action: 'view_room',
room_id: roomString,
event_id: eventId,
third_party_invite: third_party_invite,
oob_data: oob_data,
});
payload.room_id = roomString;
}
// we can't view a room unless we're logged in
// (a guest account is fine)
if (!this.state.logged_in) {
this.starting_room_alias_payload = payload;
// Login is the default screen, so we'd do this anyway,
// but this will set the URL bar appropriately.
dis.dispatch({ action: 'start_login' });
return;
} else {
dis.dispatch(payload);
}
}
else {
@ -892,7 +835,7 @@ module.exports = React.createClass({
onAliasClick: function(event, alias) {
event.preventDefault();
dis.dispatch({action: 'view_room_alias', room_alias: alias});
dis.dispatch({action: 'view_room', room_alias: alias});
},
onUserClick: function(event, userId) {
@ -1038,10 +981,10 @@ module.exports = React.createClass({
onUserSettingsClose: function() {
// XXX: use browser history instead to find the previous room?
// or maintain a this.state.pageHistory in _setPage()?
if (this.state.currentRoom) {
if (this.state.currentRoomId) {
dis.dispatch({
action: 'view_room',
room_id: this.state.currentRoom,
room_id: this.state.currentRoomId,
});
}
else {
@ -1051,6 +994,13 @@ module.exports = React.createClass({
}
},
onRoomIdResolved: function(room_id) {
// It's the RoomView's resposibility to look up room aliases, but we need the
// ID to pass into things like the Member List, so the Room View tells us when
// its done that resolution so we can display things that take a room ID.
this.setState({currentRoomId: room_id});
},
render: function() {
var LeftPanel = sdk.getComponent('structures.LeftPanel');
var RoomView = sdk.getComponent('structures.RoomView');
@ -1081,20 +1031,21 @@ module.exports = React.createClass({
page_element = (
<RoomView
ref="roomView"
roomAddress={this.state.currentRoom || this.state.currentRoomAlias}
roomAddress={this.state.currentRoomAlias || this.state.currentRoomId}
onRoomIdResolved={this.onRoomIdResolved}
eventId={this.state.initialEventId}
thirdPartyInvite={this.state.thirdPartyInvite}
oobData={this.state.roomOobData}
highlightedEventId={this.state.highlightedEventId}
eventPixelOffset={this.state.initialEventPixelOffset}
key={this.state.currentRoom}
key={this.state.currentRoomAlias || this.state.currentRoomId}
opacity={this.state.middleOpacity}
ConferenceHandler={this.props.ConferenceHandler} />
);
right_panel = <RightPanel roomId={this.state.currentRoom} collapsed={this.state.collapse_rhs} opacity={this.state.sideOpacity} />
right_panel = <RightPanel roomId={this.state.currentRoomId} collapsed={this.state.collapse_rhs} opacity={this.state.sideOpacity} />
break;
case this.PageTypes.UserSettings:
page_element = <UserSettings onClose={this.onUserSettingsClose} version={this.state.version} />
page_element = <UserSettings onClose={this.onUserSettingsClose} version={this.state.version} brand={this.props.config.brand} />
right_panel = <RightPanel collapsed={this.state.collapse_rhs} opacity={this.state.sideOpacity}/>
break;
case this.PageTypes.CreateRoom:
@ -1127,7 +1078,7 @@ module.exports = React.createClass({
<div className="mx_MatrixChat_wrapper">
{topBar}
<div className={bodyClasses}>
<LeftPanel selectedRoom={this.state.currentRoom} collapsed={this.state.collapse_lhs} opacity={this.state.sideOpacity}/>
<LeftPanel selectedRoom={this.state.currentRoomId} collapsed={this.state.collapse_lhs} opacity={this.state.sideOpacity}/>
<main className="mx_MatrixChat_middlePanel">
{page_element}
</main>

View file

@ -86,6 +86,10 @@ module.exports = React.createClass({
// to manage its animations
this._readReceiptMap = {};
// Remember the read marker ghost node so we can do the cleanup that
// Velocity requires
this._readMarkerGhostNode = null;
this._isMounted = true;
},
@ -422,9 +426,16 @@ module.exports = React.createClass({
},
_startAnimation: function(ghostNode) {
Velocity(ghostNode, {opacity: '0', width: '10%'},
{duration: 400, easing: 'easeInSine',
delay: 1000});
if (this._readMarkerGhostNode) {
Velocity.Utilities.removeData(this._readMarkerGhostNode);
}
this._readMarkerGhostNode = ghostNode;
if (ghostNode) {
Velocity(ghostNode, {opacity: '0', width: '10%'},
{duration: 400, easing: 'easeInSine',
delay: 1000});
}
},
_getReadMarkerGhostTile: function() {

View file

@ -39,6 +39,7 @@ var dis = require("../../dispatcher");
var Tinter = require("../../Tinter");
var rate_limited_func = require('../../ratelimitedfunc');
var ObjectUtils = require('../../ObjectUtils');
var MatrixTools = require('../../MatrixTools');
var DEBUG = false;
@ -54,16 +55,17 @@ module.exports = React.createClass({
propTypes: {
ConferenceHandler: React.PropTypes.any,
// the ID for this room (or, if we don't know it, an alias for it)
//
// XXX: if this is an alias, we will display a 'join' dialogue,
// regardless of whether we are already a member, or if the room is
// peekable. Currently there is a big mess, where at least four
// different components (RoomView, MatrixChat, RoomDirectory,
// SlashCommands) have logic for turning aliases into rooms, and each
// of them do it differently and have different edge cases.
// Either a room ID or room alias for the room to display.
// If the room is being displayed as a result of the user clicking
// on a room alias, the alias should be supplied. Otherwise, a room
// ID should be supplied.
roomAddress: React.PropTypes.string.isRequired,
// If a room alias is passed to roomAddress, a function can be
// provided here that will be called with the ID of the room
// once it has been resolved.
onRoomIdResolved: React.PropTypes.func,
// An object representing a third party invite to join this room
// Fields:
// * inviteSignUrl (string) The URL used to join this room from an email invite
@ -100,17 +102,17 @@ module.exports = React.createClass({
},
getInitialState: function() {
var room = MatrixClientPeg.get().getRoom(this.props.roomAddress);
return {
room: room,
roomLoading: !room,
room: null,
roomId: null,
roomLoading: true,
editingRoomSettings: false,
uploadingRoomSettings: false,
numUnreadMessages: 0,
draggingFile: false,
searching: false,
searchResults: null,
hasUnsentMessages: this._hasUnsentMessages(room),
hasUnsentMessages: false,
callState: null,
guestsCanJoin: false,
canPeek: false,
@ -142,6 +144,39 @@ module.exports = React.createClass({
}
});
if (this.props.roomAddress[0] == '#') {
// we always look up the alias from the directory server:
// we want the room that the given alias is pointing to
// right now. We may have joined that alias before but there's
// no guarantee the alias hasn't subsequently been remapped.
MatrixClientPeg.get().getRoomIdForAlias(this.props.roomAddress).done((result) => {
if (this.props.onRoomIdResolved) {
this.props.onRoomIdResolved(result.room_id);
}
var room = MatrixClientPeg.get().getRoom(result.room_id);
this.setState({
room: room,
roomId: result.room_id,
roomLoading: !room,
hasUnsentMessages: this._hasUnsentMessages(room),
}, this._updatePeeking);
}, (err) => {
this.setState({
roomLoading: false,
});
});
} else {
var room = MatrixClientPeg.get().getRoom(this.props.roomAddress);
this.setState({
roomId: this.props.roomAddress,
room: room,
roomLoading: !room,
hasUnsentMessages: this._hasUnsentMessages(room),
}, this._updatePeeking);
}
},
_updatePeeking: function() {
// if this is an unknown room then we're in one of three states:
// - This is a room we can peek into (search engine) (we can /peek)
// - This is a room we can publicly join or were invited to. (we can /join)
@ -149,10 +184,13 @@ module.exports = React.createClass({
// We can't try to /join because this may implicitly accept invites (!)
// We can /peek though. If it fails then we present the join UI. If it
// succeeds then great, show the preview (but we still may be able to /join!).
if (!this.state.room) {
console.log("Attempting to peek into room %s", this.props.roomAddress);
// Note that peeking works by room ID and room ID only, as opposed to joining
// which must be by alias or invite wherever possible (peeking currently does
// not work over federation).
if (!this.state.room && this.state.roomId) {
console.log("Attempting to peek into room %s", this.state.roomId);
MatrixClientPeg.get().peekInRoom(this.props.roomAddress).then((room) => {
MatrixClientPeg.get().peekInRoom(this.state.roomId).then((room) => {
this.setState({
room: room,
roomLoading: false,
@ -171,7 +209,7 @@ module.exports = React.createClass({
throw err;
}
}).done();
} else {
} else if (this.state.room) {
MatrixClientPeg.get().stopPeeking();
this._onRoomLoaded(this.state.room);
}

View file

@ -20,18 +20,36 @@ var MatrixClientPeg = require("../../MatrixClientPeg");
var Modal = require('../../Modal');
var dis = require("../../dispatcher");
var q = require('q');
var version = require('../../../package.json').version;
var package_json = require('../../../package.json');
var UserSettingsStore = require('../../UserSettingsStore');
var GeminiScrollbar = require('react-gemini-scrollbar');
var Email = require('../../email');
var AddThreepid = require('../../AddThreepid');
const LABS_FEATURES = [
{
name: 'Rich Text Editor',
id: 'rich_text_editor'
},
{
name: 'End-to-End Encryption',
id: 'e2e_encryption'
}
];
// if this looks like a release, use the 'version' from package.json; else use
// the git sha.
const REACT_SDK_VERSION =
'dist' in package_json ? package_json.version : package_json.gitHead || "<local>";
module.exports = React.createClass({
displayName: 'UserSettings',
propTypes: {
version: React.PropTypes.string,
onClose: React.PropTypes.func
onClose: React.PropTypes.func,
// The brand string given when creating email pushers
brand: React.PropTypes.string,
},
getDefaultProps: function() {
@ -44,7 +62,6 @@ module.exports = React.createClass({
return {
avatarUrl: null,
threePids: [],
clientVersion: version,
phase: "UserSettings.LOADING", // LOADING, DISPLAY
email_add_pending: false,
};
@ -244,6 +261,27 @@ module.exports = React.createClass({
});
},
_renderDeviceInfo: function() {
if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) {
return null;
}
var client = MatrixClientPeg.get();
var deviceId = client.deviceId;
var olmKey = client.getDeviceEd25519Key() || "<not supported>";
return (
<div>
<h3>Cryptography</h3>
<div className="mx_UserSettings_section">
<ul>
<li>Device ID: {deviceId}</li>
<li>Device key: {olmKey}</li>
</ul>
</div>
</div>
);
},
render: function() {
var self = this;
var Loader = sdk.getComponent("elements.Spinner");
@ -333,11 +371,35 @@ module.exports = React.createClass({
<h3>Notifications</h3>
<div className="mx_UserSettings_section">
<Notifications threepids={this.state.threepids} />
<Notifications threepids={this.state.threepids} brand={this.props.brand} />
</div>
</div>);
}
this._renderLabs = function () {
let features = LABS_FEATURES.map(feature => (
<div key={feature.id}>
<input
type="checkbox"
id={feature.id}
name={feature.id}
defaultChecked={UserSettingsStore.isFeatureEnabled(feature.id)}
onChange={e => UserSettingsStore.setFeatureEnabled(feature.id, e.target.checked)} />
<label htmlFor={feature.id}>{feature.name}</label>
</div>
));
return (
<div>
<h3>Labs</h3>
<div className="mx_UserSettings_section">
<p>These are experimental features that may break in unexpected ways. Use with caution.</p>
{features}
</div>
</div>
)
};
return (
<div className="mx_UserSettings">
<SimpleRoomHeader title="Settings" onCancelClick={ this.props.onClose }/>
@ -390,6 +452,10 @@ module.exports = React.createClass({
{notification_area}
{this._renderDeviceInfo()}
{this._renderLabs()}
<h3>Advanced</h3>
<div className="mx_UserSettings_section">
@ -403,7 +469,7 @@ module.exports = React.createClass({
Identity Server is { MatrixClientPeg.get().getIdentityServerUrl() }
</div>
<div className="mx_UserSettings_advanced">
matrix-react-sdk version: {this.state.clientVersion}<br/>
matrix-react-sdk version: {REACT_SDK_VERSION}<br/>
vector-web version: {this.props.version}<br/>
</div>
</div>

View file

@ -17,8 +17,8 @@ limitations under the License.
'use strict';
var React = require('react');
var Velocity = require('velocity-animate');
require('velocity-ui-pack');
var Velocity = require('velocity-vector');
require('velocity-vector/velocity.ui');
var sdk = require('../../../index');
var Email = require('../../../email');
var Modal = require("../../../Modal");

View file

@ -128,16 +128,24 @@ module.exports = React.createClass({
},
getInitialState: function() {
return {menu: false, allReadAvatars: false};
return {menu: false, allReadAvatars: false, verified: null};
},
componentWillMount: function() {
// don't do RR animations until we are mounted
this._suppressReadReceiptAnimation = true;
this._verifyEvent(this.props.mxEvent);
},
componentDidMount: function() {
this._suppressReadReceiptAnimation = false;
MatrixClientPeg.get().on("deviceVerified", this.onDeviceVerified);
},
componentWillReceiveProps: function (nextProps) {
if (nextProps.mxEvent !== this.props.mxEvent) {
this._verifyEvent(nextProps.mxEvent);
}
},
shouldComponentUpdate: function (nextProps, nextState) {
@ -152,6 +160,31 @@ module.exports = React.createClass({
return false;
},
componentWillUnmount: function() {
var client = MatrixClientPeg.get();
if (client) {
client.removeListener("deviceVerified", this.onDeviceVerified);
}
},
onDeviceVerified: function(userId, device) {
if (userId == this.props.mxEvent.getSender()) {
this._verifyEvent(this.props.mxEvent);
}
},
_verifyEvent: function(mxEvent) {
var verified = null;
if (mxEvent.isEncrypted()) {
verified = MatrixClientPeg.get().isEventSenderVerified(mxEvent);
}
this.setState({
verified: verified
});
},
_propsEqual: function(objA, objB) {
var keysA = Object.keys(objA);
var keysB = Object.keys(objB);
@ -346,6 +379,8 @@ module.exports = React.createClass({
mx_EventTile_last: this.props.last,
mx_EventTile_contextual: this.props.contextual,
menu: this.state.menu,
mx_EventTile_verified: this.state.verified == true,
mx_EventTile_unverified: this.state.verified == false,
});
var timestamp = <a href={ "#/room/" + this.props.mxEvent.getRoomId() +"/"+ this.props.mxEvent.getId() }>
<MessageTimestamp ts={this.props.mxEvent.getTs()} />

View file

@ -0,0 +1,68 @@
/*
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 React = require('react');
var MatrixClientPeg = require("../../../MatrixClientPeg");
module.exports = React.createClass({
displayName: 'MemberDeviceInfo',
propTypes: {
userId: React.PropTypes.string.isRequired,
device: React.PropTypes.object.isRequired,
},
onVerifyClick: function() {
MatrixClientPeg.get().setDeviceVerified(
this.props.userId, this.props.device.id, true
);
},
onUnverifyClick: function() {
MatrixClientPeg.get().setDeviceVerified(
this.props.userId, this.props.device.id, false
);
},
render: function() {
var indicator = null, button = null;
if (this.props.device.verified) {
indicator = (
<div className="mx_MemberDeviceInfo_verified">&#x2714;</div>
);
button = (
<div className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_unverify"
onClick={this.onUnverifyClick}>
Unverify
</div>
);
} else {
button = (
<div className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_verify"
onClick={this.onVerifyClick}>
Verify
</div>
);
}
return (
<div className="mx_MemberDeviceInfo">
<div className="mx_MemberDeviceInfo_deviceId">{this.props.device.id}</div>
<div className="mx_MemberDeviceInfo_deviceKey">{this.props.device.key}</div>
{indicator}
{button}
</div>
);
},
});

View file

@ -30,27 +30,107 @@ var MatrixClientPeg = require("../../../MatrixClientPeg");
var dis = require("../../../dispatcher");
var Modal = require("../../../Modal");
var sdk = require('../../../index');
var UserSettingsStore = require('../../../UserSettingsStore');
var createRoom = require('../../../createRoom');
module.exports = React.createClass({
displayName: 'MemberInfo',
propTypes: {
member: React.PropTypes.object.isRequired,
onFinished: React.PropTypes.func,
},
getDefaultProps: function() {
return {
onFinished: function() {}
};
},
componentDidMount: function() {
// work out the current state
if (this.props.member) {
var memberState = this._calculateOpsPermissions(this.props.member);
this.setState(memberState);
getInitialState: function() {
return {
can: {
kick: false,
ban: false,
mute: false,
modifyLevel: false
},
muted: false,
isTargetMod: false,
updating: 0,
devicesLoading: true,
devices: null,
}
},
componentWillMount: function() {
this._cancelDeviceList = null;
},
componentDidMount: function() {
this._updateStateForNewMember(this.props.member);
MatrixClientPeg.get().on("deviceVerified", this.onDeviceVerified);
},
componentWillReceiveProps: function(newProps) {
var memberState = this._calculateOpsPermissions(newProps.member);
this.setState(memberState);
if (this.props.member.userId != newProps.member.userId) {
this._updateStateForNewMember(newProps.member);
}
},
componentWillUnmount: function() {
var client = MatrixClientPeg.get();
if (client) {
client.removeListener("deviceVerified", this.onDeviceVerified);
}
if (this._cancelDeviceList) {
this._cancelDeviceList();
}
},
onDeviceVerified: function(userId, device) {
if (userId == this.props.member.userId) {
// no need to re-download the whole thing; just update our copy of
// the list.
var devices = MatrixClientPeg.get().listDeviceKeys(userId);
this.setState({devices: devices});
}
},
_updateStateForNewMember: function(member) {
var newState = this._calculateOpsPermissions(member);
newState.devicesLoading = true;
newState.devices = null;
this.setState(newState);
if (this._cancelDeviceList) {
this._cancelDeviceList();
this._cancelDeviceList = null;
}
this._downloadDeviceList(member);
},
_downloadDeviceList: function(member) {
var cancelled = false;
this._cancelDeviceList = function() { cancelled = true; }
var client = MatrixClientPeg.get();
var self = this;
client.downloadKeys([member.userId], true).finally(function() {
self._cancelDeviceList = null;
}).done(function() {
if (cancelled) {
// we got cancelled - presumably a different user now
return;
}
var devices = client.listDeviceKeys(member.userId);
self.setState({devicesLoading: false, devices: devices});
}, function(err) {
console.log("Error downloading devices", err);
self.setState({devicesLoading: false});
});
},
onKick: function() {
@ -315,51 +395,15 @@ module.exports = React.createClass({
this.props.onFinished();
}
else {
if (MatrixClientPeg.get().isGuest()) {
var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog");
Modal.createDialog(NeedToRegisterDialog, {
title: "Please Register",
description: "Guest users can't create new rooms. Please register to create room and start a chat."
});
self.props.onFinished();
return;
}
self.setState({ updating: self.state.updating + 1 });
MatrixClientPeg.get().createRoom({
// XXX: FIXME: deduplicate this with "view_create_room" in MatrixChat
invite: [this.props.member.userId],
preset: "private_chat",
// Allow guests by default since the room is private and they'd
// need an invite. This means clicking on a 3pid invite email can
// actually drop you right in to a chat.
initial_state: [
{
content: {
guest_access: 'can_join'
},
type: 'm.room.guest_access',
state_key: '',
visibility: 'private',
}
],
}).then(
function(res) {
dis.dispatch({
action: 'view_room',
room_id: res.room_id
});
self.props.onFinished();
}, function(err) {
Modal.createDialog(ErrorDialog, {
title: "Failure to start chat",
description: err.message
});
self.props.onFinished();
}
).finally(()=>{
createRoom({
createOpts: {
invite: [this.props.member.userId],
},
}).finally(function() {
self.props.onFinished();
self.setState({ updating: self.state.updating - 1 });
});
}).done();
}
},
@ -371,20 +415,6 @@ module.exports = React.createClass({
this.props.onFinished();
},
getInitialState: function() {
return {
can: {
kick: false,
ban: false,
mute: false,
modifyLevel: false
},
muted: false,
isTargetMod: false,
updating: 0,
}
},
_calculateOpsPermissions: function(member) {
var defaultPerms = {
can: {},
@ -476,6 +506,40 @@ module.exports = React.createClass({
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
},
_renderDevices: function() {
if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) {
return null;
}
var devices = this.state.devices;
var MemberDeviceInfo = sdk.getComponent('rooms.MemberDeviceInfo');
var Spinner = sdk.getComponent("elements.Spinner");
var devComponents;
if (this.state.devicesLoading) {
// still loading
devComponents = <Spinner />;
} else if (devices === null) {
devComponents = "Unable to load device list";
} else if (devices.length === 0) {
devComponents = "No registered devices";
} else {
devComponents = [];
for (var i = 0; i < devices.length; i++) {
devComponents.push(<MemberDeviceInfo key={i}
userId={this.props.member.userId}
device={devices[i]}/>);
}
}
return (
<div>
<h3>Devices</h3>
{devComponents}
</div>
);
},
render: function() {
var startChat, kickButton, banButton, muteButton, giveModButton, spinner;
if (this.props.member.userId !== MatrixClientPeg.get().credentials.userId) {
@ -552,6 +616,8 @@ module.exports = React.createClass({
{ startChat }
{ this._renderDevices() }
{ adminTools }
{ spinner }
@ -559,4 +625,3 @@ module.exports = React.createClass({
);
}
});

View file

@ -21,6 +21,8 @@ var Modal = require('../../../Modal');
var sdk = require('../../../index');
var dis = require('../../../dispatcher');
import UserSettingsStore from '../../../UserSettingsStore';
module.exports = React.createClass({
displayName: 'MessageComposer',
@ -131,7 +133,8 @@ module.exports = React.createClass({
var uploadInputStyle = {display: 'none'};
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var TintableSvg = sdk.getComponent("elements.TintableSvg");
var MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput");
var MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput" +
(UserSettingsStore.isFeatureEnabled('rich_text_editor') ? "" : "Old"));
var controls = [];

View file

@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require("react");
import React from 'react';
var marked = require("marked");
marked.setOptions({
@ -27,6 +27,12 @@ marked.setOptions({
smartypants: false
});
import {Editor, EditorState, RichUtils, CompositeDecorator,
convertFromRaw, convertToRaw, Modifier, EditorChangeType,
getDefaultKeyBinding, KeyBindingUtil, ContentState} from 'draft-js';
import {stateToMarkdown} from 'draft-js-export-markdown';
var MatrixClientPeg = require("../../../MatrixClientPeg");
var SlashCommands = require("../../../SlashCommands");
var Modal = require("../../../Modal");
@ -36,10 +42,13 @@ var sdk = require('../../../index');
var dis = require("../../../dispatcher");
var KeyCode = require("../../../KeyCode");
var TYPING_USER_TIMEOUT = 10000;
var TYPING_SERVER_TIMEOUT = 30000;
var MARKDOWN_ENABLED = true;
import * as RichText from '../../../RichText';
const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
const KEY_M = 77;
// FIXME Breaks markdown with multiple paragraphs, since it only strips first and last <p>
function mdownToHtml(mdown) {
var html = marked(mdown) || "";
html = html.trim();
@ -56,29 +65,63 @@ function mdownToHtml(mdown) {
/*
* The textInput part of the MessageComposer
*/
module.exports = React.createClass({
displayName: 'MessageComposerInput',
export default class MessageComposerInput extends React.Component {
constructor(props, context) {
super(props, context);
this.onAction = this.onAction.bind(this);
this.onInputClick = this.onInputClick.bind(this);
this.handleReturn = this.handleReturn.bind(this);
this.handleKeyCommand = this.handleKeyCommand.bind(this);
this.onChange = this.onChange.bind(this);
statics: {
// the height we limit the composer to
MAX_HEIGHT: 100,
},
let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled');
if(isRichtextEnabled == null) {
isRichtextEnabled = 'true';
}
isRichtextEnabled = isRichtextEnabled === 'true';
propTypes: {
tabComplete: React.PropTypes.any,
this.state = {
isRichtextEnabled: isRichtextEnabled,
editorState: null
};
// a callback which is called when the height of the composer is
// changed due to a change in content.
onResize: React.PropTypes.func,
// bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled
this.state.editorState = this.createEditorState();
// js-sdk Room object
room: React.PropTypes.object.isRequired,
},
this.client = MatrixClientPeg.get();
}
componentWillMount: function() {
this.oldScrollHeight = 0;
this.markdownEnabled = MARKDOWN_ENABLED;
var self = this;
static getKeyBinding(e: SyntheticKeyboardEvent): string {
// C-m => Toggles between rich text and markdown modes
if(e.keyCode == KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) {
return 'toggle-mode';
}
return getDefaultKeyBinding(e);
}
/**
* "Does the right thing" to create an EditorState, based on:
* - whether we've got rich text mode enabled
* - contentState was passed in
*/
createEditorState(richText: boolean, contentState: ?ContentState): EditorState {
let decorators = richText ? RichText.getScopedRTDecorators(this.props) :
RichText.getScopedMDDecorators(this.props),
compositeDecorator = new CompositeDecorator(decorators);
let editorState = null;
if (contentState) {
editorState = EditorState.createWithContent(contentState, compositeDecorator);
} else {
editorState = EditorState.createEmpty(compositeDecorator);
}
return EditorState.moveFocusToEnd(editorState);
}
componentWillMount() {
const component = this;
this.sentHistory = {
// The list of typed messages. Index 0 is more recent
data: [],
@ -96,7 +139,7 @@ module.exports = React.createClass({
this.element = element;
this.position = -1;
var storedData = window.sessionStorage.getItem(
"history_" + roomId
"mx_messagecomposer_history_" + roomId
);
if (storedData) {
this.data = JSON.parse(storedData);
@ -110,7 +153,7 @@ module.exports = React.createClass({
// store a message in the sent history
this.data.unshift(text);
window.sessionStorage.setItem(
"history_" + this.roomId,
"mx_messagecomposer_history_" + this.roomId,
JSON.stringify(this.data)
);
// reset history position
@ -149,7 +192,6 @@ module.exports = React.createClass({
this.element.value = this.originalText;
}
self.resizeInput();
return true;
},
@ -157,76 +199,68 @@ module.exports = React.createClass({
// save the currently entered text in order to restore it later.
// NB: This isn't 'originalText' because we want to restore
// sent history items too!
var text = this.element.value;
window.sessionStorage.setItem("input_" + this.roomId, text);
let contentJSON = JSON.stringify(convertToRaw(component.state.editorState.getCurrentContent()));
window.sessionStorage.setItem("mx_messagecomposer_input_" + this.roomId, contentJSON);
},
setLastTextEntry: function() {
var text = window.sessionStorage.getItem("input_" + this.roomId);
if (text) {
this.element.value = text;
self.resizeInput();
let contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId);
if (contentJSON) {
let content = convertFromRaw(JSON.parse(contentJSON));
component.setState({
editorState: component.createEditorState(component.state.isRichtextEnabled, content)
});
}
}
};
},
}
componentDidMount: function() {
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
this.sentHistory.init(
this.refs.textarea,
this.refs.editor,
this.props.room.roomId
);
this.resizeInput();
if (this.props.tabComplete) {
this.props.tabComplete.setTextArea(this.refs.textarea);
}
},
// this is disabled for now, since https://github.com/matrix-org/matrix-react-sdk/pull/296 will land soon
// if (this.props.tabComplete) {
// this.props.tabComplete.setEditor(this.refs.editor);
// }
}
componentWillUnmount: function() {
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
this.sentHistory.saveLastTextEntry();
},
}
onAction(payload) {
var editor = this.refs.editor;
onAction: function(payload) {
var textarea = this.refs.textarea;
switch (payload.action) {
case 'focus_composer':
textarea.focus();
editor.focus();
break;
case 'insert_displayname':
if (textarea.value.length) {
var left = textarea.value.substring(0, textarea.selectionStart);
var right = textarea.value.substring(textarea.selectionEnd);
if (right.length) {
left += payload.displayname;
}
else {
left = left.replace(/( ?)$/, " " + payload.displayname);
}
textarea.value = left + right;
textarea.focus();
textarea.setSelectionRange(left.length, left.length);
}
else {
textarea.value = payload.displayname + ": ";
textarea.focus();
}
break;
}
},
onKeyDown: function (ev) {
if (ev.keyCode === KeyCode.ENTER && !ev.shiftKey) {
var input = this.refs.textarea.value;
if (input.length === 0) {
ev.preventDefault();
return;
}
this.sentHistory.push(input);
this.onEnter(ev);
// TODO change this so we insert a complete user alias
case 'insert_displayname':
if (this.state.editorState.getCurrentContent().hasText()) {
console.log(payload);
let contentState = Modifier.replaceText(
this.state.editorState.getCurrentContent(),
this.state.editorState.getSelection(),
payload.displayname
);
this.setState({
editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters')
});
editor.focus();
}
break;
}
else if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) {
}
onKeyDown(ev) {
if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) {
var oldSelectionStart = this.refs.textarea.selectionStart;
// Remember the keyCode because React will recycle the synthetic event
var keyCode = ev.keyCode;
@ -235,78 +269,167 @@ module.exports = React.createClass({
setTimeout(() => {
if (this.refs.textarea.selectionStart == oldSelectionStart) {
this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1);
this.resizeInput();
}
}, 0);
}
}
if (this.props.tabComplete) {
this.props.tabComplete.onKeyDown(ev);
onTypingActivity() {
this.isTyping = true;
if (!this.userTypingTimer) {
this.sendTyping(true);
}
this.startUserTypingTimer();
this.startServerTypingTimer();
}
onFinishedTyping() {
this.isTyping = false;
this.sendTyping(false);
this.stopUserTypingTimer();
this.stopServerTypingTimer();
}
startUserTypingTimer() {
this.stopUserTypingTimer();
var self = this;
setTimeout(function() {
if (self.refs.textarea && self.refs.textarea.value != '') {
self.onTypingActivity();
} else {
self.onFinishedTyping();
}
}, 10); // XXX: what is this 10ms setTimeout doing? Looks hacky :(
},
this.userTypingTimer = setTimeout(function() {
self.isTyping = false;
self.sendTyping(self.isTyping);
self.userTypingTimer = null;
}, TYPING_USER_TIMEOUT);
}
resizeInput: function() {
// scrollHeight is at least equal to clientHeight, so we have to
// temporarily crimp clientHeight to 0 to get an accurate scrollHeight value
this.refs.textarea.style.height = "20px"; // 20 hardcoded from CSS
var newHeight = Math.min(this.refs.textarea.scrollHeight,
this.constructor.MAX_HEIGHT);
this.refs.textarea.style.height = Math.ceil(newHeight) + "px";
this.oldScrollHeight = this.refs.textarea.scrollHeight;
if (this.props.onResize) {
// kick gemini-scrollbar to re-layout
this.props.onResize();
stopUserTypingTimer() {
if (this.userTypingTimer) {
clearTimeout(this.userTypingTimer);
this.userTypingTimer = null;
}
},
}
onKeyUp: function(ev) {
if (this.refs.textarea.scrollHeight !== this.oldScrollHeight ||
ev.keyCode === KeyCode.DELETE ||
ev.keyCode === KeyCode.BACKSPACE)
{
this.resizeInput();
startServerTypingTimer() {
if (!this.serverTypingTimer) {
var self = this;
this.serverTypingTimer = setTimeout(function() {
if (self.isTyping) {
self.sendTyping(self.isTyping);
self.startServerTypingTimer();
}
}, TYPING_SERVER_TIMEOUT / 2);
}
},
}
onEnter: function(ev) {
var contentText = this.refs.textarea.value;
// bodge for now to set markdown state on/off. We probably want a separate
// area for "local" commands which don't hit out to the server.
if (contentText.indexOf("/markdown") === 0) {
ev.preventDefault();
this.refs.textarea.value = '';
if (contentText.indexOf("/markdown on") === 0) {
this.markdownEnabled = true;
}
else if (contentText.indexOf("/markdown off") === 0) {
this.markdownEnabled = false;
}
else {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Unknown command",
description: "Usage: /markdown on|off"
});
}
return;
stopServerTypingTimer() {
if (this.serverTypingTimer) {
clearTimeout(this.servrTypingTimer);
this.serverTypingTimer = null;
}
}
sendTyping(isTyping) {
MatrixClientPeg.get().sendTyping(
this.props.room.roomId,
this.isTyping, TYPING_SERVER_TIMEOUT
).done();
}
refreshTyping() {
if (this.typingTimeout) {
clearTimeout(this.typingTimeout);
this.typingTimeout = null;
}
}
onInputClick(ev) {
this.refs.editor.focus();
}
onChange(editorState: EditorState) {
this.setState({editorState});
if(editorState.getCurrentContent().hasText()) {
this.onTypingActivity()
} else {
this.onFinishedTyping();
}
}
enableRichtext(enabled: boolean) {
if (enabled) {
let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText());
this.setState({
editorState: this.createEditorState(enabled, RichText.HTMLtoContentState(html))
});
} else {
let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()),
contentState = ContentState.createFromText(markdown);
this.setState({
editorState: this.createEditorState(enabled, contentState)
});
}
window.localStorage.setItem('mx_editor_rte_enabled', enabled);
this.setState({
isRichtextEnabled: enabled
});
}
handleKeyCommand(command: string): boolean {
if(command === 'toggle-mode') {
this.enableRichtext(!this.state.isRichtextEnabled);
return true;
}
let newState: ?EditorState = null;
// Draft handles rich text mode commands by default but we need to do it ourselves for Markdown.
if(!this.state.isRichtextEnabled) {
let contentState = this.state.editorState.getCurrentContent(),
selection = this.state.editorState.getSelection();
let modifyFn = {
bold: text => `**${text}**`,
italic: text => `*${text}*`,
underline: text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug*
code: text => `\`${text}\``
}[command];
if(modifyFn) {
newState = EditorState.push(
this.state.editorState,
RichText.modifyText(contentState, selection, modifyFn),
'insert-characters'
);
}
}
if(newState == null)
newState = RichUtils.handleKeyCommand(this.state.editorState, command);
if (newState != null) {
this.onChange(newState);
return true;
}
return false;
}
handleReturn(ev) {
if(ev.shiftKey)
return false;
const contentState = this.state.editorState.getCurrentContent();
if(!contentState.hasText())
return true;
let contentText = contentState.getPlainText(), contentHTML;
var cmd = SlashCommands.processInput(this.props.room.roomId, contentText);
if (cmd) {
ev.preventDefault();
if (!cmd.error) {
this.refs.textarea.value = '';
this.setState({
editorState: this.createEditorState()
});
}
if (cmd.promise) {
cmd.promise.done(function() {
@ -328,121 +451,75 @@ module.exports = React.createClass({
description: cmd.error
});
}
return;
return true;
}
var isEmote = /^\/me( |$)/i.test(contentText);
var sendMessagePromise;
if (isEmote) {
contentText = contentText.substring(4);
}
else if (contentText[0] === '/') {
contentText = contentText.substring(1);
if(this.state.isRichtextEnabled) {
contentHTML = RichText.contentStateToHTML(contentState);
} else {
contentHTML = mdownToHtml(contentText);
}
var htmlText;
if (this.markdownEnabled && (htmlText = mdownToHtml(contentText)) !== contentText) {
sendMessagePromise = isEmote ?
MatrixClientPeg.get().sendHtmlEmote(this.props.room.roomId, contentText, htmlText) :
MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText);
}
else {
sendMessagePromise = isEmote ?
MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) :
MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText);
let sendFn = this.client.sendHtmlMessage;
if (contentText.startsWith('/me')) {
contentText = contentText.replace('/me', '');
// bit of a hack, but the alternative would be quite complicated
contentHTML = contentHTML.replace('/me', '');
sendFn = this.client.sendHtmlEmote;
}
sendMessagePromise.done(function() {
this.sentHistory.push(contentHTML);
let sendMessagePromise = sendFn.call(this.client, this.props.room.roomId, contentText, contentHTML);
sendMessagePromise.done(() => {
dis.dispatch({
action: 'message_sent'
});
}, function() {
}, () => {
dis.dispatch({
action: 'message_send_failed'
});
});
this.refs.textarea.value = '';
this.resizeInput();
ev.preventDefault();
},
onTypingActivity: function() {
this.isTyping = true;
if (!this.userTypingTimer) {
this.sendTyping(true);
this.setState({
editorState: this.createEditorState()
});
return true;
}
render() {
let className = "mx_MessageComposer_input";
if(this.state.isRichtextEnabled) {
className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode
}
this.startUserTypingTimer();
this.startServerTypingTimer();
},
onFinishedTyping: function() {
this.isTyping = false;
this.sendTyping(false);
this.stopUserTypingTimer();
this.stopServerTypingTimer();
},
startUserTypingTimer: function() {
this.stopUserTypingTimer();
var self = this;
this.userTypingTimer = setTimeout(function() {
self.isTyping = false;
self.sendTyping(self.isTyping);
self.userTypingTimer = null;
}, TYPING_USER_TIMEOUT);
},
stopUserTypingTimer: function() {
if (this.userTypingTimer) {
clearTimeout(this.userTypingTimer);
this.userTypingTimer = null;
}
},
startServerTypingTimer: function() {
if (!this.serverTypingTimer) {
var self = this;
this.serverTypingTimer = setTimeout(function() {
if (self.isTyping) {
self.sendTyping(self.isTyping);
self.startServerTypingTimer();
}
}, TYPING_SERVER_TIMEOUT / 2);
}
},
stopServerTypingTimer: function() {
if (this.serverTypingTimer) {
clearTimeout(this.servrTypingTimer);
this.serverTypingTimer = null;
}
},
sendTyping: function(isTyping) {
MatrixClientPeg.get().sendTyping(
this.props.room.roomId,
this.isTyping, TYPING_SERVER_TIMEOUT
).done();
},
refreshTyping: function() {
if (this.typingTimeout) {
clearTimeout(this.typingTimeout);
this.typingTimeout = null;
}
},
onInputClick: function(ev) {
this.refs.textarea.focus();
},
render: function() {
return (
<div className="mx_MessageComposer_input" onClick={ this.onInputClick }>
<textarea autoFocus ref="textarea" rows="1" onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} placeholder="Type a message..." />
<div className={className}
onClick={ this.onInputClick }>
<Editor ref="editor"
placeholder="Type a message…"
editorState={this.state.editorState}
onChange={this.onChange}
keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn}
stripPastedStyles={!this.state.isRichtextEnabled}
spellCheck={true} />
</div>
);
}
});
};
MessageComposerInput.propTypes = {
tabComplete: React.PropTypes.any,
// a callback which is called when the height of the composer is
// changed due to a change in content.
onResize: React.PropTypes.func,
// js-sdk Room object
room: React.PropTypes.object.isRequired
};

View file

@ -0,0 +1,447 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require("react");
var marked = require("marked");
marked.setOptions({
renderer: new marked.Renderer(),
gfm: true,
tables: true,
breaks: true,
pedantic: false,
sanitize: true,
smartLists: true,
smartypants: false
});
var MatrixClientPeg = require("../../../MatrixClientPeg");
var SlashCommands = require("../../../SlashCommands");
var Modal = require("../../../Modal");
var MemberEntry = require("../../../TabCompleteEntries").MemberEntry;
var sdk = require('../../../index');
var dis = require("../../../dispatcher");
var KeyCode = require("../../../KeyCode");
var TYPING_USER_TIMEOUT = 10000;
var TYPING_SERVER_TIMEOUT = 30000;
var MARKDOWN_ENABLED = true;
function mdownToHtml(mdown) {
var html = marked(mdown) || "";
html = html.trim();
// strip start and end <p> tags else you get 'orrible spacing
if (html.indexOf("<p>") === 0) {
html = html.substring("<p>".length);
}
if (html.lastIndexOf("</p>") === (html.length - "</p>".length)) {
html = html.substring(0, html.length - "</p>".length);
}
return html;
}
/*
* The textInput part of the MessageComposer
*/
module.exports = React.createClass({
displayName: 'MessageComposerInput',
statics: {
// the height we limit the composer to
MAX_HEIGHT: 100,
},
propTypes: {
tabComplete: React.PropTypes.any,
// a callback which is called when the height of the composer is
// changed due to a change in content.
onResize: React.PropTypes.func,
// js-sdk Room object
room: React.PropTypes.object.isRequired,
},
componentWillMount: function() {
this.oldScrollHeight = 0;
this.markdownEnabled = MARKDOWN_ENABLED;
var self = this;
this.sentHistory = {
// The list of typed messages. Index 0 is more recent
data: [],
// The position in data currently displayed
position: -1,
// The room the history is for.
roomId: null,
// The original text before they hit UP
originalText: null,
// The textarea element to set text to.
element: null,
init: function(element, roomId) {
this.roomId = roomId;
this.element = element;
this.position = -1;
var storedData = window.sessionStorage.getItem(
"history_" + roomId
);
if (storedData) {
this.data = JSON.parse(storedData);
}
if (this.roomId) {
this.setLastTextEntry();
}
},
push: function(text) {
// store a message in the sent history
this.data.unshift(text);
window.sessionStorage.setItem(
"history_" + this.roomId,
JSON.stringify(this.data)
);
// reset history position
this.position = -1;
this.originalText = null;
},
// move in the history. Returns true if we managed to move.
next: function(offset) {
if (this.position === -1) {
// user is going into the history, save the current line.
this.originalText = this.element.value;
}
else {
// user may have modified this line in the history; remember it.
this.data[this.position] = this.element.value;
}
if (offset > 0 && this.position === (this.data.length - 1)) {
// we've run out of history
return false;
}
// retrieve the next item (bounded).
var newPosition = this.position + offset;
newPosition = Math.max(-1, newPosition);
newPosition = Math.min(newPosition, this.data.length - 1);
this.position = newPosition;
if (this.position !== -1) {
// show the message
this.element.value = this.data[this.position];
}
else if (this.originalText !== undefined) {
// restore the original text the user was typing.
this.element.value = this.originalText;
}
self.resizeInput();
return true;
},
saveLastTextEntry: function() {
// save the currently entered text in order to restore it later.
// NB: This isn't 'originalText' because we want to restore
// sent history items too!
var text = this.element.value;
window.sessionStorage.setItem("input_" + this.roomId, text);
},
setLastTextEntry: function() {
var text = window.sessionStorage.getItem("input_" + this.roomId);
if (text) {
this.element.value = text;
self.resizeInput();
}
}
};
},
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
this.sentHistory.init(
this.refs.textarea,
this.props.room.roomId
);
this.resizeInput();
if (this.props.tabComplete) {
this.props.tabComplete.setTextArea(this.refs.textarea);
}
},
componentWillUnmount: function() {
dis.unregister(this.dispatcherRef);
this.sentHistory.saveLastTextEntry();
},
onAction: function(payload) {
var textarea = this.refs.textarea;
switch (payload.action) {
case 'focus_composer':
textarea.focus();
break;
case 'insert_displayname':
if (textarea.value.length) {
var left = textarea.value.substring(0, textarea.selectionStart);
var right = textarea.value.substring(textarea.selectionEnd);
if (right.length) {
left += payload.displayname;
}
else {
left = left.replace(/( ?)$/, " " + payload.displayname);
}
textarea.value = left + right;
textarea.focus();
textarea.setSelectionRange(left.length, left.length);
}
else {
textarea.value = payload.displayname + ": ";
textarea.focus();
}
break;
}
},
onKeyDown: function (ev) {
if (ev.keyCode === KeyCode.ENTER && !ev.shiftKey) {
var input = this.refs.textarea.value;
if (input.length === 0) {
ev.preventDefault();
return;
}
this.sentHistory.push(input);
this.onEnter(ev);
}
else if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) {
var oldSelectionStart = this.refs.textarea.selectionStart;
// Remember the keyCode because React will recycle the synthetic event
var keyCode = ev.keyCode;
// set a callback so we can see if the cursor position changes as
// a result of this event. If it doesn't, we cycle history.
setTimeout(() => {
if (this.refs.textarea.selectionStart == oldSelectionStart) {
this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1);
this.resizeInput();
}
}, 0);
}
if (this.props.tabComplete) {
this.props.tabComplete.onKeyDown(ev);
}
var self = this;
setTimeout(function() {
if (self.refs.textarea && self.refs.textarea.value != '') {
self.onTypingActivity();
} else {
self.onFinishedTyping();
}
}, 10); // XXX: what is this 10ms setTimeout doing? Looks hacky :(
},
resizeInput: function() {
// scrollHeight is at least equal to clientHeight, so we have to
// temporarily crimp clientHeight to 0 to get an accurate scrollHeight value
this.refs.textarea.style.height = "20px"; // 20 hardcoded from CSS
var newHeight = Math.min(this.refs.textarea.scrollHeight,
this.constructor.MAX_HEIGHT);
this.refs.textarea.style.height = Math.ceil(newHeight) + "px";
this.oldScrollHeight = this.refs.textarea.scrollHeight;
if (this.props.onResize) {
// kick gemini-scrollbar to re-layout
this.props.onResize();
}
},
onKeyUp: function(ev) {
if (this.refs.textarea.scrollHeight !== this.oldScrollHeight ||
ev.keyCode === KeyCode.DELETE ||
ev.keyCode === KeyCode.BACKSPACE)
{
this.resizeInput();
}
},
onEnter: function(ev) {
var contentText = this.refs.textarea.value;
// bodge for now to set markdown state on/off. We probably want a separate
// area for "local" commands which don't hit out to the server.
if (contentText.indexOf("/markdown") === 0) {
ev.preventDefault();
this.refs.textarea.value = '';
if (contentText.indexOf("/markdown on") === 0) {
this.markdownEnabled = true;
}
else if (contentText.indexOf("/markdown off") === 0) {
this.markdownEnabled = false;
}
else {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Unknown command",
description: "Usage: /markdown on|off"
});
}
return;
}
var cmd = SlashCommands.processInput(this.props.room.roomId, contentText);
if (cmd) {
ev.preventDefault();
if (!cmd.error) {
this.refs.textarea.value = '';
}
if (cmd.promise) {
cmd.promise.done(function() {
console.log("Command success.");
}, function(err) {
console.error("Command failure: %s", err);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Server error",
description: err.message
});
});
}
else if (cmd.error) {
console.error(cmd.error);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Command error",
description: cmd.error
});
}
return;
}
var isEmote = /^\/me( |$)/i.test(contentText);
var sendMessagePromise;
if (isEmote) {
contentText = contentText.substring(4);
}
else if (contentText[0] === '/') {
contentText = contentText.substring(1);
}
var htmlText;
if (this.markdownEnabled && (htmlText = mdownToHtml(contentText)) !== contentText) {
sendMessagePromise = isEmote ?
MatrixClientPeg.get().sendHtmlEmote(this.props.room.roomId, contentText, htmlText) :
MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText);
}
else {
sendMessagePromise = isEmote ?
MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) :
MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText);
}
sendMessagePromise.done(function() {
dis.dispatch({
action: 'message_sent'
});
}, function() {
dis.dispatch({
action: 'message_send_failed'
});
});
this.refs.textarea.value = '';
this.resizeInput();
ev.preventDefault();
},
onTypingActivity: function() {
this.isTyping = true;
if (!this.userTypingTimer) {
this.sendTyping(true);
}
this.startUserTypingTimer();
this.startServerTypingTimer();
},
onFinishedTyping: function() {
this.isTyping = false;
this.sendTyping(false);
this.stopUserTypingTimer();
this.stopServerTypingTimer();
},
startUserTypingTimer: function() {
this.stopUserTypingTimer();
var self = this;
this.userTypingTimer = setTimeout(function() {
self.isTyping = false;
self.sendTyping(self.isTyping);
self.userTypingTimer = null;
}, TYPING_USER_TIMEOUT);
},
stopUserTypingTimer: function() {
if (this.userTypingTimer) {
clearTimeout(this.userTypingTimer);
this.userTypingTimer = null;
}
},
startServerTypingTimer: function() {
if (!this.serverTypingTimer) {
var self = this;
this.serverTypingTimer = setTimeout(function() {
if (self.isTyping) {
self.sendTyping(self.isTyping);
self.startServerTypingTimer();
}
}, TYPING_SERVER_TIMEOUT / 2);
}
},
stopServerTypingTimer: function() {
if (this.serverTypingTimer) {
clearTimeout(this.servrTypingTimer);
this.serverTypingTimer = null;
}
},
sendTyping: function(isTyping) {
MatrixClientPeg.get().sendTyping(
this.props.room.roomId,
this.isTyping, TYPING_SERVER_TIMEOUT
).done();
},
refreshTyping: function() {
if (this.typingTimeout) {
clearTimeout(this.typingTimeout);
this.typingTimeout = null;
}
},
onInputClick: function(ev) {
this.refs.textarea.focus();
},
render: function() {
return (
<div className="mx_MessageComposer_input" onClick={ this.onInputClick }>
<textarea autoFocus ref="textarea" rows="1" onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} placeholder="Type a message..." />
</div>
);
}
});