Merge in changes from develop

This commit is contained in:
Richard Lewis 2017-06-27 11:44:36 +01:00
commit ddc0da396d
75 changed files with 5789 additions and 955 deletions

View file

@ -212,6 +212,7 @@ export default React.createClass({
const HomePage = sdk.getComponent('structures.HomePage');
const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
const NewVersionBar = sdk.getComponent('globals.NewVersionBar');
const UpdateCheckBar = sdk.getComponent('globals.UpdateCheckBar');
const PasswordNagBar = sdk.getComponent('globals.PasswordNagBar');
let page_element;
@ -239,7 +240,6 @@ export default React.createClass({
page_element = <UserSettings
onClose={this.props.onUserSettingsClose}
brand={this.props.config.brand}
collapsedRhs={this.props.collapse_rhs}
enableLabs={this.props.config.enableLabs}
referralBaseUrl={this.props.config.referralBaseUrl}
teamToken={this.props.teamToken}
@ -270,7 +270,6 @@ export default React.createClass({
this.props.config.teamServerConfig.teamServerURL : null;
page_element = <HomePage
collapsedRhs={this.props.collapse_rhs}
teamServerUrl={teamServerUrl}
teamToken={this.props.teamToken}
homePageUrl={this.props.config.welcomePageUrl}
@ -283,12 +282,14 @@ export default React.createClass({
break;
}
let topBar;
const isGuest = this.props.matrixClient.isGuest();
var topBar;
if (this.props.hasNewVersion) {
topBar = <NewVersionBar version={this.props.version} newVersion={this.props.newVersion}
releaseNotes={this.props.newVersionReleaseNotes}
releaseNotes={this.props.newVersionReleaseNotes}
/>;
} else if (this.props.checkingForUpdate) {
topBar = <UpdateCheckBar {...this.props.checkingForUpdate} />;
} else if (this.state.userHasGeneratedPassword) {
topBar = <PasswordNagBar />;
} else if (!isGuest && Notifier.supportsDesktopNotifications() && !Notifier.isEnabled() && !Notifier.isToolbarHidden()) {

View file

@ -41,9 +41,44 @@ import PageTypes from '../../PageTypes';
import createRoom from "../../createRoom";
import * as UDEHandler from '../../UnknownDeviceErrorHandler';
import KeyRequestHandler from '../../KeyRequestHandler';
import { _t, getCurrentLanguage } from '../../languageHandler';
/** constants for MatrixChat.state.view */
const VIEWS = {
// a special initial state which is only used at startup, while we are
// trying to re-animate a matrix client or register as a guest.
LOADING: 0,
// we are showing the login view
LOGIN: 1,
// we are showing the registration view
REGISTER: 2,
// completeing the registration flow
POST_REGISTRATION: 3,
// showing the 'forgot password' view
FORGOT_PASSWORD: 4,
// we have valid matrix credentials (either via an explicit login, via the
// initial re-animation/guest registration, or via a registration), and are
// now setting up a matrixclient to talk to it. This isn't an instant
// process because (a) we need to clear out indexeddb, and (b) we need to
// talk to the team server; while it is going on we show a big spinner.
LOGGING_IN: 5,
// we are logged in with an active matrix client.
LOGGED_IN: 6,
};
module.exports = React.createClass({
// we export this so that the integration tests can use it :-S
statics: {
VIEWS: VIEWS,
},
displayName: 'MatrixChat',
propTypes: {
@ -59,8 +94,8 @@ module.exports = React.createClass({
// the initial queryParams extracted from the hash-fragment of the URI
startingFragmentQueryParams: React.PropTypes.object,
// called when the session load completes
onLoadCompleted: React.PropTypes.func,
// called when we have completed a token login
onTokenLoginCompleted: React.PropTypes.func,
// Represents the screen to display as a result of parsing the initial
// window.location
@ -93,14 +128,11 @@ module.exports = React.createClass({
getInitialState: function() {
const s = {
loading: true,
screen: undefined,
screenAfterLogin: this.props.initialScreenAfterLogin,
// the master view we are showing.
view: VIEWS.LOADING,
// 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,
// a thing to call showScreen with once login completes.
screenAfterLogin: this.props.initialScreenAfterLogin,
// What the LoggedInView would be showing if visible
page_type: null,
@ -113,8 +145,6 @@ module.exports = React.createClass({
// If we're trying to just view a user ID (i.e. /user URL), this is it
viewUserId: null,
loggedIn: false,
loggingIn: false,
collapse_lhs: false,
collapse_rhs: false,
ready: false,
@ -127,6 +157,7 @@ module.exports = React.createClass({
newVersion: null,
hasNewVersion: false,
newVersionReleaseNotes: null,
checkingForUpdate: null,
// Parameters used in the registration dance with the IS
register_client_secret: null,
@ -143,7 +174,7 @@ module.exports = React.createClass({
realQueryParams: {},
startingFragmentQueryParams: {},
config: {},
onLoadCompleted: () => {},
onTokenLoginCompleted: () => {},
};
},
@ -263,30 +294,52 @@ module.exports = React.createClass({
window.addEventListener('resize', this.handleResize);
this.handleResize();
if (this.props.config.teamServerConfig &&
this.props.config.teamServerConfig.teamServerURL
) {
Lifecycle.initRtsClient(this.props.config.teamServerConfig.teamServerURL);
}
const teamServerConfig = this.props.config.teamServerConfig || {};
Lifecycle.initRtsClient(teamServerConfig.teamServerURL);
// the extra q() ensures that synchronous exceptions hit the same codepath as
// asynchronous ones.
q().then(() => {
return Lifecycle.loadSession({
realQueryParams: this.props.realQueryParams,
fragmentQueryParams: this.props.startingFragmentQueryParams,
enableGuest: this.props.enableGuest,
guestHsUrl: this.getCurrentHsUrl(),
guestIsUrl: this.getCurrentIsUrl(),
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
// the first thing to do is to try the token params in the query-string
Lifecycle.attemptTokenLogin(this.props.realQueryParams).then((loggedIn) => {
if(loggedIn) {
this.props.onTokenLoginCompleted();
// don't do anything else until the page reloads - just stay in
// the 'loading' state.
return;
}
// if the user has followed a login or register link, don't reanimate
// the old creds, but rather go straight to the relevant page
const firstScreen = this.state.screenAfterLogin ?
this.state.screenAfterLogin.screen : null;
if (firstScreen === 'login' ||
firstScreen === 'register' ||
firstScreen === 'forgot_password') {
this.setState({loading: false});
this._showScreenAfterLogin();
return;
}
// the extra q() ensures that synchronous exceptions hit the same codepath as
// asynchronous ones.
return q().then(() => {
return Lifecycle.loadSession({
fragmentQueryParams: this.props.startingFragmentQueryParams,
enableGuest: this.props.enableGuest,
guestHsUrl: this.getCurrentHsUrl(),
guestIsUrl: this.getCurrentIsUrl(),
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
});
}).catch((e) => {
console.error(`Error attempting to load session: ${e}`);
return false;
}).then((loadedSession) => {
if (!loadedSession) {
// fall back to showing the login screen
dis.dispatch({action: "start_login"});
}
});
}).catch((e) => {
console.error("Unable to load session", e);
}).done(()=>{
// stuff this through the dispatcher so that it happens
// after the on_logged_in action.
dis.dispatch({action: 'load_completed'});
});
}).done();
},
componentWillUnmount: function() {
@ -305,18 +358,19 @@ module.exports = React.createClass({
}
},
setStateForNewScreen: function(state) {
setStateForNewView: function(state) {
if (state.view === undefined) {
throw new Error("setStateForNewView with no view!");
}
const newState = {
screen: undefined,
viewUserId: null,
loggedIn: false,
ready: false,
};
Object.assign(newState, state);
this.setState(newState);
},
onAction: function(payload) {
// console.log(`MatrixClientPeg.onAction: ${payload.action}`);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
@ -328,26 +382,19 @@ module.exports = React.createClass({
this._startRegistration(payload.params || {});
break;
case 'start_login':
if (MatrixClientPeg.get() &&
MatrixClientPeg.get().isGuest()
) {
this.setState({
guestCreds: MatrixClientPeg.getCredentials(),
});
}
this.setStateForNewScreen({
screen: 'login',
this.setStateForNewView({
view: VIEWS.LOGIN,
});
this.notifyNewScreen('login');
break;
case 'start_post_registration':
this.setState({ // don't clobber loggedIn status
screen: 'post_registration',
this.setState({
view: VIEWS.POST_REGISTRATION,
});
break;
case 'start_password_recovery':
this.setStateForNewScreen({
screen: 'forgot_password',
this.setStateForNewView({
view: VIEWS.FORGOT_PASSWORD,
});
this.notifyNewScreen('forgot_password');
break;
@ -371,7 +418,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().leave(payload.room_id).done(() => {
modal.close();
if (this.currentRoomId === payload.room_id) {
if (this.state.currentRoomId === payload.room_id) {
dis.dispatch({action: 'view_next_room'});
}
}, (err) => {
@ -488,10 +535,11 @@ module.exports = React.createClass({
break;
case 'on_logging_in':
// We are now logging in, so set the state to reflect that
// and also that we're not ready (we'll be marked as logged
// in once the login completes, then ready once the sync
// completes).
this.setState({loggingIn: true, ready: false});
// NB. This does not touch 'ready' since if our dispatches
// are delayed, the sync could already have completed
this.setStateForNewView({
view: VIEWS.LOGGING_IN,
});
break;
case 'on_logged_in':
this._onLoggedIn(payload.teamToken);
@ -500,10 +548,12 @@ module.exports = React.createClass({
this._onLoggedOut();
break;
case 'will_start_client':
this._onWillStartClient();
break;
case 'load_completed':
this._onLoadCompleted();
this.setState({ready: false}, () => {
// if the client is about to start, we are, by definition, not ready.
// Set ready to false now, then it'll be set to true when the sync
// listener we set below fires.
this._onWillStartClient();
});
break;
case 'new_version':
this.onVersion(
@ -511,6 +561,12 @@ module.exports = React.createClass({
payload.releaseNotes,
);
break;
case 'check_updates':
this.setState({ checkingForUpdate: payload.value });
break;
case 'send_event':
this.onSendEvent(payload.room_id, payload.event);
break;
}
},
@ -525,8 +581,8 @@ module.exports = React.createClass({
},
_startRegistration: function(params) {
this.setStateForNewScreen({
screen: 'register',
this.setStateForNewView({
view: VIEWS.REGISTER,
// these params may be undefined, but if they are,
// unset them from our state: we don't want to
// resume a previous registration session if the
@ -834,22 +890,6 @@ module.exports = React.createClass({
});
},
/**
* Called when the sessionloader has finished
*/
_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();
}
},
/**
* Called whenever someone changes the theme
*
@ -902,9 +942,7 @@ module.exports = React.createClass({
*/
_onLoggedIn: function(teamToken) {
this.setState({
guestCreds: null,
loggedIn: true,
loggingIn: false,
view: VIEWS.LOGGED_IN,
});
if (teamToken) {
@ -913,10 +951,6 @@ module.exports = React.createClass({
dis.dispatch({action: 'view_home_page'});
} else if (this._is_registered) {
this._is_registered = false;
// reset the 'have completed first sync' flag,
// since we've just logged in and will be about to sync
this.firstSyncComplete = false;
this.firstSyncPromise = q.defer();
// Set the display name = user ID localpart
MatrixClientPeg.get().setDisplayName(
@ -946,6 +980,7 @@ module.exports = React.createClass({
this.state.screenAfterLogin.screen,
this.state.screenAfterLogin.params,
);
// XXX: is this necessary? `showScreen` should do it for us.
this.notifyNewScreen(this.state.screenAfterLogin.screen);
this.setState({screenAfterLogin: null});
} else if (localStorage && localStorage.getItem('mx_last_room_id')) {
@ -964,8 +999,8 @@ module.exports = React.createClass({
*/
_onLoggedOut: function() {
this.notifyNewScreen('login');
this.setStateForNewScreen({
loggedIn: false,
this.setStateForNewView({
view: VIEWS.LOGIN,
ready: false,
collapse_lhs: false,
collapse_rhs: false,
@ -973,6 +1008,7 @@ module.exports = React.createClass({
page_type: PageTypes.RoomDirectory,
});
this._teamToken = null;
this._setPageSubtitle();
},
/**
@ -981,6 +1017,12 @@ module.exports = React.createClass({
*/
_onWillStartClient() {
const self = this;
// reset the 'have completed first sync' flag,
// since we're about to start the client and therefore about
// to do the first sync
this.firstSyncComplete = false;
this.firstSyncPromise = q.defer();
const cli = MatrixClientPeg.get();
// Allow the JS SDK to reap timeline events. This reduces the amount of
@ -1051,6 +1093,14 @@ module.exports = React.createClass({
}
}
});
const krh = new KeyRequestHandler(cli);
cli.on("crypto.roomKeyRequest", (req) => {
krh.handleKeyRequest(req);
});
cli.on("crypto.roomKeyRequestCancellation", (req) => {
krh.handleKeyRequestCancellation(req);
});
},
showScreen: function(screen, params) {
@ -1128,7 +1178,7 @@ module.exports = React.createClass({
// we can't view a room unless we're logged in
// (a guest account is fine)
if (this.state.loggedIn) {
if (this.state.view === VIEWS.LOGGED_IN) {
dis.dispatch(payload);
}
} else if (screen.indexOf('user/') == 0) {
@ -1226,27 +1276,25 @@ module.exports = React.createClass({
this.showScreen("forgot_password");
},
onReturnToGuestClick: function() {
// reanimate our guest login
if (this.state.guestCreds) {
Lifecycle.setLoggedIn(this.state.guestCreds);
this.setState({guestCreds: null});
}
onReturnToAppClick: function() {
// treat it the same as if the user had completed the login
this._onLoggedIn(null);
},
// returns a promise which resolves to the new MatrixClient
onRegistered: function(credentials, teamToken) {
// XXX: These both should be in state or ideally store(s) because we risk not
// rendering the most up-to-date view of state otherwise.
// teamToken may not be truthy
this._teamToken = teamToken;
this._is_registered = true;
Lifecycle.setLoggedIn(credentials);
return Lifecycle.setLoggedIn(credentials);
},
onFinishPostRegistration: function() {
// Don't confuse this with "PageType" which is the middle window to show
this.setState({
screen: undefined,
view: VIEWS.LOGGED_IN,
});
this.showScreen("settings");
},
@ -1257,9 +1305,35 @@ module.exports = React.createClass({
newVersion: latest,
hasNewVersion: current !== latest,
newVersionReleaseNotes: releaseNotes,
checkingForUpdate: null,
});
},
onSendEvent: function(roomId, event) {
const cli = MatrixClientPeg.get();
if (!cli) {
dis.dispatch({action: 'message_send_failed'});
return;
}
cli.sendEvent(roomId, event.getType(), event.getContent()).done(() => {
dis.dispatch({action: 'message_sent'});
}, (err) => {
if (err.name === 'UnknownDeviceError') {
dis.dispatch({
action: 'unknown_device_error',
err: err,
room: cli.getRoom(roomId),
});
}
dis.dispatch({action: 'message_send_failed'});
});
},
_setPageSubtitle: function(subtitle='') {
document.title = `Riot ${subtitle}`;
},
updateStatusIndicator: function(state, prevState) {
let notifCount = 0;
@ -1280,15 +1354,15 @@ module.exports = React.createClass({
PlatformPeg.get().setNotificationCount(notifCount);
}
let title = "Riot ";
let subtitle = '';
if (state === "ERROR") {
title += `[${_t("Offline")}] `;
subtitle += `[${_t("Offline")}] `;
}
if (notifCount > 0) {
title += `[${notifCount}]`;
subtitle += `[${notifCount}]`;
}
document.title = title;
this._setPageSubtitle(subtitle);
},
onUserSettingsClose: function() {
@ -1314,11 +1388,9 @@ module.exports = React.createClass({
},
render: function() {
// `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) {
// console.log(`Rendering MatrixChat with view ${this.state.view}`);
if (this.state.view === VIEWS.LOADING || this.state.view === VIEWS.LOGGING_IN) {
const Spinner = sdk.getComponent('elements.Spinner');
return (
<div className="mx_MatrixChat_splash">
@ -1328,7 +1400,7 @@ module.exports = React.createClass({
}
// needs to be before normal PageTypes as you are logged in technically
if (this.state.screen == 'post_registration') {
if (this.state.view === VIEWS.POST_REGISTRATION) {
const PostRegistration = sdk.getComponent('structures.login.PostRegistration');
return (
<PostRegistration
@ -1336,38 +1408,42 @@ module.exports = React.createClass({
);
}
// `ready` and `loggedIn` may be set before `page_type` (because the
// latter is set via the dispatcher). If we don't yet have a `page_type`,
// keep showing the spinner for now.
if (this.state.loggedIn && this.state.ready && this.state.page_type) {
/* 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()}
onRoomCreated={this.onRoomCreated}
onUserSettingsClose={this.onUserSettingsClose}
onRegistered={this.onRegistered}
currentRoomId={this.state.currentRoomId}
teamToken={this._teamToken}
{...this.props}
{...this.state}
/>
);
} else if (this.state.loggedIn) {
// we think we are logged in, but are still waiting for the /sync to complete
const Spinner = sdk.getComponent('elements.Spinner');
return (
<div className="mx_MatrixChat_splash">
<Spinner />
<a href="#" className="mx_MatrixChat_splashButtons" onClick={ this.onLogoutClick }>
{ _t('Logout') }
</a>
</div>
);
} else if (this.state.screen == 'register') {
if (this.state.view === VIEWS.LOGGED_IN) {
// `ready` and `view==LOGGED_IN` may be set before `page_type` (because the
// latter is set via the dispatcher). If we don't yet have a `page_type`,
// keep showing the spinner for now.
if (this.state.ready && this.state.page_type) {
/* 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()}
onRoomCreated={this.onRoomCreated}
onUserSettingsClose={this.onUserSettingsClose}
onRegistered={this.onRegistered}
currentRoomId={this.state.currentRoomId}
teamToken={this._teamToken}
{...this.props}
{...this.state}
/>
);
} else {
// we think we are logged in, but are still waiting for the /sync to complete
const Spinner = sdk.getComponent('elements.Spinner');
return (
<div className="mx_MatrixChat_splash">
<Spinner />
<a href="#" className="mx_MatrixChat_splashButtons" onClick={ this.onLogoutClick }>
{ _t('Logout') }
</a>
</div>
);
}
}
if (this.state.view === VIEWS.REGISTER) {
const Registration = sdk.getComponent('structures.login.Registration');
return (
<Registration
@ -1387,10 +1463,13 @@ module.exports = React.createClass({
onLoggedIn={this.onRegistered}
onLoginClick={this.onLoginClick}
onRegisterClick={this.onRegisterClick}
onCancelClick={this.state.guestCreds ? this.onReturnToGuestClick : null}
onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null}
/>
);
} else if (this.state.screen == 'forgot_password') {
}
if (this.state.view === VIEWS.FORGOT_PASSWORD) {
const ForgotPassword = sdk.getComponent('structures.login.ForgotPassword');
return (
<ForgotPassword
@ -1402,7 +1481,9 @@ module.exports = React.createClass({
onRegisterClick={this.onRegisterClick}
onLoginClick={this.onLoginClick} />
);
} else {
}
if (this.state.view === VIEWS.LOGIN) {
const Login = sdk.getComponent('structures.login.Login');
return (
<Login
@ -1416,9 +1497,11 @@ module.exports = React.createClass({
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
onForgotPasswordClick={this.onForgotPasswordClick}
enableGuest={this.props.enableGuest}
onCancelClick={this.state.guestCreds ? this.onReturnToGuestClick : null}
onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null}
/>
);
}
console.error(`Unknown view ${this.state.view}`);
},
});

View file

@ -94,6 +94,7 @@ module.exports = React.createClass({
userId: null,
roomLoading: true,
peekLoading: false,
shouldPeek: true,
// The event to be scrolled to initially
initialEventId: null,
@ -170,8 +171,32 @@ module.exports = React.createClass({
initialEventId: RoomViewStore.getInitialEventId(),
initialEventPixelOffset: RoomViewStore.getInitialEventPixelOffset(),
isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(),
forwardingEvent: RoomViewStore.getForwardingEvent(),
shouldPeek: RoomViewStore.shouldPeek(),
};
// finished joining, start waiting for a room and show a spinner. See onRoom.
newState.waitingForRoom = this.state.joining && !newState.joining &&
!RoomViewStore.getJoinError();
// Temporary logging to diagnose https://github.com/vector-im/riot-web/issues/4307
console.log(
'RVS update:',
newState.roomId,
newState.roomAlias,
'loading?', newState.roomLoading,
'joining?', newState.joining,
'initial?', initial,
'waiting?', newState.waitingForRoom,
'shouldPeek?', newState.shouldPeek,
);
// NB: This does assume that the roomID will not change for the lifetime of
// the RoomView instance
if (initial) {
newState.room = MatrixClientPeg.get().getRoom(newState.roomId);
}
// Clear the search results when clicking a search result (which changes the
// currently scrolled to event, this.state.initialEventId).
if (this.state.initialEventId !== newState.initialEventId) {
@ -187,8 +212,9 @@ module.exports = React.createClass({
this.setState(newState, () => {
// At this point, this.state.roomId could be null (e.g. the alias might not
// have been resolved yet) so anything called here must handle this case.
this._onHaveRoom();
this.onRoom(MatrixClientPeg.get().getRoom(this.state.roomId));
if (initial) {
this._onHaveRoom();
}
});
},
@ -204,25 +230,22 @@ module.exports = React.createClass({
// which must be by alias or invite wherever possible (peeking currently does
// not work over federation).
// NB. We peek if we are not in the room, although if we try to peek into
// a room in which we have a member event (ie. we've left) synapse will just
// send us the same data as we get in the sync (ie. the last events we saw).
const room = MatrixClientPeg.get().getRoom(this.state.roomId);
let isUserJoined = null;
// NB. We peek if we have never seen the room before (i.e. js-sdk does not know
// about it). We don't peek in the historical case where we were joined but are
// now not joined because the js-sdk peeking API will clobber our historical room,
// making it impossible to indicate a newly joined room.
const room = this.state.room;
if (room) {
isUserJoined = room.hasMembershipState(
MatrixClientPeg.get().credentials.userId, 'join',
);
this._updateAutoComplete(room);
this.tabComplete.loadEntries(room);
this.setState({
unsentMessageError: this._getUnsentMessageError(room),
});
this._onRoomLoaded(room);
}
if (!isUserJoined && !this.state.joining && this.state.roomId) {
if (!this.state.joining && this.state.roomId) {
if (this.props.autoJoin) {
this.onJoinButtonClicked();
} else if (this.state.roomId) {
} else if (!room && this.state.shouldPeek) {
console.log("Attempting to peek into room %s", this.state.roomId);
this.setState({
peekLoading: true,
});
@ -246,7 +269,8 @@ module.exports = React.createClass({
}
}).done();
}
} else if (isUserJoined) {
} else if (room) {
// Stop peeking because we have joined this room previously
MatrixClientPeg.get().stopPeeking();
this.setState({
showApps: this._shouldShowApps(room),
@ -496,8 +520,7 @@ module.exports = React.createClass({
// and that has probably just changed
if (ev.sender) {
this.tabComplete.onMemberSpoke(ev.sender);
// nb. we don't need to update the new autocomplete here since
// its results are currently ordered purely by search score.
UserProvider.getInstance().onUserSpoke(ev.sender);
}
},
@ -520,6 +543,8 @@ module.exports = React.createClass({
this._warnAboutEncryption(room);
this._calculatePeekRules(room);
this._updatePreviewUrlVisibility(room);
this.tabComplete.loadEntries(room);
UserProvider.getInstance().setUserListFromRoom(room);
},
_warnAboutEncryption: function(room) {
@ -620,6 +645,7 @@ module.exports = React.createClass({
}
this.setState({
room: room,
waitingForRoom: false,
}, () => {
this._onRoomLoaded(room);
});
@ -675,7 +701,14 @@ module.exports = React.createClass({
onRoomMemberMembership: function(ev, member, oldMembership) {
if (member.userId == MatrixClientPeg.get().credentials.userId) {
this.forceUpdate();
if (member.membership === 'join') {
this.setState({
waitingForRoom: false,
});
} else {
this.forceUpdate();
}
}
},
@ -688,7 +721,7 @@ module.exports = React.createClass({
// refresh the tab complete list
this.tabComplete.loadEntries(this.state.room);
this._updateAutoComplete(this.state.room);
UserProvider.getInstance().setUserListFromRoom(this.state.room);
// if we are now a member of the room, where we were not before, that
// means we have finished joining a room we were previously peeking
@ -1153,8 +1186,13 @@ module.exports = React.createClass({
this.updateTint();
this.setState({
editingRoomSettings: false,
forwardingEvent: null,
});
if (this.state.forwardingEvent) {
dis.dispatch({
action: 'forward_event',
event: null,
});
}
dis.dispatch({action: 'focus_composer'});
},
@ -1408,14 +1446,6 @@ module.exports = React.createClass({
}
},
_updateAutoComplete: function(room) {
const myUserId = MatrixClientPeg.get().credentials.userId;
const members = room.getJoinedMembers().filter(function(member) {
if (member.userId !== myUserId) return true;
});
UserProvider.getInstance().setUserList(members);
},
render: function() {
const RoomHeader = sdk.getComponent('rooms.RoomHeader');
const MessageComposer = sdk.getComponent('rooms.MessageComposer');
@ -1429,6 +1459,10 @@ module.exports = React.createClass({
const Loader = sdk.getComponent("elements.Spinner");
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
// Whether the preview bar spinner should be shown. We do this when joining or
// when waiting for a room to be returned by js-sdk when joining
const previewBarSpinner = this.state.joining || this.state.waitingForRoom;
if (!this.state.room) {
if (this.state.roomLoading || this.state.peekLoading) {
return (
@ -1448,7 +1482,7 @@ module.exports = React.createClass({
// We have no room object for this room, only the ID.
// We've got to this room by following a link, possibly a third party invite.
var room_alias = this.state.room_alias;
const roomAlias = this.state.roomAlias;
return (
<div className="mx_RoomView">
<RoomHeader ref="header"
@ -1461,8 +1495,8 @@ module.exports = React.createClass({
onForgetClick={ this.onForgetClick }
onRejectClick={ this.onRejectThreepidInviteButtonClicked }
canPreview={ false } error={ this.state.roomLoadError }
roomAlias={room_alias}
spinner={this.state.joining}
roomAlias={roomAlias}
spinner={previewBarSpinner}
inviterName={inviterName}
invitedEmail={invitedEmail}
room={this.state.room}
@ -1505,7 +1539,7 @@ module.exports = React.createClass({
onRejectClick={ this.onRejectButtonClicked }
inviterName={ inviterName }
canPreview={ false }
spinner={this.state.joining}
spinner={previewBarSpinner}
room={this.state.room}
/>
</div>
@ -1561,7 +1595,7 @@ module.exports = React.createClass({
} else if (this.state.uploadingRoomSettings) {
aux = <Loader/>;
} else if (this.state.forwardingEvent !== null) {
aux = <ForwardMessage onCancelClick={this.onCancelClick} currentRoomId={this.state.room.roomId} mxEvent={this.state.forwardingEvent} />;
aux = <ForwardMessage onCancelClick={this.onCancelClick} />;
} else if (this.state.searching) {
hideCancel = true; // has own cancel
aux = <SearchBar ref="search_bar" searchInProgress={this.state.searchInProgress } onCancelClick={this.onCancelSearchClick} onSearch={this.onSearch}/>;
@ -1581,7 +1615,7 @@ module.exports = React.createClass({
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onForgetClick={ this.onForgetClick }
onRejectClick={this.onRejectThreepidInviteButtonClicked}
spinner={this.state.joining}
spinner={previewBarSpinner}
inviterName={inviterName}
invitedEmail={invitedEmail}
canPreview={this.state.canPeek}

View file

@ -352,13 +352,14 @@ module.exports = React.createClass({
const tile = tiles[backwards ? i : tiles.length - 1 - i];
// Subtract height of tile as if it were unpaginated
excessHeight -= tile.clientHeight;
//If removing the tile would lead to future pagination, break before setting scroll token
if (tile.clientHeight > excessHeight) {
break;
}
// The tile may not have a scroll token, so guard it
if (tile.dataset.scrollTokens) {
markerScrollToken = tile.dataset.scrollTokens.split(',')[0];
}
if (tile.clientHeight > excessHeight) {
break;
}
}
if (markerScrollToken) {

View file

@ -21,6 +21,7 @@ const MatrixClientPeg = require("../../MatrixClientPeg");
const PlatformPeg = require("../../PlatformPeg");
const Modal = require('../../Modal');
const dis = require("../../dispatcher");
import sessionStore from '../../stores/SessionStore';
const q = require('q');
const packageJson = require('../../../package.json');
const UserSettingsStore = require('../../UserSettingsStore');
@ -92,6 +93,10 @@ const SETTINGS_LABELS = [
id: 'disableMarkdown',
label: 'Disable markdown formatting',
},
{
id: 'enableSyntaxHighlightLanguageDetection',
label: 'Enable automatic language detection for syntax highlighting',
},
/*
{
id: 'useFixedWidthFont',
@ -169,9 +174,6 @@ module.exports = React.createClass({
// The base URL to use in the referral link. Defaults to window.location.origin.
referralBaseUrl: React.PropTypes.string,
// true if RightPanel is collapsed
collapsedRhs: React.PropTypes.bool,
// Team token for the referral link. If falsy, the referral section will
// not appear
teamToken: React.PropTypes.string,
@ -246,6 +248,12 @@ module.exports = React.createClass({
this.setState({
language: languageHandler.getCurrentLanguage(),
});
this._sessionStore = sessionStore;
this._sessionStoreToken = this._sessionStore.addListener(
this._setStateFromSessionStore,
);
this._setStateFromSessionStore();
},
componentDidMount: function() {
@ -272,6 +280,22 @@ module.exports = React.createClass({
}
},
// `UserSettings` assumes that the client peg will not be null, so give it some
// sort of assurance here by only allowing a re-render if the client is truthy.
//
// This is required because `UserSettings` maintains its own state and if this state
// updates (e.g. during _setStateFromSessionStore) after the client peg has been made
// null (during logout), then it will attempt to re-render and throw errors.
shouldComponentUpdate: function() {
return Boolean(MatrixClientPeg.get());
},
_setStateFromSessionStore: function() {
this.setState({
userHasGeneratedPassword: Boolean(this._sessionStore.getCachedPassword()),
});
},
_electronSettings: function(ev, settings) {
this.setState({ electron_settings: settings });
},
@ -622,6 +646,10 @@ module.exports = React.createClass({
},
_renderUserInterfaceSettings: function() {
// TODO: this ought to be a separate component so that we don't need
// to rebind the onChange each time we render
const onChange = (e) =>
UserSettingsStore.setLocalSetting('autocompleteDelay', + e.target.value);
return (
<div>
<h3>{ _t("User Interface") }</h3>
@ -629,8 +657,21 @@ module.exports = React.createClass({
{ this._renderUrlPreviewSelector() }
{ SETTINGS_LABELS.map( this._renderSyncedSetting ) }
{ THEMES.map( this._renderThemeSelector ) }
<table>
<tbody>
<tr>
<td><strong>{_t('Autocomplete Delay (ms):')}</strong></td>
<td>
<input
type="number"
defaultValue={UserSettingsStore.getLocalSetting('autocompleteDelay', 200)}
onChange={onChange}
/>
</td>
</tr>
</tbody>
</table>
{ this._renderLanguageSetting() }
</div>
</div>
);
@ -864,6 +905,21 @@ module.exports = React.createClass({
</div>;
},
_renderCheckUpdate: function() {
const platform = PlatformPeg.get();
if ('canSelfUpdate' in platform && platform.canSelfUpdate() && 'startUpdateCheck' in platform) {
return <div>
<h3>{_t('Updates')}</h3>
<div className="mx_UserSettings_section">
<AccessibleButton className="mx_UserSettings_button" onClick={platform.startUpdateCheck}>
{_t('Check for update')}
</AccessibleButton>
</div>
</div>;
}
return <div />;
},
_renderBulkOptions: function() {
const invitedRooms = MatrixClientPeg.get().getRooms().filter((r) => {
return r.hasMembershipState(this._me, "invite");
@ -1164,7 +1220,6 @@ module.exports = React.createClass({
<div className="mx_UserSettings">
<SimpleRoomHeader
title={ _t("Settings") }
collapsedRhs={ this.props.collapsedRhs }
onCancelClick={ this.props.onClose }
/>
@ -1205,10 +1260,14 @@ module.exports = React.createClass({
<h3>{ _t("Account") }</h3>
<div className="mx_UserSettings_section cadcampoHide">
<AccessibleButton className="mx_UserSettings_logout mx_UserSettings_button" onClick={this.onLogoutClicked}>
{ _t("Sign out") }
</AccessibleButton>
{ this.state.userHasGeneratedPassword ?
<div className="mx_UserSettings_passwordWarning">
{ _t("To return to your account in future you need to set a password") }
</div> : null
}
{accountJsx}
</div>
@ -1262,6 +1321,8 @@ module.exports = React.createClass({
</div>
</div>
{this._renderCheckUpdate()}
{this._renderClearCache()}
{this._renderDeactivateAccount()}

View file

@ -218,29 +218,29 @@ module.exports = React.createClass({
}
trackPromise.then((teamToken) => {
this.props.onLoggedIn({
return this.props.onLoggedIn({
userId: response.user_id,
deviceId: response.device_id,
homeserverUrl: this._matrixClient.getHomeserverUrl(),
identityServerUrl: this._matrixClient.getIdentityServerUrl(),
accessToken: response.access_token
}, teamToken);
}).then(() => {
return this._setupPushers();
}).then((cli) => {
return this._setupPushers(cli);
});
},
_setupPushers: function() {
_setupPushers: function(matrixClient) {
if (!this.props.brand) {
return q();
}
return MatrixClientPeg.get().getPushers().then((resp)=>{
return matrixClient.getPushers().then((resp)=>{
const pushers = resp.pushers;
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind == 'email') {
const emailPusher = pushers[i];
emailPusher.data = { brand: this.props.brand };
MatrixClientPeg.get().setPusher(emailPusher).done(() => {
matrixClient.setPusher(emailPusher).done(() => {
console.log("Set email branding to " + this.props.brand);
}, (error) => {
console.error("Couldn't set email branding: " + error);

View file

@ -178,7 +178,7 @@ module.exports = React.createClass({
},
onQueryChanged: function(ev) {
const query = ev.target.value.toLowerCase();
const query = ev.target.value;
if (this.queryChangedDebouncer) {
clearTimeout(this.queryChangedDebouncer);
}
@ -271,10 +271,11 @@ module.exports = React.createClass({
query,
searchError: null,
});
const queryLowercase = query.toLowerCase();
const results = [];
MatrixClientPeg.get().getUsers().forEach((user) => {
if (user.userId.toLowerCase().indexOf(query) === -1 &&
user.displayName.toLowerCase().indexOf(query) === -1
if (user.userId.toLowerCase().indexOf(queryLowercase) === -1 &&
user.displayName.toLowerCase().indexOf(queryLowercase) === -1
) {
return;
}

View file

@ -0,0 +1,172 @@
/*
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.
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.
*/
import Modal from '../../../Modal';
import React from 'react';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
/**
* Dialog which asks the user whether they want to share their keys with
* an unverified device.
*
* onFinished is called with `true` if the key should be shared, `false` if it
* should not, and `undefined` if the dialog is cancelled. (In other words:
* truthy: do the key share. falsy: don't share the keys).
*/
export default React.createClass({
propTypes: {
matrixClient: React.PropTypes.object.isRequired,
userId: React.PropTypes.string.isRequired,
deviceId: React.PropTypes.string.isRequired,
onFinished: React.PropTypes.func.isRequired,
},
getInitialState: function() {
return {
deviceInfo: null,
wasNewDevice: false,
};
},
componentDidMount: function() {
this._unmounted = false;
const userId = this.props.userId;
const deviceId = this.props.deviceId;
// give the client a chance to refresh the device list
this.props.matrixClient.downloadKeys([userId], false).then((r) => {
if (this._unmounted) { return; }
const deviceInfo = r[userId][deviceId];
if(!deviceInfo) {
console.warn(`No details found for device ${userId}:${deviceId}`);
this.props.onFinished(false);
return;
}
const wasNewDevice = !deviceInfo.isKnown();
this.setState({
deviceInfo: deviceInfo,
wasNewDevice: wasNewDevice,
});
// if the device was new before, it's not any more.
if (wasNewDevice) {
this.props.matrixClient.setDeviceKnown(
userId,
deviceId,
true,
);
}
}).done();
},
componentWillUnmount: function() {
this._unmounted = true;
},
_onVerifyClicked: function() {
const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog');
console.log("KeyShareDialog: Starting verify dialog");
Modal.createDialog(DeviceVerifyDialog, {
userId: this.props.userId,
device: this.state.deviceInfo,
onFinished: (verified) => {
if (verified) {
// can automatically share the keys now.
this.props.onFinished(true);
}
},
});
},
_onShareClicked: function() {
console.log("KeyShareDialog: User clicked 'share'");
this.props.onFinished(true);
},
_onIgnoreClicked: function() {
console.log("KeyShareDialog: User clicked 'ignore'");
this.props.onFinished(false);
},
_renderContent: function() {
const displayName = this.state.deviceInfo.getDisplayName() ||
this.state.deviceInfo.deviceId;
let text;
if (this.state.wasNewDevice) {
text = "You added a new device '%(displayName)s', which is"
+ " requesting encryption keys.";
} else {
text = "Your unverified device '%(displayName)s' is requesting"
+ " encryption keys.";
}
text = _t(text, {displayName: displayName});
return (
<div>
<p>{text}</p>
<div className="mx_Dialog_buttons">
<button onClick={this._onVerifyClicked}>
{_t('Start verification')}
</button>
<button onClick={this._onShareClicked}>
{_t('Share without verifying')}
</button>
<button onClick={this._onIgnoreClicked}>
{_t('Ignore request')}
</button>
</div>
</div>
);
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const Spinner = sdk.getComponent('views.elements.Spinner');
let content;
if (this.state.deviceInfo) {
content = this._renderContent();
} else {
content = (
<div>
<p>{_t('Loading device info...')}</p>
<Spinner />
</div>
);
}
return (
<BaseDialog className='mx_KeyShareRequestDialog'
onFinished={this.props.onFinished}
title={_t('Encryption key request')}
>
{content}
</BaseDialog>
);
},
});

View file

@ -0,0 +1,164 @@
/*
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.
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.
*/
import React from 'react';
import sdk from '../../../index';
import Email from '../../../email';
import AddThreepid from '../../../AddThreepid';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
/**
* Prompt the user to set an email address.
*
* On success, `onFinished(true)` is called.
*/
export default React.createClass({
displayName: 'SetEmailDialog',
propTypes: {
onFinished: React.PropTypes.func.isRequired,
},
getInitialState: function() {
return {
emailAddress: null,
emailBusy: false,
};
},
componentDidMount: function() {
},
onEmailAddressChanged: function(value) {
this.setState({
emailAddress: value,
});
},
onSubmit: function() {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const emailAddress = this.state.emailAddress;
if (!Email.looksValid(emailAddress)) {
Modal.createDialog(ErrorDialog, {
title: _t("Invalid Email Address"),
description: _t("This doesn't appear to be a valid email address"),
});
return;
}
this._addThreepid = new AddThreepid();
// we always bind emails when registering, so let's do the
// same here.
this._addThreepid.addEmailAddress(emailAddress, true).done(() => {
Modal.createDialog(QuestionDialog, {
title: _t("Verification Pending"),
description: _t(
"Please check your email and click on the link it contains. Once this " +
"is done, click continue.",
),
button: _t('Continue'),
onFinished: this.onEmailDialogFinished,
});
}, (err) => {
this.setState({emailBusy: false});
console.error("Unable to add email address " + emailAddress + " " + err);
Modal.createDialog(ErrorDialog, {
title: _t("Unable to add email address"),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
});
this.setState({emailBusy: true});
},
onCancelled: function() {
this.props.onFinished(false);
},
onEmailDialogFinished: function(ok) {
if (ok) {
this.verifyEmailAddress();
} else {
this.setState({emailBusy: false});
}
},
verifyEmailAddress: function() {
this._addThreepid.checkEmailLinkClicked().done(() => {
this.props.onFinished(true);
}, (err) => {
this.setState({emailBusy: false});
if (err.errcode == 'M_THREEPID_AUTH_FAILED') {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const message = _t("Unable to verify email address.") + " " +
_t("Please check your email and click on the link it contains. Once this is done, click continue.");
Modal.createDialog(QuestionDialog, {
title: _t("Verification Pending"),
description: message,
button: _t('Continue'),
onFinished: this.onEmailDialogFinished,
});
} else {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Unable to verify email address: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("Unable to verify email address."),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
}
});
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const Spinner = sdk.getComponent('elements.Spinner');
const EditableText = sdk.getComponent('elements.EditableText');
const emailInput = this.state.emailBusy ? <Spinner /> : <EditableText
className="mx_SetEmailDialog_email_input"
placeholder={ _t("Email address") }
placeholderClassName="mx_SetEmailDialog_email_input_placeholder"
blurToCancel={ false }
onValueChanged={ this.onEmailAddressChanged } />;
return (
<BaseDialog className="mx_SetEmailDialog"
onFinished={this.onCancelled}
title={this.props.title}
>
<div className="mx_Dialog_content">
<p>
{ _t('This will allow you to reset your password and receive notifications.') }
</p>
{ emailInput }
</div>
<div className="mx_Dialog_buttons">
<input className="mx_Dialog_primary"
type="submit"
value={_t("Continue")}
onClick={this.onSubmit}
/>
<input
type="submit"
value={_t("Skip")}
onClick={this.onCancelled}
/>
</div>
</BaseDialog>
);
},
});

View file

@ -46,6 +46,10 @@ module.exports = React.createClass({
};
},
componentWillMount: function() {
this._captchaWidgetId = null;
},
componentDidMount: function() {
// Just putting a script tag into the returned jsx doesn't work, annoyingly,
// so we do this instead.
@ -75,6 +79,10 @@ module.exports = React.createClass({
}
},
componentWillUnmount: function() {
this._resetRecaptcha();
},
_renderRecaptcha: function(divId) {
if (!global.grecaptcha) {
console.error("grecaptcha not loaded!");
@ -90,12 +98,18 @@ module.exports = React.createClass({
}
console.log("Rendering to %s", divId);
global.grecaptcha.render(divId, {
this._captchaWidgetId = global.grecaptcha.render(divId, {
sitekey: publicKey,
callback: this.props.onCaptchaResponse,
});
},
_resetRecaptcha: function() {
if (this._captchaWidgetId !== null) {
global.grecaptcha.reset(this._captchaWidgetId);
}
},
_onCaptchaLoaded: function() {
console.log("Loaded recaptcha script.");
try {

View file

@ -69,10 +69,19 @@ class PasswordLogin extends React.Component {
onSubmitForm(ev) {
ev.preventDefault();
if (this.state.loginType === PasswordLogin.LOGIN_FIELD_PHONE) {
this.props.onSubmit(
'', // XXX: Synapse breaks if you send null here:
this.state.phoneCountry,
this.state.phoneNumber,
this.state.password,
);
return;
}
this.props.onSubmit(
this.state.username,
this.state.phoneCountry,
this.state.phoneNumber,
null,
null,
this.state.password,
);
}

View file

@ -79,7 +79,7 @@ module.exports = React.createClass({
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
return this.state.decryptedThumbnailUrl;
} else if (content.info.thumbnail_url) {
} else if (content.info && content.info.thumbnail_url) {
return MatrixClientPeg.get().mxcUrlToHttp(content.info.thumbnail_url);
} else {
return null;

View file

@ -29,6 +29,7 @@ import Modal from '../../../Modal';
import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import UserSettingsStore from "../../../UserSettingsStore";
linkifyMatrix(linkify);
@ -90,7 +91,18 @@ module.exports = React.createClass({
setTimeout(() => {
if (this._unmounted) return;
for (let i = 0; i < blocks.length; i++) {
highlight.highlightBlock(blocks[i]);
if (UserSettingsStore.getSyncedSetting("enableSyntaxHighlightLanguageDetection", false)) {
highlight.highlightBlock(blocks[i])
} else {
// Only syntax highlight if there's a class starting with language-
let classes = blocks[i].className.split(/\s+/).filter(function (cl) {
return cl.startsWith('language-');
});
if (classes.length != 0) {
highlight.highlightBlock(blocks[i]);
}
}
}
}, 10);
}

View file

@ -6,6 +6,7 @@ import isEqual from 'lodash/isEqual';
import sdk from '../../../index';
import type {Completion} from '../../../autocomplete/Autocompleter';
import Q from 'q';
import UserSettingsStore from '../../../UserSettingsStore';
import {getCompletions} from '../../../autocomplete/Autocompleter';
@ -58,7 +59,7 @@ export default class Autocomplete extends React.Component {
return;
}
const completionList = flatMap(completions, provider => provider.completions);
const completionList = flatMap(completions, (provider) => provider.completions);
// Reset selection when completion list becomes empty.
let selectionOffset = COMPOSER_SELECTED;
@ -69,27 +70,35 @@ export default class Autocomplete extends React.Component {
const currentSelection = this.state.selectionOffset === 0 ? null :
this.state.completionList[this.state.selectionOffset - 1].completion;
selectionOffset = completionList.findIndex(
completion => completion.completion === currentSelection);
(completion) => completion.completion === currentSelection);
if (selectionOffset === -1) {
selectionOffset = COMPOSER_SELECTED;
} else {
selectionOffset++; // selectionOffset is 1-indexed!
}
} else {
// If no completions were returned, we should turn off force completion.
forceComplete = false;
}
let hide = this.state.hide;
// These are lists of booleans that indicate whether whether the corresponding provider had a matching pattern
const oldMatches = this.state.completions.map(completion => !!completion.command.command),
newMatches = completions.map(completion => !!completion.command.command);
const oldMatches = this.state.completions.map((completion) => !!completion.command.command),
newMatches = completions.map((completion) => !!completion.command.command);
// So, essentially, we re-show autocomplete if any provider finds a new pattern or stops finding an old one
if (!isEqual(oldMatches, newMatches)) {
hide = false;
}
const autocompleteDelay = UserSettingsStore.getSyncedSetting('autocompleteDelay', 200);
// We had no completions before, but do now, so we should apply our display delay here
if (this.state.completionList.length === 0 && completionList.length > 0 &&
!forceComplete && autocompleteDelay > 0) {
await Q.delay(autocompleteDelay);
}
// Force complete is turned off each time since we can't edit the query in that case
forceComplete = false;
this.setState({
completions,
completionList,
@ -149,6 +158,7 @@ export default class Autocomplete extends React.Component {
const done = Q.defer();
this.setState({
forceComplete: true,
hide: false,
}, () => {
this.complete(this.props.query, this.props.selection).then(() => {
done.resolve();
@ -169,7 +179,7 @@ export default class Autocomplete extends React.Component {
}
setSelection(selectionOffset: number) {
this.setState({selectionOffset});
this.setState({selectionOffset, hide: false});
}
componentDidUpdate() {
@ -185,21 +195,24 @@ export default class Autocomplete extends React.Component {
}
}
setState(state, func) {
super.setState(state, func);
}
render() {
const EmojiText = sdk.getComponent('views.elements.EmojiText');
let position = 1;
let renderedCompletions = this.state.completions.map((completionResult, i) => {
let completions = completionResult.completions.map((completion, i) => {
const renderedCompletions = this.state.completions.map((completionResult, i) => {
const completions = completionResult.completions.map((completion, i) => {
const className = classNames('mx_Autocomplete_Completion', {
'selected': position === this.state.selectionOffset,
});
let componentPosition = position;
const componentPosition = position;
position++;
let onMouseOver = () => this.setSelection(componentPosition);
let onClick = () => {
const onMouseOver = () => this.setSelection(componentPosition);
const onClick = () => {
this.setSelection(componentPosition);
this.onCompletionClicked();
};
@ -220,7 +233,7 @@ export default class Autocomplete extends React.Component {
{completionResult.provider.renderCompletions(completions)}
</div>
) : null;
}).filter(completion => !!completion);
}).filter((completion) => !!completion);
return !this.state.hide && renderedCompletions.length > 0 ? (
<div className="mx_Autocomplete" ref={(e) => this.container = e}>

View file

@ -336,6 +336,7 @@ module.exports = WithMatrixClient(React.createClass({
suppressAnimation={this._suppressReadReceiptAnimation}
onClick={this.toggleAllReadAvatars}
timestamp={receipt.ts}
showTwelveHour={this.props.isTwelveHour}
/>
);
}

View file

@ -17,7 +17,6 @@
import React from 'react';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import dis from '../../../dispatcher';
import KeyCode from '../../../KeyCode';
@ -26,11 +25,6 @@ module.exports = React.createClass({
displayName: 'ForwardMessage',
propTypes: {
currentRoomId: React.PropTypes.string.isRequired,
/* the MatrixEvent to be forwarded */
mxEvent: React.PropTypes.object.isRequired,
onCancelClick: React.PropTypes.func.isRequired,
},
@ -44,7 +38,6 @@ module.exports = React.createClass({
},
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
document.addEventListener('keydown', this._onKeyDown);
},
@ -54,30 +47,9 @@ module.exports = React.createClass({
sideOpacity: 1.0,
middleOpacity: 1.0,
});
dis.unregister(this.dispatcherRef);
document.removeEventListener('keydown', this._onKeyDown);
},
onAction: function(payload) {
if (payload.action === 'view_room') {
const event = this.props.mxEvent;
const Client = MatrixClientPeg.get();
Client.sendEvent(payload.room_id, event.getType(), event.getContent()).done(() => {
dis.dispatch({action: 'message_sent'});
}, (err) => {
if (err.name === "UnknownDeviceError") {
dis.dispatch({
action: 'unknown_device_error',
err: err,
room: Client.getRoom(payload.room_id),
});
}
dis.dispatch({action: 'message_send_failed'});
});
if (this.props.currentRoomId === payload.room_id) this.props.onCancelClick();
}
},
_onKeyDown: function(ev) {
switch (ev.keyCode) {
case KeyCode.ESCAPE:

View file

@ -20,7 +20,6 @@ import {Editor, EditorState, RichUtils, CompositeDecorator,
convertFromRaw, convertToRaw, Modifier, EditorChangeType,
getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState} from 'draft-js';
import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown';
import classNames from 'classnames';
import escape from 'lodash/escape';
import Q from 'q';
@ -41,6 +40,7 @@ import * as HtmlUtils from '../../../HtmlUtils';
import Autocomplete from './Autocomplete';
import {Completion} from "../../../autocomplete/Autocompleter";
import Markdown from '../../../Markdown';
import ComposerHistoryManager from '../../../ComposerHistoryManager';
import {onSendMessageFailed} from './MessageComposerInputOld';
const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
@ -58,6 +58,29 @@ function stateToMarkdown(state) {
* The textInput part of the MessageComposer
*/
export default class MessageComposerInput extends React.Component {
static 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,
// called with current plaintext content (as a string) whenever it changes
onContentChanged: React.PropTypes.func,
onUpArrow: React.PropTypes.func,
onDownArrow: React.PropTypes.func,
// attempts to confirm currently selected completion, returns whether actually confirmed
tryComplete: React.PropTypes.func,
onInputStateChanged: React.PropTypes.func,
};
static getKeyBinding(e: SyntheticKeyboardEvent): string {
// C-m => Toggles between rich text and markdown modes
if (e.keyCode === KeyCode.KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) {
@ -77,6 +100,7 @@ export default class MessageComposerInput extends React.Component {
client: MatrixClient;
autocomplete: Autocomplete;
historyManager: ComposerHistoryManager;
constructor(props, context) {
super(props, context);
@ -84,7 +108,6 @@ export default class MessageComposerInput extends React.Component {
this.handleReturn = this.handleReturn.bind(this);
this.handleKeyCommand = this.handleKeyCommand.bind(this);
this.onEditorContentChanged = this.onEditorContentChanged.bind(this);
this.setEditorState = this.setEditorState.bind(this);
this.onUpArrow = this.onUpArrow.bind(this);
this.onDownArrow = this.onDownArrow.bind(this);
this.onTab = this.onTab.bind(this);
@ -119,7 +142,7 @@ export default class MessageComposerInput extends React.Component {
*/
createEditorState(richText: boolean, contentState: ?ContentState): EditorState {
let decorators = richText ? RichText.getScopedRTDecorators(this.props) :
RichText.getScopedMDDecorators(this.props),
RichText.getScopedMDDecorators(this.props),
compositeDecorator = new CompositeDecorator(decorators);
let editorState = null;
@ -132,110 +155,13 @@ export default class MessageComposerInput extends React.Component {
return EditorState.moveFocusToEnd(editorState);
}
componentWillMount() {
const component = 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(
"mx_messagecomposer_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(
"mx_messagecomposer_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;
}
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!
let contentJSON = JSON.stringify(convertToRaw(component.state.editorState.getCurrentContent()));
window.sessionStorage.setItem("mx_messagecomposer_input_" + this.roomId, contentJSON);
},
setLastTextEntry: function() {
let contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId);
if (contentJSON) {
let content = convertFromRaw(JSON.parse(contentJSON));
component.setEditorState(component.createEditorState(component.state.isRichtextEnabled, content));
}
},
};
}
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
this.sentHistory.init(
this.refs.editor,
this.props.room.roomId
);
this.historyManager = new ComposerHistoryManager(this.props.room.roomId);
}
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
this.sentHistory.saveLastTextEntry();
}
componentWillUpdate(nextProps, nextState) {
@ -247,8 +173,8 @@ export default class MessageComposerInput extends React.Component {
}
}
onAction(payload) {
let editor = this.refs.editor;
onAction = (payload) => {
const editor = this.refs.editor;
let contentState = this.state.editorState.getCurrentContent();
switch (payload.action) {
@ -262,22 +188,22 @@ export default class MessageComposerInput extends React.Component {
contentState = Modifier.replaceText(
contentState,
this.state.editorState.getSelection(),
`${payload.displayname}: `
`${payload.displayname}: `,
);
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter());
this.onEditorContentChanged(editorState);
editor.focus();
}
break;
break;
case 'quote': {
let {body, formatted_body} = payload.event.getContent();
formatted_body = formatted_body || escape(body);
if (formatted_body) {
let content = RichText.HTMLtoContentState(`<blockquote>${formatted_body}</blockquote>`);
let content = RichText.htmlToContentState(`<blockquote>${formatted_body}</blockquote>`);
if (!this.state.isRichtextEnabled) {
content = ContentState.createFromText(stateToMarkdown(content));
content = ContentState.createFromText(RichText.stateToMarkdown(content));
}
const blockMap = content.getBlockMap();
@ -291,14 +217,14 @@ export default class MessageComposerInput extends React.Component {
if (this.state.isRichtextEnabled) {
contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote');
}
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
const editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
this.onEditorContentChanged(editorState);
editor.focus();
}
}
break;
break;
}
}
};
onTypingActivity() {
this.isTyping = true;
@ -318,7 +244,7 @@ export default class MessageComposerInput extends React.Component {
startUserTypingTimer() {
this.stopUserTypingTimer();
var self = this;
const self = this;
this.userTypingTimer = setTimeout(function() {
self.isTyping = false;
self.sendTyping(self.isTyping);
@ -335,7 +261,7 @@ export default class MessageComposerInput extends React.Component {
startServerTypingTimer() {
if (!this.serverTypingTimer) {
var self = this;
const self = this;
this.serverTypingTimer = setTimeout(function() {
if (self.isTyping) {
self.sendTyping(self.isTyping);
@ -356,7 +282,7 @@ export default class MessageComposerInput extends React.Component {
if (UserSettingsStore.getSyncedSetting('dontSendTypingNotifications', false)) return;
MatrixClientPeg.get().sendTyping(
this.props.room.roomId,
this.isTyping, TYPING_SERVER_TIMEOUT
this.isTyping, TYPING_SERVER_TIMEOUT,
).done();
}
@ -367,60 +293,80 @@ export default class MessageComposerInput extends React.Component {
}
}
// Called by Draft to change editor contents, and by setEditorState
onEditorContentChanged(editorState: EditorState, didRespondToUserInput: boolean = true) {
// Called by Draft to change editor contents
onEditorContentChanged = (editorState: EditorState) => {
editorState = RichText.attachImmutableEntitiesToEmoji(editorState);
const contentChanged = Q.defer();
/* If a modification was made, set originalEditorState to null, since newState is now our original */
/* Since a modification was made, set originalEditorState to null, since newState is now our original */
this.setState({
editorState,
originalEditorState: didRespondToUserInput ? null : this.state.originalEditorState,
}, () => contentChanged.resolve());
originalEditorState: null,
});
};
if (editorState.getCurrentContent().hasText()) {
this.onTypingActivity();
} else {
this.onFinishedTyping();
/**
* We're overriding setState here because it's the most convenient way to monitor changes to the editorState.
* Doing it using a separate function that calls setState is a possibility (and was the old approach), but that
* approach requires a callback and an extra setState whenever trying to set multiple state properties.
*
* @param state
* @param callback
*/
setState(state, callback) {
if (state.editorState != null) {
state.editorState = RichText.attachImmutableEntitiesToEmoji(
state.editorState);
if (state.editorState.getCurrentContent().hasText()) {
this.onTypingActivity();
} else {
this.onFinishedTyping();
}
if (!state.hasOwnProperty('originalEditorState')) {
state.originalEditorState = null;
}
}
if (this.props.onContentChanged) {
const textContent = editorState.getCurrentContent().getPlainText();
const selection = RichText.selectionStateToTextOffsets(editorState.getSelection(),
editorState.getCurrentContent().getBlocksAsArray());
super.setState(state, () => {
if (callback != null) {
callback();
}
this.props.onContentChanged(textContent, selection);
}
return contentChanged.promise;
}
setEditorState(editorState: EditorState) {
return this.onEditorContentChanged(editorState, false);
if (this.props.onContentChanged) {
const textContent = this.state.editorState
.getCurrentContent().getPlainText();
const selection = RichText.selectionStateToTextOffsets(
this.state.editorState.getSelection(),
this.state.editorState.getCurrentContent().getBlocksAsArray());
this.props.onContentChanged(textContent, selection);
}
});
}
enableRichtext(enabled: boolean) {
if (enabled === this.state.isRichtextEnabled) return;
let contentState = null;
if (enabled) {
const md = new Markdown(this.state.editorState.getCurrentContent().getPlainText());
contentState = RichText.HTMLtoContentState(md.toHTML());
contentState = RichText.htmlToContentState(md.toHTML());
} else {
let markdown = stateToMarkdown(this.state.editorState.getCurrentContent());
let markdown = RichText.stateToMarkdown(this.state.editorState.getCurrentContent());
if (markdown[markdown.length - 1] === '\n') {
markdown = markdown.substring(0, markdown.length - 1); // stateToMarkdown tacks on an extra newline (?!?)
}
contentState = ContentState.createFromText(markdown);
}
this.setEditorState(this.createEditorState(enabled, contentState)).then(() => {
this.setState({
isRichtextEnabled: enabled,
});
UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled);
this.setState({
editorState: this.createEditorState(enabled, contentState),
isRichtextEnabled: enabled,
});
UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled);
}
handleKeyCommand(command: string): boolean {
handleKeyCommand = (command: string): boolean => {
if (command === 'toggle-mode') {
this.enableRichtext(!this.state.isRichtextEnabled);
return true;
@ -434,31 +380,35 @@ export default class MessageComposerInput extends React.Component {
const blockCommands = ['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item'];
if (blockCommands.includes(command)) {
this.setEditorState(RichUtils.toggleBlockType(this.state.editorState, command));
this.setState({
editorState: RichUtils.toggleBlockType(this.state.editorState, command),
});
} else if (command === 'strike') {
// this is the only inline style not handled by Draft by default
this.setEditorState(RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH'));
this.setState({
editorState: RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH'),
});
}
} else {
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*
'strike': text => `~~${text}~~`,
'code': text => `\`${text}\``,
'blockquote': text => text.split('\n').map(line => `> ${line}\n`).join(''),
'unordered-list-item': text => text.split('\n').map(line => `- ${line}\n`).join(''),
'ordered-list-item': text => text.split('\n').map((line, i) => `${i+1}. ${line}\n`).join(''),
const modifyFn = {
'bold': (text) => `**${text}**`,
'italic': (text) => `*${text}*`,
'underline': (text) => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug*
'strike': (text) => `<del>${text}</del>`,
'code-block': (text) => `\`\`\`\n${text}\n\`\`\``,
'blockquote': (text) => text.split('\n').map((line) => `> ${line}\n`).join(''),
'unordered-list-item': (text) => text.split('\n').map((line) => `\n- ${line}`).join(''),
'ordered-list-item': (text) => text.split('\n').map((line, i) => `\n${i + 1}. ${line}`).join(''),
}[command];
if (modifyFn) {
newState = EditorState.push(
this.state.editorState,
RichText.modifyText(contentState, selection, modifyFn),
'insert-characters'
'insert-characters',
);
}
}
@ -468,7 +418,7 @@ export default class MessageComposerInput extends React.Component {
}
if (newState != null) {
this.setEditorState(newState);
this.setState({editorState: newState});
return true;
}
@ -481,6 +431,13 @@ export default class MessageComposerInput extends React.Component {
return true;
}
const currentBlockType = RichUtils.getCurrentBlockType(this.state.editorState);
// If we're in any of these three types of blocks, shift enter should insert soft newlines
// And just enter should end the block
if(['blockquote', 'unordered-list-item', 'ordered-list-item'].includes(currentBlockType)) {
return false;
}
const contentState = this.state.editorState.getCurrentContent();
if (!contentState.hasText()) {
return true;
@ -489,11 +446,11 @@ export default class MessageComposerInput extends React.Component {
let contentText = contentState.getPlainText(), contentHTML;
var cmd = SlashCommands.processInput(this.props.room.roomId, contentText);
const cmd = SlashCommands.processInput(this.props.room.roomId, contentText);
if (cmd) {
if (!cmd.error) {
this.setState({
editorState: this.createEditorState()
editorState: this.createEditorState(),
});
}
if (cmd.promise) {
@ -501,16 +458,15 @@ export default class MessageComposerInput extends React.Component {
console.log("Command success.");
}, function(err) {
console.error("Command failure: %s", err);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: _t("Server error"),
description: ((err && err.message) ? err.message : _t("Server unavailable, overloaded, or something else went wrong.")),
});
});
}
else if (cmd.error) {
} else if (cmd.error) {
console.error(cmd.error);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: _t("Command error"),
description: cmd.error,
@ -521,7 +477,7 @@ export default class MessageComposerInput extends React.Component {
if (this.state.isRichtextEnabled) {
contentHTML = HtmlUtils.stripParagraphs(
RichText.contentStateToHTML(contentState)
RichText.contentStateToHTML(contentState),
);
} else {
const md = new Markdown(contentText);
@ -543,12 +499,14 @@ export default class MessageComposerInput extends React.Component {
sendTextFn = this.client.sendEmoteMessage;
}
// XXX: We don't actually seem to use this history?
this.sentHistory.push(contentHTML || contentText);
this.historyManager.addItem(
this.state.isRichtextEnabled ? contentHTML : contentState.getPlainText(),
this.state.isRichtextEnabled ? 'html' : 'markdown');
let sendMessagePromise;
if (contentHTML) {
sendMessagePromise = sendHtmlFn.call(
this.client, this.props.room.roomId, contentText, contentHTML
this.client, this.props.room.roomId, contentText, contentHTML,
);
} else {
sendMessagePromise = sendTextFn.call(this.client, this.props.room.roomId, contentText);
@ -567,87 +525,106 @@ export default class MessageComposerInput extends React.Component {
this.autocomplete.hide();
return true;
}
};
async onUpArrow(e) {
onUpArrow = async (e) => {
const completion = this.autocomplete.onUpArrow();
if (completion != null) {
e.preventDefault();
if (completion == null) {
const newContent = this.historyManager.getItem(-1, this.state.isRichtextEnabled ? 'html' : 'markdown');
if (!newContent) return false;
const editorState = EditorState.push(this.state.editorState,
newContent,
'insert-characters');
this.setState({editorState});
return true;
}
return await this.setDisplayedCompletion(completion);
}
async onDownArrow(e) {
const completion = this.autocomplete.onDownArrow();
e.preventDefault();
return await this.setDisplayedCompletion(completion);
}
};
onDownArrow = async (e) => {
const completion = this.autocomplete.onDownArrow();
if (completion == null) {
const newContent = this.historyManager.getItem(+1, this.state.isRichtextEnabled ? 'html' : 'markdown');
if (!newContent) return false;
const editorState = EditorState.push(this.state.editorState,
newContent,
'insert-characters');
this.setState({editorState});
return true;
}
e.preventDefault();
return await this.setDisplayedCompletion(completion);
};
// tab and shift-tab are mapped to down and up arrow respectively
async onTab(e) {
onTab = async (e) => {
e.preventDefault(); // we *never* want tab's default to happen, but we do want up/down sometimes
const didTab = await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e);
if (!didTab && this.autocomplete) {
this.autocomplete.forceComplete().then(() => {
this.onDownArrow(e);
});
if (this.autocomplete.state.completionList.length === 0) {
await this.autocomplete.forceComplete();
this.onDownArrow(e);
} else {
await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e);
}
}
};
onEscape(e) {
onEscape = async (e) => {
e.preventDefault();
if (this.autocomplete) {
this.autocomplete.onEscape(e);
}
this.setDisplayedCompletion(null); // restore originalEditorState
}
await this.setDisplayedCompletion(null); // restore originalEditorState
};
/* If passed null, restores the original editor content from state.originalEditorState.
* If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState.
*/
async setDisplayedCompletion(displayedCompletion: ?Completion): boolean {
setDisplayedCompletion = async (displayedCompletion: ?Completion): boolean => {
const activeEditorState = this.state.originalEditorState || this.state.editorState;
if (displayedCompletion == null) {
if (this.state.originalEditorState) {
this.setEditorState(this.state.originalEditorState);
let editorState = this.state.originalEditorState;
// This is a workaround from https://github.com/facebook/draft-js/issues/458
// Due to the way we swap editorStates, Draft does not rerender at times
editorState = EditorState.forceSelection(editorState,
editorState.getSelection());
this.setState({editorState});
}
return false;
}
const {range = {}, completion = ''} = displayedCompletion;
let contentState = Modifier.replaceText(
const contentState = Modifier.replaceText(
activeEditorState.getCurrentContent(),
RichText.textOffsetsToSelectionState(range, activeEditorState.getCurrentContent().getBlocksAsArray()),
completion
completion,
);
let editorState = EditorState.push(activeEditorState, contentState, 'insert-characters');
editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter());
const originalEditorState = activeEditorState;
await this.setEditorState(editorState);
this.setState({originalEditorState});
this.setState({editorState, originalEditorState: activeEditorState});
// for some reason, doing this right away does not update the editor :(
setTimeout(() => this.refs.editor.focus(), 50);
// setTimeout(() => this.refs.editor.focus(), 50);
return true;
}
};
onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) {
e.preventDefault(); // don't steal focus from the editor!
const command = {
code: 'code-block',
quote: 'blockquote',
bullet: 'unordered-list-item',
numbullet: 'ordered-list-item',
}[name] || name;
code: 'code-block',
quote: 'blockquote',
bullet: 'unordered-list-item',
numbullet: 'ordered-list-item',
}[name] || name;
this.handleKeyCommand(command);
}
/* returns inline style and block type of current SelectionState so MessageComposer can render formatting
buttons. */
buttons. */
getSelectionInfo(editorState: EditorState) {
const styleName = {
BOLD: 'bold',
@ -658,8 +635,8 @@ export default class MessageComposerInput extends React.Component {
const originalStyle = editorState.getCurrentInlineStyle().toArray();
const style = originalStyle
.map(style => styleName[style] || null)
.filter(styleName => !!styleName);
.map((style) => styleName[style] || null)
.filter((styleName) => !!styleName);
const blockName = {
'code-block': 'code',
@ -678,10 +655,10 @@ export default class MessageComposerInput extends React.Component {
};
}
onMarkdownToggleClicked(e) {
onMarkdownToggleClicked = (e) => {
e.preventDefault(); // don't steal focus from the editor!
this.handleKeyCommand('toggle-mode');
}
};
render() {
const activeEditorState = this.state.originalEditorState || this.state.editorState;
@ -698,7 +675,7 @@ export default class MessageComposerInput extends React.Component {
}
const className = classNames('mx_MessageComposer_input', {
mx_MessageComposer_input_empty: hidePlaceholder,
mx_MessageComposer_input_empty: hidePlaceholder,
});
const content = activeEditorState.getCurrentContent();
@ -713,7 +690,7 @@ export default class MessageComposerInput extends React.Component {
ref={(e) => this.autocomplete = e}
onConfirm={this.setDisplayedCompletion}
query={contentText}
selection={selection} />
selection={selection}/>
</div>
<div className={className}>
<img className="mx_MessageComposer_input_markdownIndicator mx_filterFlipColor"
@ -735,7 +712,7 @@ export default class MessageComposerInput extends React.Component {
onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow}
onEscape={this.onEscape}
spellCheck={true} />
spellCheck={true}/>
</div>
</div>
);

View file

@ -66,6 +66,9 @@ module.exports = React.createClass({
// Timestamp when the receipt was read
timestamp: React.PropTypes.number,
// True to show twelve hour format, false otherwise
showTwelveHour: React.PropTypes.bool,
},
getDefaultProps: function() {
@ -172,7 +175,7 @@ module.exports = React.createClass({
if (this.props.timestamp) {
title = _t(
"Seen by %(userName)s at %(dateTime)s",
{userName: this.props.member.userId, dateTime: DateUtils.formatDate(new Date(this.props.timestamp))}
{userName: this.props.member.userId, dateTime: DateUtils.formatDate(new Date(this.props.timestamp), this.props.showTwelveHour)}
);
}

View file

@ -18,7 +18,7 @@ limitations under the License.
'use strict';
var React = require("react");
var ReactDOM = require("react-dom");
import { _t } from '../../../languageHandler';
import { _t, _tJsx } from '../../../languageHandler';
var GeminiScrollbar = require('react-gemini-scrollbar');
var MatrixClientPeg = require("../../../MatrixClientPeg");
var CallHandler = require('../../../CallHandler');
@ -33,11 +33,28 @@ var Receipt = require('../../../utils/Receipt');
const HIDE_CONFERENCE_CHANS = true;
const VERBS = {
'm.favourite': 'favourite',
'im.vector.fake.direct': 'tag direct chat',
'im.vector.fake.recent': 'restore',
'm.lowpriority': 'demote',
function phraseForSection(section) {
// These would probably be better as individual strings,
// but for some reason we have translations for these strings
// as-is, so keeping it like this for now.
let verb;
switch (section) {
case 'm.favourite':
verb = _t('to favourite');
break;
case 'im.vector.fake.direct':
verb = _t('to tag direct chat');
break;
case 'im.vector.fake.recent':
verb = _t('to restore');
break;
case 'm.lowpriority':
verb = _t('to demote');
break;
default:
return _t('Drop here to tag %(section)s', {section: section});
}
return _t('Drop here %(toAction)s', {toAction: verb});
};
module.exports = React.createClass({
@ -478,17 +495,25 @@ module.exports = React.createClass({
switch (section) {
case 'im.vector.fake.direct':
return <div className="mx_RoomList_emptySubListTip">
Press
<StartChatButton size="16" callout={true}/>
to start a chat with someone
{_tJsx(
"Press <StartChatButton> to start a chat with someone",
[/<StartChatButton>/],
[
(sub) => <StartChatButton size="16" callout={true}/>
]
)}
</div>;
case 'im.vector.fake.recent':
return <div className="mx_RoomList_emptySubListTip">
You're not in any rooms yet! Press
<CreateRoomButton size="16" callout={true}/>
to make a room or
<RoomDirectoryButton size="16" callout={true}/>
to browse the directory
{_tJsx(
"You're not in any rooms yet! Press <CreateRoomButton> to make a room or"+
" <RoomDirectoryButton> to browse the directory",
[/<CreateRoomButton>/, /<RoomDirectoryButton>/],
[
(sub) => <CreateRoomButton size="16" callout={true}/>,
(sub) => <RoomDirectoryButton size="16" callout={true}/>
]
)}
</div>;
}
@ -497,7 +522,7 @@ module.exports = React.createClass({
return null;
}
const labelText = 'Drop here to ' + (VERBS[section] || 'tag ' + section);
const labelText = phraseForSection(section);
return <RoomDropTarget label={labelText} />;
},

View file

@ -594,7 +594,7 @@ module.exports = React.createClass({
<div>
<label>
{ isEncrypted
? <img className="mx_RoomSettings_e2eIcon mx_filterFlipColor" src="img/e2e-verified.svg" width="10" height="12" />
? <img className="mx_RoomSettings_e2eIcon" src="img/e2e-verified.svg" width="10" height="12" />
: <img className="mx_RoomSettings_e2eIcon mx_filterFlipColor" src="img/e2e-unencrypted.svg" width="12" height="12" />
}
{ isEncrypted ? _t("Encryption is enabled in this room") : _t("Encryption is not enabled in this room") }.

View file

@ -14,10 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import dis from '../../../dispatcher';
import AccessibleButton from '../elements/AccessibleButton';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
@ -45,17 +42,10 @@ export default React.createClass({
title: React.PropTypes.string,
onCancelClick: React.PropTypes.func,
// is the RightPanel collapsed?
collapsedRhs: React.PropTypes.bool,
// `src` to a TintableSvg. Optional.
icon: React.PropTypes.string,
},
onShowRhsClick: function(ev) {
dis.dispatch({ action: 'show_right_panel' });
},
render: function() {
let cancelButton;
let icon;
@ -70,25 +60,12 @@ export default React.createClass({
/>;
}
let showRhsButton;
/* // don't bother cluttering things up with this for now.
const TintableSvg = sdk.getComponent("elements.TintableSvg");
if (this.props.collapsedRhs) {
showRhsButton =
<div className="mx_RoomHeader_button" style={{ float: 'right' }} onClick={this.onShowRhsClick} title=">">
<TintableSvg src="img/minimise.svg" width="10" height="16"/>
</div>
}
*/
return (
<div className="mx_RoomHeader" >
<div className="mx_RoomHeader_wrapper">
<div className="mx_RoomHeader_simpleHeader">
{ icon }
{ this.props.title }
{ showRhsButton }
{ cancelButton }
</div>
</div>

View file

@ -20,6 +20,8 @@ var React = require('react');
var MatrixClientPeg = require("../../../MatrixClientPeg");
var Modal = require("../../../Modal");
var sdk = require("../../../index");
import q from 'q';
import AccessibleButton from '../elements/AccessibleButton';
import { _t } from '../../../languageHandler';
@ -140,7 +142,15 @@ module.exports = React.createClass({
});
cli.setPassword(authDict, newPassword).then(() => {
this.props.onFinished();
if (this.props.shouldAskForEmail) {
return this._optionallySetEmail().then((confirmed) => {
this.props.onFinished({
didSetEmail: confirmed,
});
});
} else {
this.props.onFinished();
}
}, (err) => {
this.props.onError(err);
}).finally(() => {
@ -150,6 +160,20 @@ module.exports = React.createClass({
}).done();
},
_optionallySetEmail: function() {
const deferred = q.defer();
// Ask for an email otherwise the user has no way to reset their password
const SetEmailDialog = sdk.getComponent("dialogs.SetEmailDialog");
Modal.createDialog(SetEmailDialog, {
title: _t('Do you want to set an email address?'),
onFinished: (confirmed) => {
// ignore confirmed, setting an email is optional
deferred.resolve(confirmed);
},
});
return deferred.promise;
},
_onExportE2eKeysClicked: function() {
Modal.createDialogAsync(
(cb) => {
@ -199,7 +223,7 @@ module.exports = React.createClass({
const passwordLabel = this.state.cachedPassword ?
_t('Password') : _t('New Password');
return (
<div className={this.props.className}>
<form className={this.props.className} onSubmit={this.onClickChange}>
{ currentPassword }
<div className={rowClassName}>
<div className={rowLabelClassName}>
@ -222,7 +246,7 @@ module.exports = React.createClass({
element="button">
{ _t('Change Password') }
</AccessibleButton>
</div>
</form>
);
case this.Phases.Uploading:
var Loader = sdk.getComponent("elements.Spinner");