Merge branch 'develop' into rte-fixes

Conflicts:
	src/UserSettingsStore.js
	src/autocomplete/EmojiProvider.js
	src/components/views/rooms/MessageComposerInput.js
This commit is contained in:
Luke Barnard 2017-05-08 17:08:59 +01:00
commit fe121126f5
88 changed files with 5170 additions and 1126 deletions

View file

@ -140,13 +140,20 @@ export default React.createClass({
});
},
_requestCallback: function(auth) {
_requestCallback: function(auth, background) {
const makeRequestPromise = this.props.makeRequest(auth);
// if it's a background request, just do it: we don't want
// it to affect the state of our UI.
if (background) return makeRequestPromise;
// otherwise, manage the state of the spinner and error messages
this.setState({
busy: true,
errorText: null,
stageErrorText: null,
});
return this.props.makeRequest(auth).finally(() => {
return makeRequestPromise.finally(() => {
if (this._unmounted) {
return;
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -81,6 +82,13 @@ export default React.createClass({
return this._scrollStateMap[roomId];
},
canResetTimelineInRoom: function(roomId) {
if (!this.refs.roomView) {
return true;
}
return this.refs.roomView.canResetTimeline();
},
_onKeyDown: function(ev) {
/*
// Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers
@ -99,9 +107,21 @@ export default React.createClass({
var handled = false;
switch (ev.keyCode) {
case KeyCode.ESCAPE:
// Implemented this way so possible handling for other pages is neater
switch (this.props.page_type) {
case PageTypes.UserSettings:
this.props.onUserSettingsClose();
handled = true;
break;
}
break;
case KeyCode.UP:
case KeyCode.DOWN:
if (ev.altKey) {
if (ev.altKey && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
var action = ev.keyCode == KeyCode.UP ?
'view_prev_room' : 'view_next_room';
dis.dispatch({action: action});
@ -111,13 +131,15 @@ export default React.createClass({
case KeyCode.PAGE_UP:
case KeyCode.PAGE_DOWN:
this._onScrollKeyPressed(ev);
handled = true;
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this._onScrollKeyPressed(ev);
handled = true;
}
break;
case KeyCode.HOME:
case KeyCode.END:
if (ev.ctrlKey) {
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this._onScrollKeyPressed(ev);
handled = true;
}
@ -135,22 +157,25 @@ export default React.createClass({
if (this.refs.roomView) {
this.refs.roomView.handleScrollKey(ev);
}
else if (this.refs.roomDirectory) {
this.refs.roomDirectory.handleScrollKey(ev);
}
},
render: function() {
var LeftPanel = sdk.getComponent('structures.LeftPanel');
var RightPanel = sdk.getComponent('structures.RightPanel');
var RoomView = sdk.getComponent('structures.RoomView');
var UserSettings = sdk.getComponent('structures.UserSettings');
var CreateRoom = sdk.getComponent('structures.CreateRoom');
var RoomDirectory = sdk.getComponent('structures.RoomDirectory');
var HomePage = sdk.getComponent('structures.HomePage');
var MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
var GuestWarningBar = sdk.getComponent('globals.GuestWarningBar');
var NewVersionBar = sdk.getComponent('globals.NewVersionBar');
const LeftPanel = sdk.getComponent('structures.LeftPanel');
const RightPanel = sdk.getComponent('structures.RightPanel');
const RoomView = sdk.getComponent('structures.RoomView');
const UserSettings = sdk.getComponent('structures.UserSettings');
const CreateRoom = sdk.getComponent('structures.CreateRoom');
const RoomDirectory = sdk.getComponent('structures.RoomDirectory');
const HomePage = sdk.getComponent('structures.HomePage');
const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
const GuestWarningBar = sdk.getComponent('globals.GuestWarningBar');
const NewVersionBar = sdk.getComponent('globals.NewVersionBar');
var page_element;
var right_panel = '';
let page_element;
let right_panel = '';
switch (this.props.page_type) {
case PageTypes.RoomView:
@ -195,10 +220,9 @@ export default React.createClass({
case PageTypes.RoomDirectory:
page_element = <RoomDirectory
collapsedRhs={this.props.collapse_rhs}
ref="roomDirectory"
config={this.props.config.roomDirectory}
/>;
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>;
break;
case PageTypes.HomePage:

View file

@ -29,10 +29,6 @@ var UserActivity = require("../../UserActivity");
var Presence = require("../../Presence");
var dis = require("../../dispatcher");
var Login = require("./login/Login");
var Registration = require("./login/Registration");
var PostRegistration = require("./login/PostRegistration");
var Modal = require("../../Modal");
var Tinter = require("../../Tinter");
var sdk = require('../../index');
@ -63,6 +59,13 @@ module.exports = React.createClass({
// called when the session load completes
onLoadCompleted: React.PropTypes.func,
// Represents the screen to display as a result of parsing the initial
// window.location
initialScreenAfterLogin: React.PropTypes.shape({
screen: React.PropTypes.string.isRequired,
params: React.PropTypes.object,
}),
// displayname, if any, to set on the device when logging
// in/registering.
defaultDeviceDisplayName: React.PropTypes.string,
@ -89,6 +92,12 @@ module.exports = React.createClass({
var s = {
loading: true,
screen: undefined,
screenAfterLogin: this.props.initialScreenAfterLogin,
// Stashed guest credentials if the user logs out
// whilst logged in as a guest user (so they can change
// their mind & log back in)
guestCreds: null,
// What the LoggedInView would be showing if visible
page_type: null,
@ -104,7 +113,8 @@ module.exports = React.createClass({
// If we're trying to just view a user ID (i.e. /user URL), this is it
viewUserId: null,
logged_in: false,
loggedIn: false,
loggingIn: false,
collapse_lhs: false,
collapse_rhs: false,
ready: false,
@ -184,13 +194,9 @@ module.exports = React.createClass({
componentWillMount: function() {
SdkConfig.put(this.props.config);
// Stashed guest credentials if the user logs out
// whilst logged in as a guest user (so they can change
// their mind & log back in)
this.guestCreds = null;
// if the automatic session load failed, the error
this.sessionLoadError = null;
// Used by _viewRoom before getting state from sync
this.firstSyncComplete = false;
this.firstSyncPromise = q.defer();
if (this.props.config.sync_timeline_limit) {
MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit;
@ -280,7 +286,6 @@ module.exports = React.createClass({
});
}).catch((e) => {
console.error("Unable to load session", e);
this.sessionLoadError = e.message;
}).done(()=>{
// stuff this through the dispatcher so that it happens
// after the on_logged_in action.
@ -307,7 +312,7 @@ module.exports = React.createClass({
const newState = {
screen: undefined,
viewUserId: null,
logged_in: false,
loggedIn: false,
ready: false,
upgradeUsername: null,
guestAccessToken: null,
@ -317,14 +322,13 @@ module.exports = React.createClass({
},
onAction: function(payload) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
var roomIndexDelta = 1;
var self = this;
switch (payload.action) {
case 'logout':
if (MatrixClientPeg.get().isGuest()) {
this.guestCreds = MatrixClientPeg.getCredentials();
}
Lifecycle.logout();
break;
case 'start_registration':
@ -344,14 +348,20 @@ module.exports = React.createClass({
this.notifyNewScreen('register');
break;
case 'start_login':
if (this.state.logged_in) return;
if (MatrixClientPeg.get() &&
MatrixClientPeg.get().isGuest()
) {
this.setState({
guestCreds: MatrixClientPeg.getCredentials(),
});
}
this.setStateForNewScreen({
screen: 'login',
});
this.notifyNewScreen('login');
break;
case 'start_post_registration':
this.setState({ // don't clobber logged_in status
this.setState({ // don't clobber loggedIn status
screen: 'post_registration'
});
break;
@ -359,8 +369,8 @@ module.exports = React.createClass({
// also stash our credentials, then if we restore the session,
// we can just do it the same way whether we started upgrade
// registration or explicitly logged out
this.guestCreds = MatrixClientPeg.getCredentials();
this.setStateForNewScreen({
guestCreds: MatrixClientPeg.getCredentials(),
screen: "register",
upgradeUsername: MatrixClientPeg.get().getUserIdLocalpart(),
guestAccessToken: MatrixClientPeg.get().getAccessToken(),
@ -375,35 +385,60 @@ module.exports = React.createClass({
this.notifyNewScreen('register');
break;
case 'start_password_recovery':
if (this.state.logged_in) return;
if (this.state.loggedIn) return;
this.setStateForNewScreen({
screen: 'forgot_password',
});
this.notifyNewScreen('forgot_password');
break;
case 'leave_room':
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
var roomId = payload.room_id;
Modal.createDialog(QuestionDialog, {
title: "Leave room",
description: "Are you sure you want to leave the room?",
onFinished: function(should_leave) {
onFinished: (should_leave) => {
if (should_leave) {
var d = MatrixClientPeg.get().leave(roomId);
const d = MatrixClientPeg.get().leave(payload.room_id);
// FIXME: controller shouldn't be loading a view :(
var Loader = sdk.getComponent("elements.Spinner");
var modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
const Loader = sdk.getComponent("elements.Spinner");
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
d.then(function() {
d.then(() => {
modal.close();
dis.dispatch({action: 'view_next_room'});
}, function(err) {
if (this.currentRoomId === payload.room_id) {
dis.dispatch({action: 'view_next_room'});
}
}, (err) => {
modal.close();
console.error("Failed to leave room " + payload.room_id + " " + err);
Modal.createDialog(ErrorDialog, {
title: "Failed to leave room",
description: (err && err.message ? err.message : "Server may be unavailable, overloaded, or you hit a bug."),
});
});
}
}
});
break;
case 'reject_invite':
Modal.createDialog(QuestionDialog, {
title: "Reject invitation",
description: "Are you sure you want to reject the invitation?",
onFinished: (confirm) => {
if (confirm) {
// FIXME: controller shouldn't be loading a view :(
const Loader = sdk.getComponent("elements.Spinner");
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
MatrixClientPeg.get().leave(payload.room_id).done(() => {
modal.close();
if (this.currentRoomId === payload.room_id) {
dis.dispatch({action: 'view_next_room'});
}
}, (err) => {
modal.close();
Modal.createDialog(ErrorDialog, {
title: "Failed to reject invitation",
description: err.toString()
});
});
@ -530,6 +565,9 @@ module.exports = React.createClass({
case 'set_theme':
this._onSetTheme(payload.value);
break;
case 'on_logging_in':
this.setState({loggingIn: true});
break;
case 'on_logged_in':
this._onLoggedIn(payload.teamToken);
break;
@ -603,36 +641,38 @@ module.exports = React.createClass({
}
}
if (this.sdkReady) {
// if the SDK is not ready yet, remember what room
// we're supposed to be on but don't notify about
// the new screen yet (we won't be showing it yet)
// The normal case where this happens is navigating
// to the room in the URL bar on page load.
var presentedId = room_info.room_alias || room_info.room_id;
var room = MatrixClientPeg.get().getRoom(room_info.room_id);
// Wait for the first sync to complete so that if a room does have an alias,
// it would have been retrieved.
let waitFor = q(null);
if (!this.firstSyncComplete) {
if (!this.firstSyncPromise) {
console.warn('Cannot view a room before first sync. room_id:', room_info.room_id);
return;
}
waitFor = this.firstSyncPromise.promise;
}
waitFor.done(() => {
let presentedId = room_info.room_alias || room_info.room_id;
const room = MatrixClientPeg.get().getRoom(room_info.room_id);
if (room) {
var theAlias = Rooms.getDisplayAliasForRoom(room);
const theAlias = Rooms.getDisplayAliasForRoom(room);
if (theAlias) presentedId = theAlias;
// No need to do this given RoomView triggers it itself...
// var color_scheme_event = room.getAccountData("org.matrix.room.color_scheme");
// var color_scheme = {};
// if (color_scheme_event) {
// color_scheme = color_scheme_event.getContent();
// // XXX: we should validate the event
// }
// console.log("Tinter.tint from _viewRoom");
// Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
// Store this as the ID of the last room accessed. This is so that we can
// persist which room is being stored across refreshes and browser quits.
if (localStorage) {
localStorage.setItem('mx_last_room_id', room.roomId);
}
}
if (room_info.event_id) {
presentedId += "/"+room_info.event_id;
presentedId += "/" + room_info.event_id;
}
this.notifyNewScreen('room/'+presentedId);
this.notifyNewScreen('room/' + presentedId);
newState.ready = true;
}
this.setState(newState);
this.setState(newState);
});
},
_createChat: function() {
@ -658,6 +698,14 @@ module.exports = React.createClass({
_onLoadCompleted: function() {
this.props.onLoadCompleted();
this.setState({loading: false});
// Show screens (like 'register') that need to be shown without _onLoggedIn
// being called. 'register' needs to be routed here when the email confirmation
// link is clicked on.
if (this.state.screenAfterLogin &&
['register'].indexOf(this.state.screenAfterLogin.screen) !== -1) {
this._showScreenAfterLogin();
}
},
/**
@ -708,18 +756,46 @@ module.exports = React.createClass({
* Called when a new logged in session has started
*/
_onLoggedIn: function(teamToken) {
this.guestCreds = null;
this.notifyNewScreen('');
this.setState({
screen: undefined,
logged_in: true,
guestCreds: null,
loggedIn: true,
loggingIn: false,
});
if (teamToken) {
// A team member has logged in, not a guest
this._teamToken = teamToken;
this._setPage(PageTypes.HomePage);
dis.dispatch({action: 'view_home_page'});
} else if (this._is_registered) {
this._setPage(PageTypes.UserSettings);
// The user has just logged in after registering
dis.dispatch({action: 'view_user_settings'});
} else {
this._showScreenAfterLogin();
}
},
_showScreenAfterLogin: function() {
// If screenAfterLogin is set, use that, then null it so that a second login will
// result in view_home_page, _user_settings or _room_directory
if (this.state.screenAfterLogin && this.state.screenAfterLogin.screen) {
this.showScreen(
this.state.screenAfterLogin.screen,
this.state.screenAfterLogin.params
);
this.notifyNewScreen(this.state.screenAfterLogin.screen);
this.setState({screenAfterLogin: null});
} else if (localStorage && localStorage.getItem('mx_last_room_id')) {
// Before defaulting to directory, show the last viewed room
dis.dispatch({
action: 'view_room',
room_id: localStorage.getItem('mx_last_room_id'),
});
} else if (this._teamToken) {
// Team token might be set if we're a guest.
// Guests do not call _onLoggedIn with a teamToken
dis.dispatch({action: 'view_home_page'});
} else {
dis.dispatch({action: 'view_room_directory'});
}
},
@ -729,7 +805,7 @@ module.exports = React.createClass({
_onLoggedOut: function() {
this.notifyNewScreen('login');
this.setStateForNewScreen({
logged_in: false,
loggedIn: false,
ready: false,
collapse_lhs: false,
collapse_rhs: false,
@ -745,9 +821,31 @@ module.exports = React.createClass({
* (useful for setting listeners)
*/
_onWillStartClient() {
var self = this;
var cli = MatrixClientPeg.get();
var self = this;
// Allow the JS SDK to reap timeline events. This reduces the amount of
// memory consumed as the JS SDK stores multiple distinct copies of room
// state (each of which can be 10s of MBs) for each DISJOINT timeline. This is
// particularly noticeable when there are lots of 'limited' /sync responses
// such as when laptops unsleep.
// https://github.com/vector-im/riot-web/issues/3307#issuecomment-282895568
cli.setCanResetTimelineCallback(function(roomId) {
console.log("Request to reset timeline in room ", roomId, " viewing:", self.state.currentRoomId);
if (roomId !== self.state.currentRoomId) {
// It is safe to remove events from rooms we are not viewing.
return true;
}
// We are viewing the room which we want to reset. It is only safe to do
// this if we are not scrolled up in the view. To find out, delegate to
// the timeline panel. If the timeline panel doesn't exist, then we assume
// it is safe to reset the timeline.
if (!self.refs.loggedInView) {
return true;
}
return self.refs.loggedInView.canResetTimelineInRoom(roomId);
});
cli.on('sync', function(state, prevState) {
self.updateStatusIndicator(state, prevState);
if (state === "SYNCING" && prevState === "SYNCING") {
@ -755,55 +853,12 @@ module.exports = React.createClass({
}
console.log("MatrixClient sync state => %s", state);
if (state !== "PREPARED") { return; }
self.sdkReady = true;
if (self.starting_room_alias_payload) {
dis.dispatch(self.starting_room_alias_payload);
delete self.starting_room_alias_payload;
} else if (!self.state.page_type) {
if (!self.state.currentRoomId) {
var firstRoom = null;
if (cli.getRooms() && cli.getRooms().length) {
firstRoom = RoomListSorter.mostRecentActivityFirst(
cli.getRooms()
)[0].roomId;
self.setState({ready: true, currentRoomId: firstRoom, page_type: PageTypes.RoomView});
} else {
if (self._teamToken) {
self.setState({ready: true, page_type: PageTypes.HomePage});
} else {
self.setState({ready: true, page_type: PageTypes.RoomDirectory});
}
}
} else {
self.setState({ready: true, page_type: PageTypes.RoomView});
}
self.firstSyncComplete = true;
self.firstSyncPromise.resolve();
// we notifyNewScreen now because now the room will actually be displayed,
// and (mostly) now we can get the correct alias.
var presentedId = self.state.currentRoomId;
var room = MatrixClientPeg.get().getRoom(self.state.currentRoomId);
if (room) {
var theAlias = Rooms.getDisplayAliasForRoom(room);
if (theAlias) presentedId = theAlias;
}
if (presentedId != undefined) {
self.notifyNewScreen('room/'+presentedId);
} else {
// There is no information on presentedId
// so point user to fallback like /directory
if (self._teamToken) {
self.notifyNewScreen('home');
} else {
self.notifyNewScreen('directory');
}
}
dis.dispatch({action: 'focus_composer'});
} else {
self.setState({ready: true});
}
dis.dispatch({action: 'focus_composer'});
self.setState({ready: true});
});
cli.on('Call.incoming', function(call) {
dis.dispatch({
@ -903,12 +958,7 @@ module.exports = React.createClass({
// we can't view a room unless we're logged in
// (a guest account is fine)
if (!this.state.logged_in) {
// we may still be loading (ie, trying to register a guest
// session); otherwise we're (probably) already showing a login
// screen. Either way, we'll show the room once the client starts.
this.starting_room_alias_payload = payload;
} else {
if (this.state.loggedIn) {
dis.dispatch(payload);
}
} else if (screen.indexOf('user/') == 0) {
@ -1002,9 +1052,9 @@ module.exports = React.createClass({
onReturnToGuestClick: function() {
// reanimate our guest login
if (this.guestCreds) {
Lifecycle.setLoggedIn(this.guestCreds);
this.guestCreds = null;
if (this.state.guestCreds) {
Lifecycle.setLoggedIn(this.state.guestCreds);
this.setState({guestCreds: null});
}
},
@ -1086,14 +1136,12 @@ module.exports = React.createClass({
},
render: function() {
var ForgotPassword = sdk.getComponent('structures.login.ForgotPassword');
var LoggedInView = sdk.getComponent('structures.LoggedInView');
// console.log("rendering; loading="+this.state.loading+"; screen="+this.state.screen +
// "; logged_in="+this.state.logged_in+"; ready="+this.state.ready);
if (this.state.loading) {
var Spinner = sdk.getComponent('elements.Spinner');
// `loading` might be set to false before `loggedIn = true`, causing the default
// (`<Login>`) to be visible for a few MS (say, whilst a request is in-flight to
// the RTS). So in the meantime, use `loggingIn`, which is true between
// actions `on_logging_in` and `on_logged_in`.
if (this.state.loading || this.state.loggingIn) {
const Spinner = sdk.getComponent('elements.Spinner');
return (
<div className="mx_MatrixChat_splash">
<Spinner />
@ -1102,15 +1150,17 @@ module.exports = React.createClass({
}
// needs to be before normal PageTypes as you are logged in technically
else if (this.state.screen == 'post_registration') {
const PostRegistration = sdk.getComponent('structures.login.PostRegistration');
return (
<PostRegistration
onComplete={this.onFinishPostRegistration} />
);
} else if (this.state.logged_in && this.state.ready) {
} else if (this.state.loggedIn && this.state.ready) {
/* for now, we stuff the entirety of our props and state into the LoggedInView.
* we should go through and figure out what we actually need to pass down, as well
* as using something like redux to avoid having a billion bits of state kicking around.
*/
const LoggedInView = sdk.getComponent('structures.LoggedInView');
return (
<LoggedInView ref="loggedInView" matrixClient={MatrixClientPeg.get()}
onRoomIdResolved={this.onRoomIdResolved}
@ -1121,9 +1171,9 @@ module.exports = React.createClass({
{...this.state}
/>
);
} else if (this.state.logged_in) {
} else if (this.state.loggedIn) {
// we think we are logged in, but are still waiting for the /sync to complete
var Spinner = sdk.getComponent('elements.Spinner');
const Spinner = sdk.getComponent('elements.Spinner');
return (
<div className="mx_MatrixChat_splash">
<Spinner />
@ -1133,6 +1183,7 @@ module.exports = React.createClass({
</div>
);
} else if (this.state.screen == 'register') {
const Registration = sdk.getComponent('structures.login.Registration');
return (
<Registration
clientSecret={this.state.register_client_secret}
@ -1153,10 +1204,11 @@ module.exports = React.createClass({
onLoggedIn={this.onRegistered}
onLoginClick={this.onLoginClick}
onRegisterClick={this.onRegisterClick}
onCancelClick={this.guestCreds ? this.onReturnToGuestClick : null}
onCancelClick={this.state.guestCreds ? this.onReturnToGuestClick : null}
/>
);
} else if (this.state.screen == 'forgot_password') {
const ForgotPassword = sdk.getComponent('structures.login.ForgotPassword');
return (
<ForgotPassword
defaultHsUrl={this.getDefaultHsUrl()}
@ -1168,7 +1220,8 @@ module.exports = React.createClass({
onLoginClick={this.onLoginClick} />
);
} else {
var r = (
const Login = sdk.getComponent('structures.login.Login');
return (
<Login
onLoggedIn={Lifecycle.setLoggedIn}
onRegisterClick={this.onRegisterClick}
@ -1180,17 +1233,9 @@ module.exports = React.createClass({
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
onForgotPasswordClick={this.onForgotPasswordClick}
enableGuest={this.props.enableGuest}
onCancelClick={this.guestCreds ? this.onReturnToGuestClick : null}
initialErrorText={this.sessionLoadError}
onCancelClick={this.state.guestCreds ? this.onReturnToGuestClick : null}
/>
);
// we only want to show the session load error the first time the
// Login component is rendered. This is pretty hacky but I can't
// think of another way to achieve it.
this.sessionLoadError = null;
return r;
}
}
});

View file

@ -279,23 +279,25 @@ module.exports = React.createClass({
this.currentGhostEventId = null;
}
var isMembershipChange = (e) =>
e.getType() === 'm.room.member'
&& (!e.getPrevContent() || e.getContent().membership !== e.getPrevContent().membership);
var isMembershipChange = (e) => e.getType() === 'm.room.member';
for (i = 0; i < this.props.events.length; i++) {
var mxEv = this.props.events[i];
var wantTile = true;
var eventId = mxEv.getId();
let mxEv = this.props.events[i];
let wantTile = true;
let eventId = mxEv.getId();
let readMarkerInMels = false;
if (!EventTile.haveTileForEvent(mxEv)) {
wantTile = false;
}
var last = (i == lastShownEventIndex);
let last = (i == lastShownEventIndex);
// Wrap consecutive member events in a ListSummary, ignore if redacted
if (isMembershipChange(mxEv) && EventTile.haveTileForEvent(mxEv)) {
if (isMembershipChange(mxEv) &&
EventTile.haveTileForEvent(mxEv) &&
!mxEv.isRedacted()
) {
let ts1 = mxEv.getTs();
// Ensure that the key of the MemberEventListSummary does not change with new
// member events. This will prevent it from being re-created unnecessarily, and
@ -331,6 +333,9 @@ module.exports = React.createClass({
let eventTiles = summarisedEvents.map(
(e) => {
if (e.getId() === this.props.readMarkerEventId) {
readMarkerInMels = true;
}
// In order to prevent DateSeparators from appearing in the expanded form
// of MemberEventListSummary, render each member event as if the previous
// one was itself. This way, the timestamp of the previous event === the
@ -349,12 +354,16 @@ module.exports = React.createClass({
<MemberEventListSummary
key={key}
events={summarisedEvents}
data-scroll-token={eventId}
onToggle={this._onWidgetLoad} // Update scroll state
>
{eventTiles}
</MemberEventListSummary>
);
if (readMarkerInMels) {
ret.push(this._getReadMarkerTile(visible));
}
continue;
}
@ -385,6 +394,8 @@ module.exports = React.createClass({
isVisibleReadMarker = visible;
}
// XXX: there should be no need for a ghost tile - we should just use a
// a dispatch (user_activity_end) to start the RM animation.
if (eventId == this.currentGhostEventId) {
// if we're showing an animation, continue to show it.
ret.push(this._getReadMarkerGhostTile());
@ -408,7 +419,9 @@ module.exports = React.createClass({
// is this a continuation of the previous message?
var continuation = false;
if (prevEvent !== null && prevEvent.sender && mxEv.sender
if (prevEvent !== null
&& prevEvent.sender && mxEv.sender
&& mxEv.sender.userId === prevEvent.sender.userId
&& mxEv.getType() == prevEvent.getType()) {
continuation = true;
@ -459,8 +472,9 @@ module.exports = React.createClass({
ret.push(
<li key={eventId}
ref={this._collectEventNode.bind(this, eventId)}
data-scroll-token={scrollToken}>
data-scroll-tokens={scrollToken}>
<EventTile mxEvent={mxEv} continuation={continuation}
isRedacted={mxEv.isRedacted()}
onWidgetLoad={this._onWidgetLoad}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
@ -481,13 +495,17 @@ module.exports = React.createClass({
// here.
return !this.props.suppressFirstDateSeparator;
}
const prevEventDate = prevEvent.getDate();
if (!nextEventDate || !prevEventDate) {
return false;
}
// Return early for events that are > 24h apart
if (Math.abs(prevEvent.getTs() - nextEventDate.getTime()) > MILLIS_IN_DAY) {
return true;
}
// Compare weekdays
return prevEvent.getDate().getDay() !== nextEventDate.getDay();
return prevEventDate.getDay() !== nextEventDate.getDay();
},
// get a list of read receipts that should be shown next to this event

View file

@ -96,26 +96,12 @@ module.exports = React.createClass({
componentWillMount: function() {
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
this._checkSize();
},
componentDidUpdate: function(prevProps, prevState) {
if(this.props.onResize && this._checkForResize(prevProps, prevState)) {
this.props.onResize();
}
const size = this._getSize(this.props, this.state);
if (size > 0) {
this.props.onVisible();
} else {
if (this.hideDebouncer) {
clearTimeout(this.hideDebouncer);
}
this.hideDebouncer = setTimeout(() => {
// temporarily stop hiding the statusbar as per
// https://github.com/vector-im/riot-web/issues/1991#issuecomment-276953915
// this.props.onHidden();
}, HIDE_DEBOUNCE_MS);
}
componentDidUpdate: function() {
this._checkSize();
},
componentWillUnmount: function() {
@ -142,33 +128,33 @@ module.exports = React.createClass({
});
},
// Check whether current size is greater than 0, if yes call props.onVisible
_checkSize: function () {
if (this.props.onVisible && this._getSize()) {
this.props.onVisible();
}
},
// We don't need the actual height - just whether it is likely to have
// changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes.
_getSize: function(props, state) {
if (state.syncState === "ERROR" ||
(state.usersTyping.length > 0) ||
props.numUnreadMessages ||
!props.atEndOfLiveTimeline ||
props.hasActiveCall ||
props.tabComplete.isTabCompleting()
_getSize: function() {
if (this.state.syncState === "ERROR" ||
(this.state.usersTyping.length > 0) ||
this.props.numUnreadMessages ||
!this.props.atEndOfLiveTimeline ||
this.props.hasActiveCall ||
this.props.tabComplete.isTabCompleting()
) {
return STATUS_BAR_EXPANDED;
} else if (props.tabCompleteEntries) {
} else if (this.props.tabCompleteEntries) {
return STATUS_BAR_HIDDEN;
} else if (props.unsentMessageError) {
} else if (this.props.unsentMessageError) {
return STATUS_BAR_EXPANDED_LARGE;
}
return STATUS_BAR_HIDDEN;
},
// determine if we need to call onResize
_checkForResize: function(prevProps, prevState) {
// figure out the old height and the new height of the status bar.
return this._getSize(prevProps, prevState)
!== this._getSize(this.props, this.state);
},
// return suitable content for the image on the left of the status bar.
//
// if wantPlaceholder is true, we include a "..." placeholder if

View file

@ -26,6 +26,7 @@ var q = require("q");
var classNames = require("classnames");
var Matrix = require("matrix-js-sdk");
var UserSettingsStore = require('../../UserSettingsStore');
var MatrixClientPeg = require("../../MatrixClientPeg");
var ContentMessages = require("../../ContentMessages");
var Modal = require("../../Modal");
@ -270,6 +271,7 @@ module.exports = React.createClass({
this._updateConfCallNotification();
window.addEventListener('beforeunload', this.onPageUnload);
window.addEventListener('resize', this.onResize);
this.onResize();
@ -352,6 +354,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
}
window.removeEventListener('beforeunload', this.onPageUnload);
window.removeEventListener('resize', this.onResize);
document.removeEventListener("keydown", this.onKeyDown);
@ -364,6 +367,17 @@ module.exports = React.createClass({
// Tinter.tint(); // reset colourscheme
},
onPageUnload(event) {
if (ContentMessages.getCurrentUploads().length > 0) {
return event.returnValue =
'You seem to be uploading files, are you sure you want to quit?';
} else if (this._getCallForRoom() && this.state.callState !== 'ended') {
return event.returnValue =
'You seem to be in a call, are you sure you want to quit?';
}
},
onKeyDown: function(ev) {
let handled = false;
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
@ -489,6 +503,13 @@ module.exports = React.createClass({
}
},
canResetTimeline: function() {
if (!this.refs.messagePanel) {
return true;
}
return this.refs.messagePanel.canResetTimeline();
},
// called when state.room is first initialised (either at initial load,
// after a successful peek, or after we join the room).
_onRoomLoaded: function(room) {
@ -914,8 +935,6 @@ module.exports = React.createClass({
},
uploadFile: function(file) {
var self = this;
if (MatrixClientPeg.get().isGuest()) {
var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog");
Modal.createDialog(NeedToRegisterDialog, {
@ -927,11 +946,20 @@ module.exports = React.createClass({
ContentMessages.sendContentToRoom(
file, this.state.room.roomId, MatrixClientPeg.get()
).done(undefined, function(error) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
).done(undefined, (error) => {
if (error.name === "UnknownDeviceError") {
dis.dispatch({
action: 'unknown_device_error',
err: error,
room: this.state.room,
});
return;
}
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to upload file " + file + " " + error);
Modal.createDialog(ErrorDialog, {
title: "Failed to upload file",
description: error.toString()
description: ((error && error.message) ? error.message : "Server may be unavailable, overloaded, or the file too big"),
});
});
},
@ -1015,9 +1043,10 @@ module.exports = React.createClass({
});
}, function(error) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Search failed: " + error);
Modal.createDialog(ErrorDialog, {
title: "Search failed",
description: error.toString()
description: ((error && error.message) ? error.message : "Server may be unavailable, overloaded, or search timed out :("),
});
}).finally(function() {
self.setState({
@ -1165,6 +1194,7 @@ module.exports = React.createClass({
console.log("updateTint from onCancelClick");
this.updateTint();
this.setState({editingRoomSettings: false});
dis.dispatch({action: 'focus_composer'});
},
onLeaveClick: function() {
@ -1238,6 +1268,7 @@ module.exports = React.createClass({
// jump down to the bottom of this room, where new events are arriving
jumpToLiveTimeline: function() {
this.refs.messagePanel.jumpToLiveTimeline();
dis.dispatch({action: 'focus_composer'});
},
// jump up to wherever our read marker is
@ -1257,12 +1288,7 @@ module.exports = React.createClass({
return;
}
var pos = this.refs.messagePanel.getReadMarkerPosition();
// we want to show the bar if the read-marker is off the top of the
// screen.
var showBar = (pos < 0);
const showBar = this.refs.messagePanel.canJumpToReadMarker();
if (this.state.showTopUnreadMessagesBar != showBar) {
this.setState({showTopUnreadMessagesBar: showBar},
this.onChildResize);
@ -1701,7 +1727,7 @@ module.exports = React.createClass({
var messagePanel = (
<TimelinePanel ref={this._gatherTimelinePanelRef}
timelineSet={this.state.room.getUnfilteredTimelineSet()}
manageReadReceipts={true}
manageReadReceipts={!UserSettingsStore.getSyncedSetting('hideReadReceipts', false)}
manageReadMarkers={true}
hidden={hideMessagePanel}
highlightedEventId={this.props.highlightedEventId}

View file

@ -25,7 +25,7 @@ var DEBUG_SCROLL = false;
// The amount of extra scroll distance to allow prior to unfilling.
// See _getExcessHeight.
const UNPAGINATION_PADDING = 3000;
const UNPAGINATION_PADDING = 6000;
// The number of milliseconds to debounce calls to onUnfillRequest, to prevent
// many scroll events causing many unfilling requests.
const UNFILL_REQUEST_DEBOUNCE_MS = 200;
@ -46,9 +46,13 @@ if (DEBUG_SCROLL) {
* It also provides a hook which allows parents to provide more list elements
* when we get close to the start or end of the list.
*
* Each child element should have a 'data-scroll-token'. This token is used to
* serialise the scroll state, and returned as the 'trackedScrollToken'
* attribute by getScrollState().
* Each child element should have a 'data-scroll-tokens'. This string of
* comma-separated tokens may contain a single token or many, where many indicates
* that the element contains elements that have scroll tokens themselves. The first
* token in 'data-scroll-tokens' is used to serialise the scroll state, and returned
* as the 'trackedScrollToken' attribute by getScrollState().
*
* IMPORTANT: INDIVIDUAL TOKENS WITHIN 'data-scroll-tokens' MUST NOT CONTAIN COMMAS.
*
* Some notes about the implementation:
*
@ -333,33 +337,27 @@ module.exports = React.createClass({
if (excessHeight <= 0) {
return;
}
var itemlist = this.refs.itemlist;
var tiles = itemlist.children;
const tiles = this.refs.itemlist.children;
// The scroll token of the first/last tile to be unpaginated
let markerScrollToken = null;
// Subtract clientHeights to simulate the events being unpaginated whilst counting
// the events to be unpaginated.
if (backwards) {
// Iterate forwards from start of tiles, subtracting event tile height
let i = 0;
while (i < tiles.length && excessHeight > tiles[i].clientHeight) {
excessHeight -= tiles[i].clientHeight;
if (tiles[i].dataset.scrollToken) {
markerScrollToken = tiles[i].dataset.scrollToken;
}
i++;
// Subtract heights of tiles to simulate the tiles being unpaginated until the
// excess height is less than the height of the next tile to subtract. This
// prevents excessHeight becoming negative, which could lead to future
// pagination.
//
// If backwards is true, we unpaginate (remove) tiles from the back (top).
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[backwards ? i : tiles.length - 1 - i];
// Subtract height of tile as if it were unpaginated
excessHeight -= tile.clientHeight;
// The tile may not have a scroll token, so guard it
if (tile.dataset.scrollTokens) {
markerScrollToken = tile.dataset.scrollTokens.split(',')[0];
}
} else {
// Iterate backwards from end of tiles, subtracting event tile height
let i = tiles.length - 1;
while (i > 0 && excessHeight > tiles[i].clientHeight) {
excessHeight -= tiles[i].clientHeight;
if (tiles[i].dataset.scrollToken) {
markerScrollToken = tiles[i].dataset.scrollToken;
}
i--;
if (tile.clientHeight > excessHeight) {
break;
}
}
@ -425,7 +423,8 @@ module.exports = React.createClass({
* scroll. false if we are tracking a particular child.
*
* string trackedScrollToken: undefined if stuckAtBottom is true; if it is
* false, the data-scroll-token of the child which we are tracking.
* false, the first token in data-scroll-tokens of the child which we are
* tracking.
*
* number pixelOffset: undefined if stuckAtBottom is true; if it is false,
* the number of pixels the bottom of the tracked child is above the
@ -489,21 +488,25 @@ module.exports = React.createClass({
handleScrollKey: function(ev) {
switch (ev.keyCode) {
case KeyCode.PAGE_UP:
this.scrollRelative(-1);
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this.scrollRelative(-1);
}
break;
case KeyCode.PAGE_DOWN:
this.scrollRelative(1);
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this.scrollRelative(1);
}
break;
case KeyCode.HOME:
if (ev.ctrlKey) {
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this.scrollToTop();
}
break;
case KeyCode.END:
if (ev.ctrlKey) {
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
this.scrollToBottom();
}
break;
@ -553,8 +556,10 @@ module.exports = React.createClass({
var messages = this.refs.itemlist.children;
for (var i = messages.length-1; i >= 0; --i) {
var m = messages[i];
if (!m.dataset.scrollToken) continue;
if (m.dataset.scrollToken == scrollToken) {
// 'data-scroll-tokens' is a DOMString of comma-separated scroll tokens
// There might only be one scroll token
if (m.dataset.scrollTokens &&
m.dataset.scrollTokens.split(',').indexOf(scrollToken) !== -1) {
node = m;
break;
}
@ -570,7 +575,7 @@ module.exports = React.createClass({
var boundingRect = node.getBoundingClientRect();
var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom;
debuglog("ScrollPanel: scrolling to token '" + node.dataset.scrollToken + "'+" +
debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" +
pixelOffset + " (delta: "+scrollDelta+")");
if(scrollDelta != 0) {
@ -589,24 +594,34 @@ module.exports = React.createClass({
var itemlist = this.refs.itemlist;
var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
var messages = itemlist.children;
let newScrollState = null;
for (var i = messages.length-1; i >= 0; --i) {
var node = messages[i];
if (!node.dataset.scrollToken) continue;
if (!node.dataset.scrollTokens) continue;
var boundingRect = node.getBoundingClientRect();
if (boundingRect.bottom < wrapperRect.bottom) {
this.scrollState = {
stuckAtBottom: false,
trackedScrollToken: node.dataset.scrollToken,
pixelOffset: wrapperRect.bottom - boundingRect.bottom,
};
debuglog("ScrollPanel: saved scroll state", this.scrollState);
return;
newScrollState = {
stuckAtBottom: false,
trackedScrollToken: node.dataset.scrollTokens.split(',')[0],
pixelOffset: wrapperRect.bottom - boundingRect.bottom,
};
// If the bottom of the panel intersects the ClientRect of node, use this node
// as the scrollToken.
// If this is false for the entire for-loop, we default to the last node
// (which is why newScrollState is set on every iteration).
if (boundingRect.top < wrapperRect.bottom) {
// Use this node as the scrollToken
break;
}
}
debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport");
// This is only false if there were no nodes with `node.dataset.scrollTokens` set.
if (newScrollState) {
this.scrollState = newScrollState;
debuglog("ScrollPanel: saved scroll state", this.scrollState);
} else {
debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport");
}
},
_restoreSavedScrollState: function() {

View file

@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -102,9 +103,6 @@ var TimelinePanel = React.createClass({
},
statics: {
// a map from room id to read marker event ID
roomReadMarkerMap: {},
// a map from room id to read marker event timestamp
roomReadMarkerTsMap: {},
},
@ -121,10 +119,14 @@ var TimelinePanel = React.createClass({
getInitialState: function() {
// XXX: we could track RM per TimelineSet rather than per Room.
// but for now we just do it per room for simplicity.
let initialReadMarker = null;
if (this.props.manageReadMarkers) {
var initialReadMarker =
TimelinePanel.roomReadMarkerMap[this.props.timelineSet.room.roomId]
|| this._getCurrentReadReceipt();
const readmarker = this.props.timelineSet.room.getAccountData('m.fully_read');
if (readmarker){
initialReadMarker = readmarker.getContent().event_id;
} else {
initialReadMarker = this._getCurrentReadReceipt();
}
}
return {
@ -166,6 +168,9 @@ var TimelinePanel = React.createClass({
backPaginating: false,
forwardPaginating: false,
// cache of matrixClient.getSyncState() (but from the 'sync' event)
clientSyncState: MatrixClientPeg.get().getSyncState(),
};
},
@ -173,6 +178,7 @@ var TimelinePanel = React.createClass({
debuglog("TimelinePanel: mounting");
this.last_rr_sent_event_id = undefined;
this.last_rm_sent_event_id = undefined;
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
@ -180,6 +186,8 @@ var TimelinePanel = React.createClass({
MatrixClientPeg.get().on("Room.redaction", this.onRoomRedaction);
MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
MatrixClientPeg.get().on("Room.accountData", this.onAccountData);
MatrixClientPeg.get().on("sync", this.onSync);
this._initTimeline(this.props);
},
@ -247,14 +255,18 @@ var TimelinePanel = React.createClass({
client.removeListener("Room.redaction", this.onRoomRedaction);
client.removeListener("Room.receipt", this.onRoomReceipt);
client.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
client.removeListener("Room.accountData", this.onAccountData);
client.removeListener("sync", this.onSync);
}
},
onMessageListUnfillRequest: function(backwards, scrollToken) {
// If backwards, unpaginate from the back (i.e. the start of the timeline)
let dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
debuglog("TimelinePanel: unpaginating events in direction", dir);
// All tiles are inserted by MessagePanel to have a scrollToken === eventId
// All tiles are inserted by MessagePanel to have a scrollToken === eventId, and
// this particular event should be the first or last to be unpaginated.
let eventId = scrollToken;
let marker = this.state.events.findIndex(
@ -412,6 +424,7 @@ var TimelinePanel = React.createClass({
} else if(lastEv && this.getReadMarkerPosition() === 0) {
// we know we're stuckAtBottom, so we can advance the RM
// immediately, to save a later render cycle
this._setReadMarker(lastEv.getId(), lastEv.getTs(), true);
updatedState.readMarkerVisible = false;
updatedState.readMarkerEventId = lastEv.getId();
@ -431,6 +444,10 @@ var TimelinePanel = React.createClass({
}
},
canResetTimeline: function() {
return this.refs.messagePanel && this.refs.messagePanel.isAtBottom();
},
onRoomRedaction: function(ev, room) {
if (this.unmounted) return;
@ -460,6 +477,25 @@ var TimelinePanel = React.createClass({
this._reloadEvents();
},
onAccountData: function(ev, room) {
if (this.unmounted) return;
// ignore events for other rooms
if (room !== this.props.timelineSet.room) return;
if (ev.getType() !== "m.fully_read") return;
// XXX: roomReadMarkerTsMap not updated here so it is now inconsistent. Replace
// this mechanism of determining where the RM is relative to the view-port with
// one supported by the server (the client needs more than an event ID).
this.setState({
readMarkerEventId: ev.getContent().event_id,
}, this.props.onReadMarkerUpdated);
},
onSync: function(state, prevState, data) {
this.setState({clientSyncState: state});
},
sendReadReceipt: function() {
if (!this.refs.messagePanel) return;
@ -467,15 +503,9 @@ var TimelinePanel = React.createClass({
// This happens on user_activity_end which is delayed, and it's
// very possible have logged out within that timeframe, so check
// we still have a client.
if (!MatrixClientPeg.get()) return;
// if we are scrolled to the bottom, do a quick-reset of our unreadNotificationCount
// to avoid having to wait from the remote echo from the homeserver.
if (this.isAtEndOfLiveTimeline()) {
this.props.timelineSet.room.setUnreadNotificationCount('total', 0);
this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0);
// XXX: i'm a bit surprised we don't have to emit an event or dispatch to get this picked up
}
const cli = MatrixClientPeg.get();
// if no client or client is guest don't send RR
if (!cli || cli.isGuest()) return;
var currentReadUpToEventId = this._getCurrentReadReceipt(true);
var currentReadUpToEventIndex = this._indexForEventId(currentReadUpToEventId);
@ -507,13 +537,44 @@ var TimelinePanel = React.createClass({
// we also remember the last read receipt we sent to avoid spamming the
// same one at the server repeatedly
if (lastReadEventIndex > currentReadUpToEventIndex
&& this.last_rr_sent_event_id != lastReadEvent.getId()) {
if ((lastReadEventIndex > currentReadUpToEventIndex &&
this.last_rr_sent_event_id != lastReadEvent.getId()) ||
this.last_rm_sent_event_id != this.state.readMarkerEventId) {
this.last_rr_sent_event_id = lastReadEvent.getId();
MatrixClientPeg.get().sendReadReceipt(lastReadEvent).catch(() => {
this.last_rm_sent_event_id = this.state.readMarkerEventId;
MatrixClientPeg.get().setRoomReadMarkers(
this.props.timelineSet.room.roomId,
this.state.readMarkerEventId,
lastReadEvent
).catch((e) => {
// /read_markers API is not implemented on this HS, fallback to just RR
if (e.errcode === 'M_UNRECOGNIZED') {
return MatrixClientPeg.get().sendReadReceipt(
lastReadEvent
).catch(() => {
this.last_rr_sent_event_id = undefined;
});
}
// it failed, so allow retries next time the user is active
this.last_rr_sent_event_id = undefined;
this.last_rm_sent_event_id = undefined;
});
// do a quick-reset of our unreadNotificationCount to avoid having
// to wait from the remote echo from the homeserver.
// we only do this if we're right at the end, because we're just assuming
// that sending an RR for the latest message will set our notif counter
// to zero: it may not do this if we send an RR for somewhere before the end.
if (this.isAtEndOfLiveTimeline()) {
this.props.timelineSet.room.setUnreadNotificationCount('total', 0);
this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0);
dis.dispatch({
action: 'on_room_read',
room: this.props.timelineSet.room,
});
}
}
},
@ -695,7 +756,7 @@ var TimelinePanel = React.createClass({
// the messagePanel doesn't know where the read marker is.
// if we know the timestamp of the read marker, make a guess based on that.
var rmTs = TimelinePanel.roomReadMarkerTsMap[this.props.timelineSet.roomId];
const rmTs = TimelinePanel.roomReadMarkerTsMap[this.props.timelineSet.room.roomId];
if (rmTs && this.state.events.length > 0) {
if (rmTs < this.state.events[0].getTs()) {
return -1;
@ -707,6 +768,19 @@ var TimelinePanel = React.createClass({
return null;
},
canJumpToReadMarker: function() {
// 1. Do not show jump bar if neither the RM nor the RR are set.
// 2. Only show jump bar if RR !== RM. If they are the same, there are only fully
// read messages and unread messages. We already have a badge count and the bottom
// bar to jump to "live" when we have unread messages.
// 3. We want to show the bar if the read-marker is off the top of the screen.
// 4. Also, if pos === null, the event might not be paginated - show the unread bar
const pos = this.getReadMarkerPosition();
return this.state.readMarkerEventId !== null && // 1.
this.state.readMarkerEventId !== this._getCurrentReadReceipt() && // 2.
(pos < 0 || pos === null); // 3., 4.
},
/**
* called by the parent component when PageUp/Down/etc is pressed.
*
@ -717,7 +791,9 @@ var TimelinePanel = React.createClass({
// jump to the live timeline on ctrl-end, rather than the end of the
// timeline window.
if (ev.ctrlKey && ev.keyCode == KeyCode.END) {
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey &&
ev.keyCode == KeyCode.END)
{
this.jumpToLiveTimeline();
} else {
this.refs.messagePanel.handleScrollKey(ev);
@ -810,7 +886,7 @@ var TimelinePanel = React.createClass({
// go via the dispatcher so that the URL is updated
dis.dispatch({
action: 'view_room',
room_id: this.props.timelineSet.roomId,
room_id: this.props.timelineSet.room.roomId,
});
};
}
@ -945,16 +1021,12 @@ var TimelinePanel = React.createClass({
_setReadMarker: function(eventId, eventTs, inhibitSetState) {
var roomId = this.props.timelineSet.room.roomId;
if (TimelinePanel.roomReadMarkerMap[roomId] == eventId) {
// don't update the state (and cause a re-render) if there is
// no change to the RM.
// don't update the state (and cause a re-render) if there is
// no change to the RM.
if (eventId === this.state.readMarkerEventId) {
return;
}
// ideally we'd sync these via the server, but for now just stash them
// in a map.
TimelinePanel.roomReadMarkerMap[roomId] = eventId;
// in order to later figure out if the read marker is
// above or below the visible timeline, we stash the timestamp.
TimelinePanel.roomReadMarkerTsMap[roomId] = eventTs;
@ -963,6 +1035,7 @@ var TimelinePanel = React.createClass({
return;
}
// Do the local echo of the RM
// run the render cycle before calling the callback, so that
// getReadMarkerPosition() returns the right thing.
this.setState({
@ -1011,11 +1084,17 @@ var TimelinePanel = React.createClass({
// of paginating our way through the entire history of the room.
var stickyBottom = !this._timelineWindow.canPaginate(EventTimeline.FORWARDS);
// If the state is PREPARED, we're still waiting for the js-sdk to sync with
// the HS and fetch the latest events, so we are effectively forward paginating.
const forwardPaginating = (
this.state.forwardPaginating || this.state.clientSyncState == 'PREPARED'
);
return (
<MessagePanel ref="messagePanel"
hidden={ this.props.hidden }
backPaginating={ this.state.backPaginating }
forwardPaginating={ this.state.forwardPaginating }
forwardPaginating={ forwardPaginating }
events={ this.state.events }
highlightedEventId={ this.props.highlightedEventId }
readMarkerEventId={ this.state.readMarkerEventId }

View file

@ -25,12 +25,13 @@ module.exports = React.createClass({displayName: 'UploadBar',
},
componentDidMount: function() {
dis.register(this.onAction);
this.dispatcherRef = dis.register(this.onAction);
this.mounted = true;
},
componentWillUnmount: function() {
this.mounted = false;
dis.unregister(this.dispatcherRef);
},
onAction: function(payload) {

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -13,27 +14,40 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require('react');
var ReactDOM = require('react-dom');
var sdk = require('../../index');
var MatrixClientPeg = require("../../MatrixClientPeg");
var PlatformPeg = require("../../PlatformPeg");
var Modal = require('../../Modal');
var dis = require("../../dispatcher");
var q = require('q');
var package_json = require('../../../package.json');
var UserSettingsStore = require('../../UserSettingsStore');
var GeminiScrollbar = require('react-gemini-scrollbar');
var Email = require('../../email');
var AddThreepid = require('../../AddThreepid');
var SdkConfig = require('../../SdkConfig');
const React = require('react');
const ReactDOM = require('react-dom');
const sdk = require('../../index');
const MatrixClientPeg = require("../../MatrixClientPeg");
const PlatformPeg = require("../../PlatformPeg");
const Modal = require('../../Modal');
const dis = require("../../dispatcher");
const q = require('q');
const packageJson = require('../../../package.json');
const UserSettingsStore = require('../../UserSettingsStore');
const GeminiScrollbar = require('react-gemini-scrollbar');
const Email = require('../../email');
const AddThreepid = require('../../AddThreepid');
const SdkConfig = require('../../SdkConfig');
import AccessibleButton from '../views/elements/AccessibleButton';
// 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>";
// the git sha. Prepend version with v, to look like riot-web version
const REACT_SDK_VERSION = 'dist' in packageJson ? packageJson.version : packageJson.gitHead || '<local>';
// Simple method to help prettify GH Release Tags and Commit Hashes.
const semVerRegex = /^v?(\d+\.\d+\.\d+(?:-rc.+)?)(?:-(?:\d+-g)?([0-9a-fA-F]+))?(?:-dirty)?$/i;
const gHVersionLabel = function(repo, token) {
const match = token.match(semVerRegex);
let url;
if (match && match[1]) { // basic semVer string possibly with commit hash
url = (match.length > 1 && match[2])
? `https://github.com/${repo}/commit/${match[2]}`
: `https://github.com/${repo}/releases/tag/v${match[1]}`;
} else {
url = `https://github.com/${repo}/commit/${token.split('-')[0]}`;
}
return <a href={url}>{token}</a>;
};
// Enumerate some simple 'flip a bit' UI settings (if any).
// 'id' gives the key name in the im.vector.web.settings account data event
@ -43,6 +57,14 @@ const SETTINGS_LABELS = [
id: 'autoplayGifsAndVideos',
label: 'Autoplay GIFs and videos',
},
{
id: 'hideReadReceipts',
label: 'Hide read receipts',
},
{
id: 'dontSendTypingNotifications',
label: "Don't send typing notifications",
},
/*
{
id: 'alwaysShowTimestamps',
@ -93,7 +115,7 @@ const THEMES = [
id: 'theme',
label: 'Dark theme',
value: 'dark',
}
},
];
@ -139,6 +161,7 @@ module.exports = React.createClass({
componentWillMount: function() {
this._unmounted = false;
this._addThreepid = null;
if (PlatformPeg.get()) {
q().then(() => {
@ -166,7 +189,7 @@ module.exports = React.createClass({
});
this._refreshFromServer();
var syncedSettings = UserSettingsStore.getSyncedSettings();
const syncedSettings = UserSettingsStore.getSyncedSettings();
if (!syncedSettings.theme) {
syncedSettings.theme = 'light';
}
@ -188,16 +211,16 @@ module.exports = React.createClass({
middleOpacity: 1.0,
});
dis.unregister(this.dispatcherRef);
let cli = MatrixClientPeg.get();
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomMember.membership", this._onInviteStateChange);
}
},
_refreshFromServer: function() {
var self = this;
const self = this;
q.all([
UserSettingsStore.loadProfileInfo(), UserSettingsStore.loadThreePids()
UserSettingsStore.loadProfileInfo(), UserSettingsStore.loadThreePids(),
]).done(function(resps) {
self.setState({
avatarUrl: resps[0].avatar_url,
@ -205,10 +228,11 @@ module.exports = React.createClass({
phase: "UserSettings.DISPLAY",
});
}, function(error) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to load user settings: " + error);
Modal.createDialog(ErrorDialog, {
title: "Can't load user settings",
description: error.toString()
description: ((error && error.message) ? error.message : "Server may be unavailable or overloaded"),
});
});
},
@ -221,7 +245,7 @@ module.exports = React.createClass({
onAvatarPickerClick: function(ev) {
if (MatrixClientPeg.get().isGuest()) {
var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog");
const NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog");
Modal.createDialog(NeedToRegisterDialog, {
title: "Please Register",
description: "Guests can't set avatars. Please register.",
@ -235,8 +259,8 @@ module.exports = React.createClass({
},
onAvatarSelected: function(ev) {
var self = this;
var changeAvatar = this.refs.changeAvatar;
const self = this;
const changeAvatar = this.refs.changeAvatar;
if (!changeAvatar) {
console.error("No ChangeAvatar found to upload image to!");
return;
@ -245,27 +269,34 @@ module.exports = React.createClass({
// dunno if the avatar changed, re-check it.
self._refreshFromServer();
}, function(err) {
var errMsg = (typeof err === "string") ? err : (err.error || "");
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// const errMsg = (typeof err === "string") ? err : (err.error || "");
console.error("Failed to set avatar: " + err);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Error",
description: "Failed to set avatar. " + errMsg
title: "Failed to set avatar",
description: ((err && err.message) ? err.message : "Operation failed"),
});
});
},
onLogoutClicked: function(ev) {
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, {
title: "Sign out?",
description:
<div>
For security, logging out will delete any end-to-end encryption keys from this browser,
making previous encrypted chat history unreadable if you log back in.
In future this <a href="https://github.com/vector-im/riot-web/issues/2108">will be improved</a>,
but for now be warned.
For security, logging out will delete any end-to-end encryption keys from this browser.
If you want to be able to decrypt your conversation history from future Riot sessions,
please export your room keys for safe-keeping.
</div>,
button: "Sign out",
extraButtons: [
<button key="export" className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
Export E2E room keys
</button>,
],
onFinished: (confirmed) => {
if (confirmed) {
dis.dispatch({action: 'logout'});
@ -278,33 +309,33 @@ module.exports = React.createClass({
},
onPasswordChangeError: function(err) {
var errMsg = err.error || "";
let errMsg = err.error || "";
if (err.httpStatus === 403) {
errMsg = "Failed to change password. Is your password correct?";
}
else if (err.httpStatus) {
} else if (err.httpStatus) {
errMsg += ` (HTTP status ${err.httpStatus})`;
}
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to change password: " + errMsg);
Modal.createDialog(ErrorDialog, {
title: "Error",
description: errMsg
description: errMsg,
});
},
onPasswordChanged: function() {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Success",
description: `Your password was successfully changed. You will not
receive push notifications on other devices until you
log back in to them.`
log back in to them.`,
});
},
onUpgradeClicked: function() {
dis.dispatch({
action: "start_upgrade_registration"
action: "start_upgrade_registration",
});
},
@ -312,23 +343,27 @@ module.exports = React.createClass({
UserSettingsStore.setEnableNotifications(event.target.checked);
},
onAddThreepidClicked: function(value, shouldSubmit) {
_onAddEmailEditFinished: function(value, shouldSubmit) {
if (!shouldSubmit) return;
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
this._addEmail();
},
var email_address = this.refs.add_threepid_input.value;
if (!Email.looksValid(email_address)) {
_addEmail: function() {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const emailAddress = this.refs.add_email_input.value;
if (!Email.looksValid(emailAddress)) {
Modal.createDialog(ErrorDialog, {
title: "Invalid Email Address",
description: "This doesn't appear to be a valid email address",
});
return;
}
this.add_threepid = new AddThreepid();
this._addThreepid = new AddThreepid();
// we always bind emails when registering, so let's do the
// same here.
this.add_threepid.addEmailAddress(email_address, true).done(() => {
this._addThreepid.addEmailAddress(emailAddress, true).done(() => {
Modal.createDialog(QuestionDialog, {
title: "Verification Pending",
description: "Please check your email and click on the link it contains. Once this is done, click continue.",
@ -337,12 +372,13 @@ module.exports = React.createClass({
});
}, (err) => {
this.setState({email_add_pending: false});
console.error("Unable to add email address " + emailAddress + " " + err);
Modal.createDialog(ErrorDialog, {
title: "Unable to add email address",
description: err.message
description: ((err && err.message) ? err.message : "Operation failed"),
});
});
ReactDOM.findDOMNode(this.refs.add_threepid_input).blur();
ReactDOM.findDOMNode(this.refs.add_email_input).blur();
this.setState({email_add_pending: true});
},
@ -361,9 +397,10 @@ module.exports = React.createClass({
return this._refreshFromServer();
}).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Unable to remove contact information: " + err);
Modal.createDialog(ErrorDialog, {
title: "Unable to remove contact information",
description: err.toString(),
description: ((err && err.message) ? err.message : "Operation failed"),
});
}).done();
}
@ -380,8 +417,8 @@ module.exports = React.createClass({
},
verifyEmailAddress: function() {
this.add_threepid.checkEmailLinkClicked().done(() => {
this.add_threepid = undefined;
this._addThreepid.checkEmailLinkClicked().done(() => {
this._addThreepid = null;
this.setState({
phase: "UserSettings.LOADING",
});
@ -389,9 +426,9 @@ module.exports = React.createClass({
this.setState({email_add_pending: false});
}, (err) => {
this.setState({email_add_pending: false});
if (err.errcode == 'M_THREEPID_AUTH_FAILED') {
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
var message = "Unable to verify email address. ";
if (err.errcode === 'M_THREEPID_AUTH_FAILED') {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
let message = "Unable to verify email address. ";
message += "Please check your email and click on the link it contains. Once this is done, click continue.";
Modal.createDialog(QuestionDialog, {
title: "Verification Pending",
@ -400,10 +437,11 @@ module.exports = React.createClass({
onFinished: this.onEmailDialogFinished,
});
} else {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Unable to verify email address: " + err);
Modal.createDialog(ErrorDialog, {
title: "Unable to verify email address",
description: err.toString(),
description: ((err && err.message) ? err.message : "Operation failed"),
});
}
});
@ -423,10 +461,11 @@ module.exports = React.createClass({
},
_onClearCacheClicked: function() {
if (!PlatformPeg.get()) return;
MatrixClientPeg.get().stopClient();
MatrixClientPeg.get().store.deleteAllData().done(() => {
// forceReload=false since we don't really need new HTML/JS files
// we just need to restart the JS runtime.
window.location.reload(false);
PlatformPeg.get().reload();
});
},
@ -438,17 +477,17 @@ module.exports = React.createClass({
_onRejectAllInvitesClicked: function(rooms, ev) {
this.setState({
rejectingInvites: true
rejectingInvites: true,
});
// reject the invites
let promises = rooms.map((room) => {
const promises = rooms.map((room) => {
return MatrixClientPeg.get().leave(room.roomId);
});
// purposefully drop errors to the floor: we'll just have a non-zero number on the UI
// after trying to reject all the invites.
q.allSettled(promises).then(() => {
this.setState({
rejectingInvites: false
rejectingInvites: false,
});
}).done();
},
@ -461,7 +500,7 @@ module.exports = React.createClass({
}, "e2e-export");
}, {
matrixClient: MatrixClientPeg.get(),
}
},
);
},
@ -473,7 +512,7 @@ module.exports = React.createClass({
}, "e2e-export");
}, {
matrixClient: MatrixClientPeg.get(),
}
},
);
},
@ -499,8 +538,6 @@ module.exports = React.createClass({
},
_renderUserInterfaceSettings: function() {
var client = MatrixClientPeg.get();
return (
<div>
<h3>User Interface</h3>
@ -527,7 +564,7 @@ module.exports = React.createClass({
<input id="urlPreviewsDisabled"
type="checkbox"
defaultChecked={ UserSettingsStore.getUrlPreviewsDisabled() }
onChange={ e => UserSettingsStore.setUrlPreviewsDisabled(e.target.checked) }
onChange={ (e) => UserSettingsStore.setUrlPreviewsDisabled(e.target.checked) }
/>
<label htmlFor="urlPreviewsDisabled">
Disable inline URL previews by default
@ -540,7 +577,7 @@ module.exports = React.createClass({
<input id={ setting.id }
type="checkbox"
defaultChecked={ this._syncedSettings[setting.id] }
onChange={ e => UserSettingsStore.setSyncedSetting(setting.id, e.target.checked) }
onChange={ (e) => UserSettingsStore.setSyncedSetting(setting.id, e.target.checked) }
/>
<label htmlFor={ setting.id }>
{ setting.label }
@ -555,7 +592,7 @@ module.exports = React.createClass({
name={ setting.id }
value={ setting.value }
defaultChecked={ this._syncedSettings[setting.id] === setting.value }
onChange={ e => {
onChange={ (e) => {
if (e.target.checked) {
UserSettingsStore.setSyncedSetting(setting.id, setting.value);
}
@ -617,8 +654,8 @@ module.exports = React.createClass({
type="checkbox"
defaultChecked={ this._localSettings[setting.id] }
onChange={
e => {
UserSettingsStore.setLocalSetting(setting.id, e.target.checked)
(e) => {
UserSettingsStore.setLocalSetting(setting.id, e.target.checked);
if (setting.id === 'blacklistUnverifiedDevices') { // XXX: this is a bit ugly
client.setGlobalBlacklistUnverifiedDevices(e.target.checked);
}
@ -632,7 +669,7 @@ module.exports = React.createClass({
},
_renderDevicesPanel: function() {
var DevicesPanel = sdk.getComponent('settings.DevicesPanel');
const DevicesPanel = sdk.getComponent('settings.DevicesPanel');
return (
<div>
<h3>Devices</h3>
@ -643,7 +680,7 @@ module.exports = React.createClass({
_renderBugReport: function() {
if (!SdkConfig.get().bug_report_endpoint_url) {
return <div />
return <div />;
}
return (
<div>
@ -662,17 +699,17 @@ module.exports = React.createClass({
// default to enabled if undefined
if (this.props.enableLabs === false) return null;
let features = UserSettingsStore.LABS_FEATURES.map(feature => (
const features = UserSettingsStore.LABS_FEATURES.map((feature) => (
<div key={feature.id} className="mx_UserSettings_toggle">
<input
type="checkbox"
id={feature.id}
name={feature.id}
defaultChecked={ UserSettingsStore.isFeatureEnabled(feature.id) }
onChange={e => {
onChange={(e) => {
if (MatrixClientPeg.get().isGuest()) {
e.target.checked = false;
var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog");
const NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog");
Modal.createDialog(NeedToRegisterDialog, {
title: "Please Register",
description: "Guests can't use labs features. Please register.",
@ -724,14 +761,14 @@ module.exports = React.createClass({
},
_renderBulkOptions: function() {
let invitedRooms = MatrixClientPeg.get().getRooms().filter((r) => {
const invitedRooms = MatrixClientPeg.get().getRooms().filter((r) => {
return r.hasMembershipState(this._me, "invite");
});
if (invitedRooms.length === 0) {
return null;
}
let Spinner = sdk.getComponent("elements.Spinner");
const Spinner = sdk.getComponent("elements.Spinner");
let reject = <Spinner />;
if (!this.state.rejectingInvites) {
@ -753,13 +790,33 @@ module.exports = React.createClass({
</div>;
},
_showSpoiler: function(event) {
const target = event.target;
target.innerHTML = target.getAttribute('data-spoiler');
const range = document.createRange();
range.selectNodeContents(target);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
},
nameForMedium: function(medium) {
if (medium == 'msisdn') return 'Phone';
if (medium === 'msisdn') return 'Phone';
return medium[0].toUpperCase() + medium.slice(1);
},
presentableTextForThreepid: function(threepid) {
if (threepid.medium === 'msisdn') {
return '+' + threepid.address;
} else {
return threepid.address;
}
},
render: function() {
var Loader = sdk.getComponent("elements.Spinner");
const Loader = sdk.getComponent("elements.Spinner");
switch (this.state.phase) {
case "UserSettings.LOADING":
return (
@ -771,18 +828,18 @@ module.exports = React.createClass({
throw new Error("Unknown state.phase => " + this.state.phase);
}
// can only get here if phase is UserSettings.DISPLAY
var SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
var ChangeDisplayName = sdk.getComponent("views.settings.ChangeDisplayName");
var ChangePassword = sdk.getComponent("views.settings.ChangePassword");
var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
var Notifications = sdk.getComponent("settings.Notifications");
var EditableText = sdk.getComponent('elements.EditableText');
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
const ChangeDisplayName = sdk.getComponent("views.settings.ChangeDisplayName");
const ChangePassword = sdk.getComponent("views.settings.ChangePassword");
const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
const Notifications = sdk.getComponent("settings.Notifications");
const EditableText = sdk.getComponent('elements.EditableText');
var avatarUrl = (
const avatarUrl = (
this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null
);
var threepidsSection = this.state.threepids.map((val, pidIndex) => {
const threepidsSection = this.state.threepids.map((val, pidIndex) => {
const id = "3pid-" + val.address;
return (
<div className="mx_UserSettings_profileTableRow" key={pidIndex}>
@ -790,7 +847,9 @@ module.exports = React.createClass({
<label htmlFor={id}>{this.nameForMedium(val.medium)}</label>
</div>
<div className="mx_UserSettings_profileInputCell">
<input type="text" key={val.address} id={id} value={val.address} disabled />
<input type="text" key={val.address} id={id}
value={this.presentableTextForThreepid(val)} disabled
/>
</div>
<div className="mx_UserSettings_threepidButton mx_filterFlipColor">
<img src="img/cancel-small.svg" width="14" height="14" alt="Remove" onClick={this.onRemoveThreepidClicked.bind(this, val)} />
@ -798,32 +857,37 @@ module.exports = React.createClass({
</div>
);
});
var addThreepidSection;
let addEmailSection;
if (this.state.email_add_pending) {
addThreepidSection = <Loader />;
addEmailSection = <Loader key="_email_add_spinner" />;
} else if (!MatrixClientPeg.get().isGuest()) {
addThreepidSection = (
<div className="mx_UserSettings_profileTableRow" key="new">
addEmailSection = (
<div className="mx_UserSettings_profileTableRow" key="_newEmail">
<div className="mx_UserSettings_profileLabelCell">
</div>
<div className="mx_UserSettings_profileInputCell">
<EditableText
ref="add_threepid_input"
ref="add_email_input"
className="mx_UserSettings_editable"
placeholderClassName="mx_UserSettings_threepidPlaceholder"
placeholder={ "Add email address" }
blurToCancel={ false }
onValueChanged={ this.onAddThreepidClicked } />
onValueChanged={ this._onAddEmailEditFinished } />
</div>
<div className="mx_UserSettings_threepidButton mx_filterFlipColor">
<img src="img/plus.svg" width="14" height="14" alt="Add" onClick={ this.onAddThreepidClicked.bind(this, undefined, true) }/>
<img src="img/plus.svg" width="14" height="14" alt="Add" onClick={this._addEmail} />
</div>
</div>
);
}
threepidsSection.push(addThreepidSection);
const AddPhoneNumber = sdk.getComponent('views.settings.AddPhoneNumber');
const addMsisdnSection = (
<AddPhoneNumber key="_addMsisdn" onThreepidAdded={this._refreshFromServer} />
);
threepidsSection.push(addEmailSection);
threepidsSection.push(addMsisdnSection);
var accountJsx;
let accountJsx;
if (MatrixClientPeg.get().isGuest()) {
accountJsx = (
@ -831,8 +895,7 @@ module.exports = React.createClass({
Create an account
</div>
);
}
else {
} else {
accountJsx = (
<ChangePassword
className="mx_UserSettings_accountTable"
@ -844,9 +907,9 @@ module.exports = React.createClass({
onFinished={this.onPasswordChanged} />
);
}
var notification_area;
let notificationArea;
if (!MatrixClientPeg.get().isGuest() && this.state.threepids !== undefined) {
notification_area = (<div>
notificationArea = (<div>
<h3>Notifications</h3>
<div className="mx_UserSettings_section">
@ -855,12 +918,12 @@ module.exports = React.createClass({
</div>);
}
var olmVersion = MatrixClientPeg.get().olmVersion;
const olmVersion = MatrixClientPeg.get().olmVersion;
// If the olmVersion is not defined then either crypto is disabled, or
// we are using a version old version of olm. We assume the former.
var olmVersionString = "<not-enabled>";
let olmVersionString = "<not-enabled>";
if (olmVersion !== undefined) {
olmVersionString = olmVersion[0] + "." + olmVersion[1] + "." + olmVersion[2];
olmVersionString = `${olmVersion[0]}.${olmVersion[1]}.${olmVersion[2]}`;
}
return (
@ -918,7 +981,7 @@ module.exports = React.createClass({
{this._renderReferral()}
{notification_area}
{notificationArea}
{this._renderUserInterfaceSettings()}
{this._renderLabs()}
@ -933,6 +996,12 @@ module.exports = React.createClass({
<div className="mx_UserSettings_advanced">
Logged in as {this._me}
</div>
<div className="mx_UserSettings_advanced">
Access Token: <span className="mx_UserSettings_advanced_spoiler"
onClick={this._showSpoiler}
data-spoiler={ MatrixClientPeg.get().getAccessToken() }
>&lt;click to reveal&gt;</span>
</div>
<div className="mx_UserSettings_advanced">
Homeserver is { MatrixClientPeg.get().getHomeserverUrl() }
</div>
@ -940,8 +1009,14 @@ module.exports = React.createClass({
Identity Server is { MatrixClientPeg.get().getIdentityServerUrl() }
</div>
<div className="mx_UserSettings_advanced">
matrix-react-sdk version: {REACT_SDK_VERSION}<br/>
riot-web version: {this.state.vectorVersion !== null ? this.state.vectorVersion : 'unknown'}<br/>
matrix-react-sdk version: {(REACT_SDK_VERSION !== '<local>')
? gHVersionLabel('matrix-org/matrix-react-sdk', REACT_SDK_VERSION)
: REACT_SDK_VERSION
}<br/>
riot-web version: {(this.state.vectorVersion !== null)
? gHVersionLabel('vector-im/riot-web', this.state.vectorVersion)
: 'unknown'
}<br/>
olm version: {olmVersionString}<br/>
</div>
</div>
@ -953,5 +1028,5 @@ module.exports = React.createClass({
</GeminiScrollbar>
</div>
);
}
},
});

View file

@ -93,11 +93,17 @@ module.exports = React.createClass({
description:
<div>
Resetting password will currently reset any end-to-end encryption keys on all devices,
making encrypted chat history unreadable.
In future this <a href="https://github.com/vector-im/riot-web/issues/2671">may be improved</a>,
but for now be warned.
making encrypted chat history unreadable, unless you first export your room keys
and re-import them afterwards.
In future this <a href="https://github.com/vector-im/riot-web/issues/2671">will be improved</a>.
</div>,
button: "Continue",
extraButtons: [
<button className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
Export E2E room keys
</button>
],
onFinished: (confirmed) => {
if (confirmed) {
this.submitPasswordReset(
@ -110,6 +116,18 @@ module.exports = React.createClass({
}
},
_onExportE2eKeysClicked: function() {
Modal.createDialogAsync(
(cb) => {
require.ensure(['../../../async-components/views/dialogs/ExportE2eKeysDialog'], () => {
cb(require('../../../async-components/views/dialogs/ExportE2eKeysDialog'));
}, "e2e-export");
}, {
matrixClient: MatrixClientPeg.get(),
}
);
},
onInputChanged: function(stateKey, ev) {
this.setState({
[stateKey]: ev.target.value

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,13 +17,14 @@ limitations under the License.
'use strict';
var React = require('react');
var ReactDOM = require('react-dom');
var sdk = require('../../../index');
var Login = require("../../../Login");
var PasswordLogin = require("../../views/login/PasswordLogin");
var CasLogin = require("../../views/login/CasLogin");
var ServerConfig = require("../../views/login/ServerConfig");
import React from 'react';
import ReactDOM from 'react-dom';
import url from 'url';
import sdk from '../../../index';
import Login from '../../../Login';
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9\(\)\-\s]*$/;
/**
* A wire component which glues together login UI components and Login logic
@ -52,20 +54,21 @@ module.exports = React.createClass({
// login shouldn't care how password recovery is done.
onForgotPasswordClick: React.PropTypes.func,
onCancelClick: React.PropTypes.func,
initialErrorText: React.PropTypes.string,
},
getInitialState: function() {
return {
busy: false,
errorText: this.props.initialErrorText,
errorText: null,
loginIncorrect: false,
enteredHomeserverUrl: this.props.customHsUrl || this.props.defaultHsUrl,
enteredIdentityServerUrl: this.props.customIsUrl || this.props.defaultIsUrl,
// used for preserving username when changing homeserver
// used for preserving form values when changing homeserver
username: "",
phoneCountry: null,
phoneNumber: "",
currentFlow: "m.login.password",
};
},
@ -73,20 +76,21 @@ module.exports = React.createClass({
this._initLoginLogic();
},
onPasswordLogin: function(username, password) {
var self = this;
self.setState({
onPasswordLogin: function(username, phoneCountry, phoneNumber, password) {
this.setState({
busy: true,
errorText: null,
loginIncorrect: false,
});
this._loginLogic.loginViaPassword(username, password).then(function(data) {
self.props.onLoggedIn(data);
}, function(error) {
self._setStateFromError(error, true);
}).finally(function() {
self.setState({
this._loginLogic.loginViaPassword(
username, phoneCountry, phoneNumber, password,
).then((data) => {
this.props.onLoggedIn(data);
}, (error) => {
this._setStateFromError(error, true);
}).finally(() => {
this.setState({
busy: false
});
}).done();
@ -119,23 +123,36 @@ module.exports = React.createClass({
this.setState({ username: username });
},
onHsUrlChanged: function(newHsUrl) {
var self = this;
onPhoneCountryChanged: function(phoneCountry) {
this.setState({ phoneCountry: phoneCountry });
},
onPhoneNumberChanged: function(phoneNumber) {
// Validate the phone number entered
if (!PHONE_NUMBER_REGEX.test(phoneNumber)) {
this.setState({ errorText: 'The phone number entered looks invalid' });
return;
}
this.setState({
enteredHomeserverUrl: newHsUrl,
errorText: null, // reset err messages
}, function() {
self._initLoginLogic(newHsUrl);
phoneNumber: phoneNumber,
errorText: null,
});
},
onIsUrlChanged: function(newIsUrl) {
onServerConfigChange: function(config) {
var self = this;
this.setState({
enteredIdentityServerUrl: newIsUrl,
let newState = {
errorText: null, // reset err messages
}, function() {
self._initLoginLogic(null, newIsUrl);
};
if (config.hsUrl !== undefined) {
newState.enteredHomeserverUrl = config.hsUrl;
}
if (config.isUrl !== undefined) {
newState.enteredIdentityServerUrl = config.isUrl;
}
this.setState(newState, function() {
self._initLoginLogic(config.hsUrl || null, config.isUrl);
});
},
@ -151,25 +168,28 @@ module.exports = React.createClass({
});
this._loginLogic = loginLogic;
loginLogic.getFlows().then(function(flows) {
// old behaviour was to always use the first flow without presenting
// options. This works in most cases (we don't have a UI for multiple
// logins so let's skip that for now).
loginLogic.chooseFlow(0);
}, function(err) {
self._setStateFromError(err, false);
}).finally(function() {
self.setState({
busy: false
});
});
this.setState({
enteredHomeserverUrl: hsUrl,
enteredIdentityServerUrl: isUrl,
busy: true,
loginIncorrect: false,
});
loginLogic.getFlows().then(function(flows) {
// old behaviour was to always use the first flow without presenting
// options. This works in most cases (we don't have a UI for multiple
// logins so let's skip that for now).
loginLogic.chooseFlow(0);
self.setState({
currentFlow: self._getCurrentFlowStep(),
});
}, function(err) {
self._setStateFromError(err, false);
}).finally(function() {
self.setState({
busy: false,
});
});
},
_getCurrentFlowStep: function() {
@ -221,16 +241,29 @@ module.exports = React.createClass({
componentForStep: function(step) {
switch (step) {
case 'm.login.password':
const PasswordLogin = sdk.getComponent('login.PasswordLogin');
// HSs that are not matrix.org may not be configured to have their
// domain name === domain part.
let hsDomain = url.parse(this.state.enteredHomeserverUrl).hostname;
if (hsDomain !== 'matrix.org') {
hsDomain = null;
}
return (
<PasswordLogin
onSubmit={this.onPasswordLogin}
initialUsername={this.state.username}
initialPhoneCountry={this.state.phoneCountry}
initialPhoneNumber={this.state.phoneNumber}
onUsernameChanged={this.onUsernameChanged}
onPhoneCountryChanged={this.onPhoneCountryChanged}
onPhoneNumberChanged={this.onPhoneNumberChanged}
onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect}
hsDomain={hsDomain}
/>
);
case 'm.login.cas':
const CasLogin = sdk.getComponent('login.CasLogin');
return (
<CasLogin onSubmit={this.onCasLogin} />
);
@ -248,10 +281,11 @@ module.exports = React.createClass({
},
render: function() {
var Loader = sdk.getComponent("elements.Spinner");
var LoginHeader = sdk.getComponent("login.LoginHeader");
var LoginFooter = sdk.getComponent("login.LoginFooter");
var loader = this.state.busy ? <div className="mx_Login_loader"><Loader /></div> : null;
const Loader = sdk.getComponent("elements.Spinner");
const LoginHeader = sdk.getComponent("login.LoginHeader");
const LoginFooter = sdk.getComponent("login.LoginFooter");
const ServerConfig = sdk.getComponent("login.ServerConfig");
const loader = this.state.busy ? <div className="mx_Login_loader"><Loader /></div> : null;
var loginAsGuestJsx;
if (this.props.enableGuest) {
@ -277,15 +311,14 @@ module.exports = React.createClass({
<h2>Sign in
{ loader }
</h2>
{ this.componentForStep(this._getCurrentFlowStep()) }
{ this.componentForStep(this.state.currentFlow) }
<ServerConfig ref="serverConfig"
withToggleButton={true}
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onHsUrlChanged={this.onHsUrlChanged}
onIsUrlChanged={this.onIsUrlChanged}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={1000}/>
<div className="mx_Login_error">
{ this.state.errorText }

View file

@ -123,18 +123,17 @@ module.exports = React.createClass({
}
},
onHsUrlChanged: function(newHsUrl) {
this.setState({
hsUrl: newHsUrl,
onServerConfigChange: function(config) {
let newState = {};
if (config.hsUrl !== undefined) {
newState.hsUrl = config.hsUrl;
}
if (config.isUrl !== undefined) {
newState.isUrl = config.isUrl;
}
this.setState(newState, function() {
this._replaceClient();
});
this._replaceClient();
},
onIsUrlChanged: function(newIsUrl) {
this.setState({
isUrl: newIsUrl,
});
this._replaceClient();
},
_replaceClient: function() {
@ -155,10 +154,21 @@ module.exports = React.createClass({
_onUIAuthFinished: function(success, response, extra) {
if (!success) {
let msg = response.message || response.toString();
// can we give a better error message?
if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
let msisdn_available = false;
for (const flow of response.available_flows) {
msisdn_available |= flow.stages.indexOf('m.login.msisdn') > -1;
}
if (!msisdn_available) {
msg = "This server does not support authentication with a phone number";
}
}
this.setState({
busy: false,
doingUIAuth: false,
errorText: response.message || response.toString(),
errorText: msg,
});
return;
}
@ -185,7 +195,6 @@ module.exports = React.createClass({
const teamToken = data.team_token;
// Store for use /w welcome pages
window.localStorage.setItem('mx_team_token', teamToken);
this.props.onTeamMemberRegistered(teamToken);
this._rtsClient.getTeam(teamToken).then((team) => {
console.log(
@ -262,6 +271,9 @@ module.exports = React.createClass({
case "RegistrationForm.ERR_EMAIL_INVALID":
errMsg = "This doesn't look like a valid email address";
break;
case "RegistrationForm.ERR_PHONE_NUMBER_INVALID":
errMsg = "This doesn't look like a valid phone number";
break;
case "RegistrationForm.ERR_USERNAME_INVALID":
errMsg = "User names may only contain letters, numbers, dots, hyphens and underscores.";
break;
@ -296,15 +308,20 @@ module.exports = React.createClass({
guestAccessToken = null;
}
// Only send the bind params if we're sending username / pw params
// (Since we need to send no params at all to use the ones saved in the
// session).
const bindThreepids = this.state.formVals.password ? {
email: true,
msisdn: true,
} : {};
return this._matrixClient.register(
this.state.formVals.username,
this.state.formVals.password,
undefined, // session id: included in the auth dict already
auth,
// Only send the bind_email param if we're sending username / pw params
// (Since we need to send no params at all to use the ones saved in the
// session).
Boolean(this.state.formVals.username) || undefined,
bindThreepids,
guestAccessToken,
);
},
@ -355,6 +372,8 @@ module.exports = React.createClass({
<RegistrationForm
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPhoneCountry={this.state.formVals.phoneCountry}
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password}
teamsConfig={this.state.teamsConfig}
guestUsername={guestUsername}
@ -370,8 +389,7 @@ module.exports = React.createClass({
customIsUrl={this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onHsUrlChanged={this.onHsUrlChanged}
onIsUrlChanged={this.onIsUrlChanged}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={1000}
/>
</div>