Merge branch 'develop' into matthew/whitelist-uri-schemes

This commit is contained in:
Luke Barnard 2017-04-13 14:08:19 +01:00 committed by GitHub
commit ec2a5cce74
80 changed files with 4742 additions and 1738 deletions

23
.editorconfig Normal file
View File

@ -0,0 +1,23 @@
# Copyright 2017 Aviral Dasgupta
#
# 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.
root = true
[*]
charset=utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true

View File

@ -1,3 +1,249 @@
Changes in [0.8.7](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.7) (2017-04-12)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.7-rc.4...v0.8.7)
* No changes
Changes in [0.8.7-rc.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.7-rc.4) (2017-04-11)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.7-rc.3...v0.8.7-rc.4)
* Fix people section vanishing on 'clear cache'
[\#799](https://github.com/matrix-org/matrix-react-sdk/pull/799)
* Make the clear cache button work on desktop
[\#798](https://github.com/matrix-org/matrix-react-sdk/pull/798)
Changes in [0.8.7-rc.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.7-rc.3) (2017-04-10)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.7-rc.2...v0.8.7-rc.3)
* Use matrix-js-sdk v0.7.6-rc.2
Changes in [0.8.7-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.7-rc.2) (2017-04-10)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.7-rc.1...v0.8.7-rc.2)
* fix the warning shown to users about needing to export e2e keys
[\#797](https://github.com/matrix-org/matrix-react-sdk/pull/797)
Changes in [0.8.7-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.7-rc.1) (2017-04-07)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.6...v0.8.7-rc.1)
* Add support for using indexeddb in a webworker
[\#792](https://github.com/matrix-org/matrix-react-sdk/pull/792)
* Fix infinite pagination/glitches with pagination
[\#795](https://github.com/matrix-org/matrix-react-sdk/pull/795)
* Fix issue where teamTokenMap was ignored for guests
[\#793](https://github.com/matrix-org/matrix-react-sdk/pull/793)
* Click emote sender -> insert display name into composer
[\#791](https://github.com/matrix-org/matrix-react-sdk/pull/791)
* Fix scroll token selection logic
[\#785](https://github.com/matrix-org/matrix-react-sdk/pull/785)
* Replace sdkReady with firstSyncPromise, add mx_last_room_id
[\#790](https://github.com/matrix-org/matrix-react-sdk/pull/790)
* Change "Unread messages." to "Jump to first unread message."
[\#789](https://github.com/matrix-org/matrix-react-sdk/pull/789)
* Update for new IndexedDBStore interface
[\#786](https://github.com/matrix-org/matrix-react-sdk/pull/786)
* Add <ol start="..."> to allowed attributes list
[\#787](https://github.com/matrix-org/matrix-react-sdk/pull/787)
* Fix the onFinished for timeline pos dialog
[\#784](https://github.com/matrix-org/matrix-react-sdk/pull/784)
* Only join a room when enter is hit if the join button is shown
[\#776](https://github.com/matrix-org/matrix-react-sdk/pull/776)
* Remove non-functional session load error
[\#783](https://github.com/matrix-org/matrix-react-sdk/pull/783)
* Use Login & Register via component interface
[\#782](https://github.com/matrix-org/matrix-react-sdk/pull/782)
* Attempt to fix the flakyness seen with tests
[\#781](https://github.com/matrix-org/matrix-react-sdk/pull/781)
* Remove React warning
[\#780](https://github.com/matrix-org/matrix-react-sdk/pull/780)
* Only clear the local notification count if needed
[\#779](https://github.com/matrix-org/matrix-react-sdk/pull/779)
* Don't re-notify about messages on browser refresh
[\#777](https://github.com/matrix-org/matrix-react-sdk/pull/777)
* Improve zeroing of RoomList notification badges
[\#775](https://github.com/matrix-org/matrix-react-sdk/pull/775)
* Fix VOIP bar hidden on first render of RoomStatusBar
[\#774](https://github.com/matrix-org/matrix-react-sdk/pull/774)
* Correct confirm prompt for disinvite
[\#772](https://github.com/matrix-org/matrix-react-sdk/pull/772)
* Add state loggingIn to MatrixChat to fix flashing login
[\#773](https://github.com/matrix-org/matrix-react-sdk/pull/773)
* Fix bug where you can't invite a valid address
[\#771](https://github.com/matrix-org/matrix-react-sdk/pull/771)
* Fix people section DropTarget and refactor Rooms
[\#761](https://github.com/matrix-org/matrix-react-sdk/pull/761)
* Read Receipt offset
[\#770](https://github.com/matrix-org/matrix-react-sdk/pull/770)
* Support adding phone numbers in UserSettings
[\#756](https://github.com/matrix-org/matrix-react-sdk/pull/756)
* Prevent crash on login of no guest session
[\#769](https://github.com/matrix-org/matrix-react-sdk/pull/769)
* Add canResetTimeline callback and thread it through to TimelinePanel
[\#768](https://github.com/matrix-org/matrix-react-sdk/pull/768)
* Show spinner whilst processing recaptcha response
[\#767](https://github.com/matrix-org/matrix-react-sdk/pull/767)
* Login / registration with phone number, mark 2
[\#750](https://github.com/matrix-org/matrix-react-sdk/pull/750)
* Display threepids slightly prettier
[\#758](https://github.com/matrix-org/matrix-react-sdk/pull/758)
* Fix extraneous leading space in sent emotes
[\#764](https://github.com/matrix-org/matrix-react-sdk/pull/764)
* Add ConfirmRedactDialog component
[\#763](https://github.com/matrix-org/matrix-react-sdk/pull/763)
* Fix password UI auth test
[\#760](https://github.com/matrix-org/matrix-react-sdk/pull/760)
* Display timestamps and profiles for redacted events
[\#759](https://github.com/matrix-org/matrix-react-sdk/pull/759)
* Fix UDD for voip in e2e rooms
[\#757](https://github.com/matrix-org/matrix-react-sdk/pull/757)
* Add "Export E2E keys" option to logout dialog
[\#755](https://github.com/matrix-org/matrix-react-sdk/pull/755)
* Fix People section a bit
[\#754](https://github.com/matrix-org/matrix-react-sdk/pull/754)
* Do routing to /register _onLoadCompleted
[\#753](https://github.com/matrix-org/matrix-react-sdk/pull/753)
* Double UNPAGINATION_PADDING again
[\#747](https://github.com/matrix-org/matrix-react-sdk/pull/747)
* Add null check to start_login
[\#751](https://github.com/matrix-org/matrix-react-sdk/pull/751)
* Merge the two RoomTile context menus into one
[\#746](https://github.com/matrix-org/matrix-react-sdk/pull/746)
* Fix import for Lifecycle
[\#748](https://github.com/matrix-org/matrix-react-sdk/pull/748)
* Make UDD appear when UDE on uploading a file
[\#745](https://github.com/matrix-org/matrix-react-sdk/pull/745)
* Decide on which screen to show after login in one place
[\#743](https://github.com/matrix-org/matrix-react-sdk/pull/743)
* Add onClick to permalinks to route within Riot
[\#744](https://github.com/matrix-org/matrix-react-sdk/pull/744)
* Add support for pasting files into the text box
[\#605](https://github.com/matrix-org/matrix-react-sdk/pull/605)
* Show message redactions as black event tiles
[\#739](https://github.com/matrix-org/matrix-react-sdk/pull/739)
* Allow user to choose from existing DMs on new chat
[\#736](https://github.com/matrix-org/matrix-react-sdk/pull/736)
* Fix the team server registration
[\#741](https://github.com/matrix-org/matrix-react-sdk/pull/741)
* Clarify "No devices" message
[\#740](https://github.com/matrix-org/matrix-react-sdk/pull/740)
* Change timestamp permalinks to matrix.to
[\#735](https://github.com/matrix-org/matrix-react-sdk/pull/735)
* Fix resend bar and "send anyway" in UDD
[\#734](https://github.com/matrix-org/matrix-react-sdk/pull/734)
* Make COLOR_REGEX stricter
[\#737](https://github.com/matrix-org/matrix-react-sdk/pull/737)
* Port registration over to use InteractiveAuth
[\#729](https://github.com/matrix-org/matrix-react-sdk/pull/729)
* Test to see how fuse feels
[\#732](https://github.com/matrix-org/matrix-react-sdk/pull/732)
* Submit a new display name on blur of input field
[\#733](https://github.com/matrix-org/matrix-react-sdk/pull/733)
* Allow [bf]g colors for <font> style attrib
[\#610](https://github.com/matrix-org/matrix-react-sdk/pull/610)
* MELS: either expanded or summary, not both
[\#683](https://github.com/matrix-org/matrix-react-sdk/pull/683)
* Autoplay videos and GIFs if enabled by the user.
[\#730](https://github.com/matrix-org/matrix-react-sdk/pull/730)
* Warn users about using e2e for the first time
[\#731](https://github.com/matrix-org/matrix-react-sdk/pull/731)
* Show UDDialog on UDE during VoIP calls
[\#721](https://github.com/matrix-org/matrix-react-sdk/pull/721)
* Notify MatrixChat of teamToken after login
[\#726](https://github.com/matrix-org/matrix-react-sdk/pull/726)
* Fix a couple of issues with RRs
[\#727](https://github.com/matrix-org/matrix-react-sdk/pull/727)
* Do not push a dummy element with a scroll token for invisible events
[\#718](https://github.com/matrix-org/matrix-react-sdk/pull/718)
* MELS: check scroll on load + use mels-1,-2,... key
[\#715](https://github.com/matrix-org/matrix-react-sdk/pull/715)
* Fix message composer placeholders
[\#723](https://github.com/matrix-org/matrix-react-sdk/pull/723)
* Clarify non-e2e vs. e2e /w composers placeholder
[\#720](https://github.com/matrix-org/matrix-react-sdk/pull/720)
* Fix status bar expanded on tab-complete
[\#722](https://github.com/matrix-org/matrix-react-sdk/pull/722)
* add .editorconfig
[\#713](https://github.com/matrix-org/matrix-react-sdk/pull/713)
* Change the name of the database
[\#719](https://github.com/matrix-org/matrix-react-sdk/pull/719)
* Allow setting the default HS from the query parameter
[\#716](https://github.com/matrix-org/matrix-react-sdk/pull/716)
* first cut of improving UX for deleting devices.
[\#717](https://github.com/matrix-org/matrix-react-sdk/pull/717)
* Fix block quotes all being on a single line
[\#711](https://github.com/matrix-org/matrix-react-sdk/pull/711)
* Support reasons for kick / ban
[\#710](https://github.com/matrix-org/matrix-react-sdk/pull/710)
* Show when you've been kicked or banned
[\#709](https://github.com/matrix-org/matrix-react-sdk/pull/709)
* Add a 'Clear Cache' button
[\#708](https://github.com/matrix-org/matrix-react-sdk/pull/708)
* Update the room view on room name change
[\#707](https://github.com/matrix-org/matrix-react-sdk/pull/707)
* Add a button to un-ban users in RoomSettings
[\#698](https://github.com/matrix-org/matrix-react-sdk/pull/698)
* Use IndexedDBStore from the JS-SDK
[\#687](https://github.com/matrix-org/matrix-react-sdk/pull/687)
* Make UserSettings use the right teamToken
[\#706](https://github.com/matrix-org/matrix-react-sdk/pull/706)
* If the home page is somehow accessed, goto directory
[\#705](https://github.com/matrix-org/matrix-react-sdk/pull/705)
* Display avatar initials in typing notifications
[\#699](https://github.com/matrix-org/matrix-react-sdk/pull/699)
* fix eslint's no-invalid-this rule for class properties
[\#703](https://github.com/matrix-org/matrix-react-sdk/pull/703)
* If a referrer hasn't been specified, use empty string
[\#701](https://github.com/matrix-org/matrix-react-sdk/pull/701)
* Don't force-logout the user if reading localstorage fails
[\#700](https://github.com/matrix-org/matrix-react-sdk/pull/700)
* Convert some missed buttons to AccessibleButton
[\#697](https://github.com/matrix-org/matrix-react-sdk/pull/697)
* Make ban either ban or unban
[\#696](https://github.com/matrix-org/matrix-react-sdk/pull/696)
* Add confirmation dialog to kick/ban buttons
[\#694](https://github.com/matrix-org/matrix-react-sdk/pull/694)
* Fix typo with Scalar popup
[\#695](https://github.com/matrix-org/matrix-react-sdk/pull/695)
* Treat the literal team token string "undefined" as undefined
[\#693](https://github.com/matrix-org/matrix-react-sdk/pull/693)
* Store retrieved sid in the signupInstance of EmailIdentityStage
[\#692](https://github.com/matrix-org/matrix-react-sdk/pull/692)
* Split out InterActiveAuthDialog
[\#691](https://github.com/matrix-org/matrix-react-sdk/pull/691)
* View /home on registered /w team
[\#689](https://github.com/matrix-org/matrix-react-sdk/pull/689)
* Instead of sending userId, userEmail, send sid, client_secret
[\#688](https://github.com/matrix-org/matrix-react-sdk/pull/688)
* Enable branded URLs again by parsing the path client-side
[\#686](https://github.com/matrix-org/matrix-react-sdk/pull/686)
* Use new method of getting team icon
[\#680](https://github.com/matrix-org/matrix-react-sdk/pull/680)
* Persist query parameter team token across refreshes
[\#685](https://github.com/matrix-org/matrix-react-sdk/pull/685)
* Thread teamToken through to LeftPanel for "Home" button
[\#684](https://github.com/matrix-org/matrix-react-sdk/pull/684)
* Fix typing notif and status bar
[\#682](https://github.com/matrix-org/matrix-react-sdk/pull/682)
* Consider emails ending in matrix.org as a uni email
[\#681](https://github.com/matrix-org/matrix-react-sdk/pull/681)
* Set referrer qp in nextLink
[\#679](https://github.com/matrix-org/matrix-react-sdk/pull/679)
* Do not set team_token if not returned by RTS on login
[\#678](https://github.com/matrix-org/matrix-react-sdk/pull/678)
* Get team_token from the RTS on login
[\#676](https://github.com/matrix-org/matrix-react-sdk/pull/676)
* Quick and dirty support for custom welcome pages
[\#550](https://github.com/matrix-org/matrix-react-sdk/pull/550)
* RTS Welcome Pages
[\#666](https://github.com/matrix-org/matrix-react-sdk/pull/666)
* Logging to try to track down riot-web#3148
[\#677](https://github.com/matrix-org/matrix-react-sdk/pull/677)
Changes in [0.8.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.6) (2017-02-04) Changes in [0.8.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.6) (2017-02-04)
=================================================================================================== ===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.6-rc.3...v0.8.6) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.6-rc.3...v0.8.6)

View File

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "0.8.6", "version": "0.8.7",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -51,11 +52,36 @@ class AddThreepid {
}); });
} }
/**
* Attempt to add a msisdn threepid. This will trigger a side-effect of
* sending a test message to the provided phone number.
* @param {string} phoneCountry The ISO 2 letter code of the country to resolve phoneNumber in
* @param {string} phoneNumber The national or international formatted phone number to add
* @param {boolean} bind If True, bind this phone number to this mxid on the Identity Server
* @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken().
*/
addMsisdn(phoneCountry, phoneNumber, bind) {
this.bind = bind;
return MatrixClientPeg.get().requestAdd3pidMsisdnToken(
phoneCountry, phoneNumber, this.clientSecret, 1,
).then((res) => {
this.sessionId = res.sid;
return res;
}, function(err) {
if (err.errcode == 'M_THREEPID_IN_USE') {
err.message = "This phone number is already in use";
} else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`;
}
throw err;
});
}
/** /**
* Checks if the email link has been clicked by attempting to add the threepid * Checks if the email link has been clicked by attempting to add the threepid
* @return {Promise} Resolves if the password was reset. Rejects with an object * @return {Promise} Resolves if the email address was added. Rejects with an object
* with a "message" property which contains a human-readable message detailing why * with a "message" property which contains a human-readable message detailing why
* the reset failed, e.g. "There is no mapped matrix user ID for the given email address". * the request failed.
*/ */
checkEmailLinkClicked() { checkEmailLinkClicked() {
var identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1]; var identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1];
@ -73,6 +99,29 @@ class AddThreepid {
throw err; throw err;
}); });
} }
/**
* Takes a phone number verification code as entered by the user and validates
* it with the ID server, then if successful, adds the phone number.
* @return {Promise} Resolves if the phone number was added. Rejects with an object
* with a "message" property which contains a human-readable message detailing why
* the request failed.
*/
haveMsisdnToken(token) {
return MatrixClientPeg.get().submitMsisdnToken(
this.sessionId, this.clientSecret, token,
).then((result) => {
if (result.errcode) {
throw result;
}
const identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1];
return MatrixClientPeg.get().addThreePid({
sid: this.sessionId,
client_secret: this.clientSecret,
id_server: identityServerDomain
}, this.bind);
});
}
} }
module.exports = AddThreepid; module.exports = AddThreepid;

View File

@ -82,4 +82,12 @@ export default class BasePlatform {
screenCaptureErrorString() { screenCaptureErrorString() {
return "Not implemented"; return "Not implemented";
} }
/**
* Restarts the application, without neccessarily reloading
* any application code
*/
reload() {
throw new Error("reload not implemented!");
}
} }

View File

@ -105,6 +105,15 @@ function _setCallListeners(call) {
call.hangup(); call.hangup();
_setCallState(undefined, call.roomId, "ended"); _setCallState(undefined, call.roomId, "ended");
}); });
call.on('send_event_error', function(err) {
if (err.name === "UnknownDeviceError") {
dis.dispatch({
action: 'unknown_device_error',
err: err,
room: MatrixClientPeg.get().getRoom(call.roomId),
});
}
});
call.on("hangup", function() { call.on("hangup", function() {
_setCallState(undefined, call.roomId, "ended"); _setCallState(undefined, call.roomId, "ended");
}); });
@ -301,9 +310,10 @@ function _onAction(payload) {
placeCall(call); placeCall(call);
}, function(err) { }, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Conference call failed: " + err);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Failed to set up conference call", title: "Failed to set up conference call",
description: "Conference call failed: " + err, description: "Conference call failed.",
}); });
}); });
} }

View File

@ -276,7 +276,7 @@ class ContentMessages {
sendContentToRoom(file, roomId, matrixClient) { sendContentToRoom(file, roomId, matrixClient) {
const content = { const content = {
body: file.name, body: file.name || 'Attachment',
info: { info: {
size: file.size, size: file.size,
} }
@ -316,7 +316,7 @@ class ContentMessages {
} }
const upload = { const upload = {
fileName: file.name, fileName: file.name || 'Attachment',
roomId: roomId, roomId: roomId,
total: 0, total: 0,
loaded: 0, loaded: 0,

View File

@ -28,6 +28,7 @@ emojione.imagePathSVG = 'emojione/svg/';
emojione.imageType = 'svg'; emojione.imageType = 'svg';
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi"); const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi");
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
/* modified from https://github.com/Ranks/emojione/blob/master/lib/js/emojione.js /* modified from https://github.com/Ranks/emojione/blob/master/lib/js/emojione.js
* because we want to include emoji shortnames in title text * because we want to include emoji shortnames in title text
@ -57,6 +58,22 @@ export function unicodeToImage(str) {
return str; return str;
} }
/**
* Given one or more unicode characters (represented by unicode
* character number), return an image node with the corresponding
* emoji.
*
* @param alt {string} String to use for the image alt text
* @param unicode {integer} One or more integers representing unicode characters
* @returns A img node with the corresponding emoji
*/
export function charactersToImageNode(alt, ...unicode) {
const fileName = unicode.map((u) => {
return u.toString(16);
}).join('-');
return <img alt={alt} src={`${emojione.imagePathSVG}${fileName}.svg${emojione.cacheBustParam}`}/>;
}
export function stripParagraphs(html: string): string { export function stripParagraphs(html: string): string {
const contentDiv = document.createElement('div'); const contentDiv = document.createElement('div');
contentDiv.innerHTML = html; contentDiv.innerHTML = html;
@ -87,15 +104,17 @@ var sanitizeHtmlParams = {
// deliberately no h1/h2 to stop people shouting. // deliberately no h1/h2 to stop people shouting.
'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol',
'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'img', 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img',
], ],
allowedAttributes: { allowedAttributes: {
// custom ones first: // custom ones first:
font: ['color'], // custom to matrix font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix
a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
// We don't currently allow img itself by default, but this // We don't currently allow img itself by default, but this
// would make sense if we did // would make sense if we did
img: ['src'], img: ['src'],
ol: ['start'],
}, },
// Lots of these won't come up by default because we don't allow them // Lots of these won't come up by default because we don't allow them
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
@ -136,6 +155,38 @@ var sanitizeHtmlParams = {
attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/
return { tagName: tagName, attribs : attribs }; return { tagName: tagName, attribs : attribs };
}, },
'*': function(tagName, attribs) {
// Delete any style previously assigned, style is an allowedTag for font and span
// because attributes are stripped after transforming
delete attribs.style;
// Sanitise and transform data-mx-color and data-mx-bg-color to their CSS
// equivalents
const customCSSMapper = {
'data-mx-color': 'color',
'data-mx-bg-color': 'background-color',
// $customAttributeKey: $cssAttributeKey
};
let style = "";
Object.keys(customCSSMapper).forEach((customAttributeKey) => {
const cssAttributeKey = customCSSMapper[customAttributeKey];
const customAttributeValue = attribs[customAttributeKey];
if (customAttributeValue &&
typeof customAttributeValue === 'string' &&
COLOR_REGEX.test(customAttributeValue)
) {
style += cssAttributeKey + ":" + customAttributeValue + ";";
delete attribs[customAttributeKey];
}
});
if (style) {
attribs.style = style;
}
return { tagName: tagName, attribs: attribs };
},
}, },
}; };

View File

@ -19,8 +19,7 @@ import MultiInviter from './utils/MultiInviter';
const emailRegex = /^\S+@\S+\.\S+$/; const emailRegex = /^\S+@\S+\.\S+$/;
// We allow localhost for mxids to avoid confusion const mxidRegex = /^@\S+:\S+$/
const mxidRegex = /^@\S+:(?:\S+\.\S+|localhost)$/
export function getAddressType(inputText) { export function getAddressType(inputText) {
const isEmailAddress = emailRegex.test(inputText); const isEmailAddress = emailRegex.test(inputText);

View File

@ -155,7 +155,7 @@ function _loginWithToken(queryParams, defaultDeviceDisplayName) {
function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
console.log("Doing guest login on %s", hsUrl); console.log("Doing guest login on %s", hsUrl);
// TODO: we should probably de-duplicate this and Signup.Login.loginAsGuest. // TODO: we should probably de-duplicate this and Login.loginAsGuest.
// Not really sure where the right home for it is. // Not really sure where the right home for it is.
// create a temporary MatrixClient to do the login // create a temporary MatrixClient to do the login
@ -276,6 +276,14 @@ export function setLoggedIn(credentials) {
console.log("setLoggedIn => %s (guest=%s) hs=%s", console.log("setLoggedIn => %s (guest=%s) hs=%s",
credentials.userId, credentials.guest, credentials.userId, credentials.guest,
credentials.homeserverUrl); credentials.homeserverUrl);
// This is dispatched to indicate that the user is still in the process of logging in
// because `teamPromise` may take some time to resolve, breaking the assumption that
// `setLoggedIn` takes an "instant" to complete, and dispatch `on_logged_in` a few ms
// later than MatrixChat might assume.
dis.dispatch({action: 'on_logging_in'});
// Resolves by default
let teamPromise = Promise.resolve(null);
// persist the session // persist the session
if (localStorage) { if (localStorage) {
@ -300,25 +308,29 @@ export function setLoggedIn(credentials) {
console.warn("Error using local storage: can't persist session!", e); console.warn("Error using local storage: can't persist session!", e);
} }
if (rtsClient) { if (rtsClient && !credentials.guest) {
rtsClient.login(credentials.userId).then((body) => { teamPromise = rtsClient.login(credentials.userId).then((body) => {
if (body.team_token) { if (body.team_token) {
localStorage.setItem("mx_team_token", body.team_token); localStorage.setItem("mx_team_token", body.team_token);
} }
}, (err) =>{ return body.team_token;
console.error(
"Failed to get team token on login, not persisting to localStorage",
err
);
}); });
} }
} else { } else {
console.warn("No local storage available: can't persist session!"); console.warn("No local storage available: can't persist session!");
} }
// stop any running clients before we create a new one with these new credentials
stopMatrixClient();
MatrixClientPeg.replaceUsingCreds(credentials); MatrixClientPeg.replaceUsingCreds(credentials);
dis.dispatch({action: 'on_logged_in'}); teamPromise.then((teamToken) => {
dis.dispatch({action: 'on_logged_in', teamToken: teamToken});
}, (err) => {
console.warn("Failed to get team token on login", err);
dis.dispatch({action: 'on_logged_in', teamToken: null});
});
startMatrixClient(); startMatrixClient();
} }

205
src/Login.js Normal file
View File

@ -0,0 +1,205 @@
/*
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.
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 Matrix from "matrix-js-sdk";
import q from 'q';
import url from 'url';
export default class Login {
constructor(hsUrl, isUrl, fallbackHsUrl, opts) {
this._hsUrl = hsUrl;
this._isUrl = isUrl;
this._fallbackHsUrl = fallbackHsUrl;
this._currentFlowIndex = 0;
this._flows = [];
this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
}
getHomeserverUrl() {
return this._hsUrl;
}
getIdentityServerUrl() {
return this._isUrl;
}
setHomeserverUrl(hsUrl) {
this._hsUrl = hsUrl;
}
setIdentityServerUrl(isUrl) {
this._isUrl = isUrl;
}
/**
* Get a temporary MatrixClient, which can be used for login or register
* requests.
*/
_createTemporaryClient() {
return Matrix.createClient({
baseUrl: this._hsUrl,
idBaseUrl: this._isUrl,
});
}
getFlows() {
var self = this;
var client = this._createTemporaryClient();
return client.loginFlows().then(function(result) {
self._flows = result.flows;
self._currentFlowIndex = 0;
// technically the UI should display options for all flows for the
// user to then choose one, so return all the flows here.
return self._flows;
});
}
chooseFlow(flowIndex) {
this._currentFlowIndex = flowIndex;
}
getCurrentFlowStep() {
// technically the flow can have multiple steps, but no one does this
// for login so we can ignore it.
var flowStep = this._flows[this._currentFlowIndex];
return flowStep ? flowStep.type : null;
}
loginAsGuest() {
var client = this._createTemporaryClient();
return client.registerGuest({
body: {
initial_device_display_name: this._defaultDeviceDisplayName,
},
}).then((creds) => {
return {
userId: creds.user_id,
deviceId: creds.device_id,
accessToken: creds.access_token,
homeserverUrl: this._hsUrl,
identityServerUrl: this._isUrl,
guest: true
};
}, (error) => {
if (error.httpStatus === 403) {
error.friendlyText = "Guest access is disabled on this Home Server.";
} else {
error.friendlyText = "Failed to register as guest: " + error.data;
}
throw error;
});
}
loginViaPassword(username, phoneCountry, phoneNumber, pass) {
const self = this;
const isEmail = username.indexOf("@") > 0;
let identifier;
let legacyParams; // parameters added to support old HSes
if (phoneCountry && phoneNumber) {
identifier = {
type: 'm.id.phone',
country: phoneCountry,
number: phoneNumber,
};
// No legacy support for phone number login
} else if (isEmail) {
identifier = {
type: 'm.id.thirdparty',
medium: 'email',
address: username,
};
legacyParams = {
medium: 'email',
address: username,
};
} else {
identifier = {
type: 'm.id.user',
user: username,
};
legacyParams = {
user: username,
};
}
const loginParams = {
password: pass,
identifier: identifier,
initial_device_display_name: this._defaultDeviceDisplayName,
};
Object.assign(loginParams, legacyParams);
const client = this._createTemporaryClient();
return client.login('m.login.password', loginParams).then(function(data) {
return q({
homeserverUrl: self._hsUrl,
identityServerUrl: self._isUrl,
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token
});
}, function(error) {
if (error.httpStatus == 400 && loginParams.medium) {
error.friendlyText = (
'This Home Server does not support login using email address.'
);
}
else if (error.httpStatus === 403) {
error.friendlyText = (
'Incorrect username and/or password.'
);
if (self._fallbackHsUrl) {
var fbClient = Matrix.createClient({
baseUrl: self._fallbackHsUrl,
idBaseUrl: this._isUrl,
});
return fbClient.login('m.login.password', loginParams).then(function(data) {
return q({
homeserverUrl: self._fallbackHsUrl,
identityServerUrl: self._isUrl,
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token
});
}, function(fallback_error) {
// throw the original error
throw error;
});
}
}
else {
error.friendlyText = (
'There was a problem logging in. (HTTP ' + error.httpStatus + ")"
);
}
throw error;
});
}
redirectToCas() {
var client = this._createTemporaryClient();
var parsedUrl = url.parse(window.location.href, true);
parsedUrl.query["homeserver"] = client.getHomeserverUrl();
parsedUrl.query["identityServer"] = client.getIdentityServerUrl();
var casUrl = client.getCasLoginUrl(url.format(parsedUrl));
window.location.href = casUrl;
}
}

View File

@ -92,7 +92,16 @@ export default class Markdown {
} }
toHTML() { toHTML() {
const renderer = new commonmark.HtmlRenderer({safe: false}); const renderer = new commonmark.HtmlRenderer({
safe: false,
// Set soft breaks to hard HTML breaks: commonmark
// puts softbreaks in for multiple lines in a blockquote,
// so if these are just newline characters then the
// block quote ends up all on one line
// (https://github.com/vector-im/riot-web/issues/3154)
softbreak: '<br />',
});
const real_paragraph = renderer.paragraph; const real_paragraph = renderer.paragraph;
renderer.paragraph = function(node, entering) { renderer.paragraph = function(node, entering) {

View File

@ -50,6 +50,18 @@ class MatrixClientPeg {
this.opts = { this.opts = {
initialSyncLimit: 20, initialSyncLimit: 20,
}; };
this.indexedDbWorkerScript = null;
}
/**
* Sets the script href passed to the IndexedDB web worker
* If set, a separate web worker will be started to run the IndexedDB
* queries on.
*
* @param {string} script href to the script to be passed to the web worker
*/
setIndexedDbWorkerScript(script) {
this.indexedDbWorkerScript = script;
} }
get(): MatrixClient { get(): MatrixClient {
@ -122,12 +134,15 @@ class MatrixClientPeg {
opts.sessionStore = new Matrix.WebStorageSessionStore(localStorage); opts.sessionStore = new Matrix.WebStorageSessionStore(localStorage);
} }
if (window.indexedDB && localStorage) { if (window.indexedDB && localStorage) {
opts.store = new Matrix.IndexedDBStore( // FIXME: bodge to remove old database. Remove this after a few weeks.
new Matrix.IndexedDBStoreBackend(window.indexedDB), window.indexedDB.deleteDatabase("matrix-js-sdk:default");
new Matrix.SyncAccumulator(), {
opts.store = new Matrix.IndexedDBStore({
indexedDB: window.indexedDB,
dbName: "riot-web-sync",
localStorage: localStorage, localStorage: localStorage,
} workerScript: this.indexedDbWorkerScript,
); });
} }
this.matrixClient = Matrix.createClient(opts); this.matrixClient = Matrix.createClient(opts);

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,8 +15,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
'use strict';
var MatrixClientPeg = require("./MatrixClientPeg"); var MatrixClientPeg = require("./MatrixClientPeg");
var PlatformPeg = require("./PlatformPeg"); var PlatformPeg = require("./PlatformPeg");
var TextForEvent = require('./TextForEvent'); var TextForEvent = require('./TextForEvent');
@ -99,16 +98,16 @@ var Notifier = {
MatrixClientPeg.get().on("Room.receipt", this.boundOnRoomReceipt); MatrixClientPeg.get().on("Room.receipt", this.boundOnRoomReceipt);
MatrixClientPeg.get().on("sync", this.boundOnSyncStateChange); MatrixClientPeg.get().on("sync", this.boundOnSyncStateChange);
this.toolbarHidden = false; this.toolbarHidden = false;
this.isPrepared = false; this.isSyncing = false;
}, },
stop: function() { stop: function() {
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get() && this.boundOnRoomTimeline) {
MatrixClientPeg.get().removeListener('Room.timeline', this.boundOnRoomTimeline); MatrixClientPeg.get().removeListener('Room.timeline', this.boundOnRoomTimeline);
MatrixClientPeg.get().removeListener("Room.receipt", this.boundOnRoomReceipt); MatrixClientPeg.get().removeListener("Room.receipt", this.boundOnRoomReceipt);
MatrixClientPeg.get().removeListener('sync', this.boundOnSyncStateChange); MatrixClientPeg.get().removeListener('sync', this.boundOnSyncStateChange);
} }
this.isPrepared = false; this.isSyncing = false;
}, },
supportsDesktopNotifications: function() { supportsDesktopNotifications: function() {
@ -214,18 +213,18 @@ var Notifier = {
}, },
onSyncStateChange: function(state) { onSyncStateChange: function(state) {
if (state === "PREPARED" || state === "SYNCING") { if (state === "SYNCING") {
this.isPrepared = true; this.isSyncing = true;
} }
else if (state === "STOPPED" || state === "ERROR") { else if (state === "STOPPED" || state === "ERROR") {
this.isPrepared = false; this.isSyncing = false;
} }
}, },
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
if (toStartOfTimeline) return; if (toStartOfTimeline) return;
if (!room) return; if (!room) return;
if (!this.isPrepared) return; // don't alert for any messages initially if (!this.isSyncing) return; // don't alert for any messages initially
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) return; if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) return;
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;

View File

@ -18,11 +18,27 @@ var MatrixClientPeg = require('./MatrixClientPeg');
var dis = require('./dispatcher'); var dis = require('./dispatcher');
var sdk = require('./index'); var sdk = require('./index');
var Modal = require('./Modal'); var Modal = require('./Modal');
import { EventStatus } from 'matrix-js-sdk';
module.exports = { module.exports = {
resendUnsentEvents: function(room) {
room.getPendingEvents().filter(function(ev) {
return ev.status === EventStatus.NOT_SENT;
}).forEach(function(event) {
module.exports.resend(event);
});
},
cancelUnsentEvents: function(room) {
room.getPendingEvents().filter(function(ev) {
return ev.status === EventStatus.NOT_SENT;
}).forEach(function(event) {
module.exports.removeFromQueue(event);
});
},
resend: function(event) { resend: function(event) {
const room = MatrixClientPeg.get().getRoom(event.getRoomId());
MatrixClientPeg.get().resendEvent( MatrixClientPeg.get().resendEvent(
event, MatrixClientPeg.get().getRoom(event.getRoomId()) event, room
).done(function(res) { ).done(function(res) {
dis.dispatch({ dis.dispatch({
action: 'message_sent', action: 'message_sent',
@ -33,16 +49,11 @@ module.exports = {
// https://github.com/vector-im/riot-web/issues/3148 // https://github.com/vector-im/riot-web/issues/3148
console.log('Resend got send failure: ' + err.name + '('+err+')'); console.log('Resend got send failure: ' + err.name + '('+err+')');
if (err.name === "UnknownDeviceError") { if (err.name === "UnknownDeviceError") {
var UnknownDeviceDialog = sdk.getComponent("dialogs.UnknownDeviceDialog"); dis.dispatch({
Modal.createDialog(UnknownDeviceDialog, { action: 'unknown_device_error',
devices: err.devices, err: err,
room: MatrixClientPeg.get().getRoom(event.getRoomId()), room: room,
onFinished: (r) => { });
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('UnknownDeviceDialog closed with '+r);
},
}, "mx_Dialog_unknownDevice");
} }
dis.dispatch({ dis.dispatch({
@ -51,7 +62,6 @@ module.exports = {
}); });
}); });
}, },
removeFromQueue: function(event) { removeFromQueue: function(event) {
MatrixClientPeg.get().cancelPendingEvent(event); MatrixClientPeg.get().cancelPendingEvent(event);
dis.dispatch({ dis.dispatch({

29
src/Roles.js Normal file
View File

@ -0,0 +1,29 @@
/*
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.
*/
export const LEVEL_ROLE_MAP = {
undefined: 'Default',
0: 'User',
50: 'Moderator',
100: 'Admin',
};
export function textualPowerLevel(level, userDefault) {
if (LEVEL_ROLE_MAP[level]) {
return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${userDefault})`);
} else {
return level;
}
}

View File

@ -79,6 +79,20 @@ export function looksLikeDirectMessageRoom(room, me) {
return false; return false;
} }
export function guessAndSetDMRoom(room, isDirect) {
let newTarget;
if (isDirect) {
const guessedTarget = guessDMRoomTarget(
room, room.getMember(MatrixClientPeg.get().credentials.userId),
);
newTarget = guessedTarget.userId;
} else {
newTarget = null;
}
return setDMRoom(room.roomId, newTarget);
}
/** /**
* Marks or unmarks the given room as being as a DM room. * Marks or unmarks the given room as being as a DM room.
* @param {string} roomId The ID of the room to modify * @param {string} roomId The ID of the room to modify

View File

@ -1,465 +0,0 @@
"use strict";
import Matrix from "matrix-js-sdk";
var MatrixClientPeg = require("./MatrixClientPeg");
var SignupStages = require("./SignupStages");
var dis = require("./dispatcher");
var q = require("q");
var url = require("url");
const EMAIL_STAGE_TYPE = "m.login.email.identity";
/**
* A base class for common functionality between Registration and Login e.g.
* storage of HS/IS URLs.
*/
class Signup {
constructor(hsUrl, isUrl, opts) {
this._hsUrl = hsUrl;
this._isUrl = isUrl;
this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
}
getHomeserverUrl() {
return this._hsUrl;
}
getIdentityServerUrl() {
return this._isUrl;
}
setHomeserverUrl(hsUrl) {
this._hsUrl = hsUrl;
}
setIdentityServerUrl(isUrl) {
this._isUrl = isUrl;
}
/**
* Get a temporary MatrixClient, which can be used for login or register
* requests.
*/
_createTemporaryClient() {
return Matrix.createClient({
baseUrl: this._hsUrl,
idBaseUrl: this._isUrl,
});
}
}
/**
* Registration logic class
* This exists for the lifetime of a user's attempt to register an account,
* so if their registration attempt fails for whatever reason and they
* try again, call register() on the same instance again.
*
* TODO: parts of this overlap heavily with InteractiveAuth in the js-sdk. It
* would be nice to make use of that rather than rolling our own version of it.
*/
class Register extends Signup {
constructor(hsUrl, isUrl, opts) {
super(hsUrl, isUrl, opts);
this.setStep("START");
this.data = null; // from the server
// random other stuff (e.g. query params, NOT params from the server)
this.params = {};
this.credentials = null;
this.activeStage = null;
this.registrationPromise = null;
// These values MUST be undefined else we'll send "username: null" which
// will error on Synapse rather than having the key absent.
this.username = undefined; // desired
this.email = undefined; // desired
this.password = undefined; // desired
}
setClientSecret(secret) {
this.params.clientSecret = secret;
}
setSessionId(sessionId) {
this.params.sessionId = sessionId;
}
setRegistrationUrl(regUrl) {
this.params.registrationUrl = regUrl;
}
setIdSid(idSid) {
this.params.idSid = idSid;
}
setReferrer(referrer) {
this.params.referrer = referrer;
}
setGuestAccessToken(token) {
this.guestAccessToken = token;
}
getStep() {
return this._step;
}
getCredentials() {
return this.credentials;
}
getServerData() {
return this.data || {};
}
getPromise() {
return this.registrationPromise;
}
setStep(step) {
this._step = 'Register.' + step;
// TODO:
// It's a shame this is going to the global dispatcher, we only really
// want things which have an instance of this class to be able to add
// listeners...
console.log("Dispatching 'registration_step_update' for step %s", this._step);
dis.dispatch({
action: "registration_step_update"
});
}
/**
* Starts the registration process from the first stage
*/
register(formVals) {
var {username, password, email} = formVals;
this.email = email;
this.username = username;
this.password = password;
const client = this._createTemporaryClient();
this.activeStage = null;
// If there hasn't been a client secret set by this point,
// generate one for this session. It will only be used if
// we do email verification, but far simpler to just make
// sure we have one.
// We re-use this same secret over multiple calls to register
// so that the identity server can honour the sendAttempt
// parameter and not re-send email unless we actually want
// another mail to be sent.
if (!this.params.clientSecret) {
this.params.clientSecret = client.generateClientSecret();
}
return this._tryRegister(client);
}
_tryRegister(client, authDict, poll_for_success) {
var self = this;
var bindEmail;
if (this.username && this.password) {
// only need to bind_email when sending u/p - sending it at other
// times clobbers the u/p resulting in M_MISSING_PARAM (password)
bindEmail = true;
}
// TODO need to figure out how to send the device display name to /register.
return client.register(
this.username, this.password, this.params.sessionId, authDict, bindEmail,
this.guestAccessToken
).then(function(result) {
self.credentials = result;
self.setStep("COMPLETE");
return result; // contains the credentials
}, function(error) {
if (error.httpStatus === 401) {
if (error.data && error.data.flows) {
// Remember the session ID from the server:
// Either this is our first 401 in which case we need to store the
// session ID for future calls, or it isn't in which case this
// is just a no-op since it ought to be the same (or if it isn't,
// we should use the latest one from the server in any case).
self.params.sessionId = error.data.session;
self.data = error.data || {};
var flow = self.chooseFlow(error.data.flows);
if (flow) {
console.log("Active flow => %s", JSON.stringify(flow));
var flowStage = self.firstUncompletedStage(flow);
if (!self.activeStage || flowStage != self.activeStage.type) {
return self._startStage(client, flowStage).catch(function(err) {
self.setStep('START');
throw err;
});
}
}
}
if (poll_for_success) {
return q.delay(2000).then(function() {
return self._tryRegister(client, authDict, poll_for_success);
});
} else {
throw new Error("Authorisation failed!");
}
} else {
if (error.errcode === 'M_USER_IN_USE') {
throw new Error("Username in use");
} else if (error.errcode == 'M_INVALID_USERNAME') {
throw new Error("User names may only contain alphanumeric characters, underscores or dots!");
} else if (error.httpStatus >= 400 && error.httpStatus < 500) {
let msg = null;
if (error.message) {
msg = error.message;
} else if (error.errcode) {
msg = error.errcode;
}
if (msg) {
throw new Error(`Registration failed! (${error.httpStatus}) - ${msg}`);
} else {
throw new Error(`Registration failed! (${error.httpStatus}) - That's all we know.`);
}
} else if (error.httpStatus >= 500 && error.httpStatus < 600) {
throw new Error(
`Server error during registration! (${error.httpStatus})`
);
} else if (error.name == "M_MISSING_PARAM") {
// The HS hasn't remembered the login params from
// the first try when the login email was sent.
throw new Error(
"This home server does not support resuming registration."
);
}
}
});
}
firstUncompletedStage(flow) {
for (var i = 0; i < flow.stages.length; ++i) {
if (!this.hasCompletedStage(flow.stages[i])) {
return flow.stages[i];
}
}
}
hasCompletedStage(stageType) {
var completed = (this.data || {}).completed || [];
return completed.indexOf(stageType) !== -1;
}
_startStage(client, stageName) {
var self = this;
this.setStep(`STEP_${stageName}`);
var StageClass = SignupStages[stageName];
if (!StageClass) {
// no idea how to handle this!
throw new Error("Unknown stage: " + stageName);
}
var stage = new StageClass(client, this);
this.activeStage = stage;
return stage.complete().then(function(request) {
if (request.auth) {
console.log("Stage %s is returning an auth dict", stageName);
return self._tryRegister(client, request.auth, request.poll_for_success);
}
else {
// never resolve the promise chain. This is for things like email auth
// which display a "check your email" message and relies on the
// link in the email to actually register you.
console.log("Waiting for external action.");
return q.defer().promise;
}
});
}
chooseFlow(flows) {
// If the user gave us an email then we want to pick an email
// flow we can do, else any other flow.
var emailFlow = null;
var otherFlow = null;
flows.forEach(function(flow) {
var flowHasEmail = false;
for (var stageI = 0; stageI < flow.stages.length; ++stageI) {
var stage = flow.stages[stageI];
if (!SignupStages[stage]) {
// we can't do this flow, don't have a Stage impl.
return;
}
if (stage === EMAIL_STAGE_TYPE) {
flowHasEmail = true;
}
}
if (flowHasEmail) {
emailFlow = flow;
} else {
otherFlow = flow;
}
});
if (this.email || this.hasCompletedStage(EMAIL_STAGE_TYPE)) {
// we've been given an email or we've already done an email part
return emailFlow;
} else {
return otherFlow;
}
}
recheckState() {
// We've been given a bunch of data from a previous register step,
// this only happens for email auth currently. It's kinda ming we need
// to know this though. A better solution would be to ask the stages if
// they are ready to do something rather than accepting that we know about
// email auth and its internals.
this.params.hasEmailInfo = (
this.params.clientSecret && this.params.sessionId && this.params.idSid
);
if (this.params.hasEmailInfo) {
const client = this._createTemporaryClient();
this.registrationPromise = this._startStage(client, EMAIL_STAGE_TYPE);
}
return this.registrationPromise;
}
tellStage(stageName, data) {
if (this.activeStage && this.activeStage.type === stageName) {
console.log("Telling stage %s about something..", stageName);
this.activeStage.onReceiveData(data);
}
}
}
class Login extends Signup {
constructor(hsUrl, isUrl, fallbackHsUrl, opts) {
super(hsUrl, isUrl, opts);
this._fallbackHsUrl = fallbackHsUrl;
this._currentFlowIndex = 0;
this._flows = [];
}
getFlows() {
var self = this;
var client = this._createTemporaryClient();
return client.loginFlows().then(function(result) {
self._flows = result.flows;
self._currentFlowIndex = 0;
// technically the UI should display options for all flows for the
// user to then choose one, so return all the flows here.
return self._flows;
});
}
chooseFlow(flowIndex) {
this._currentFlowIndex = flowIndex;
}
getCurrentFlowStep() {
// technically the flow can have multiple steps, but no one does this
// for login so we can ignore it.
var flowStep = this._flows[this._currentFlowIndex];
return flowStep ? flowStep.type : null;
}
loginAsGuest() {
var client = this._createTemporaryClient();
return client.registerGuest({
body: {
initial_device_display_name: this._defaultDeviceDisplayName,
},
}).then((creds) => {
return {
userId: creds.user_id,
deviceId: creds.device_id,
accessToken: creds.access_token,
homeserverUrl: this._hsUrl,
identityServerUrl: this._isUrl,
guest: true
};
}, (error) => {
if (error.httpStatus === 403) {
error.friendlyText = "Guest access is disabled on this Home Server.";
} else {
error.friendlyText = "Failed to register as guest: " + error.data;
}
throw error;
});
}
loginViaPassword(username, pass) {
var self = this;
var isEmail = username.indexOf("@") > 0;
var loginParams = {
password: pass,
initial_device_display_name: this._defaultDeviceDisplayName,
};
if (isEmail) {
loginParams.medium = 'email';
loginParams.address = username;
} else {
loginParams.user = username;
}
var client = this._createTemporaryClient();
return client.login('m.login.password', loginParams).then(function(data) {
return q({
homeserverUrl: self._hsUrl,
identityServerUrl: self._isUrl,
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token
});
}, function(error) {
if (error.httpStatus == 400 && loginParams.medium) {
error.friendlyText = (
'This Home Server does not support login using email address.'
);
}
else if (error.httpStatus === 403) {
error.friendlyText = (
'Incorrect username and/or password.'
);
if (self._fallbackHsUrl) {
var fbClient = Matrix.createClient({
baseUrl: self._fallbackHsUrl,
idBaseUrl: this._isUrl,
});
return fbClient.login('m.login.password', loginParams).then(function(data) {
return q({
homeserverUrl: self._fallbackHsUrl,
identityServerUrl: self._isUrl,
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token
});
}, function(fallback_error) {
// throw the original error
throw error;
});
}
}
else {
error.friendlyText = (
'There was a problem logging in. (HTTP ' + error.httpStatus + ")"
);
}
throw error;
});
}
redirectToCas() {
var client = this._createTemporaryClient();
var parsedUrl = url.parse(window.location.href, true);
parsedUrl.query["homeserver"] = client.getHomeserverUrl();
parsedUrl.query["identityServer"] = client.getIdentityServerUrl();
var casUrl = client.getCasLoginUrl(url.format(parsedUrl));
window.location.href = casUrl;
}
}
module.exports.Register = Register;
module.exports.Login = Login;

View File

@ -1,177 +0,0 @@
"use strict";
var q = require("q");
/**
* An interface class which login types should abide by.
*/
class Stage {
constructor(type, matrixClient, signupInstance) {
this.type = type;
this.client = matrixClient;
this.signupInstance = signupInstance;
}
complete() {
// Return a promise which is:
// RESOLVED => With an Object which has an 'auth' key which is the auth dict
// to submit.
// REJECTED => With an Error if there was a problem with this stage.
// Has a "message" string and an "isFatal" flag.
return q.reject("NOT IMPLEMENTED");
}
onReceiveData() {
// NOP
}
}
Stage.TYPE = "NOT IMPLEMENTED";
/**
* This stage requires no auth.
*/
class DummyStage extends Stage {
constructor(matrixClient, signupInstance) {
super(DummyStage.TYPE, matrixClient, signupInstance);
}
complete() {
return q({
auth: {
type: DummyStage.TYPE
}
});
}
}
DummyStage.TYPE = "m.login.dummy";
/**
* This stage uses Google's Recaptcha to do auth.
*/
class RecaptchaStage extends Stage {
constructor(matrixClient, signupInstance) {
super(RecaptchaStage.TYPE, matrixClient, signupInstance);
this.authDict = {
auth: {
type: 'm.login.recaptcha',
// we'll add in the response param if we get one from the local user.
},
poll_for_success: true,
};
}
// called when the recaptcha has been completed.
onReceiveData(data) {
if (!data || !data.response) {
return;
}
this.authDict.auth.response = data.response;
}
complete() {
// we return the authDict with no response, telling Signup to keep polling
// the server in case the captcha is filled in on another window (e.g. by
// following a nextlink from an email signup). If the user completes the
// captcha locally, then we return at the next poll.
return q(this.authDict);
}
}
RecaptchaStage.TYPE = "m.login.recaptcha";
/**
* This state uses the IS to verify email addresses.
*/
class EmailIdentityStage extends Stage {
constructor(matrixClient, signupInstance) {
super(EmailIdentityStage.TYPE, matrixClient, signupInstance);
}
_completeVerify() {
// pull out the host of the IS URL by creating an anchor element
var isLocation = document.createElement('a');
isLocation.href = this.signupInstance.getIdentityServerUrl();
var clientSecret = this.clientSecret || this.signupInstance.params.clientSecret;
var sid = this.sid || this.signupInstance.params.idSid;
return q({
auth: {
type: 'm.login.email.identity',
threepid_creds: {
sid: sid,
client_secret: clientSecret,
id_server: isLocation.host
}
}
});
}
/**
* Complete the email stage.
*
* This is called twice under different circumstances:
* 1) When requesting an email token from the IS
* 2) When validating query parameters received from the link in the email
*/
complete() {
// TODO: The Registration class shouldn't really know this info.
if (this.signupInstance.params.hasEmailInfo) {
return this._completeVerify();
}
this.clientSecret = this.signupInstance.params.clientSecret;
if (!this.clientSecret) {
return q.reject(new Error("No client secret specified by Signup class!"));
}
var nextLink = this.signupInstance.params.registrationUrl +
'?client_secret=' +
encodeURIComponent(this.clientSecret) +
"&hs_url=" +
encodeURIComponent(this.signupInstance.getHomeserverUrl()) +
"&is_url=" +
encodeURIComponent(this.signupInstance.getIdentityServerUrl()) +
"&session_id=" +
encodeURIComponent(this.signupInstance.getServerData().session);
// Add the user ID of the referring user, if set
if (this.signupInstance.params.referrer) {
nextLink += "&referrer=" + encodeURIComponent(this.signupInstance.params.referrer);
}
var self = this;
return this.client.requestRegisterEmailToken(
this.signupInstance.email,
this.clientSecret,
1, // TODO: Multiple send attempts?
nextLink
).then(function(response) {
self.sid = response.sid;
self.signupInstance.setIdSid(self.sid);
return self._completeVerify();
}).then(function(request) {
request.poll_for_success = true;
return request;
}, function(error) {
console.error(error);
var e = {
isFatal: true
};
if (error.errcode == 'M_THREEPID_IN_USE') {
e.message = "This email address is already registered";
} else {
e.message = 'Unable to contact the given identity server';
}
throw e;
});
}
}
EmailIdentityStage.TYPE = "m.login.email.identity";
module.exports = {
[DummyStage.TYPE]: DummyStage,
[RecaptchaStage.TYPE]: RecaptchaStage,
[EmailIdentityStage.TYPE]: EmailIdentityStage
};

View File

@ -17,6 +17,8 @@ limitations under the License.
var MatrixClientPeg = require("./MatrixClientPeg"); var MatrixClientPeg = require("./MatrixClientPeg");
var CallHandler = require("./CallHandler"); var CallHandler = require("./CallHandler");
import * as Roles from './Roles';
function textForMemberEvent(ev) { function textForMemberEvent(ev) {
// XXX: SYJS-16 "sender is sometimes null for join messages" // XXX: SYJS-16 "sender is sometimes null for join messages"
var senderName = ev.sender ? ev.sender.name : ev.getSender(); var senderName = ev.sender ? ev.sender.name : ev.getSender();
@ -116,7 +118,6 @@ function textForRoomNameEvent(ev) {
function textForMessageEvent(ev) { function textForMessageEvent(ev) {
var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
var message = senderDisplayName + ': ' + ev.getContent().body; var message = senderDisplayName + ': ' + ev.getContent().body;
if (ev.getContent().msgtype === "m.emote") { if (ev.getContent().msgtype === "m.emote") {
message = "* " + senderDisplayName + " " + message; message = "* " + senderDisplayName + " " + message;
@ -183,6 +184,45 @@ function textForEncryptionEvent(event) {
return senderName + " turned on end-to-end encryption (algorithm " + event.getContent().algorithm + ")"; return senderName + " turned on end-to-end encryption (algorithm " + event.getContent().algorithm + ")";
} }
// Currently will only display a change if a user's power level is changed
function textForPowerEvent(event) {
const senderName = event.sender ? event.sender.name : event.getSender();
if (!event.getPrevContent() || !event.getPrevContent().users) {
return '';
}
const userDefault = event.getContent().users_default || 0;
// Construct set of userIds
let users = [];
Object.keys(event.getContent().users).forEach(
(userId) => {
if (users.indexOf(userId) === -1) users.push(userId);
}
);
Object.keys(event.getPrevContent().users).forEach(
(userId) => {
if (users.indexOf(userId) === -1) users.push(userId);
}
);
let diff = [];
users.forEach((userId) => {
// Previous power level
const from = event.getPrevContent().users[userId];
// Current power level
const to = event.getContent().users[userId];
if (to !== from) {
diff.push(
userId +
' from ' + Roles.textualPowerLevel(from, userDefault) +
' to ' + Roles.textualPowerLevel(to, userDefault)
);
}
});
if (!diff.length) {
return '';
}
return senderName + ' changed the power level of ' + diff.join(', ');
}
var handlers = { var handlers = {
'm.room.message': textForMessageEvent, 'm.room.message': textForMessageEvent,
'm.room.name': textForRoomNameEvent, 'm.room.name': textForRoomNameEvent,
@ -194,6 +234,7 @@ var handlers = {
'm.room.third_party_invite': textForThreePidInviteEvent, 'm.room.third_party_invite': textForThreePidInviteEvent,
'm.room.history_visibility': textForHistoryVisibilityEvent, 'm.room.history_visibility': textForHistoryVisibilityEvent,
'm.room.encryption': textForEncryptionEvent, 'm.room.encryption': textForEncryptionEvent,
'm.room.power_levels': textForPowerEvent,
}; };
module.exports = { module.exports = {

View File

@ -0,0 +1,51 @@
/*
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 dis from './dispatcher';
import sdk from './index';
import Modal from './Modal';
let isDialogOpen = false;
const onAction = function(payload) {
if (payload.action === 'unknown_device_error' && !isDialogOpen) {
var UnknownDeviceDialog = sdk.getComponent("dialogs.UnknownDeviceDialog");
isDialogOpen = true;
Modal.createDialog(UnknownDeviceDialog, {
devices: payload.err.devices,
room: payload.room,
onFinished: (r) => {
isDialogOpen = false;
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('UnknownDeviceDialog closed with '+r);
},
}, "mx_Dialog_unknownDevice");
}
}
let ref = null;
export function startListening () {
ref = dis.register(onAction);
}
export function stopListening () {
if (ref) {
dis.unregister(ref);
ref = null;
}
}

View File

@ -75,8 +75,12 @@ import views$create_room$RoomAlias from './components/views/create_room/RoomAlia
views$create_room$RoomAlias && (module.exports.components['views.create_room.RoomAlias'] = views$create_room$RoomAlias); views$create_room$RoomAlias && (module.exports.components['views.create_room.RoomAlias'] = views$create_room$RoomAlias);
import views$dialogs$BaseDialog from './components/views/dialogs/BaseDialog'; import views$dialogs$BaseDialog from './components/views/dialogs/BaseDialog';
views$dialogs$BaseDialog && (module.exports.components['views.dialogs.BaseDialog'] = views$dialogs$BaseDialog); views$dialogs$BaseDialog && (module.exports.components['views.dialogs.BaseDialog'] = views$dialogs$BaseDialog);
import views$dialogs$ChatCreateOrReuseDialog from './components/views/dialogs/ChatCreateOrReuseDialog';
views$dialogs$ChatCreateOrReuseDialog && (module.exports.components['views.dialogs.ChatCreateOrReuseDialog'] = views$dialogs$ChatCreateOrReuseDialog);
import views$dialogs$ChatInviteDialog from './components/views/dialogs/ChatInviteDialog'; import views$dialogs$ChatInviteDialog from './components/views/dialogs/ChatInviteDialog';
views$dialogs$ChatInviteDialog && (module.exports.components['views.dialogs.ChatInviteDialog'] = views$dialogs$ChatInviteDialog); views$dialogs$ChatInviteDialog && (module.exports.components['views.dialogs.ChatInviteDialog'] = views$dialogs$ChatInviteDialog);
import views$dialogs$ConfirmRedactDialog from './components/views/dialogs/ConfirmRedactDialog';
views$dialogs$ConfirmRedactDialog && (module.exports.components['views.dialogs.ConfirmRedactDialog'] = views$dialogs$ConfirmRedactDialog);
import views$dialogs$ConfirmUserActionDialog from './components/views/dialogs/ConfirmUserActionDialog'; import views$dialogs$ConfirmUserActionDialog from './components/views/dialogs/ConfirmUserActionDialog';
views$dialogs$ConfirmUserActionDialog && (module.exports.components['views.dialogs.ConfirmUserActionDialog'] = views$dialogs$ConfirmUserActionDialog); views$dialogs$ConfirmUserActionDialog && (module.exports.components['views.dialogs.ConfirmUserActionDialog'] = views$dialogs$ConfirmUserActionDialog);
import views$dialogs$DeactivateAccountDialog from './components/views/dialogs/DeactivateAccountDialog'; import views$dialogs$DeactivateAccountDialog from './components/views/dialogs/DeactivateAccountDialog';
@ -107,6 +111,8 @@ import views$elements$DeviceVerifyButtons from './components/views/elements/Devi
views$elements$DeviceVerifyButtons && (module.exports.components['views.elements.DeviceVerifyButtons'] = views$elements$DeviceVerifyButtons); views$elements$DeviceVerifyButtons && (module.exports.components['views.elements.DeviceVerifyButtons'] = views$elements$DeviceVerifyButtons);
import views$elements$DirectorySearchBox from './components/views/elements/DirectorySearchBox'; import views$elements$DirectorySearchBox from './components/views/elements/DirectorySearchBox';
views$elements$DirectorySearchBox && (module.exports.components['views.elements.DirectorySearchBox'] = views$elements$DirectorySearchBox); views$elements$DirectorySearchBox && (module.exports.components['views.elements.DirectorySearchBox'] = views$elements$DirectorySearchBox);
import views$elements$Dropdown from './components/views/elements/Dropdown';
views$elements$Dropdown && (module.exports.components['views.elements.Dropdown'] = views$elements$Dropdown);
import views$elements$EditableText from './components/views/elements/EditableText'; import views$elements$EditableText from './components/views/elements/EditableText';
views$elements$EditableText && (module.exports.components['views.elements.EditableText'] = views$elements$EditableText); views$elements$EditableText && (module.exports.components['views.elements.EditableText'] = views$elements$EditableText);
import views$elements$EditableTextContainer from './components/views/elements/EditableTextContainer'; import views$elements$EditableTextContainer from './components/views/elements/EditableTextContainer';
@ -129,6 +135,8 @@ import views$login$CaptchaForm from './components/views/login/CaptchaForm';
views$login$CaptchaForm && (module.exports.components['views.login.CaptchaForm'] = views$login$CaptchaForm); views$login$CaptchaForm && (module.exports.components['views.login.CaptchaForm'] = views$login$CaptchaForm);
import views$login$CasLogin from './components/views/login/CasLogin'; import views$login$CasLogin from './components/views/login/CasLogin';
views$login$CasLogin && (module.exports.components['views.login.CasLogin'] = views$login$CasLogin); views$login$CasLogin && (module.exports.components['views.login.CasLogin'] = views$login$CasLogin);
import views$login$CountryDropdown from './components/views/login/CountryDropdown';
views$login$CountryDropdown && (module.exports.components['views.login.CountryDropdown'] = views$login$CountryDropdown);
import views$login$CustomServerDialog from './components/views/login/CustomServerDialog'; import views$login$CustomServerDialog from './components/views/login/CustomServerDialog';
views$login$CustomServerDialog && (module.exports.components['views.login.CustomServerDialog'] = views$login$CustomServerDialog); views$login$CustomServerDialog && (module.exports.components['views.login.CustomServerDialog'] = views$login$CustomServerDialog);
import views$login$InteractiveAuthEntryComponents from './components/views/login/InteractiveAuthEntryComponents'; import views$login$InteractiveAuthEntryComponents from './components/views/login/InteractiveAuthEntryComponents';
@ -221,6 +229,8 @@ import views$rooms$TopUnreadMessagesBar from './components/views/rooms/TopUnread
views$rooms$TopUnreadMessagesBar && (module.exports.components['views.rooms.TopUnreadMessagesBar'] = views$rooms$TopUnreadMessagesBar); views$rooms$TopUnreadMessagesBar && (module.exports.components['views.rooms.TopUnreadMessagesBar'] = views$rooms$TopUnreadMessagesBar);
import views$rooms$UserTile from './components/views/rooms/UserTile'; import views$rooms$UserTile from './components/views/rooms/UserTile';
views$rooms$UserTile && (module.exports.components['views.rooms.UserTile'] = views$rooms$UserTile); views$rooms$UserTile && (module.exports.components['views.rooms.UserTile'] = views$rooms$UserTile);
import views$settings$AddPhoneNumber from './components/views/settings/AddPhoneNumber';
views$settings$AddPhoneNumber && (module.exports.components['views.settings.AddPhoneNumber'] = views$settings$AddPhoneNumber);
import views$settings$ChangeAvatar from './components/views/settings/ChangeAvatar'; import views$settings$ChangeAvatar from './components/views/settings/ChangeAvatar';
views$settings$ChangeAvatar && (module.exports.components['views.settings.ChangeAvatar'] = views$settings$ChangeAvatar); views$settings$ChangeAvatar && (module.exports.components['views.settings.ChangeAvatar'] = views$settings$ChangeAvatar);
import views$settings$ChangeDisplayName from './components/views/settings/ChangeDisplayName'; import views$settings$ChangeDisplayName from './components/views/settings/ChangeDisplayName';

View File

@ -27,6 +27,9 @@ export default React.createClass({
displayName: 'InteractiveAuth', displayName: 'InteractiveAuth',
propTypes: { propTypes: {
// matrix client to use for UI auth requests
matrixClient: React.PropTypes.object.isRequired,
// response from initial request. If not supplied, will do a request on // response from initial request. If not supplied, will do a request on
// mount. // mount.
authData: React.PropTypes.shape({ authData: React.PropTypes.shape({
@ -38,11 +41,34 @@ export default React.createClass({
// callback // callback
makeRequest: React.PropTypes.func.isRequired, makeRequest: React.PropTypes.func.isRequired,
// callback called when the auth process has finished // callback called when the auth process has finished,
// successfully or unsuccessfully.
// @param {bool} status True if the operation requiring // @param {bool} status True if the operation requiring
// auth was completed sucessfully, false if canceled. // auth was completed sucessfully, false if canceled.
// @param result The result of the authenticated call // @param {object} result The result of the authenticated call
onFinished: React.PropTypes.func.isRequired, // if successful, otherwise the error object
// @param {object} extra Additional information about the UI Auth
// process:
// * emailSid {string} If email auth was performed, the sid of
// the auth session.
// * clientSecret {string} The client secret used in auth
// sessions with the ID server.
onAuthFinished: React.PropTypes.func.isRequired,
// Inputs provided by the user to the auth process
// and used by various stages. As passed to js-sdk
// interactive-auth
inputs: React.PropTypes.object,
// As js-sdk interactive-auth
makeRegistrationUrl: React.PropTypes.func,
sessionId: React.PropTypes.string,
clientSecret: React.PropTypes.string,
emailSid: React.PropTypes.string,
// If true, poll to see if the auth flow has been completed
// out-of-band
poll: React.PropTypes.bool,
}, },
getInitialState: function() { getInitialState: function() {
@ -60,12 +86,22 @@ export default React.createClass({
this._authLogic = new InteractiveAuth({ this._authLogic = new InteractiveAuth({
authData: this.props.authData, authData: this.props.authData,
doRequest: this._requestCallback, doRequest: this._requestCallback,
startAuthStage: this._startAuthStage, inputs: this.props.inputs,
stateUpdated: this._authStateUpdated,
matrixClient: this.props.matrixClient,
sessionId: this.props.sessionId,
clientSecret: this.props.clientSecret,
emailSid: this.props.emailSid,
}); });
this._authLogic.attemptAuth().then((result) => { this._authLogic.attemptAuth().then((result) => {
this.props.onFinished(true, result); const extra = {
emailSid: this._authLogic.getEmailSid(),
clientSecret: this._authLogic.getClientSecret(),
};
this.props.onAuthFinished(true, result, extra);
}).catch((error) => { }).catch((error) => {
this.props.onAuthFinished(false, error);
console.error("Error during user-interactive auth:", error); console.error("Error during user-interactive auth:", error);
if (this._unmounted) { if (this._unmounted) {
return; return;
@ -76,26 +112,48 @@ export default React.createClass({
errorText: msg errorText: msg
}); });
}).done(); }).done();
this._intervalId = null;
if (this.props.poll) {
this._intervalId = setInterval(() => {
this._authLogic.poll();
}, 2000);
}
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
this._unmounted = true; this._unmounted = true;
if (this._intervalId !== null) {
clearInterval(this._intervalId);
}
}, },
_startAuthStage: function(stageType, error) { _authStateUpdated: function(stageType, stageState) {
const oldStage = this.state.authStage;
this.setState({ this.setState({
authStage: stageType, authStage: stageType,
errorText: error ? error.error : null, stageState: stageState,
}, this._setFocus); errorText: stageState.error,
}, () => {
if (oldStage != stageType) this._setFocus();
});
}, },
_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({ this.setState({
busy: true, busy: true,
errorText: null, errorText: null,
stageErrorText: null, stageErrorText: null,
}); });
return this.props.makeRequest(auth).finally(() => { return makeRequestPromise.finally(() => {
if (this._unmounted) { if (this._unmounted) {
return; return;
} }
@ -117,19 +175,35 @@ export default React.createClass({
_renderCurrentStage: function() { _renderCurrentStage: function() {
const stage = this.state.authStage; const stage = this.state.authStage;
var StageComponent = getEntryComponentForLoginType(stage); if (!stage) return null;
const StageComponent = getEntryComponentForLoginType(stage);
return ( return (
<StageComponent ref="stageComponent" <StageComponent ref="stageComponent"
loginType={stage} loginType={stage}
matrixClient={this.props.matrixClient}
authSessionId={this._authLogic.getSessionId()} authSessionId={this._authLogic.getSessionId()}
clientSecret={this._authLogic.getClientSecret()}
stageParams={this._authLogic.getStageParams(stage)} stageParams={this._authLogic.getStageParams(stage)}
submitAuthDict={this._submitAuthDict} submitAuthDict={this._submitAuthDict}
errorText={this.state.stageErrorText} errorText={this.state.stageErrorText}
busy={this.state.busy} busy={this.state.busy}
inputs={this.props.inputs}
stageState={this.state.stageState}
fail={this._onAuthStageFailed}
setEmailSid={this._setEmailSid}
makeRegistrationUrl={this.props.makeRegistrationUrl}
/> />
); );
}, },
_onAuthStageFailed: function(e) {
this.props.onAuthFinished(false, e);
},
_setEmailSid: function(sid) {
this._authLogic.setEmailSid(sid);
},
render: function() { render: function() {
let error = null; let error = null;
if (this.state.errorText) { if (this.state.errorText) {

View File

@ -49,11 +49,16 @@ export default React.createClass({
childContextTypes: { childContextTypes: {
matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient), matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient),
authCache: React.PropTypes.object,
}, },
getChildContext: function() { getChildContext: function() {
return { return {
matrixClient: this._matrixClient, matrixClient: this._matrixClient,
authCache: {
auth: {},
lastUpdate: 0,
},
}; };
}, },
@ -76,6 +81,13 @@ export default React.createClass({
return this._scrollStateMap[roomId]; return this._scrollStateMap[roomId];
}, },
canResetTimelineInRoom: function(roomId) {
if (!this.refs.roomView) {
return true;
}
return this.refs.roomView.canResetTimeline();
},
_onKeyDown: function(ev) { _onKeyDown: function(ev) {
/* /*
// Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers // Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers
@ -94,6 +106,17 @@ export default React.createClass({
var handled = false; var handled = false;
switch (ev.keyCode) { 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.UP:
case KeyCode.DOWN: case KeyCode.DOWN:
if (ev.altKey) { if (ev.altKey) {

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -28,10 +29,6 @@ var UserActivity = require("../../UserActivity");
var Presence = require("../../Presence"); var Presence = require("../../Presence");
var dis = require("../../dispatcher"); var dis = require("../../dispatcher");
var Login = require("./login/Login");
var Registration = require("./login/Registration");
var PostRegistration = require("./login/PostRegistration");
var Modal = require("../../Modal"); var Modal = require("../../Modal");
var Tinter = require("../../Tinter"); var Tinter = require("../../Tinter");
var sdk = require('../../index'); var sdk = require('../../index');
@ -41,6 +38,7 @@ var Lifecycle = require('../../Lifecycle');
var PageTypes = require('../../PageTypes'); var PageTypes = require('../../PageTypes');
var createRoom = require("../../createRoom"); var createRoom = require("../../createRoom");
import * as UDEHandler from '../../UnknownDeviceErrorHandler';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MatrixChat', displayName: 'MatrixChat',
@ -61,9 +59,19 @@ module.exports = React.createClass({
// called when the session load completes // called when the session load completes
onLoadCompleted: React.PropTypes.func, 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 // displayname, if any, to set on the device when logging
// in/registering. // in/registering.
defaultDeviceDisplayName: React.PropTypes.string, defaultDeviceDisplayName: React.PropTypes.string,
// A function that makes a registration URL
makeRegistrationUrl: React.PropTypes.func.isRequired,
}, },
childContextTypes: { childContextTypes: {
@ -84,6 +92,12 @@ module.exports = React.createClass({
var s = { var s = {
loading: true, loading: true,
screen: undefined, 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 // What the LoggedInView would be showing if visible
page_type: null, page_type: null,
@ -99,7 +113,8 @@ module.exports = React.createClass({
// If we're trying to just view a user ID (i.e. /user URL), this is it // If we're trying to just view a user ID (i.e. /user URL), this is it
viewUserId: null, viewUserId: null,
logged_in: false, loggedIn: false,
loggingIn: false,
collapse_lhs: false, collapse_lhs: false,
collapse_rhs: false, collapse_rhs: false,
ready: false, ready: false,
@ -179,13 +194,9 @@ module.exports = React.createClass({
componentWillMount: function() { componentWillMount: function() {
SdkConfig.put(this.props.config); SdkConfig.put(this.props.config);
// Stashed guest credentials if the user logs out // Used by _viewRoom before getting state from sync
// whilst logged in as a guest user (so they can change this.firstSyncComplete = false;
// their mind & log back in) this.firstSyncPromise = q.defer();
this.guestCreds = null;
// if the automatic session load failed, the error
this.sessionLoadError = null;
if (this.props.config.sync_timeline_limit) { if (this.props.config.sync_timeline_limit) {
MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit; MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit;
@ -226,10 +237,20 @@ module.exports = React.createClass({
if (this._teamToken) { if (this._teamToken) {
console.info(`Team token set to ${this._teamToken}`); console.info(`Team token set to ${this._teamToken}`);
} }
// Set a default HS with query param `hs_url`
const paramHs = this.props.startingFragmentQueryParams.hs_url;
if (paramHs) {
console.log('Setting register_hs_url ', paramHs);
this.setState({
register_hs_url: paramHs,
});
}
}, },
componentDidMount: function() { componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
UDEHandler.startListening();
this.focusComposer = false; this.focusComposer = false;
window.addEventListener("focus", this.onFocus); window.addEventListener("focus", this.onFocus);
@ -265,7 +286,6 @@ module.exports = React.createClass({
}); });
}).catch((e) => { }).catch((e) => {
console.error("Unable to load session", e); console.error("Unable to load session", e);
this.sessionLoadError = e.message;
}).done(()=>{ }).done(()=>{
// stuff this through the dispatcher so that it happens // stuff this through the dispatcher so that it happens
// after the on_logged_in action. // after the on_logged_in action.
@ -276,6 +296,7 @@ module.exports = React.createClass({
componentWillUnmount: function() { componentWillUnmount: function() {
Lifecycle.stopMatrixClient(); Lifecycle.stopMatrixClient();
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
UDEHandler.stopListening();
window.removeEventListener("focus", this.onFocus); window.removeEventListener("focus", this.onFocus);
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
}, },
@ -291,7 +312,7 @@ module.exports = React.createClass({
const newState = { const newState = {
screen: undefined, screen: undefined,
viewUserId: null, viewUserId: null,
logged_in: false, loggedIn: false,
ready: false, ready: false,
upgradeUsername: null, upgradeUsername: null,
guestAccessToken: null, guestAccessToken: null,
@ -301,88 +322,123 @@ module.exports = React.createClass({
}, },
onAction: function(payload) { onAction: function(payload) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
var roomIndexDelta = 1; var roomIndexDelta = 1;
var self = this; var self = this;
switch (payload.action) { switch (payload.action) {
case 'logout': case 'logout':
if (MatrixClientPeg.get().isGuest()) {
this.guestCreds = MatrixClientPeg.getCredentials();
}
Lifecycle.logout(); Lifecycle.logout();
break; break;
case 'start_registration': case 'start_registration':
var newState = payload.params || {}; const params = payload.params || {};
newState.screen = 'register'; this.setStateForNewScreen({
if ( screen: 'register',
payload.params && // these params may be undefined, but if they are,
payload.params.client_secret && // unset them from our state: we don't want to
payload.params.session_id && // resume a previous registration session if the
payload.params.hs_url && // user just clicked 'register'
payload.params.is_url && register_client_secret: params.client_secret,
payload.params.sid register_session_id: params.session_id,
) { register_hs_url: params.hs_url,
newState.register_client_secret = payload.params.client_secret; register_is_url: params.is_url,
newState.register_session_id = payload.params.session_id; register_id_sid: params.sid,
newState.register_hs_url = payload.params.hs_url; });
newState.register_is_url = payload.params.is_url;
newState.register_id_sid = payload.params.sid;
}
this.setStateForNewScreen(newState);
this.notifyNewScreen('register'); this.notifyNewScreen('register');
break; break;
case 'start_login': case 'start_login':
if (this.state.logged_in) return; if (MatrixClientPeg.get() &&
MatrixClientPeg.get().isGuest()
) {
this.setState({
guestCreds: MatrixClientPeg.getCredentials(),
});
}
this.setStateForNewScreen({ this.setStateForNewScreen({
screen: 'login', screen: 'login',
}); });
this.notifyNewScreen('login'); this.notifyNewScreen('login');
break; break;
case 'start_post_registration': case 'start_post_registration':
this.setState({ // don't clobber logged_in status this.setState({ // don't clobber loggedIn status
screen: 'post_registration' screen: 'post_registration'
}); });
break; break;
case 'start_upgrade_registration': case 'start_upgrade_registration':
// stash our guest creds so we can backout if needed // also stash our credentials, then if we restore the session,
this.guestCreds = MatrixClientPeg.getCredentials(); // we can just do it the same way whether we started upgrade
// registration or explicitly logged out
this.setStateForNewScreen({ this.setStateForNewScreen({
guestCreds: MatrixClientPeg.getCredentials(),
screen: "register", screen: "register",
upgradeUsername: MatrixClientPeg.get().getUserIdLocalpart(), upgradeUsername: MatrixClientPeg.get().getUserIdLocalpart(),
guestAccessToken: MatrixClientPeg.get().getAccessToken(), guestAccessToken: MatrixClientPeg.get().getAccessToken(),
}); });
// stop the client: if we are syncing whilst the registration
// is completed in another browser, we'll be 401ed for using
// a guest access token for a non-guest account.
// It will be restarted in onReturnToGuestClick
Lifecycle.stopMatrixClient();
this.notifyNewScreen('register'); this.notifyNewScreen('register');
break; break;
case 'start_password_recovery': case 'start_password_recovery':
if (this.state.logged_in) return; if (this.state.loggedIn) return;
this.setStateForNewScreen({ this.setStateForNewScreen({
screen: 'forgot_password', screen: 'forgot_password',
}); });
this.notifyNewScreen('forgot_password'); this.notifyNewScreen('forgot_password');
break; break;
case 'leave_room': case 'leave_room':
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
var roomId = payload.room_id;
Modal.createDialog(QuestionDialog, { Modal.createDialog(QuestionDialog, {
title: "Leave room", title: "Leave room",
description: "Are you sure you want to leave the room?", description: "Are you sure you want to leave the room?",
onFinished: function(should_leave) { onFinished: (should_leave) => {
if (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 :( // FIXME: controller shouldn't be loading a view :(
var Loader = sdk.getComponent("elements.Spinner"); const Loader = sdk.getComponent("elements.Spinner");
var modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
d.then(function() { d.then(() => {
modal.close(); modal.close();
if (this.currentRoomId === payload.room_id) {
dis.dispatch({action: 'view_next_room'}); dis.dispatch({action: 'view_next_room'});
}, function(err) { }
}, (err) => {
modal.close(); modal.close();
console.error("Failed to leave room " + payload.room_id + " " + err);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Failed to leave room", title: "Failed to leave room",
description: "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() description: err.toString()
}); });
}); });
@ -509,8 +565,11 @@ module.exports = React.createClass({
case 'set_theme': case 'set_theme':
this._onSetTheme(payload.value); this._onSetTheme(payload.value);
break; break;
case 'on_logging_in':
this.setState({loggingIn: true});
break;
case 'on_logged_in': case 'on_logged_in':
this._onLoggedIn(); this._onLoggedIn(payload.teamToken);
break; break;
case 'on_logged_out': case 'on_logged_out':
this._onLoggedOut(); this._onLoggedOut();
@ -582,27 +641,29 @@ module.exports = React.createClass({
} }
} }
if (this.sdkReady) { // Wait for the first sync to complete so that if a room does have an alias,
// if the SDK is not ready yet, remember what room // it would have been retrieved.
// we're supposed to be on but don't notify about let waitFor = q(null);
// the new screen yet (we won't be showing it yet) if (!this.firstSyncComplete) {
// The normal case where this happens is navigating if (!this.firstSyncPromise) {
// to the room in the URL bar on page load. console.warn('Cannot view a room before first sync. room_id:', room_info.room_id);
var presentedId = room_info.room_alias || room_info.room_id; return;
var room = MatrixClientPeg.get().getRoom(room_info.room_id); }
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) { if (room) {
var theAlias = Rooms.getDisplayAliasForRoom(room); const theAlias = Rooms.getDisplayAliasForRoom(room);
if (theAlias) presentedId = theAlias; if (theAlias) presentedId = theAlias;
// No need to do this given RoomView triggers it itself... // Store this as the ID of the last room accessed. This is so that we can
// var color_scheme_event = room.getAccountData("org.matrix.room.color_scheme"); // persist which room is being stored across refreshes and browser quits.
// var color_scheme = {}; if (localStorage) {
// if (color_scheme_event) { localStorage.setItem('mx_last_room_id', room.roomId);
// 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);
} }
if (room_info.event_id) { if (room_info.event_id) {
@ -610,8 +671,8 @@ module.exports = React.createClass({
} }
this.notifyNewScreen('room/' + presentedId); this.notifyNewScreen('room/' + presentedId);
newState.ready = true; newState.ready = true;
}
this.setState(newState); this.setState(newState);
});
}, },
_createChat: function() { _createChat: function() {
@ -637,6 +698,14 @@ module.exports = React.createClass({
_onLoadCompleted: function() { _onLoadCompleted: function() {
this.props.onLoadCompleted(); this.props.onLoadCompleted();
this.setState({loading: false}); 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();
}
}, },
/** /**
@ -686,13 +755,48 @@ module.exports = React.createClass({
/** /**
* Called when a new logged in session has started * Called when a new logged in session has started
*/ */
_onLoggedIn: function(credentials) { _onLoggedIn: function(teamToken) {
this.guestCreds = null;
this.notifyNewScreen('');
this.setState({ this.setState({
screen: undefined, guestCreds: null,
logged_in: true, loggedIn: true,
loggingIn: false,
}); });
if (teamToken) {
// A team member has logged in, not a guest
this._teamToken = teamToken;
dis.dispatch({action: 'view_home_page'});
} else if (this._is_registered) {
// 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'});
}
}, },
/** /**
@ -701,7 +805,7 @@ module.exports = React.createClass({
_onLoggedOut: function() { _onLoggedOut: function() {
this.notifyNewScreen('login'); this.notifyNewScreen('login');
this.setStateForNewScreen({ this.setStateForNewScreen({
logged_in: false, loggedIn: false,
ready: false, ready: false,
collapse_lhs: false, collapse_lhs: false,
collapse_rhs: false, collapse_rhs: false,
@ -709,6 +813,7 @@ module.exports = React.createClass({
currentRoomId: null, currentRoomId: null,
page_type: PageTypes.RoomDirectory, page_type: PageTypes.RoomDirectory,
}); });
this._teamToken = null;
}, },
/** /**
@ -716,9 +821,31 @@ module.exports = React.createClass({
* (useful for setting listeners) * (useful for setting listeners)
*/ */
_onWillStartClient() { _onWillStartClient() {
var self = this;
var cli = MatrixClientPeg.get(); 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) { cli.on('sync', function(state, prevState) {
self.updateStatusIndicator(state, prevState); self.updateStatusIndicator(state, prevState);
if (state === "SYNCING" && prevState === "SYNCING") { if (state === "SYNCING" && prevState === "SYNCING") {
@ -726,55 +853,12 @@ module.exports = React.createClass({
} }
console.log("MatrixClient sync state => %s", state); console.log("MatrixClient sync state => %s", state);
if (state !== "PREPARED") { return; } if (state !== "PREPARED") { return; }
self.sdkReady = true;
if (self.starting_room_alias_payload) { self.firstSyncComplete = true;
dis.dispatch(self.starting_room_alias_payload); self.firstSyncPromise.resolve();
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});
}
// 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'}); dis.dispatch({action: 'focus_composer'});
} else {
self.setState({ready: true}); self.setState({ready: true});
}
}); });
cli.on('Call.incoming', function(call) { cli.on('Call.incoming', function(call) {
dis.dispatch({ dis.dispatch({
@ -874,12 +958,7 @@ module.exports = React.createClass({
// we can't view a room unless we're logged in // we can't view a room unless we're logged in
// (a guest account is fine) // (a guest account is fine)
if (!this.state.logged_in) { if (this.state.loggedIn) {
// 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 {
dis.dispatch(payload); dis.dispatch(payload);
} }
} else if (screen.indexOf('user/') == 0) { } else if (screen.indexOf('user/') == 0) {
@ -973,29 +1052,17 @@ module.exports = React.createClass({
onReturnToGuestClick: function() { onReturnToGuestClick: function() {
// reanimate our guest login // reanimate our guest login
if (this.guestCreds) { if (this.state.guestCreds) {
Lifecycle.setLoggedIn(this.guestCreds); Lifecycle.setLoggedIn(this.state.guestCreds);
this.guestCreds = null; this.setState({guestCreds: null});
} }
}, },
onRegistered: function(credentials) { onRegistered: function(credentials, teamToken) {
Lifecycle.setLoggedIn(credentials); // teamToken may not be truthy
// do post-registration stuff
// This now goes straight to user settings
// We use _setPage since if we wait for
// showScreen to do the dispatch loop,
// the showScreen dispatch will race with the
// sdk sync finishing and we'll probably see
// the page type still unset when the MatrixClient
// is started and show the Room Directory instead.
//this.showScreen("view_user_settings");
this._setPage(PageTypes.UserSettings);
},
onTeamMemberRegistered: function(teamToken) {
this._teamToken = teamToken; this._teamToken = teamToken;
this._setPage(PageTypes.HomePage); this._is_registered = true;
Lifecycle.setLoggedIn(credentials);
}, },
onFinishPostRegistration: function() { onFinishPostRegistration: function() {
@ -1061,15 +1128,20 @@ module.exports = React.createClass({
this.setState({currentRoomId: room_id}); this.setState({currentRoomId: room_id});
}, },
_makeRegistrationUrl: function(params) {
if (this.props.startingFragmentQueryParams.referrer) {
params.referrer = this.props.startingFragmentQueryParams.referrer;
}
return this.props.makeRegistrationUrl(params);
},
render: function() { render: function() {
var ForgotPassword = sdk.getComponent('structures.login.ForgotPassword'); // `loading` might be set to false before `loggedIn = true`, causing the default
var LoggedInView = sdk.getComponent('structures.LoggedInView'); // (`<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
// console.log("rendering; loading="+this.state.loading+"; screen="+this.state.screen + // actions `on_logging_in` and `on_logged_in`.
// "; logged_in="+this.state.logged_in+"; ready="+this.state.ready); if (this.state.loading || this.state.loggingIn) {
const Spinner = sdk.getComponent('elements.Spinner');
if (this.state.loading) {
var Spinner = sdk.getComponent('elements.Spinner');
return ( return (
<div className="mx_MatrixChat_splash"> <div className="mx_MatrixChat_splash">
<Spinner /> <Spinner />
@ -1078,15 +1150,17 @@ module.exports = React.createClass({
} }
// needs to be before normal PageTypes as you are logged in technically // needs to be before normal PageTypes as you are logged in technically
else if (this.state.screen == 'post_registration') { else if (this.state.screen == 'post_registration') {
const PostRegistration = sdk.getComponent('structures.login.PostRegistration');
return ( return (
<PostRegistration <PostRegistration
onComplete={this.onFinishPostRegistration} /> 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. /* 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 * 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. * as using something like redux to avoid having a billion bits of state kicking around.
*/ */
const LoggedInView = sdk.getComponent('structures.LoggedInView');
return ( return (
<LoggedInView ref="loggedInView" matrixClient={MatrixClientPeg.get()} <LoggedInView ref="loggedInView" matrixClient={MatrixClientPeg.get()}
onRoomIdResolved={this.onRoomIdResolved} onRoomIdResolved={this.onRoomIdResolved}
@ -1097,9 +1171,9 @@ module.exports = React.createClass({
{...this.state} {...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 // 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 ( return (
<div className="mx_MatrixChat_splash"> <div className="mx_MatrixChat_splash">
<Spinner /> <Spinner />
@ -1109,6 +1183,7 @@ module.exports = React.createClass({
</div> </div>
); );
} else if (this.state.screen == 'register') { } else if (this.state.screen == 'register') {
const Registration = sdk.getComponent('structures.login.Registration');
return ( return (
<Registration <Registration
clientSecret={this.state.register_client_secret} clientSecret={this.state.register_client_secret}
@ -1124,16 +1199,16 @@ module.exports = React.createClass({
teamServerConfig={this.props.config.teamServerConfig} teamServerConfig={this.props.config.teamServerConfig}
customHsUrl={this.getCurrentHsUrl()} customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()} customIsUrl={this.getCurrentIsUrl()}
registrationUrl={this.props.registrationUrl} makeRegistrationUrl={this._makeRegistrationUrl}
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName} defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
onTeamMemberRegistered={this.onTeamMemberRegistered}
onLoggedIn={this.onRegistered} onLoggedIn={this.onRegistered}
onLoginClick={this.onLoginClick} onLoginClick={this.onLoginClick}
onRegisterClick={this.onRegisterClick} onRegisterClick={this.onRegisterClick}
onCancelClick={this.guestCreds ? this.onReturnToGuestClick : null} onCancelClick={this.state.guestCreds ? this.onReturnToGuestClick : null}
/> />
); );
} else if (this.state.screen == 'forgot_password') { } else if (this.state.screen == 'forgot_password') {
const ForgotPassword = sdk.getComponent('structures.login.ForgotPassword');
return ( return (
<ForgotPassword <ForgotPassword
defaultHsUrl={this.getDefaultHsUrl()} defaultHsUrl={this.getDefaultHsUrl()}
@ -1145,7 +1220,8 @@ module.exports = React.createClass({
onLoginClick={this.onLoginClick} /> onLoginClick={this.onLoginClick} />
); );
} else { } else {
var r = ( const Login = sdk.getComponent('structures.login.Login');
return (
<Login <Login
onLoggedIn={Lifecycle.setLoggedIn} onLoggedIn={Lifecycle.setLoggedIn}
onRegisterClick={this.onRegisterClick} onRegisterClick={this.onRegisterClick}
@ -1157,17 +1233,9 @@ module.exports = React.createClass({
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName} defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
onForgotPasswordClick={this.onForgotPasswordClick} onForgotPasswordClick={this.onForgotPasswordClick}
enableGuest={this.props.enableGuest} enableGuest={this.props.enableGuest}
onCancelClick={this.guestCreds ? this.onReturnToGuestClick : null} onCancelClick={this.state.guestCreds ? this.onReturnToGuestClick : null}
initialErrorText={this.sessionLoadError}
/> />
); );
// 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

@ -295,7 +295,10 @@ module.exports = React.createClass({
var last = (i == lastShownEventIndex); var last = (i == lastShownEventIndex);
// Wrap consecutive member events in a ListSummary, ignore if redacted // 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(); let ts1 = mxEv.getTs();
// Ensure that the key of the MemberEventListSummary does not change with new // Ensure that the key of the MemberEventListSummary does not change with new
// member events. This will prevent it from being re-created unnecessarily, and // member events. This will prevent it from being re-created unnecessarily, and
@ -349,7 +352,9 @@ module.exports = React.createClass({
<MemberEventListSummary <MemberEventListSummary
key={key} key={key}
events={summarisedEvents} events={summarisedEvents}
data-scroll-token={eventId}> data-scroll-token={eventId}
onToggle={this._onWidgetLoad} // Update scroll state
>
{eventTiles} {eventTiles}
</MemberEventListSummary> </MemberEventListSummary>
); );
@ -362,10 +367,6 @@ module.exports = React.createClass({
// replacing all of the DOM elements every time we paginate. // replacing all of the DOM elements every time we paginate.
ret.push(...this._getTilesForEvent(prevEvent, mxEv, last)); ret.push(...this._getTilesForEvent(prevEvent, mxEv, last));
prevEvent = mxEv; prevEvent = mxEv;
} else if (!mxEv.status) {
// if we aren't showing the event, put in a dummy scroll token anyway, so
// that we can scroll to the right place.
ret.push(<li key={eventId} data-scroll-token={eventId}/>);
} }
var isVisibleReadMarker = false; var isVisibleReadMarker = false;
@ -410,7 +411,9 @@ module.exports = React.createClass({
// is this a continuation of the previous message? // is this a continuation of the previous message?
var continuation = false; var continuation = false;
if (prevEvent !== null && prevEvent.sender && mxEv.sender
if (prevEvent !== null
&& prevEvent.sender && mxEv.sender
&& mxEv.sender.userId === prevEvent.sender.userId && mxEv.sender.userId === prevEvent.sender.userId
&& mxEv.getType() == prevEvent.getType()) { && mxEv.getType() == prevEvent.getType()) {
continuation = true; continuation = true;
@ -463,6 +466,7 @@ module.exports = React.createClass({
ref={this._collectEventNode.bind(this, eventId)} ref={this._collectEventNode.bind(this, eventId)}
data-scroll-token={scrollToken}> data-scroll-token={scrollToken}>
<EventTile mxEvent={mxEv} continuation={continuation} <EventTile mxEvent={mxEv} continuation={continuation}
isRedacted={mxEv.isRedacted()}
onWidgetLoad={this._onWidgetLoad} onWidgetLoad={this._onWidgetLoad}
readReceipts={readReceipts} readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap} readReceiptMap={this._readReceiptMap}
@ -483,13 +487,17 @@ module.exports = React.createClass({
// here. // here.
return !this.props.suppressFirstDateSeparator; return !this.props.suppressFirstDateSeparator;
} }
const prevEventDate = prevEvent.getDate();
if (!nextEventDate || !prevEventDate) {
return false;
}
// Return early for events that are > 24h apart // Return early for events that are > 24h apart
if (Math.abs(prevEvent.getTs() - nextEventDate.getTime()) > MILLIS_IN_DAY) { if (Math.abs(prevEvent.getTs() - nextEventDate.getTime()) > MILLIS_IN_DAY) {
return true; return true;
} }
// Compare weekdays // 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 // 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() { componentWillMount: function() {
MatrixClientPeg.get().on("sync", this.onSyncStateChange); MatrixClientPeg.get().on("sync", this.onSyncStateChange);
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
this._checkSize();
}, },
componentDidUpdate: function(prevProps, prevState) { componentDidUpdate: function() {
if(this.props.onResize && this._checkForResize(prevProps, prevState)) { this._checkSize();
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);
}
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
@ -142,31 +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 // 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 // changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes. // indicate other sizes.
_getSize: function(props, state) { _getSize: function() {
if (state.syncState === "ERROR" || if (this.state.syncState === "ERROR" ||
(state.usersTyping.length > 0) || (this.state.usersTyping.length > 0) ||
props.numUnreadMessages || this.props.numUnreadMessages ||
!props.atEndOfLiveTimeline || !this.props.atEndOfLiveTimeline ||
props.hasActiveCall) { this.props.hasActiveCall ||
this.props.tabComplete.isTabCompleting()
) {
return STATUS_BAR_EXPANDED; return STATUS_BAR_EXPANDED;
} else if (props.tabCompleteEntries) { } else if (this.props.tabCompleteEntries) {
return STATUS_BAR_HIDDEN; return STATUS_BAR_HIDDEN;
} else if (props.unsentMessageError) { } else if (this.props.unsentMessageError) {
return STATUS_BAR_EXPANDED_LARGE; return STATUS_BAR_EXPANDED_LARGE;
} }
return STATUS_BAR_HIDDEN; 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. // return suitable content for the image on the left of the status bar.
// //
// if wantPlaceholder is true, we include a "..." placeholder if // if wantPlaceholder is true, we include a "..." placeholder if
@ -194,8 +182,9 @@ module.exports = React.createClass({
} }
if (this.props.hasActiveCall) { if (this.props.hasActiveCall) {
var TintableSvg = sdk.getComponent("elements.TintableSvg");
return ( return (
<img src="img/sound-indicator.svg" width="23" height="20"/> <TintableSvg src="img/sound-indicator.svg" width="23" height="20"/>
); );
} }

View File

@ -490,13 +490,49 @@ 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, // called when state.room is first initialised (either at initial load,
// after a successful peek, or after we join the room). // after a successful peek, or after we join the room).
_onRoomLoaded: function(room) { _onRoomLoaded: function(room) {
this._warnAboutEncryption(room);
this._calculatePeekRules(room); this._calculatePeekRules(room);
this._updatePreviewUrlVisibility(room); this._updatePreviewUrlVisibility(room);
}, },
_warnAboutEncryption: function (room) {
if (!MatrixClientPeg.get().isRoomEncrypted(room.roomId)) {
return;
}
let userHasUsedEncryption = false;
if (localStorage) {
userHasUsedEncryption = localStorage.getItem('mx_user_has_used_encryption');
}
if (!userHasUsedEncryption) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, {
title: "Warning!",
hasCancelButton: false,
description: (
<div>
<p>End-to-end encryption is in beta and may not be reliable.</p>
<p>You should <b>not</b> yet trust it to secure data.</p>
<p>Devices will <b>not</b> yet be able to decrypt history from before they joined the room.</p>
<p>Encrypted messages will not be visible on clients that do not yet implement encryption.</p>
</div>
),
});
}
if (localStorage) {
localStorage.setItem('mx_user_has_used_encryption', true);
}
},
_calculatePeekRules: function(room) { _calculatePeekRules: function(room) {
var guestAccessEvent = room.currentState.getStateEvents("m.room.guest_access", ""); var guestAccessEvent = room.currentState.getStateEvents("m.room.guest_access", "");
if (guestAccessEvent && guestAccessEvent.getContent().guest_access === "can_join") { if (guestAccessEvent && guestAccessEvent.getContent().guest_access === "can_join") {
@ -716,17 +752,11 @@ module.exports = React.createClass({
}, },
onResendAllClick: function() { onResendAllClick: function() {
var eventsToResend = this._getUnsentMessages(this.state.room); Resend.resendUnsentEvents(this.state.room);
eventsToResend.forEach(function(event) {
Resend.resend(event);
});
}, },
onCancelAllClick: function() { onCancelAllClick: function() {
var eventsToResend = this._getUnsentMessages(this.state.room); Resend.cancelUnsentEvents(this.state.room);
eventsToResend.forEach(function(event) {
Resend.removeFromQueue(event);
});
}, },
onJoinButtonClicked: function(ev) { onJoinButtonClicked: function(ev) {
@ -892,8 +922,6 @@ module.exports = React.createClass({
}, },
uploadFile: function(file) { uploadFile: function(file) {
var self = this;
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog");
Modal.createDialog(NeedToRegisterDialog, { Modal.createDialog(NeedToRegisterDialog, {
@ -905,11 +933,20 @@ module.exports = React.createClass({
ContentMessages.sendContentToRoom( ContentMessages.sendContentToRoom(
file, this.state.room.roomId, MatrixClientPeg.get() file, this.state.room.roomId, MatrixClientPeg.get()
).done(undefined, function(error) { ).done(undefined, (error) => {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); 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, { Modal.createDialog(ErrorDialog, {
title: "Failed to upload file", title: "Failed to upload file",
description: error.toString() description: "Server may be unavailable, overloaded, or the file too big",
}); });
}); });
}, },
@ -993,9 +1030,10 @@ module.exports = React.createClass({
}); });
}, function(error) { }, function(error) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Search failed: " + error);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Search failed", title: "Search failed",
description: error.toString() description: "Server may be unavailable, overloaded, or search timed out :("
}); });
}).finally(function() { }).finally(function() {
self.setState({ self.setState({
@ -1639,14 +1677,14 @@ module.exports = React.createClass({
videoMuteButton = videoMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteVideoClick}> <div className="mx_RoomView_voipButton" onClick={this.onMuteVideoClick}>
<img src={call.isLocalVideoMuted() ? "img/video-unmute.svg" : "img/video-mute.svg"} <TintableSvg src={call.isLocalVideoMuted() ? "img/video-unmute.svg" : "img/video-mute.svg"}
alt={call.isLocalVideoMuted() ? "Click to unmute video" : "Click to mute video"} alt={call.isLocalVideoMuted() ? "Click to unmute video" : "Click to mute video"}
width="31" height="27"/> width="31" height="27"/>
</div>; </div>;
} }
voiceMuteButton = voiceMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}> <div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}>
<img src={call.isMicrophoneMuted() ? "img/voice-unmute.svg" : "img/voice-mute.svg"} <TintableSvg src={call.isMicrophoneMuted() ? "img/voice-unmute.svg" : "img/voice-mute.svg"}
alt={call.isMicrophoneMuted() ? "Click to unmute audio" : "Click to mute audio"} alt={call.isMicrophoneMuted() ? "Click to unmute audio" : "Click to mute audio"}
width="21" height="26"/> width="21" height="26"/>
</div>; </div>;

View File

@ -25,7 +25,7 @@ var DEBUG_SCROLL = false;
// The amount of extra scroll distance to allow prior to unfilling. // The amount of extra scroll distance to allow prior to unfilling.
// See _getExcessHeight. // See _getExcessHeight.
const UNPAGINATION_PADDING = 3000; const UNPAGINATION_PADDING = 6000;
// The number of milliseconds to debounce calls to onUnfillRequest, to prevent // The number of milliseconds to debounce calls to onUnfillRequest, to prevent
// many scroll events causing many unfilling requests. // many scroll events causing many unfilling requests.
const UNFILL_REQUEST_DEBOUNCE_MS = 200; const UNFILL_REQUEST_DEBOUNCE_MS = 200;
@ -333,33 +333,27 @@ module.exports = React.createClass({
if (excessHeight <= 0) { if (excessHeight <= 0) {
return; return;
} }
var itemlist = this.refs.itemlist; const tiles = this.refs.itemlist.children;
var tiles = itemlist.children;
// The scroll token of the first/last tile to be unpaginated // The scroll token of the first/last tile to be unpaginated
let markerScrollToken = null; let markerScrollToken = null;
// Subtract clientHeights to simulate the events being unpaginated whilst counting // Subtract heights of tiles to simulate the tiles being unpaginated until the
// the events to be unpaginated. // excess height is less than the height of the next tile to subtract. This
if (backwards) { // prevents excessHeight becoming negative, which could lead to future
// Iterate forwards from start of tiles, subtracting event tile height // pagination.
let i = 0; //
while (i < tiles.length && excessHeight > tiles[i].clientHeight) { // If backwards is true, we unpaginate (remove) tiles from the back (top).
excessHeight -= tiles[i].clientHeight; for (let i = 0; i < tiles.length; i++) {
if (tiles[i].dataset.scrollToken) { const tile = tiles[backwards ? i : tiles.length - 1 - i];
markerScrollToken = tiles[i].dataset.scrollToken; // 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.scrollToken) {
markerScrollToken = tile.dataset.scrollToken;
} }
i++; if (tile.clientHeight > excessHeight) {
} break;
} 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--;
} }
} }
@ -589,24 +583,34 @@ module.exports = React.createClass({
var itemlist = this.refs.itemlist; var itemlist = this.refs.itemlist;
var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect(); var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
var messages = itemlist.children; var messages = itemlist.children;
let newScrollState = null;
for (var i = messages.length-1; i >= 0; --i) { for (var i = messages.length-1; i >= 0; --i) {
var node = messages[i]; var node = messages[i];
if (!node.dataset.scrollToken) continue; if (!node.dataset.scrollToken) continue;
var boundingRect = node.getBoundingClientRect(); var boundingRect = node.getBoundingClientRect();
if (boundingRect.bottom < wrapperRect.bottom) { newScrollState = {
this.scrollState = {
stuckAtBottom: false, stuckAtBottom: false,
trackedScrollToken: node.dataset.scrollToken, trackedScrollToken: node.dataset.scrollToken,
pixelOffset: wrapperRect.bottom - boundingRect.bottom, 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;
}
}
// This is only false if there were no nodes with `node.dataset.scrollToken` set.
if (newScrollState) {
this.scrollState = newScrollState;
debuglog("ScrollPanel: saved scroll state", this.scrollState); debuglog("ScrollPanel: saved scroll state", this.scrollState);
return; } else {
}
}
debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport"); debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport");
}
}, },
_restoreSavedScrollState: function() { _restoreSavedScrollState: function() {

View File

@ -251,10 +251,12 @@ var TimelinePanel = React.createClass({
}, },
onMessageListUnfillRequest: function(backwards, scrollToken) { onMessageListUnfillRequest: function(backwards, scrollToken) {
// If backwards, unpaginate from the back (i.e. the start of the timeline)
let dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; let dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
debuglog("TimelinePanel: unpaginating events in direction", dir); 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 eventId = scrollToken;
let marker = this.state.events.findIndex( let marker = this.state.events.findIndex(
@ -431,6 +433,10 @@ var TimelinePanel = React.createClass({
} }
}, },
canResetTimeline: function() {
return this.refs.messagePanel && this.refs.messagePanel.isAtBottom();
},
onRoomRedaction: function(ev, room) { onRoomRedaction: function(ev, room) {
if (this.unmounted) return; if (this.unmounted) return;
@ -469,14 +475,6 @@ var TimelinePanel = React.createClass({
// we still have a client. // we still have a client.
if (!MatrixClientPeg.get()) return; 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
}
var currentReadUpToEventId = this._getCurrentReadReceipt(true); var currentReadUpToEventId = this._getCurrentReadReceipt(true);
var currentReadUpToEventIndex = this._indexForEventId(currentReadUpToEventId); var currentReadUpToEventIndex = this._indexForEventId(currentReadUpToEventId);
@ -514,6 +512,19 @@ var TimelinePanel = React.createClass({
// it failed, so allow retries next time the user is active // it failed, so allow retries next time the user is active
this.last_rr_sent_event_id = undefined; this.last_rr_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',
});
}
} }
}, },
@ -810,7 +821,7 @@ var TimelinePanel = React.createClass({
// go via the dispatcher so that the URL is updated // go via the dispatcher so that the URL is updated
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: this.props.timelineSet.roomId, room_id: this.props.timelineSet.room.roomId,
}); });
}; };
} }

View File

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

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -39,6 +40,10 @@ const REACT_SDK_VERSION =
// 'id' gives the key name in the im.vector.web.settings account data event // 'id' gives the key name in the im.vector.web.settings account data event
// 'label' is how we describe it in the UI. // 'label' is how we describe it in the UI.
const SETTINGS_LABELS = [ const SETTINGS_LABELS = [
{
id: 'autoplayGifsAndVideos',
label: 'Autoplay GIFs and videos',
},
/* /*
{ {
id: 'alwaysShowTimestamps', id: 'alwaysShowTimestamps',
@ -135,6 +140,7 @@ module.exports = React.createClass({
componentWillMount: function() { componentWillMount: function() {
this._unmounted = false; this._unmounted = false;
this._addThreepid = null;
if (PlatformPeg.get()) { if (PlatformPeg.get()) {
q().then(() => { q().then(() => {
@ -202,9 +208,10 @@ module.exports = React.createClass({
}); });
}, function(error) { }, function(error) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to load user settings: " + error);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Can't load user settings", title: "Can't load user settings",
description: error.toString() description: "Server may be unavailable or overloaded",
}); });
}); });
}, },
@ -242,10 +249,11 @@ module.exports = React.createClass({
self._refreshFromServer(); self._refreshFromServer();
}, function(err) { }, function(err) {
var errMsg = (typeof err === "string") ? err : (err.error || ""); var errMsg = (typeof err === "string") ? err : (err.error || "");
console.error("Failed to set avatar: " + err);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Error", title: "Error",
description: "Failed to set avatar. " + errMsg description: "Failed to set avatar."
}); });
}); });
}, },
@ -256,12 +264,18 @@ module.exports = React.createClass({
title: "Sign out?", title: "Sign out?",
description: description:
<div> <div>
For security, logging out will delete any end-to-end encryption keys from this browser, 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>, If you want to be able to decrypt your conversation history from future Riot sessions,
but for now be warned. please export your room keys for safe-keeping.
</div>, </div>,
button: "Sign out", button: "Sign out",
extraButtons: [
<button className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
Export E2E room keys
</button>
],
onFinished: (confirmed) => { onFinished: (confirmed) => {
if (confirmed) { if (confirmed) {
dis.dispatch({action: 'logout'}); dis.dispatch({action: 'logout'});
@ -282,6 +296,7 @@ module.exports = React.createClass({
errMsg += ` (HTTP status ${err.httpStatus})`; errMsg += ` (HTTP status ${err.httpStatus})`;
} }
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to change password: " + errMsg);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Error", title: "Error",
description: errMsg description: errMsg
@ -308,12 +323,16 @@ module.exports = React.createClass({
UserSettingsStore.setEnableNotifications(event.target.checked); UserSettingsStore.setEnableNotifications(event.target.checked);
}, },
onAddThreepidClicked: function(value, shouldSubmit) { _onAddEmailEditFinished: function(value, shouldSubmit) {
if (!shouldSubmit) return; if (!shouldSubmit) return;
this._addEmail();
},
_addEmail: function() {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
var email_address = this.refs.add_threepid_input.value; var email_address = this.refs.add_email_input.value;
if (!Email.looksValid(email_address)) { if (!Email.looksValid(email_address)) {
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Invalid Email Address", title: "Invalid Email Address",
@ -321,10 +340,10 @@ module.exports = React.createClass({
}); });
return; return;
} }
this.add_threepid = new AddThreepid(); this._addThreepid = new AddThreepid();
// we always bind emails when registering, so let's do the // we always bind emails when registering, so let's do the
// same here. // same here.
this.add_threepid.addEmailAddress(email_address, true).done(() => { this._addThreepid.addEmailAddress(email_address, true).done(() => {
Modal.createDialog(QuestionDialog, { Modal.createDialog(QuestionDialog, {
title: "Verification Pending", title: "Verification Pending",
description: "Please check your email and click on the link it contains. Once this is done, click continue.", description: "Please check your email and click on the link it contains. Once this is done, click continue.",
@ -333,12 +352,13 @@ module.exports = React.createClass({
}); });
}, (err) => { }, (err) => {
this.setState({email_add_pending: false}); this.setState({email_add_pending: false});
console.error("Unable to add email address " + email_address + " " + err);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Unable to add email address", title: "Error",
description: err.message description: "Unable to add email address"
}); });
}); });
ReactDOM.findDOMNode(this.refs.add_threepid_input).blur(); ReactDOM.findDOMNode(this.refs.add_email_input).blur();
this.setState({email_add_pending: true}); this.setState({email_add_pending: true});
}, },
@ -357,9 +377,10 @@ module.exports = React.createClass({
return this._refreshFromServer(); return this._refreshFromServer();
}).catch((err) => { }).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Unable to remove contact information: " + err);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Unable to remove contact information", title: "Error",
description: err.toString(), description: "Unable to remove contact information",
}); });
}).done(); }).done();
} }
@ -376,8 +397,8 @@ module.exports = React.createClass({
}, },
verifyEmailAddress: function() { verifyEmailAddress: function() {
this.add_threepid.checkEmailLinkClicked().done(() => { this._addThreepid.checkEmailLinkClicked().done(() => {
this.add_threepid = undefined; this._addThreepid = null;
this.setState({ this.setState({
phase: "UserSettings.LOADING", phase: "UserSettings.LOADING",
}); });
@ -397,9 +418,10 @@ module.exports = React.createClass({
}); });
} else { } else {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Unable to verify email address: " + err);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Unable to verify email address", title: "Error",
description: err.toString(), description: "Unable to verify email address",
}); });
} }
}); });
@ -419,10 +441,11 @@ module.exports = React.createClass({
}, },
_onClearCacheClicked: function() { _onClearCacheClicked: function() {
if (!PlatformPeg.get()) return;
MatrixClientPeg.get().stopClient();
MatrixClientPeg.get().store.deleteAllData().done(() => { MatrixClientPeg.get().store.deleteAllData().done(() => {
// forceReload=false since we don't really need new HTML/JS files PlatformPeg.get().reload();
// we just need to restart the JS runtime.
window.location.reload(false);
}); });
}, },
@ -745,6 +768,14 @@ module.exports = React.createClass({
return medium[0].toUpperCase() + medium.slice(1); return medium[0].toUpperCase() + medium.slice(1);
}, },
presentableTextForThreepid: function(threepid) {
if (threepid.medium == 'msisdn') {
return '+' + threepid.address;
} else {
return threepid.address;
}
},
render: function() { render: function() {
var Loader = sdk.getComponent("elements.Spinner"); var Loader = sdk.getComponent("elements.Spinner");
switch (this.state.phase) { switch (this.state.phase) {
@ -777,7 +808,9 @@ module.exports = React.createClass({
<label htmlFor={id}>{this.nameForMedium(val.medium)}</label> <label htmlFor={id}>{this.nameForMedium(val.medium)}</label>
</div> </div>
<div className="mx_UserSettings_profileInputCell"> <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>
<div className="mx_UserSettings_threepidButton mx_filterFlipColor"> <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)} /> <img src="img/cancel-small.svg" width="14" height="14" alt="Remove" onClick={this.onRemoveThreepidClicked.bind(this, val)} />
@ -785,30 +818,35 @@ module.exports = React.createClass({
</div> </div>
); );
}); });
var addThreepidSection; let addEmailSection;
if (this.state.email_add_pending) { if (this.state.email_add_pending) {
addThreepidSection = <Loader />; addEmailSection = <Loader key="_email_add_spinner" />;
} else if (!MatrixClientPeg.get().isGuest()) { } else if (!MatrixClientPeg.get().isGuest()) {
addThreepidSection = ( addEmailSection = (
<div className="mx_UserSettings_profileTableRow" key="new"> <div className="mx_UserSettings_profileTableRow" key="_newEmail">
<div className="mx_UserSettings_profileLabelCell"> <div className="mx_UserSettings_profileLabelCell">
</div> </div>
<div className="mx_UserSettings_profileInputCell"> <div className="mx_UserSettings_profileInputCell">
<EditableText <EditableText
ref="add_threepid_input" ref="add_email_input"
className="mx_UserSettings_editable" className="mx_UserSettings_editable"
placeholderClassName="mx_UserSettings_threepidPlaceholder" placeholderClassName="mx_UserSettings_threepidPlaceholder"
placeholder={ "Add email address" } placeholder={ "Add email address" }
blurToCancel={ false } blurToCancel={ false }
onValueChanged={ this.onAddThreepidClicked } /> onValueChanged={ this._onAddEmailEditFinished } />
</div> </div>
<div className="mx_UserSettings_threepidButton mx_filterFlipColor"> <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>
</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; var accountJsx;

View File

@ -93,11 +93,17 @@ module.exports = React.createClass({
description: description:
<div> <div>
Resetting password will currently reset any end-to-end encryption keys on all devices, Resetting password will currently reset any end-to-end encryption keys on all devices,
making encrypted chat history unreadable. making encrypted chat history unreadable, unless you first export your room keys
In future this <a href="https://github.com/vector-im/riot-web/issues/2671">may be improved</a>, and re-import them afterwards.
but for now be warned. In future this <a href="https://github.com/vector-im/riot-web/issues/2671">will be improved</a>.
</div>, </div>,
button: "Continue", button: "Continue",
extraButtons: [
<button className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
Export E2E room keys
</button>
],
onFinished: (confirmed) => { onFinished: (confirmed) => {
if (confirmed) { if (confirmed) {
this.submitPasswordReset( 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) { onInputChanged: function(stateKey, ev) {
this.setState({ this.setState({
[stateKey]: ev.target.value [stateKey]: ev.target.value

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -19,13 +20,13 @@ limitations under the License.
var React = require('react'); var React = require('react');
var ReactDOM = require('react-dom'); var ReactDOM = require('react-dom');
var sdk = require('../../../index'); var sdk = require('../../../index');
var Signup = require("../../../Signup"); var Login = require("../../../Login");
var PasswordLogin = require("../../views/login/PasswordLogin"); var PasswordLogin = require("../../views/login/PasswordLogin");
var CasLogin = require("../../views/login/CasLogin"); var CasLogin = require("../../views/login/CasLogin");
var ServerConfig = require("../../views/login/ServerConfig"); var ServerConfig = require("../../views/login/ServerConfig");
/** /**
* A wire component which glues together login UI components and Signup logic * A wire component which glues together login UI components and Login logic
*/ */
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'Login', displayName: 'Login',
@ -52,20 +53,20 @@ module.exports = React.createClass({
// login shouldn't care how password recovery is done. // login shouldn't care how password recovery is done.
onForgotPasswordClick: React.PropTypes.func, onForgotPasswordClick: React.PropTypes.func,
onCancelClick: React.PropTypes.func, onCancelClick: React.PropTypes.func,
initialErrorText: React.PropTypes.string,
}, },
getInitialState: function() { getInitialState: function() {
return { return {
busy: false, busy: false,
errorText: this.props.initialErrorText, errorText: null,
loginIncorrect: false, loginIncorrect: false,
enteredHomeserverUrl: this.props.customHsUrl || this.props.defaultHsUrl, enteredHomeserverUrl: this.props.customHsUrl || this.props.defaultHsUrl,
enteredIdentityServerUrl: this.props.customIsUrl || this.props.defaultIsUrl, enteredIdentityServerUrl: this.props.customIsUrl || this.props.defaultIsUrl,
// used for preserving username when changing homeserver // used for preserving form values when changing homeserver
username: "", username: "",
phoneCountry: null,
phoneNumber: "",
}; };
}, },
@ -73,20 +74,21 @@ module.exports = React.createClass({
this._initLoginLogic(); this._initLoginLogic();
}, },
onPasswordLogin: function(username, password) { onPasswordLogin: function(username, phoneCountry, phoneNumber, password) {
var self = this; this.setState({
self.setState({
busy: true, busy: true,
errorText: null, errorText: null,
loginIncorrect: false, loginIncorrect: false,
}); });
this._loginLogic.loginViaPassword(username, password).then(function(data) { this._loginLogic.loginViaPassword(
self.props.onLoggedIn(data); username, phoneCountry, phoneNumber, password,
}, function(error) { ).then((data) => {
self._setStateFromError(error, true); this.props.onLoggedIn(data);
}).finally(function() { }, (error) => {
self.setState({ this._setStateFromError(error, true);
}).finally(() => {
this.setState({
busy: false busy: false
}); });
}).done(); }).done();
@ -119,6 +121,14 @@ module.exports = React.createClass({
this.setState({ username: username }); this.setState({ username: username });
}, },
onPhoneCountryChanged: function(phoneCountry) {
this.setState({ phoneCountry: phoneCountry });
},
onPhoneNumberChanged: function(phoneNumber) {
this.setState({ phoneNumber: phoneNumber });
},
onHsUrlChanged: function(newHsUrl) { onHsUrlChanged: function(newHsUrl) {
var self = this; var self = this;
this.setState({ this.setState({
@ -146,7 +156,7 @@ module.exports = React.createClass({
var fallbackHsUrl = hsUrl == this.props.defaultHsUrl ? this.props.fallbackHsUrl : null; var fallbackHsUrl = hsUrl == this.props.defaultHsUrl ? this.props.fallbackHsUrl : null;
var loginLogic = new Signup.Login(hsUrl, isUrl, fallbackHsUrl, { var loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
}); });
this._loginLogic = loginLogic; this._loginLogic = loginLogic;
@ -225,7 +235,11 @@ module.exports = React.createClass({
<PasswordLogin <PasswordLogin
onSubmit={this.onPasswordLogin} onSubmit={this.onPasswordLogin}
initialUsername={this.state.username} initialUsername={this.state.username}
initialPhoneCountry={this.state.phoneCountry}
initialPhoneNumber={this.state.phoneNumber}
onUsernameChanged={this.onUsernameChanged} onUsernameChanged={this.onUsernameChanged}
onPhoneCountryChanged={this.onPhoneCountryChanged}
onPhoneNumberChanged={this.onPhoneNumberChanged}
onForgotPasswordClick={this.props.onForgotPasswordClick} onForgotPasswordClick={this.props.onForgotPasswordClick}
loginIncorrect={this.state.loginIncorrect} loginIncorrect={this.state.loginIncorrect}
/> />

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,25 +15,21 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
'use strict'; import Matrix from 'matrix-js-sdk';
var React = require('react'); import q from 'q';
import React from 'react';
var sdk = require('../../../index'); import sdk from '../../../index';
var dis = require('../../../dispatcher'); import dis from '../../../dispatcher';
var Signup = require("../../../Signup"); import ServerConfig from '../../views/login/ServerConfig';
var ServerConfig = require("../../views/login/ServerConfig"); import MatrixClientPeg from '../../../MatrixClientPeg';
var MatrixClientPeg = require("../../../MatrixClientPeg"); import RegistrationForm from '../../views/login/RegistrationForm';
var RegistrationForm = require("../../views/login/RegistrationForm"); import CaptchaForm from '../../views/login/CaptchaForm';
var CaptchaForm = require("../../views/login/CaptchaForm"); import RtsClient from '../../../RtsClient';
var RtsClient = require("../../../RtsClient");
var MIN_PASSWORD_LENGTH = 6; const MIN_PASSWORD_LENGTH = 6;
/**
* TODO: It would be nice to make use of the InteractiveAuthEntryComponents
* here, rather than inventing our own.
*/
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'Registration', displayName: 'Registration',
@ -40,7 +37,7 @@ module.exports = React.createClass({
onLoggedIn: React.PropTypes.func.isRequired, onLoggedIn: React.PropTypes.func.isRequired,
clientSecret: React.PropTypes.string, clientSecret: React.PropTypes.string,
sessionId: React.PropTypes.string, sessionId: React.PropTypes.string,
registrationUrl: React.PropTypes.string, makeRegistrationUrl: React.PropTypes.func.isRequired,
idSid: React.PropTypes.string, idSid: React.PropTypes.string,
customHsUrl: React.PropTypes.string, customHsUrl: React.PropTypes.string,
customIsUrl: React.PropTypes.string, customIsUrl: React.PropTypes.string,
@ -58,7 +55,6 @@ module.exports = React.createClass({
teamServerURL: React.PropTypes.string.isRequired, teamServerURL: React.PropTypes.string.isRequired,
}), }),
teamSelected: React.PropTypes.object, teamSelected: React.PropTypes.object,
onTeamMemberRegistered: React.PropTypes.func.isRequired,
defaultDeviceDisplayName: React.PropTypes.string, defaultDeviceDisplayName: React.PropTypes.string,
@ -82,27 +78,20 @@ module.exports = React.createClass({
formVals: { formVals: {
email: this.props.email, email: this.props.email,
}, },
// true if we're waiting for the user to complete
// user-interactive auth
// If we've been given a session ID, we're resuming
// straight back into UI auth
doingUIAuth: Boolean(this.props.sessionId),
hsUrl: this.props.customHsUrl,
isUrl: this.props.customIsUrl,
}; };
}, },
componentWillMount: function() { componentWillMount: function() {
this._unmounted = false; this._unmounted = false;
this.dispatcherRef = dis.register(this.onAction);
// attach this to the instance rather than this.state since it isn't UI this._replaceClient();
this.registerLogic = new Signup.Register(
this.props.customHsUrl, this.props.customIsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
}
);
this.registerLogic.setClientSecret(this.props.clientSecret);
this.registerLogic.setSessionId(this.props.sessionId);
this.registerLogic.setRegistrationUrl(this.props.registrationUrl);
this.registerLogic.setIdSid(this.props.idSid);
this.registerLogic.setGuestAccessToken(this.props.guestAccessToken);
if (this.props.referrer) {
this.registerLogic.setReferrer(this.props.referrer);
}
this.registerLogic.recheckState();
if ( if (
this.props.teamServerConfig && this.props.teamServerConfig &&
@ -134,104 +123,81 @@ module.exports = React.createClass({
} }
}, },
componentWillUnmount: function() {
dis.unregister(this.dispatcherRef);
this._unmounted = true;
},
componentDidMount: function() {
// may have already done an HTTP hit (e.g. redirect from an email) so
// check for any pending response
var promise = this.registerLogic.getPromise();
if (promise) {
this.onProcessingRegistration(promise);
}
},
onHsUrlChanged: function(newHsUrl) { onHsUrlChanged: function(newHsUrl) {
this.registerLogic.setHomeserverUrl(newHsUrl); this.setState({
hsUrl: newHsUrl,
});
this._replaceClient();
}, },
onIsUrlChanged: function(newIsUrl) { onIsUrlChanged: function(newIsUrl) {
this.registerLogic.setIdentityServerUrl(newIsUrl); this.setState({
isUrl: newIsUrl,
});
this._replaceClient();
}, },
onAction: function(payload) { _replaceClient: function() {
if (payload.action !== "registration_step_update") { this._matrixClient = Matrix.createClient({
return; baseUrl: this.state.hsUrl,
} idBaseUrl: this.state.isUrl,
// If the registration state has changed, this means the
// user now needs to do something. It would be better
// to expose the explicitly in the register logic.
this.setState({
busy: false
}); });
}, },
onFormSubmit: function(formVals) { onFormSubmit: function(formVals) {
var self = this;
this.setState({ this.setState({
errorText: "", errorText: "",
busy: true, busy: true,
formVals: formVals, formVals: formVals,
doingUIAuth: true,
}); });
if (formVals.username !== this.props.username) {
// don't try to upgrade if we changed our username
this.registerLogic.setGuestAccessToken(null);
}
this.onProcessingRegistration(this.registerLogic.register(formVals));
}, },
// Promise is resolved when the registration process is FULLY COMPLETE _onUIAuthFinished: function(success, response, extra) {
onProcessingRegistration: function(promise) { if (!success) {
var self = this; let msg = response.message || response.toString();
promise.done(function(response) { // can we give a better error message?
self.setState({ if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
busy: false let msisdn_available = false;
}); for (const flow of response.available_flows) {
if (!response || !response.access_token) { msisdn_available |= flow.stages.indexOf('m.login.msisdn') > -1;
console.warn(
"FIXME: Register fulfilled without a final response, " +
"did you break the promise chain?"
);
// no matter, we'll grab it direct
response = self.registerLogic.getCredentials();
} }
if (!response || !response.user_id || !response.access_token) { if (!msisdn_available) {
console.error("Final response is missing keys."); msg = "This server does not support authentication with a phone number";
self.setState({ }
errorText: "Registration failed on server" }
this.setState({
busy: false,
doingUIAuth: false,
errorText: msg,
}); });
return; return;
} }
self.props.onLoggedIn({
userId: response.user_id, this.setState({
deviceId: response.device_id, // we're still busy until we get unmounted: don't show the registration form again
homeserverUrl: self.registerLogic.getHomeserverUrl(), busy: true,
identityServerUrl: self.registerLogic.getIdentityServerUrl(), doingUIAuth: false,
accessToken: response.access_token
}); });
// Done regardless of `teamSelected`. People registering with non-team emails // Done regardless of `teamSelected`. People registering with non-team emails
// will just nop. The point of this being we might not have the email address // will just nop. The point of this being we might not have the email address
// that the user registered with at this stage (depending on whether this // that the user registered with at this stage (depending on whether this
// is the client they initiated registration). // is the client they initiated registration).
if (self._rtsClient) { let trackPromise = q(null);
// Track referral if self.props.referrer set, get team_token in order to if (this._rtsClient && extra.emailSid) {
// Track referral if this.props.referrer set, get team_token in order to
// retrieve team config and see welcome page etc. // retrieve team config and see welcome page etc.
self._rtsClient.trackReferral( trackPromise = this._rtsClient.trackReferral(
self.props.referrer || '', // Default to empty string = not referred this.props.referrer || '', // Default to empty string = not referred
self.registerLogic.params.idSid, extra.emailSid,
self.registerLogic.params.clientSecret extra.clientSecret,
).then((data) => { ).then((data) => {
const teamToken = data.team_token; const teamToken = data.team_token;
// Store for use /w welcome pages // Store for use /w welcome pages
window.localStorage.setItem('mx_team_token', teamToken); window.localStorage.setItem('mx_team_token', teamToken);
self.props.onTeamMemberRegistered(teamToken);
self._rtsClient.getTeam(teamToken).then((team) => { this._rtsClient.getTeam(teamToken).then((team) => {
console.log( console.log(
`User successfully registered with team ${team.name}` `User successfully registered with team ${team.name}`
); );
@ -248,20 +214,39 @@ module.exports = React.createClass({
}, (err) => { }, (err) => {
console.error('Error getting team config', err); console.error('Error getting team config', err);
}); });
return teamToken;
}, (err) => { }, (err) => {
console.error('Error tracking referral', err); console.error('Error tracking referral', err);
}); });
} }
if (self.props.brand) { trackPromise.then((teamToken) => {
MatrixClientPeg.get().getPushers().done((resp)=>{ console.info('Team token promise',teamToken);
var pushers = resp.pushers; this.props.onLoggedIn({
for (var i = 0; i < pushers.length; ++i) { 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();
});
},
_setupPushers: function() {
if (!this.props.brand) {
return q();
}
return MatrixClientPeg.get().getPushers().then((resp)=>{
const pushers = resp.pushers;
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind == 'email') { if (pushers[i].kind == 'email') {
var emailPusher = pushers[i]; const emailPusher = pushers[i];
emailPusher.data = { brand: self.props.brand }; emailPusher.data = { brand: this.props.brand };
MatrixClientPeg.get().setPusher(emailPusher).done(() => { MatrixClientPeg.get().setPusher(emailPusher).done(() => {
console.log("Set email branding to " + self.props.brand); console.log("Set email branding to " + this.props.brand);
}, (error) => { }, (error) => {
console.error("Couldn't set email branding: " + error); console.error("Couldn't set email branding: " + error);
}); });
@ -270,19 +255,6 @@ module.exports = React.createClass({
}, (error) => { }, (error) => {
console.error("Couldn't get pushers: " + error); console.error("Couldn't get pushers: " + error);
}); });
}
}, function(err) {
if (err.message) {
self.setState({
errorText: err.message
});
}
self.setState({
busy: false
});
console.log(err);
});
}, },
onFormValidationFailed: function(errCode) { onFormValidationFailed: function(errCode) {
@ -300,6 +272,9 @@ module.exports = React.createClass({
case "RegistrationForm.ERR_EMAIL_INVALID": case "RegistrationForm.ERR_EMAIL_INVALID":
errMsg = "This doesn't look like a valid email address"; errMsg = "This doesn't look like a valid email address";
break; break;
case "RegistrationForm.ERR_PHONE_NUMBER_INVALID":
errMsg = "This doesn't look like a valid phone number";
break;
case "RegistrationForm.ERR_USERNAME_INVALID": case "RegistrationForm.ERR_USERNAME_INVALID":
errMsg = "User names may only contain letters, numbers, dots, hyphens and underscores."; errMsg = "User names may only contain letters, numbers, dots, hyphens and underscores.";
break; break;
@ -316,116 +291,121 @@ module.exports = React.createClass({
}); });
}, },
onCaptchaResponse: function(response) {
this.registerLogic.tellStage("m.login.recaptcha", {
response: response
});
},
onTeamSelected: function(teamSelected) { onTeamSelected: function(teamSelected) {
if (!this._unmounted) { if (!this._unmounted) {
this.setState({ teamSelected }); this.setState({ teamSelected });
} }
}, },
_getRegisterContentJsx: function() { _makeRegisterRequest: function(auth) {
const Spinner = sdk.getComponent("elements.Spinner"); let guestAccessToken = this.props.guestAccessToken;
var currStep = this.registerLogic.getStep(); if (
var registerStep; this.state.formVals.username !== this.props.username ||
switch (currStep) { this.state.hsUrl != this.props.defaultHsUrl
case "Register.COMPLETE": ) {
break; // NOP // don't try to upgrade if we changed our username
case "Register.START": // or are registering on a different HS
case "Register.STEP_m.login.dummy": guestAccessToken = null;
// NB. Our 'username' prop is specifically for upgrading
// a guest account
if (this.state.teamServerBusy) {
registerStep = <Spinner />;
break;
} }
registerStep = (
// 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,
bindThreepids,
guestAccessToken,
);
},
_getUIAuthInputs: function() {
return {
emailAddress: this.state.formVals.email,
phoneCountry: this.state.formVals.phoneCountry,
phoneNumber: this.state.formVals.phoneNumber,
}
},
render: function() {
const LoginHeader = sdk.getComponent('login.LoginHeader');
const LoginFooter = sdk.getComponent('login.LoginFooter');
const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth');
const Spinner = sdk.getComponent("elements.Spinner");
const ServerConfig = sdk.getComponent('views.login.ServerConfig');
let registerBody;
if (this.state.doingUIAuth) {
registerBody = (
<InteractiveAuth
matrixClient={this._matrixClient}
makeRequest={this._makeRegisterRequest}
onAuthFinished={this._onUIAuthFinished}
inputs={this._getUIAuthInputs()}
makeRegistrationUrl={this.props.makeRegistrationUrl}
sessionId={this.props.sessionId}
clientSecret={this.props.clientSecret}
emailSid={this.props.idSid}
poll={true}
/>
);
} else if (this.state.busy || this.state.teamServerBusy) {
registerBody = <Spinner />;
} else {
let guestUsername = this.props.username;
if (this.state.hsUrl != this.props.defaultHsUrl) {
guestUsername = null;
}
let errorSection;
if (this.state.errorText) {
errorSection = <div className="mx_Login_error">{this.state.errorText}</div>;
}
registerBody = (
<div>
<RegistrationForm <RegistrationForm
showEmail={true}
defaultUsername={this.state.formVals.username} defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email} defaultEmail={this.state.formVals.email}
defaultPhoneCountry={this.state.formVals.phoneCountry}
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password} defaultPassword={this.state.formVals.password}
teamsConfig={this.state.teamsConfig} teamsConfig={this.state.teamsConfig}
guestUsername={this.props.username} guestUsername={guestUsername}
minPasswordLength={MIN_PASSWORD_LENGTH} minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed} onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit} onRegisterClick={this.onFormSubmit}
onTeamSelected={this.onTeamSelected} onTeamSelected={this.onTeamSelected}
/> />
); {errorSection}
break;
case "Register.STEP_m.login.email.identity":
registerStep = (
<div>
Please check your email to continue registration.
</div>
);
break;
case "Register.STEP_m.login.recaptcha":
var publicKey;
var serverParams = this.registerLogic.getServerData().params;
if (serverParams && serverParams["m.login.recaptcha"]) {
publicKey = serverParams["m.login.recaptcha"].public_key;
}
registerStep = (
<CaptchaForm sitePublicKey={publicKey}
onCaptchaResponse={this.onCaptchaResponse}
/>
);
break;
default:
console.error("Unknown register state: %s", currStep);
break;
}
var busySpinner;
if (this.state.busy) {
busySpinner = (
<Spinner />
);
}
var returnToAppJsx;
if (this.props.onCancelClick) {
returnToAppJsx =
<a className="mx_Login_create" onClick={this.props.onCancelClick} href="#">
Return to app
</a>;
}
return (
<div>
<h2>Create an account</h2>
{registerStep}
<div className="mx_Login_error">{this.state.errorText}</div>
{busySpinner}
<ServerConfig ref="serverConfig" <ServerConfig ref="serverConfig"
withToggleButton={ this.registerLogic.getStep() === "Register.START" } withToggleButton={true}
customHsUrl={this.props.customHsUrl} customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl} customIsUrl={this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl} defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl} defaultIsUrl={this.props.defaultIsUrl}
onHsUrlChanged={this.onHsUrlChanged} onHsUrlChanged={this.onHsUrlChanged}
onIsUrlChanged={this.onIsUrlChanged} onIsUrlChanged={this.onIsUrlChanged}
delayTimeMs={1000} /> delayTimeMs={1000}
<div className="mx_Login_error"> />
</div>
<a className="mx_Login_create" onClick={this.props.onLoginClick} href="#">
I already have an account
</a>
{ returnToAppJsx }
</div> </div>
); );
}, }
render: function() { let returnToAppJsx;
var LoginHeader = sdk.getComponent('login.LoginHeader'); if (this.props.onCancelClick) {
var LoginFooter = sdk.getComponent('login.LoginFooter'); returnToAppJsx = (
<a className="mx_Login_create" onClick={this.props.onCancelClick} href="#">
Return to app
</a>
);
}
return ( return (
<div className="mx_Login"> <div className="mx_Login">
<div className="mx_Login_box"> <div className="mx_Login_box">
@ -435,7 +415,12 @@ module.exports = React.createClass({
this.state.teamSelected.domain + "/icon.png" : this.state.teamSelected.domain + "/icon.png" :
null} null}
/> />
{this._getRegisterContentJsx()} <h2>Create an account</h2>
{registerBody}
<a className="mx_Login_create" onClick={this.props.onLoginClick} href="#">
I already have an account
</a>
{returnToAppJsx}
<LoginFooter /> <LoginFooter />
</div> </div>
</div> </div>

View File

@ -18,6 +18,7 @@ import React from 'react';
import * as KeyCode from '../../../KeyCode'; import * as KeyCode from '../../../KeyCode';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import sdk from '../../../index';
/** /**
* Basic container for modal dialogs. * Basic container for modal dialogs.
@ -65,15 +66,14 @@ export default React.createClass({
}, },
render: function() { render: function() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
return ( return (
<div onKeyDown={this._onKeyDown} className={this.props.className}> <div onKeyDown={this._onKeyDown} className={this.props.className}>
<AccessibleButton onClick={this._onCancelClick} <AccessibleButton onClick={this._onCancelClick}
className="mx_Dialog_cancelButton" className="mx_Dialog_cancelButton"
> >
<img <TintableSvg src="img/icons-close-button.svg" width="35" height="35" />
src="img/cancel.svg" width="18" height="18"
alt="Cancel" title="Cancel"
/>
</AccessibleButton> </AccessibleButton>
<div className='mx_Dialog_title'> <div className='mx_Dialog_title'>
{ this.props.title } { this.props.title }

View File

@ -0,0 +1,115 @@
/*
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 dis from '../../../dispatcher';
import MatrixClientPeg from '../../../MatrixClientPeg';
import DMRoomMap from '../../../utils/DMRoomMap';
import AccessibleButton from '../elements/AccessibleButton';
import Unread from '../../../Unread';
import classNames from 'classnames';
import createRoom from '../../../createRoom';
export default class ChatCreateOrReuseDialog extends React.Component {
constructor(props) {
super(props);
this.onNewDMClick = this.onNewDMClick.bind(this);
this.onRoomTileClick = this.onRoomTileClick.bind(this);
}
onNewDMClick() {
createRoom({dmUserId: this.props.userId});
this.props.onFinished(true);
}
onRoomTileClick(roomId) {
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
this.props.onFinished(true);
}
render() {
const client = MatrixClientPeg.get();
const dmRoomMap = new DMRoomMap(client);
const dmRooms = dmRoomMap.getDMRoomsForUserId(this.props.userId);
const RoomTile = sdk.getComponent("rooms.RoomTile");
const tiles = [];
for (const roomId of dmRooms) {
const room = client.getRoom(roomId);
if (room) {
const me = room.getMember(client.credentials.userId);
const highlight = (
room.getUnreadNotificationCount('highlight') > 0 ||
me.membership == "invite"
);
tiles.push(
<RoomTile key={room.roomId} room={room}
collapsed={false}
selected={false}
unread={Unread.doesRoomHaveUnreadMessages(room)}
highlight={highlight}
isInvite={me.membership == "invite"}
onClick={this.onRoomTileClick}
/>
);
}
}
const labelClasses = classNames({
mx_MemberInfo_createRoom_label: true,
mx_RoomTile_name: true,
});
const startNewChat = <AccessibleButton
className="mx_MemberInfo_createRoom"
onClick={this.onNewDMClick}
>
<div className="mx_RoomTile_avatar">
<img src="img/create-big.svg" width="26" height="26" />
</div>
<div className={labelClasses}><i>Start new chat</i></div>
</AccessibleButton>;
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<BaseDialog className='mx_ChatCreateOrReuseDialog'
onFinished={() => {
this.props.onFinished(false)
}}
title='Create a new chat or reuse an existing one'
>
<div className="mx_Dialog_content">
You already have existing direct chats with this user:
<div className="mx_ChatCreateOrReuseDialog_tiles">
{tiles}
{startNewChat}
</div>
</div>
</BaseDialog>
);
}
}
ChatCreateOrReuseDialog.propTyps = {
userId: React.PropTypes.string.isRequired,
onFinished: React.PropTypes.func.isRequired,
};

View File

@ -26,18 +26,10 @@ import dis from '../../../dispatcher';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import q from 'q'; import q from 'q';
import Fuse from 'fuse.js';
const TRUNCATE_QUERY_LIST = 40; const TRUNCATE_QUERY_LIST = 40;
/*
* Escapes a string so it can be used in a RegExp
* Basically just replaces: \ ^ $ * + ? . ( ) | { } [ ]
* From http://stackoverflow.com/a/6969486
*/
function escapeRegExp(str) {
return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
}
module.exports = React.createClass({ module.exports = React.createClass({
displayName: "ChatInviteDialog", displayName: "ChatInviteDialog",
propTypes: { propTypes: {
@ -85,6 +77,19 @@ module.exports = React.createClass({
// Set the cursor at the end of the text input // Set the cursor at the end of the text input
this.refs.textinput.value = this.props.value; this.refs.textinput.value = this.props.value;
} }
// Create a Fuse instance for fuzzy searching this._userList
this._fuse = new Fuse(
// Use an empty list at first that will later be populated
// (see this._updateUserList)
[],
{
shouldSort: true,
location: 0, // The index of the query in the test string
distance: 5, // The distance away from location the query can be
// 0.0 = exact match, 1.0 = match anything
threshold: 0.3,
}
);
this._updateUserList(); this._updateUserList();
}, },
@ -97,18 +102,27 @@ module.exports = React.createClass({
if (inviteList === null) return; if (inviteList === null) return;
} }
const addrTexts = inviteList.map(addr => addr.address);
if (inviteList.length > 0) { if (inviteList.length > 0) {
if (this._isDmChat(inviteList)) { if (this._isDmChat(addrTexts)) {
const userId = inviteList[0].address;
// Direct Message chat // Direct Message chat
var room = this._getDirectMessageRoom(inviteList[0]); const rooms = this._getDirectMessageRooms(userId);
if (room) { if (rooms.length > 0) {
// A Direct Message room already exists for this user and you // A Direct Message room already exists for this user, so select a
// so go straight to that room // room from a list that is similar to the one in MemberInfo panel
dis.dispatch({ const ChatCreateOrReuseDialog = sdk.getComponent(
action: 'view_room', "views.dialogs.ChatCreateOrReuseDialog"
room_id: room.roomId, );
}); Modal.createDialog(ChatCreateOrReuseDialog, {
userId: userId,
onFinished: (success) => {
if (success) {
this.props.onFinished(true, inviteList[0]); this.props.onFinished(true, inviteList[0]);
}
// else show this ChatInviteDialog again
}
});
} else { } else {
this._startChat(inviteList); this._startChat(inviteList);
} }
@ -167,12 +181,25 @@ module.exports = React.createClass({
const query = ev.target.value; const query = ev.target.value;
let queryList = []; let queryList = [];
if (query.length < 2) {
return;
}
if (this.queryChangedDebouncer) {
clearTimeout(this.queryChangedDebouncer);
}
this.queryChangedDebouncer = setTimeout(() => {
// Only do search if there is something to search // Only do search if there is something to search
if (query.length > 0 && query != '@') { if (query.length > 0 && query != '@') {
// filter the known users list // Weighted keys prefer to match userIds when first char is @
queryList = this._userList.filter((user) => { this._fuse.options.keys = [{
return this._matches(query, user); name: 'displayName',
}).map((user) => { weight: query[0] === '@' ? 0.1 : 0.9,
},{
name: 'userId',
weight: query[0] === '@' ? 0.9 : 0.1,
}];
queryList = this._fuse.search(query).map((user) => {
// Return objects, structure of which is defined // Return objects, structure of which is defined
// by InviteAddressType // by InviteAddressType
return { return {
@ -184,28 +211,29 @@ module.exports = React.createClass({
} }
}); });
// If the query isn't a user we know about, but is a // If the query is a valid address, add an entry for that
// valid address, add an entry for that // This is important, otherwise there's no way to invite
if (queryList.length == 0) { // a perfectly valid address if there are close matches.
const addrType = getAddressType(query); const addrType = getAddressType(query);
if (addrType !== null) { if (addrType !== null) {
queryList[0] = { queryList.unshift({
addressType: addrType, addressType: addrType,
address: query, address: query,
isKnown: false, isKnown: false,
}; });
if (this._cancelThreepidLookup) this._cancelThreepidLookup(); if (this._cancelThreepidLookup) this._cancelThreepidLookup();
if (addrType == 'email') { if (addrType == 'email') {
this._lookupThreepid(addrType, query).done(); this._lookupThreepid(addrType, query).done();
} }
} }
} }
}
this.setState({ this.setState({
queryList: queryList, queryList: queryList,
error: false, error: false,
}, () => {
this.addressSelector.moveSelectionTop();
}); });
}, 200);
}, },
onDismissed: function(index) { onDismissed: function(index) {
@ -238,22 +266,20 @@ module.exports = React.createClass({
if (this._cancelThreepidLookup) this._cancelThreepidLookup(); if (this._cancelThreepidLookup) this._cancelThreepidLookup();
}, },
_getDirectMessageRoom: function(addr) { _getDirectMessageRooms: function(addr) {
const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); const dmRoomMap = new DMRoomMap(MatrixClientPeg.get());
var dmRooms = dmRoomMap.getDMRoomsForUserId(addr); const dmRooms = dmRoomMap.getDMRoomsForUserId(addr);
if (dmRooms.length > 0) { const rooms = [];
// Cycle through all the DM rooms and find the first non forgotten or parted room dmRooms.forEach(dmRoom => {
for (let i = 0; i < dmRooms.length; i++) { let room = MatrixClientPeg.get().getRoom(dmRoom);
let room = MatrixClientPeg.get().getRoom(dmRooms[i]);
if (room) { if (room) {
const me = room.getMember(MatrixClientPeg.get().credentials.userId); const me = room.getMember(MatrixClientPeg.get().credentials.userId);
if (me.membership == 'join') { if (me.membership == 'join') {
return room; rooms.push(room);
} }
} }
} });
} return rooms;
return null;
}, },
_startChat: function(addrs) { _startChat: function(addrs) {
@ -282,8 +308,8 @@ module.exports = React.createClass({
console.error(err.stack); console.error(err.stack);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Failure to invite", title: "Error",
description: err.toString() description: "Failed to invite",
}); });
return null; return null;
}) })
@ -295,8 +321,8 @@ module.exports = React.createClass({
console.error(err.stack); console.error(err.stack);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Failure to invite user", title: "Error",
description: err.toString() description: "Failed to invite user",
}); });
return null; return null;
}) })
@ -316,8 +342,8 @@ module.exports = React.createClass({
console.error(err.stack); console.error(err.stack);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Failure to invite", title: "Error",
description: err.toString() description: "Failed to invite",
}); });
return null; return null;
}) })
@ -331,49 +357,15 @@ module.exports = React.createClass({
_updateUserList: new rate_limited_func(function() { _updateUserList: new rate_limited_func(function() {
// Get all the users // Get all the users
this._userList = MatrixClientPeg.get().getUsers(); this._userList = MatrixClientPeg.get().getUsers();
// Remove current user
const meIx = this._userList.findIndex((u) => {
return u.userId === MatrixClientPeg.get().credentials.userId;
});
this._userList.splice(meIx, 1);
this._fuse.set(this._userList);
}, 500), }, 500),
// This is the search algorithm for matching users
_matches: function(query, user) {
var name = user.displayName.toLowerCase();
var uid = user.userId.toLowerCase();
query = query.toLowerCase();
// don't match any that are already on the invite list
if (this._isOnInviteList(uid)) {
return false;
}
// ignore current user
if (uid === MatrixClientPeg.get().credentials.userId) {
return false;
}
// direct prefix matches
if (name.indexOf(query) === 0 || uid.indexOf(query) === 0) {
return true;
}
// strip @ on uid and try matching again
if (uid.length > 1 && uid[0] === "@" && uid.substring(1).indexOf(query) === 0) {
return true;
}
// Try to find the query following a "word boundary", except that
// this does avoids using \b because it only considers letters from
// the roman alphabet to be word characters.
// Instead, we look for the query following either:
// * The start of the string
// * Whitespace, or
// * A fixed number of punctuation characters
const expr = new RegExp("(?:^|[\\s\\(\)'\",\.-_@\?;:{}\\[\\]\\#~`\\*\\&\\$])" + escapeRegExp(query));
if (expr.test(name)) {
return true;
}
return false;
},
_isOnInviteList: function(uid) { _isOnInviteList: function(uid) {
for (let i = 0; i < this.state.inviteList.length; i++) { for (let i = 0; i < this.state.inviteList.length; i++) {
if ( if (
@ -386,8 +378,11 @@ module.exports = React.createClass({
return false; return false;
}, },
_isDmChat: function(addrs) { _isDmChat: function(addrTexts) {
if (addrs.length === 1 && getAddressType(addrs[0]) === "mx" && !this.props.roomId) { if (addrTexts.length === 1 &&
getAddressType(addrTexts[0]) === "mx" &&
!this.props.roomId
) {
return true; return true;
} else { } else {
return false; return false;

View File

@ -0,0 +1,73 @@
/*
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 classnames from 'classnames';
/*
* A dialog for confirming a redaction.
*/
export default React.createClass({
displayName: 'ConfirmRedactDialog',
propTypes: {
onFinished: React.PropTypes.func.isRequired,
},
defaultProps: {
danger: false,
},
onOk: function() {
this.props.onFinished(true);
},
onCancel: function() {
this.props.onFinished(false);
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const title = "Confirm Redaction";
const confirmButtonClass = classnames({
'mx_Dialog_primary': true,
'danger': false,
});
return (
<BaseDialog className="mx_ConfirmUserActionDialog" onFinished={this.props.onFinished}
onEnterPressed={ this.onOk }
title={title}
>
<div className="mx_Dialog_content">
Are you sure you wish to redact (delete) this event?
Note that if you redact a room name or topic change, it could undo the change.
</div>
<div className="mx_Dialog_buttons">
<button className={confirmButtonClass} onClick={this.onOk}>
Redact
</button>
<button onClick={this.onCancel}>
Cancel
</button>
</div>
</BaseDialog>
);
},
});

View File

@ -97,7 +97,7 @@ export default React.createClass({
> >
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<div className="mx_ConfirmUserActionDialog_avatar"> <div className="mx_ConfirmUserActionDialog_avatar">
<MemberAvatar member={this.props.member} width={72} height={72} /> <MemberAvatar member={this.props.member} width={48} height={48} />
</div> </div>
<div className="mx_ConfirmUserActionDialog_name">{this.props.member.name}</div> <div className="mx_ConfirmUserActionDialog_name">{this.props.member.name}</div>
<div className="mx_ConfirmUserActionDialog_userId">{this.props.member.userId}</div> <div className="mx_ConfirmUserActionDialog_userId">{this.props.member.userId}</div>

View File

@ -18,7 +18,7 @@ import React from 'react';
import sdk from '../../../index'; import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import Lifecycle from '../../../Lifecycle'; import * as Lifecycle from '../../../Lifecycle';
import Velocity from 'velocity-vector'; import Velocity from 'velocity-vector';
export default class DeactivateAccountDialog extends React.Component { export default class DeactivateAccountDialog extends React.Component {

View File

@ -27,6 +27,9 @@ export default React.createClass({
displayName: 'InteractiveAuthDialog', displayName: 'InteractiveAuthDialog',
propTypes: { propTypes: {
// matrix client to use for UI auth requests
matrixClient: React.PropTypes.object.isRequired,
// response from initial request. If not supplied, will do a request on // response from initial request. If not supplied, will do a request on
// mount. // mount.
authData: React.PropTypes.shape({ authData: React.PropTypes.shape({
@ -49,22 +52,62 @@ export default React.createClass({
}; };
}, },
getInitialState: function() {
return {
authError: null,
}
},
_onAuthFinished: function(success, result) {
if (success) {
this.props.onFinished(true, result);
} else {
this.setState({
authError: result,
});
}
},
_onDismissClick: function() {
this.props.onFinished(false);
},
render: function() { render: function() {
const InteractiveAuth = sdk.getComponent("structures.InteractiveAuth"); const InteractiveAuth = sdk.getComponent("structures.InteractiveAuth");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
let content;
if (this.state.authError) {
content = (
<div>
<div>{this.state.authError.message || this.state.authError.toString()}</div>
<br />
<AccessibleButton onClick={this._onDismissClick}
className="mx_UserSettings_button"
>
Dismiss
</AccessibleButton>
</div>
);
} else {
content = (
<div>
<InteractiveAuth ref={this._collectInteractiveAuth}
matrixClient={this.props.matrixClient}
authData={this.props.authData}
makeRequest={this.props.makeRequest}
onAuthFinished={this._onAuthFinished}
/>
</div>
);
}
return ( return (
<BaseDialog className="mx_InteractiveAuthDialog" <BaseDialog className="mx_InteractiveAuthDialog"
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={this.props.title} title={this.state.authError ? 'Error' : this.props.title}
> >
<div> {content}
<InteractiveAuth ref={this._collectInteractiveAuth}
authData={this.props.authData}
makeRequest={this.props.makeRequest}
onFinished={this.props.onFinished}
/>
</div>
</BaseDialog> </BaseDialog>
); );
}, },

View File

@ -21,10 +21,8 @@ export default React.createClass({
displayName: 'QuestionDialog', displayName: 'QuestionDialog',
propTypes: { propTypes: {
title: React.PropTypes.string, title: React.PropTypes.string,
description: React.PropTypes.oneOfType([ description: React.PropTypes.node,
React.PropTypes.element, extraButtons: React.PropTypes.node,
React.PropTypes.string,
]),
button: React.PropTypes.string, button: React.PropTypes.string,
focus: React.PropTypes.bool, focus: React.PropTypes.bool,
onFinished: React.PropTypes.func.isRequired, onFinished: React.PropTypes.func.isRequired,
@ -34,8 +32,10 @@ export default React.createClass({
return { return {
title: "", title: "",
description: "", description: "",
extraButtons: null,
button: "OK", button: "OK",
focus: true, focus: true,
hasCancelButton: true,
}; };
}, },
@ -49,6 +49,11 @@ export default React.createClass({
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const cancelButton = this.props.hasCancelButton ? (
<button onClick={this.onCancel}>
Cancel
</button>
) : null;
return ( return (
<BaseDialog className="mx_QuestionDialog" onFinished={this.props.onFinished} <BaseDialog className="mx_QuestionDialog" onFinished={this.props.onFinished}
onEnterPressed={ this.onOk } onEnterPressed={ this.onOk }
@ -61,10 +66,8 @@ export default React.createClass({
<button className="mx_Dialog_primary" onClick={this.onOk} autoFocus={this.props.focus}> <button className="mx_Dialog_primary" onClick={this.onOk} autoFocus={this.props.focus}>
{this.props.button} {this.props.button}
</button> </button>
{this.props.extraButtons}
<button onClick={this.onCancel}> {cancelButton}
Cancel
</button>
</div> </div>
</BaseDialog> </BaseDialog>
); );

View File

@ -16,8 +16,10 @@ limitations under the License.
import React from 'react'; import React from 'react';
import sdk from '../../../index'; import sdk from '../../../index';
import dis from '../../../dispatcher';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import GeminiScrollbar from 'react-gemini-scrollbar'; import GeminiScrollbar from 'react-gemini-scrollbar';
import Resend from '../../../Resend';
function DeviceListEntry(props) { function DeviceListEntry(props) {
const {userId, device} = props; const {userId, device} = props;
@ -85,7 +87,7 @@ UnknownDeviceList.propTypes = {
export default React.createClass({ export default React.createClass({
displayName: 'UnknownEventDialog', displayName: 'UnknownDeviceDialog',
propTypes: { propTypes: {
room: React.PropTypes.object.isRequired, room: React.PropTypes.object.isRequired,
@ -125,14 +127,10 @@ export default React.createClass({
} else { } else {
warning = ( warning = (
<div> <div>
<p>
This means there is no guarantee that the devices
belong to the users they claim to.
</p>
<p> <p>
We recommend you go through the verification process We recommend you go through the verification process
for each device before continuing, but you can resend for each device to confirm they belong to their legitimate owner,
the message without verifying if you prefer. but you can resend the message without verifying if you prefer.
</p> </p>
</div> </div>
); );
@ -151,8 +149,7 @@ export default React.createClass({
> >
<GeminiScrollbar autoshow={false} className="mx_Dialog_content"> <GeminiScrollbar autoshow={false} className="mx_Dialog_content">
<h4> <h4>
This room contains unknown devices which have not been This room contains devices that you haven't seen before.
verified.
</h4> </h4>
{ warning } { warning }
Unknown devices: Unknown devices:
@ -160,6 +157,13 @@ export default React.createClass({
<UnknownDeviceList devices={this.props.devices} /> <UnknownDeviceList devices={this.props.devices} />
</GeminiScrollbar> </GeminiScrollbar>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button className="mx_Dialog_primary" autoFocus={ true }
onClick={() => {
this.props.onFinished();
Resend.resendUnsentEvents(this.props.room);
}}>
Send anyway
</button>
<button className="mx_Dialog_primary" autoFocus={ true } <button className="mx_Dialog_primary" autoFocus={ true }
onClick={() => { onClick={() => {
// XXX: temporary logging to try to diagnose // XXX: temporary logging to try to diagnose

View File

@ -27,8 +27,8 @@ import React from 'react';
export default function AccessibleButton(props) { export default function AccessibleButton(props) {
const {element, onClick, children, ...restProps} = props; const {element, onClick, children, ...restProps} = props;
restProps.onClick = onClick; restProps.onClick = onClick;
restProps.onKeyDown = function(e) { restProps.onKeyUp = function(e) {
if (e.keyCode == 13 || e.keyCode == 32) return onClick(); if (e.keyCode == 13 || e.keyCode == 32) return onClick(e);
}; };
restProps.tabIndex = restProps.tabIndex || "0"; restProps.tabIndex = restProps.tabIndex || "0";
restProps.role = "button"; restProps.role = "button";

View File

@ -61,6 +61,15 @@ export default React.createClass({
} }
}, },
moveSelectionTop: function() {
if (this.state.selected > 0) {
this.setState({
selected: 0,
hover: false,
});
}
},
moveSelectionUp: function() { moveSelectionUp: function() {
if (this.state.selected > 0) { if (this.state.selected > 0) {
this.setState({ this.setState({
@ -124,7 +133,14 @@ export default React.createClass({
// Saving the addressListElement so we can use it to work out, in the componentDidUpdate // Saving the addressListElement so we can use it to work out, in the componentDidUpdate
// method, how far to scroll when using the arrow keys // method, how far to scroll when using the arrow keys
addressList.push( addressList.push(
<div className={classes} onClick={this.onClick.bind(this, i)} onMouseEnter={this.onMouseEnter.bind(this, i)} onMouseLeave={this.onMouseLeave} key={i} ref={(ref) => { this.addressListElement = ref; }} > <div
className={classes}
onClick={this.onClick.bind(this, i)}
onMouseEnter={this.onMouseEnter.bind(this, i)}
onMouseLeave={this.onMouseLeave}
key={this.props.addressList[i].userId}
ref={(ref) => { this.addressListElement = ref; }}
>
<AddressTile address={this.props.addressList[i]} justified={true} networkName="vector" networkUrl="img/search-icon-vector.svg" /> <AddressTile address={this.props.addressList[i]} justified={true} networkName="vector" networkUrl="img/search-icon-vector.svg" />
</div> </div>
); );

View File

@ -64,19 +64,14 @@ export default React.createClass({
const address = this.props.address; const address = this.props.address;
const name = address.displayName || address.address; const name = address.displayName || address.address;
let imgUrl; let imgUrls = [];
if (address.avatarMxc) {
imgUrl = MatrixClientPeg.get().mxcUrlToHttp(
address.avatarMxc, 25, 25, 'crop'
);
}
if (address.addressType === "mx") { if (address.addressType === "mx" && address.avatarMxc) {
if (!imgUrl) imgUrl = 'img/icon-mx-user.svg'; imgUrls.push(MatrixClientPeg.get().mxcUrlToHttp(
address.avatarMxc, 25, 25, 'crop'
));
} else if (address.addressType === 'email') { } else if (address.addressType === 'email') {
if (!imgUrl) imgUrl = 'img/icon-email-user.svg'; imgUrls.push('img/icon-email-user.svg');
} else {
if (!imgUrl) imgUrl = "img/avatar-error.svg";
} }
// Removing networks for now as they're not really supported // Removing networks for now as they're not really supported
@ -168,7 +163,7 @@ export default React.createClass({
return ( return (
<div className={classes}> <div className={classes}>
<div className="mx_AddressTile_avatar"> <div className="mx_AddressTile_avatar">
<BaseAvatar width={25} height={25} name={name} title={name} url={imgUrl} /> <BaseAvatar defaultToInitialLetter={true} width={25} height={25} name={name} title={name} urls={imgUrls} />
</div> </div>
{ info } { info }
{ dismiss } { dismiss }

View File

@ -59,7 +59,7 @@ export default class DirectorySearchBox extends React.Component {
} }
_onKeyUp(ev) { _onKeyUp(ev) {
if (ev.key == 'Enter') { if (ev.key == 'Enter' && this.props.showJoinButton) {
if (this.props.onJoinClick) { if (this.props.onJoinClick) {
this.props.onJoinClick(this.state.value); this.props.onJoinClick(this.state.value);
} }

View File

@ -0,0 +1,324 @@
/*
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 classnames from 'classnames';
import AccessibleButton from './AccessibleButton';
class MenuOption extends React.Component {
constructor(props) {
super(props);
this._onMouseEnter = this._onMouseEnter.bind(this);
this._onClick = this._onClick.bind(this);
}
_onMouseEnter() {
this.props.onMouseEnter(this.props.dropdownKey);
}
_onClick(e) {
e.preventDefault();
e.stopPropagation();
this.props.onClick(this.props.dropdownKey);
}
render() {
const optClasses = classnames({
mx_Dropdown_option: true,
mx_Dropdown_option_highlight: this.props.highlighted,
});
return <div className={optClasses}
onClick={this._onClick} onKeyPress={this._onKeyPress}
onMouseEnter={this._onMouseEnter}
>
{this.props.children}
</div>
}
};
MenuOption.propTypes = {
children: React.PropTypes.oneOfType([
React.PropTypes.arrayOf(React.PropTypes.node),
React.PropTypes.node
]),
highlighted: React.PropTypes.bool,
dropdownKey: React.PropTypes.string,
onClick: React.PropTypes.func.isRequired,
onMouseEnter: React.PropTypes.func.isRequired,
};
/*
* Reusable dropdown select control, akin to react-select,
* but somewhat simpler as react-select is 79KB of minified
* javascript.
*
* TODO: Port NetworkDropdown to use this.
*/
export default class Dropdown extends React.Component {
constructor(props) {
super(props);
this.dropdownRootElement = null;
this.ignoreEvent = null;
this._onInputClick = this._onInputClick.bind(this);
this._onRootClick = this._onRootClick.bind(this);
this._onDocumentClick = this._onDocumentClick.bind(this);
this._onMenuOptionClick = this._onMenuOptionClick.bind(this);
this._onInputKeyPress = this._onInputKeyPress.bind(this);
this._onInputKeyUp = this._onInputKeyUp.bind(this);
this._onInputChange = this._onInputChange.bind(this);
this._collectRoot = this._collectRoot.bind(this);
this._collectInputTextBox = this._collectInputTextBox.bind(this);
this._setHighlightedOption = this._setHighlightedOption.bind(this);
this.inputTextBox = null;
this._reindexChildren(this.props.children);
const firstChild = React.Children.toArray(props.children)[0];
this.state = {
// True if the menu is dropped-down
expanded: false,
// The key of the highlighted option
// (the option that would become selected if you pressed enter)
highlightedOption: firstChild ? firstChild.key : null,
// the current search query
searchQuery: '',
};
}
componentWillMount() {
// Listen for all clicks on the document so we can close the
// menu when the user clicks somewhere else
document.addEventListener('click', this._onDocumentClick, false);
}
componentWillUnmount() {
document.removeEventListener('click', this._onDocumentClick, false);
}
componentWillReceiveProps(nextProps) {
this._reindexChildren(nextProps.children);
const firstChild = React.Children.toArray(nextProps.children)[0];
this.setState({
highlightedOption: firstChild ? firstChild.key : null,
});
}
_reindexChildren(children) {
this.childrenByKey = {};
React.Children.forEach(children, (child) => {
this.childrenByKey[child.key] = child;
});
}
_onDocumentClick(ev) {
// Close the dropdown if the user clicks anywhere that isn't
// within our root element
if (ev !== this.ignoreEvent) {
this.setState({
expanded: false,
});
}
}
_onRootClick(ev) {
// This captures any clicks that happen within our elements,
// such that we can then ignore them when they're seen by the
// click listener on the document handler, ie. not close the
// dropdown immediately after opening it.
// NB. We can't just stopPropagation() because then the event
// doesn't reach the React onClick().
this.ignoreEvent = ev;
}
_onInputClick(ev) {
this.setState({
expanded: !this.state.expanded,
});
ev.preventDefault();
}
_onMenuOptionClick(dropdownKey) {
this.setState({
expanded: false,
});
this.props.onOptionChange(dropdownKey);
}
_onInputKeyPress(e) {
// This needs to be on the keypress event because otherwise
// it can't cancel the form submission
if (e.key == 'Enter') {
this.setState({
expanded: false,
});
this.props.onOptionChange(this.state.highlightedOption);
e.preventDefault();
}
}
_onInputKeyUp(e) {
// These keys don't generate keypress events and so needs to
// be on keyup
if (e.key == 'Escape') {
this.setState({
expanded: false,
});
} else if (e.key == 'ArrowDown') {
this.setState({
highlightedOption: this._nextOption(this.state.highlightedOption),
});
} else if (e.key == 'ArrowUp') {
this.setState({
highlightedOption: this._prevOption(this.state.highlightedOption),
});
}
}
_onInputChange(e) {
this.setState({
searchQuery: e.target.value,
});
if (this.props.onSearchChange) {
this.props.onSearchChange(e.target.value);
}
}
_collectRoot(e) {
if (this.dropdownRootElement) {
this.dropdownRootElement.removeEventListener(
'click', this._onRootClick, false,
);
}
if (e) {
e.addEventListener('click', this._onRootClick, false);
}
this.dropdownRootElement = e;
}
_collectInputTextBox(e) {
this.inputTextBox = e;
if (e) e.focus();
}
_setHighlightedOption(optionKey) {
this.setState({
highlightedOption: optionKey,
});
}
_nextOption(optionKey) {
const keys = Object.keys(this.childrenByKey);
const index = keys.indexOf(optionKey);
return keys[(index + 1) % keys.length];
}
_prevOption(optionKey) {
const keys = Object.keys(this.childrenByKey);
const index = keys.indexOf(optionKey);
return keys[(index - 1) % keys.length];
}
_getMenuOptions() {
const options = React.Children.map(this.props.children, (child) => {
return (
<MenuOption key={child.key} dropdownKey={child.key}
highlighted={this.state.highlightedOption == child.key}
onMouseEnter={this._setHighlightedOption}
onClick={this._onMenuOptionClick}
>
{child}
</MenuOption>
);
});
if (!this.state.searchQuery) {
options.push(
<div key="_searchprompt" className="mx_Dropdown_searchPrompt">
Type to search...
</div>
);
}
return options;
}
render() {
let currentValue;
const menuStyle = {};
if (this.props.menuWidth) menuStyle.width = this.props.menuWidth;
let menu;
if (this.state.expanded) {
currentValue = <input type="text" className="mx_Dropdown_option"
ref={this._collectInputTextBox} onKeyPress={this._onInputKeyPress}
onKeyUp={this._onInputKeyUp}
onChange={this._onInputChange}
value={this.state.searchQuery}
/>;
menu = <div className="mx_Dropdown_menu" style={menuStyle}>
{this._getMenuOptions()}
</div>;
} else {
const selectedChild = this.props.getShortOption ?
this.props.getShortOption(this.props.value) :
this.childrenByKey[this.props.value];
currentValue = <div className="mx_Dropdown_option">
{selectedChild}
</div>
}
const dropdownClasses = {
mx_Dropdown: true,
};
if (this.props.className) {
dropdownClasses[this.props.className] = true;
}
// Note the menu sits inside the AccessibleButton div so it's anchored
// to the input, but overflows below it. The root contains both.
return <div className={classnames(dropdownClasses)} ref={this._collectRoot}>
<AccessibleButton className="mx_Dropdown_input" onClick={this._onInputClick}>
{currentValue}
<span className="mx_Dropdown_arrow"></span>
{menu}
</AccessibleButton>
</div>;
}
}
Dropdown.propTypes = {
// The width that the dropdown should be. If specified,
// the dropped-down part of the menu will be set to this
// width.
menuWidth: React.PropTypes.number,
// Called when the selected option changes
onOptionChange: React.PropTypes.func.isRequired,
// Called when the value of the search field changes
onSearchChange: React.PropTypes.func,
// Function that, given the key of an option, returns
// a node representing that option to be displayed in the
// box itself as the currently-selected option (ie. as
// opposed to in the actual dropped-down part). If
// unspecified, the appropriate child element is used as
// in the dropped-down menu.
getShortOption: React.PropTypes.func,
value: React.PropTypes.string,
}

View File

@ -33,7 +33,10 @@ module.exports = React.createClass({
className: React.PropTypes.string, className: React.PropTypes.string,
labelClassName: React.PropTypes.string, labelClassName: React.PropTypes.string,
placeholderClassName: React.PropTypes.string, placeholderClassName: React.PropTypes.string,
// Overrides blurToSubmit if true
blurToCancel: React.PropTypes.bool, blurToCancel: React.PropTypes.bool,
// Will cause onValueChanged(value, true) to fire on blur
blurToSubmit: React.PropTypes.bool,
editable: React.PropTypes.bool, editable: React.PropTypes.bool,
}, },
@ -51,6 +54,7 @@ module.exports = React.createClass({
editable: true, editable: true,
className: "mx_EditableText", className: "mx_EditableText",
placeholderClassName: "mx_EditableText_placeholder", placeholderClassName: "mx_EditableText_placeholder",
blurToSubmit: false,
}; };
}, },
@ -119,6 +123,7 @@ module.exports = React.createClass({
this.value = this.props.initialValue; this.value = this.props.initialValue;
this.showPlaceholder(!this.value); this.showPlaceholder(!this.value);
this.onValueChanged(false); this.onValueChanged(false);
this.refs.editable_div.blur();
}, },
onValueChanged: function(shouldSubmit) { onValueChanged: function(shouldSubmit) {
@ -182,13 +187,15 @@ module.exports = React.createClass({
} }
}, },
onFinish: function(ev) { onFinish: function(ev, shouldSubmit) {
var self = this; var self = this;
var submit = (ev.key === "Enter"); var submit = (ev.key === "Enter") || shouldSubmit;
this.setState({ this.setState({
phase: this.Phases.Display, phase: this.Phases.Display,
}, function() { }, function() {
if (this.value !== this.props.initialValue) {
self.onValueChanged(submit); self.onValueChanged(submit);
}
}); });
}, },
@ -199,7 +206,7 @@ module.exports = React.createClass({
if (this.props.blurToCancel) if (this.props.blurToCancel)
{this.cancelEdit();} {this.cancelEdit();}
else else
{this.onFinish(ev);} {this.onFinish(ev, this.props.blurToSubmit);}
this.showPlaceholder(!this.value); this.showPlaceholder(!this.value);
}, },

View File

@ -116,6 +116,7 @@ export default class EditableTextContainer extends React.Component {
<EditableText initialValue={this.state.value} <EditableText initialValue={this.state.value}
placeholder={this.props.placeholder} placeholder={this.props.placeholder}
onValueChanged={this._onValueChanged} onValueChanged={this._onValueChanged}
blurToSubmit={this.props.blurToSubmit}
/> />
); );
} }
@ -137,11 +138,15 @@ EditableTextContainer.propTypes = {
/* callback to update the value. Called with a single argument: the new /* callback to update the value. Called with a single argument: the new
* value. */ * value. */
onSubmit: React.PropTypes.func, onSubmit: React.PropTypes.func,
/* should the input submit when focus is lost? */
blurToSubmit: React.PropTypes.bool,
}; };
EditableTextContainer.defaultProps = { EditableTextContainer.defaultProps = {
initialValue: "", initialValue: "",
placeholder: "", placeholder: "",
blurToSubmit: false,
onSubmit: function(v) {return q(); }, onSubmit: function(v) {return q(); },
}; };

View File

@ -30,6 +30,8 @@ module.exports = React.createClass({
avatarsMaxLength: React.PropTypes.number, avatarsMaxLength: React.PropTypes.number,
// The minimum number of events needed to trigger summarisation // The minimum number of events needed to trigger summarisation
threshold: React.PropTypes.number, threshold: React.PropTypes.number,
// Called when the MELS expansion is toggled
onToggle: React.PropTypes.func,
}, },
getInitialState: function() { getInitialState: function() {
@ -63,6 +65,7 @@ module.exports = React.createClass({
this.setState({ this.setState({
expanded: !this.state.expanded, expanded: !this.state.expanded,
}); });
this.props.onToggle();
}, },
/** /**
@ -108,7 +111,7 @@ module.exports = React.createClass({
} }
return ( return (
<span> <span className="mx_TextualEvent mx_MemberEventListSummary_summary">
{summaries.join(", ")} {summaries.join(", ")}
</span> </span>
); );
@ -264,7 +267,7 @@ module.exports = React.createClass({
); );
}); });
return ( return (
<span> <span className="mx_MemberEventListSummary_avatars">
{avatars} {avatars}
</span> </span>
); );
@ -397,31 +400,28 @@ module.exports = React.createClass({
(seq1, seq2) => aggregate.indices[seq1] > aggregate.indices[seq2] (seq1, seq2) => aggregate.indices[seq1] > aggregate.indices[seq2]
); );
const avatars = this._renderAvatars(avatarMembers); let summaryContainer = null;
const summary = this._renderSummary(aggregate.names, orderedTransitionSequences); if (!expanded) {
const toggleButton = ( summaryContainer = (
<a className="mx_MemberEventListSummary_toggle" onClick={this._toggleSummary}>
{expanded ? 'collapse' : 'expand'}
</a>
);
const summaryContainer = (
<div className="mx_EventTile_line"> <div className="mx_EventTile_line">
<div className="mx_EventTile_info"> <div className="mx_EventTile_info">
<span className="mx_MemberEventListSummary_avatars"> {this._renderAvatars(avatarMembers)}
{avatars} {this._renderSummary(aggregate.names, orderedTransitionSequences)}
</span>
<span className="mx_TextualEvent mx_MemberEventListSummary_summary">
{summary}
</span>&nbsp;
{toggleButton}
</div> </div>
</div> </div>
); );
}
const toggleButton = (
<div className={"mx_MemberEventListSummary_toggle"} onClick={this._toggleSummary}>
{expanded ? 'collapse' : 'expand'}
</div>
);
return ( return (
<div className="mx_MemberEventListSummary"> <div className="mx_MemberEventListSummary">
{toggleButton}
{summaryContainer} {summaryContainer}
{expanded ? <div className="mx_MemberEventListSummary_line">&nbsp;</div> : null}
{expandedEvents} {expandedEvents}
</div> </div>
); );

View File

@ -16,17 +16,12 @@ limitations under the License.
'use strict'; 'use strict';
var React = require('react'); import React from 'react';
import * as Roles from '../../../Roles';
var roles = {
0: 'User',
50: 'Moderator',
100: 'Admin',
};
var reverseRoles = {}; var reverseRoles = {};
Object.keys(roles).forEach(function(key) { Object.keys(Roles.LEVEL_ROLE_MAP).forEach(function(key) {
reverseRoles[roles[key]] = key; reverseRoles[Roles.LEVEL_ROLE_MAP[key]] = key;
}); });
module.exports = React.createClass({ module.exports = React.createClass({
@ -49,7 +44,7 @@ module.exports = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
custom: (roles[this.props.value] === undefined), custom: (Roles.LEVEL_ROLE_MAP[this.props.value] === undefined),
}; };
}, },
@ -99,22 +94,34 @@ module.exports = React.createClass({
selectValue = "Custom"; selectValue = "Custom";
} }
else { else {
selectValue = roles[this.props.value] || "Custom"; selectValue = Roles.LEVEL_ROLE_MAP[this.props.value] || "Custom";
} }
var select; var select;
if (this.props.disabled) { if (this.props.disabled) {
select = <span>{ selectValue }</span>; select = <span>{ selectValue }</span>;
} }
else { else {
// Each level must have a definition in LEVEL_ROLE_MAP
const levels = [0, 50, 100];
let options = levels.map((level) => {
return {
value: Roles.LEVEL_ROLE_MAP[level],
// Give a userDefault (users_default in the power event) of 0 but
// because level !== undefined, this should never be used.
text: Roles.textualPowerLevel(level, 0),
}
});
options.push({ value: "Custom", text: "Custom level" });
options = options.map((op) => {
return <option value={op.value}>{op.text}</option>;
});
select = select =
<select ref="select" <select ref="select"
value={ this.props.controlled ? selectValue : undefined } value={ this.props.controlled ? selectValue : undefined }
defaultValue={ !this.props.controlled ? selectValue : undefined } defaultValue={ !this.props.controlled ? selectValue : undefined }
onChange={ this.onSelectChange }> onChange={ this.onSelectChange }>
<option value="User">User (0)</option> { options }
<option value="Moderator">Moderator (50)</option>
<option value="Admin">Admin (100)</option>
<option value="Custom">Custom level</option>
</select>; </select>;
} }

View File

@ -0,0 +1,125 @@
/*
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 { COUNTRIES } from '../../../phonenumber';
import { charactersToImageNode } from '../../../HtmlUtils';
const COUNTRIES_BY_ISO2 = new Object(null);
for (const c of COUNTRIES) {
COUNTRIES_BY_ISO2[c.iso2] = c;
}
function countryMatchesSearchQuery(query, country) {
if (country.name.toUpperCase().indexOf(query.toUpperCase()) == 0) return true;
if (country.iso2 == query.toUpperCase()) return true;
if (country.prefix == query) return true;
return false;
}
const MAX_DISPLAYED_ROWS = 2;
export default class CountryDropdown extends React.Component {
constructor(props) {
super(props);
this._onSearchChange = this._onSearchChange.bind(this);
this.state = {
searchQuery: '',
}
}
componentWillMount() {
if (!this.props.value) {
// If no value is given, we start with the first
// country selected, but our parent component
// doesn't know this, therefore we do this.
this.props.onOptionChange(COUNTRIES[0].iso2);
}
}
_onSearchChange(search) {
this.setState({
searchQuery: search,
});
}
_flagImgForIso2(iso2) {
// Unicode Regional Indicator Symbol letter 'A'
const RIS_A = 0x1F1E6;
const ASCII_A = 65;
return charactersToImageNode(iso2,
RIS_A + (iso2.charCodeAt(0) - ASCII_A),
RIS_A + (iso2.charCodeAt(1) - ASCII_A),
);
}
render() {
const Dropdown = sdk.getComponent('elements.Dropdown');
let displayedCountries;
if (this.state.searchQuery) {
displayedCountries = COUNTRIES.filter(
countryMatchesSearchQuery.bind(this, this.state.searchQuery),
);
if (
this.state.searchQuery.length == 2 &&
COUNTRIES_BY_ISO2[this.state.searchQuery.toUpperCase()]
) {
// exact ISO2 country name match: make the first result the matches ISO2
const matched = COUNTRIES_BY_ISO2[this.state.searchQuery.toUpperCase()];
displayedCountries = displayedCountries.filter((c) => {
return c.iso2 != matched.iso2;
});
displayedCountries.unshift(matched);
}
} else {
displayedCountries = COUNTRIES;
}
if (displayedCountries.length > MAX_DISPLAYED_ROWS) {
displayedCountries = displayedCountries.slice(0, MAX_DISPLAYED_ROWS);
}
const options = displayedCountries.map((country) => {
return <div key={country.iso2}>
{this._flagImgForIso2(country.iso2)}
{country.name}
</div>;
});
// default value here too, otherwise we need to handle null / undefined
// values between mounting and the initial value propgating
const value = this.props.value || COUNTRIES[0].iso2;
return <Dropdown className={this.props.className}
onOptionChange={this.props.onOptionChange} onSearchChange={this._onSearchChange}
menuWidth={298} getShortOption={this._flagImgForIso2}
value={value}
>
{options}
</Dropdown>
}
}
CountryDropdown.propTypes = {
className: React.PropTypes.string,
onOptionChange: React.PropTypes.func.isRequired,
value: React.PropTypes.string,
};

View File

@ -16,9 +16,10 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import url from 'url';
import classnames from 'classnames';
import sdk from '../../../index'; import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
/* This file contains a collection of components which are used by the /* This file contains a collection of components which are used by the
* InteractiveAuth to prompt the user to enter the information needed * InteractiveAuth to prompt the user to enter the information needed
@ -28,13 +29,32 @@ import MatrixClientPeg from '../../../MatrixClientPeg';
* Call getEntryComponentForLoginType() to get a component suitable for a * Call getEntryComponentForLoginType() to get a component suitable for a
* particular login type. Each component requires the same properties: * particular login type. Each component requires the same properties:
* *
* matrixClient: A matrix client. May be a different one to the one
* currently being used generally (eg. to register with
* one HS whilst beign a guest on another).
* loginType: the login type of the auth stage being attempted * loginType: the login type of the auth stage being attempted
* authSessionId: session id from the server * authSessionId: session id from the server
* clientSecret: The client secret in use for ID server auth sessions
* stageParams: params from the server for the stage being attempted * stageParams: params from the server for the stage being attempted
* errorText: error message from a previous attempt to authenticate * errorText: error message from a previous attempt to authenticate
* submitAuthDict: a function which will be called with the new auth dict * submitAuthDict: a function which will be called with the new auth dict
* busy: a boolean indicating whether the auth logic is doing something * busy: a boolean indicating whether the auth logic is doing something
* the user needs to wait for. * the user needs to wait for.
* inputs: Object of inputs provided by the user, as in js-sdk
* interactive-auth
* stageState: Stage-specific object used for communicating state information
* to the UI from the state-specific auth logic.
* Defined keys for stages are:
* m.login.email.identity:
* * emailSid: string representing the sid of the active
* verification session from the ID server, or
* null if no session is active.
* fail: a function which should be called with an error object if an
* error occurred during the auth stage. This will cause the auth
* session to be failed and the process to go back to the start.
* setEmailSid: m.login.email.identity only: a function to be called with the
* email sid after a token is requested.
* makeRegistrationUrl A function that makes a registration URL
* *
* Each component may also provide the following functions (beyond the standard React ones): * Each component may also provide the following functions (beyond the standard React ones):
* focus: set the input focus appropriately in the form. * focus: set the input focus appropriately in the form.
@ -48,6 +68,7 @@ export const PasswordAuthEntry = React.createClass({
}, },
propTypes: { propTypes: {
matrixClient: React.PropTypes.object.isRequired,
submitAuthDict: React.PropTypes.func.isRequired, submitAuthDict: React.PropTypes.func.isRequired,
errorText: React.PropTypes.string, errorText: React.PropTypes.string,
// is the auth logic currently waiting for something to // is the auth logic currently waiting for something to
@ -73,7 +94,7 @@ export const PasswordAuthEntry = React.createClass({
this.props.submitAuthDict({ this.props.submitAuthDict({
type: PasswordAuthEntry.LOGIN_TYPE, type: PasswordAuthEntry.LOGIN_TYPE,
user: MatrixClientPeg.get().credentials.userId, user: this.props.matrixClient.credentials.userId,
password: this.refs.passwordField.value, password: this.refs.passwordField.value,
}); });
}, },
@ -139,6 +160,7 @@ export const RecaptchaAuthEntry = React.createClass({
submitAuthDict: React.PropTypes.func.isRequired, submitAuthDict: React.PropTypes.func.isRequired,
stageParams: React.PropTypes.object.isRequired, stageParams: React.PropTypes.object.isRequired,
errorText: React.PropTypes.string, errorText: React.PropTypes.string,
busy: React.PropTypes.bool,
}, },
_onCaptchaResponse: function(response) { _onCaptchaResponse: function(response) {
@ -149,6 +171,11 @@ export const RecaptchaAuthEntry = React.createClass({
}, },
render: function() { render: function() {
if (this.props.busy) {
const Loader = sdk.getComponent("elements.Spinner");
return <Loader />;
}
const CaptchaForm = sdk.getComponent("views.login.CaptchaForm"); const CaptchaForm = sdk.getComponent("views.login.CaptchaForm");
var sitePublicKey = this.props.stageParams.public_key; var sitePublicKey = this.props.stageParams.public_key;
return ( return (
@ -164,10 +191,214 @@ export const RecaptchaAuthEntry = React.createClass({
}, },
}); });
export const EmailIdentityAuthEntry = React.createClass({
displayName: 'EmailIdentityAuthEntry',
statics: {
LOGIN_TYPE: "m.login.email.identity",
},
propTypes: {
matrixClient: React.PropTypes.object.isRequired,
submitAuthDict: React.PropTypes.func.isRequired,
authSessionId: React.PropTypes.string.isRequired,
clientSecret: React.PropTypes.string.isRequired,
inputs: React.PropTypes.object.isRequired,
stageState: React.PropTypes.object.isRequired,
fail: React.PropTypes.func.isRequired,
setEmailSid: React.PropTypes.func.isRequired,
makeRegistrationUrl: React.PropTypes.func.isRequired,
},
getInitialState: function() {
return {
requestingToken: false,
};
},
componentWillMount: function() {
if (this.props.stageState.emailSid === null) {
this.setState({requestingToken: true});
this._requestEmailToken().catch((e) => {
this.props.fail(e);
}).finally(() => {
this.setState({requestingToken: false});
}).done();
}
},
/*
* Requests a verification token by email.
*/
_requestEmailToken: function() {
const nextLink = this.props.makeRegistrationUrl({
client_secret: this.props.clientSecret,
hs_url: this.props.matrixClient.getHomeserverUrl(),
is_url: this.props.matrixClient.getIdentityServerUrl(),
session_id: this.props.authSessionId,
});
return this.props.matrixClient.requestRegisterEmailToken(
this.props.inputs.emailAddress,
this.props.clientSecret,
1, // TODO: Multiple send attempts?
nextLink,
).then((result) => {
this.props.setEmailSid(result.sid);
});
},
render: function() {
if (this.state.requestingToken) {
const Loader = sdk.getComponent("elements.Spinner");
return <Loader />;
} else {
return (
<div>
<p>An email has been sent to <i>{this.props.inputs.emailAddress}</i></p>
<p>Please check your email to continue registration.</p>
</div>
);
}
},
});
export const MsisdnAuthEntry = React.createClass({
displayName: 'MsisdnAuthEntry',
statics: {
LOGIN_TYPE: "m.login.msisdn",
},
propTypes: {
inputs: React.PropTypes.shape({
phoneCountry: React.PropTypes.string,
phoneNumber: React.PropTypes.string,
}),
fail: React.PropTypes.func,
clientSecret: React.PropTypes.func,
submitAuthDict: React.PropTypes.func.isRequired,
matrixClient: React.PropTypes.object,
submitAuthDict: React.PropTypes.func,
},
getInitialState: function() {
return {
token: '',
requestingToken: false,
};
},
componentWillMount: function() {
this._sid = null;
this._msisdn = null;
this._tokenBox = null;
this.setState({requestingToken: true});
this._requestMsisdnToken().catch((e) => {
this.props.fail(e);
}).finally(() => {
this.setState({requestingToken: false});
}).done();
},
/*
* Requests a verification token by SMS.
*/
_requestMsisdnToken: function() {
return this.props.matrixClient.requestRegisterMsisdnToken(
this.props.inputs.phoneCountry,
this.props.inputs.phoneNumber,
this.props.clientSecret,
1, // TODO: Multiple send attempts?
).then((result) => {
this._sid = result.sid;
this._msisdn = result.msisdn;
});
},
_onTokenChange: function(e) {
this.setState({
token: e.target.value,
});
},
_onFormSubmit: function(e) {
e.preventDefault();
if (this.state.token == '') return;
this.setState({
errorText: null,
});
this.props.matrixClient.submitMsisdnToken(
this._sid, this.props.clientSecret, this.state.token
).then((result) => {
if (result.success) {
const idServerParsedUrl = url.parse(
this.props.matrixClient.getIdentityServerUrl(),
)
this.props.submitAuthDict({
type: MsisdnAuthEntry.LOGIN_TYPE,
threepid_creds: {
sid: this._sid,
client_secret: this.props.clientSecret,
id_server: idServerParsedUrl.host,
},
});
} else {
this.setState({
errorText: "Token incorrect",
});
}
}).catch((e) => {
this.props.fail(e);
console.log("Failed to submit msisdn token");
}).done();
},
render: function() {
if (this.state.requestingToken) {
const Loader = sdk.getComponent("elements.Spinner");
return <Loader />;
} else {
const enableSubmit = Boolean(this.state.token);
const submitClasses = classnames({
mx_InteractiveAuthEntryComponents_msisdnSubmit: true,
mx_UserSettings_button: true, // XXX button classes
});
return (
<div>
<p>A text message has been sent to +<i>{this._msisdn}</i></p>
<p>Please enter the code it contains:</p>
<div className="mx_InteractiveAuthEntryComponents_msisdnWrapper">
<form onSubmit={this._onFormSubmit}>
<input type="text"
className="mx_InteractiveAuthEntryComponents_msisdnEntry"
value={this.state.token}
onChange={this._onTokenChange}
/>
<br />
<input type="submit" value="Submit"
className={submitClasses}
disabled={!enableSubmit}
/>
</form>
<div className="error">
{this.state.errorText}
</div>
</div>
</div>
);
}
},
});
export const FallbackAuthEntry = React.createClass({ export const FallbackAuthEntry = React.createClass({
displayName: 'FallbackAuthEntry', displayName: 'FallbackAuthEntry',
propTypes: { propTypes: {
matrixClient: React.PropTypes.object.isRequired,
authSessionId: React.PropTypes.string.isRequired, authSessionId: React.PropTypes.string.isRequired,
loginType: React.PropTypes.string.isRequired, loginType: React.PropTypes.string.isRequired,
submitAuthDict: React.PropTypes.func.isRequired, submitAuthDict: React.PropTypes.func.isRequired,
@ -189,7 +420,7 @@ export const FallbackAuthEntry = React.createClass({
}, },
_onShowFallbackClick: function() { _onShowFallbackClick: function() {
var url = MatrixClientPeg.get().getFallbackAuthUrl( var url = this.props.matrixClient.getFallbackAuthUrl(
this.props.loginType, this.props.loginType,
this.props.authSessionId this.props.authSessionId
); );
@ -199,7 +430,7 @@ export const FallbackAuthEntry = React.createClass({
_onReceiveMessage: function(event) { _onReceiveMessage: function(event) {
if ( if (
event.data === "authDone" && event.data === "authDone" &&
event.origin === MatrixClientPeg.get().getHomeserverUrl() event.origin === this.props.matrixClient.getHomeserverUrl()
) { ) {
this.props.submitAuthDict({}); this.props.submitAuthDict({});
} }
@ -220,6 +451,8 @@ export const FallbackAuthEntry = React.createClass({
const AuthEntryComponents = [ const AuthEntryComponents = [
PasswordAuthEntry, PasswordAuthEntry,
RecaptchaAuthEntry, RecaptchaAuthEntry,
EmailIdentityAuthEntry,
MsisdnAuthEntry,
]; ];
export function getEntryComponentForLoginType(loginType) { export function getEntryComponentForLoginType(loginType) {

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -17,6 +18,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import sdk from '../../../index';
import {field_input_incorrect} from '../../../UiEffects'; import {field_input_incorrect} from '../../../UiEffects';
@ -28,8 +30,12 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
onSubmit: React.PropTypes.func.isRequired, // fn(username, password) onSubmit: React.PropTypes.func.isRequired, // fn(username, password)
onForgotPasswordClick: React.PropTypes.func, // fn() onForgotPasswordClick: React.PropTypes.func, // fn()
initialUsername: React.PropTypes.string, initialUsername: React.PropTypes.string,
initialPhoneCountry: React.PropTypes.string,
initialPhoneNumber: React.PropTypes.string,
initialPassword: React.PropTypes.string, initialPassword: React.PropTypes.string,
onUsernameChanged: React.PropTypes.func, onUsernameChanged: React.PropTypes.func,
onPhoneCountryChanged: React.PropTypes.func,
onPhoneNumberChanged: React.PropTypes.func,
onPasswordChanged: React.PropTypes.func, onPasswordChanged: React.PropTypes.func,
loginIncorrect: React.PropTypes.bool, loginIncorrect: React.PropTypes.bool,
}, },
@ -38,7 +44,11 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
return { return {
onUsernameChanged: function() {}, onUsernameChanged: function() {},
onPasswordChanged: function() {}, onPasswordChanged: function() {},
onPhoneCountryChanged: function() {},
onPhoneNumberChanged: function() {},
initialUsername: "", initialUsername: "",
initialPhoneCountry: "",
initialPhoneNumber: "",
initialPassword: "", initialPassword: "",
loginIncorrect: false, loginIncorrect: false,
}; };
@ -48,6 +58,8 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
return { return {
username: this.props.initialUsername, username: this.props.initialUsername,
password: this.props.initialPassword, password: this.props.initialPassword,
phoneCountry: this.props.initialPhoneCountry,
phoneNumber: this.props.initialPhoneNumber,
}; };
}, },
@ -63,7 +75,12 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
onSubmitForm: function(ev) { onSubmitForm: function(ev) {
ev.preventDefault(); ev.preventDefault();
this.props.onSubmit(this.state.username, this.state.password); this.props.onSubmit(
this.state.username,
this.state.phoneCountry,
this.state.phoneNumber,
this.state.password,
);
}, },
onUsernameChanged: function(ev) { onUsernameChanged: function(ev) {
@ -71,6 +88,16 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
this.props.onUsernameChanged(ev.target.value); this.props.onUsernameChanged(ev.target.value);
}, },
onPhoneCountryChanged: function(country) {
this.setState({phoneCountry: country});
this.props.onPhoneCountryChanged(country);
},
onPhoneNumberChanged: function(ev) {
this.setState({phoneNumber: ev.target.value});
this.props.onPhoneNumberChanged(ev.target.value);
},
onPasswordChanged: function(ev) { onPasswordChanged: function(ev) {
this.setState({password: ev.target.value}); this.setState({password: ev.target.value});
this.props.onPasswordChanged(ev.target.value); this.props.onPasswordChanged(ev.target.value);
@ -92,13 +119,28 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
error: this.props.loginIncorrect, error: this.props.loginIncorrect,
}); });
const CountryDropdown = sdk.getComponent('views.login.CountryDropdown');
return ( return (
<div> <div>
<form onSubmit={this.onSubmitForm}> <form onSubmit={this.onSubmitForm}>
<input className="mx_Login_field" type="text" <input className="mx_Login_field mx_Login_username" type="text"
name="username" // make it a little easier for browser's remember-password name="username" // make it a little easier for browser's remember-password
value={this.state.username} onChange={this.onUsernameChanged} value={this.state.username} onChange={this.onUsernameChanged}
placeholder="Email or user name" autoFocus /> placeholder="Email or user name" autoFocus />
or
<div className="mx_Login_phoneSection">
<CountryDropdown ref="phone_country" onOptionChange={this.onPhoneCountryChanged}
className="mx_Login_phoneCountry"
value={this.state.phoneCountry}
/>
<input type="text" ref="phoneNumber"
onChange={this.onPhoneNumberChanged}
placeholder="Mobile phone number"
className="mx_Login_phoneNumberField mx_Login_field"
value={this.state.phoneNumber}
name="phoneNumber"
/>
</div>
<br /> <br />
<input className={pwFieldClass} ref={(e) => {this._passwordField = e;}} type="password" <input className={pwFieldClass} ref={(e) => {this._passwordField = e;}} type="password"
name="password" name="password"

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,18 +15,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
'use strict'; import React from 'react';
import { field_input_incorrect } from '../../../UiEffects';
import sdk from '../../../index';
import Email from '../../../email';
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
import Modal from '../../../Modal';
var React = require('react'); const FIELD_EMAIL = 'field_email';
var UiEffects = require('../../../UiEffects'); const FIELD_PHONE_COUNTRY = 'field_phone_country';
var sdk = require('../../../index'); const FIELD_PHONE_NUMBER = 'field_phone_number';
var Email = require('../../../email'); const FIELD_USERNAME = 'field_username';
var Modal = require("../../../Modal"); const FIELD_PASSWORD = 'field_password';
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
var FIELD_EMAIL = 'field_email';
var FIELD_USERNAME = 'field_username';
var FIELD_PASSWORD = 'field_password';
var FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
/** /**
* A pure UI component which displays a registration form. * A pure UI component which displays a registration form.
@ -36,6 +38,8 @@ module.exports = React.createClass({
propTypes: { propTypes: {
// Values pre-filled in the input boxes when the component loads // Values pre-filled in the input boxes when the component loads
defaultEmail: React.PropTypes.string, defaultEmail: React.PropTypes.string,
defaultPhoneCountry: React.PropTypes.string,
defaultPhoneNumber: React.PropTypes.string,
defaultUsername: React.PropTypes.string, defaultUsername: React.PropTypes.string,
defaultPassword: React.PropTypes.string, defaultPassword: React.PropTypes.string,
teamsConfig: React.PropTypes.shape({ teamsConfig: React.PropTypes.shape({
@ -54,15 +58,13 @@ module.exports = React.createClass({
// a different username will cause a fresh account to be generated. // a different username will cause a fresh account to be generated.
guestUsername: React.PropTypes.string, guestUsername: React.PropTypes.string,
showEmail: React.PropTypes.bool,
minPasswordLength: React.PropTypes.number, minPasswordLength: React.PropTypes.number,
onError: React.PropTypes.func, onError: React.PropTypes.func,
onRegisterClick: React.PropTypes.func // onRegisterClick(Object) => ?Promise onRegisterClick: React.PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
}, },
getDefaultProps: function() { getDefaultProps: function() {
return { return {
showEmail: false,
minPasswordLength: 6, minPasswordLength: 6,
onError: function(e) { onError: function(e) {
console.error(e); console.error(e);
@ -74,6 +76,8 @@ module.exports = React.createClass({
return { return {
fieldValid: {}, fieldValid: {},
selectedTeam: null, selectedTeam: null,
// The ISO2 country code selected in the phone number entry
phoneCountry: this.props.defaultPhoneCountry,
}; };
}, },
@ -88,6 +92,7 @@ module.exports = React.createClass({
this.validateField(FIELD_PASSWORD_CONFIRM); this.validateField(FIELD_PASSWORD_CONFIRM);
this.validateField(FIELD_PASSWORD); this.validateField(FIELD_PASSWORD);
this.validateField(FIELD_USERNAME); this.validateField(FIELD_USERNAME);
this.validateField(FIELD_PHONE_NUMBER);
this.validateField(FIELD_EMAIL); this.validateField(FIELD_EMAIL);
var self = this; var self = this;
@ -121,6 +126,8 @@ module.exports = React.createClass({
username: this.refs.username.value.trim() || this.props.guestUsername, username: this.refs.username.value.trim() || this.props.guestUsername,
password: this.refs.password.value.trim(), password: this.refs.password.value.trim(),
email: email, email: email,
phoneCountry: this.state.phoneCountry,
phoneNumber: this.refs.phoneNumber.value.trim(),
}); });
if (promise) { if (promise) {
@ -174,8 +181,13 @@ module.exports = React.createClass({
showSupportEmail: false, showSupportEmail: false,
}); });
} }
const valid = email === '' || Email.looksValid(email); const emailValid = email === '' || Email.looksValid(email);
this.markFieldValid(field_id, valid, "RegistrationForm.ERR_EMAIL_INVALID"); this.markFieldValid(field_id, emailValid, "RegistrationForm.ERR_EMAIL_INVALID");
break;
case FIELD_PHONE_NUMBER:
const phoneNumber = this.refs.phoneNumber.value;
const phoneNumberValid = phoneNumber === '' || phoneNumberLooksValid(phoneNumber);
this.markFieldValid(field_id, phoneNumberValid, "RegistrationForm.ERR_PHONE_NUMBER_INVALID");
break; break;
case FIELD_USERNAME: case FIELD_USERNAME:
// XXX: SPEC-1 // XXX: SPEC-1
@ -227,7 +239,7 @@ module.exports = React.createClass({
fieldValid[field_id] = val; fieldValid[field_id] = val;
this.setState({fieldValid: fieldValid}); this.setState({fieldValid: fieldValid});
if (!val) { if (!val) {
UiEffects.field_input_incorrect(this.fieldElementById(field_id)); field_input_incorrect(this.fieldElementById(field_id));
this.props.onError(error_code); this.props.onError(error_code);
} }
}, },
@ -236,6 +248,8 @@ module.exports = React.createClass({
switch (field_id) { switch (field_id) {
case FIELD_EMAIL: case FIELD_EMAIL:
return this.refs.email; return this.refs.email;
case FIELD_PHONE_NUMBER:
return this.refs.phoneNumber;
case FIELD_USERNAME: case FIELD_USERNAME:
return this.refs.username; return this.refs.username;
case FIELD_PASSWORD: case FIELD_PASSWORD:
@ -245,8 +259,8 @@ module.exports = React.createClass({
} }
}, },
_classForField: function(field_id, baseClass) { _classForField: function(field_id, ...baseClasses) {
let cls = baseClass || ''; let cls = baseClasses.join(' ');
if (this.state.fieldValid[field_id] === false) { if (this.state.fieldValid[field_id] === false) {
if (cls) cls += ' '; if (cls) cls += ' ';
cls += 'error'; cls += 'error';
@ -254,18 +268,26 @@ module.exports = React.createClass({
return cls; return cls;
}, },
_onPhoneCountryChange(newVal) {
this.setState({
phoneCountry: newVal,
});
},
render: function() { render: function() {
var self = this; var self = this;
var emailSection, belowEmailSection, registerButton;
if (this.props.showEmail) { const emailSection = (
emailSection = ( <div>
<input type="text" ref="email" <input type="text" ref="email"
autoFocus={true} placeholder="Email address (optional)" autoFocus={true} placeholder="Email address (optional)"
defaultValue={this.props.defaultEmail} defaultValue={this.props.defaultEmail}
className={this._classForField(FIELD_EMAIL, 'mx_Login_field')} className={this._classForField(FIELD_EMAIL, 'mx_Login_field')}
onBlur={function() {self.validateField(FIELD_EMAIL);}} onBlur={function() {self.validateField(FIELD_EMAIL);}}
value={self.state.email}/> value={self.state.email}/>
</div>
); );
let belowEmailSection;
if (this.props.teamsConfig) { if (this.props.teamsConfig) {
if (this.props.teamsConfig.supportEmail && this.state.showSupportEmail) { if (this.props.teamsConfig.supportEmail && this.state.showSupportEmail) {
belowEmailSection = ( belowEmailSection = (
@ -286,14 +308,31 @@ module.exports = React.createClass({
); );
} }
} }
}
if (this.props.onRegisterClick) { const CountryDropdown = sdk.getComponent('views.login.CountryDropdown');
registerButton = ( const phoneSection = (
<div className="mx_Login_phoneSection">
<CountryDropdown ref="phone_country" onOptionChange={this._onPhoneCountryChange}
className="mx_Login_phoneCountry"
value={this.state.phoneCountry}
/>
<input type="text" ref="phoneNumber"
placeholder="Mobile phone number (optional)"
defaultValue={this.props.defaultPhoneNumber}
className={this._classForField(
FIELD_PHONE_NUMBER, 'mx_Login_phoneNumberField', 'mx_Login_field'
)}
onBlur={function() {self.validateField(FIELD_PHONE_NUMBER);}}
value={self.state.phoneNumber}
/>
</div>
);
const registerButton = (
<input className="mx_Login_submit" type="submit" value="Register" /> <input className="mx_Login_submit" type="submit" value="Register" />
); );
}
var placeholderUserName = "User name"; let placeholderUserName = "User name";
if (this.props.guestUsername) { if (this.props.guestUsername) {
placeholderUserName += " (default: " + this.props.guestUsername + ")"; placeholderUserName += " (default: " + this.props.guestUsername + ")";
} }
@ -303,6 +342,7 @@ module.exports = React.createClass({
<form onSubmit={this.onSubmit}> <form onSubmit={this.onSubmit}>
{emailSection} {emailSection}
{belowEmailSection} {belowEmailSection}
{phoneSection}
<input type="text" ref="username" <input type="text" ref="username"
placeholder={ placeholderUserName } defaultValue={this.props.defaultUsername} placeholder={ placeholderUserName } defaultValue={this.props.defaultUsername}
className={this._classForField(FIELD_USERNAME, 'mx_Login_field')} className={this._classForField(FIELD_USERNAME, 'mx_Login_field')}

View File

@ -25,6 +25,7 @@ import sdk from '../../../index';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile'; import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
import q from 'q'; import q from 'q';
import UserSettingsStore from '../../../UserSettingsStore';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MImageBody', displayName: 'MImageBody',
@ -42,7 +43,7 @@ module.exports = React.createClass({
decryptedUrl: null, decryptedUrl: null,
decryptedThumbnailUrl: null, decryptedThumbnailUrl: null,
decryptedBlob: null, decryptedBlob: null,
error: null error: null,
}; };
}, },
@ -55,7 +56,7 @@ module.exports = React.createClass({
const ImageView = sdk.getComponent("elements.ImageView"); const ImageView = sdk.getComponent("elements.ImageView");
const params = { const params = {
src: httpUrl, src: httpUrl,
mxEvent: this.props.mxEvent mxEvent: this.props.mxEvent,
}; };
if (content.info) { if (content.info) {
@ -70,22 +71,26 @@ module.exports = React.createClass({
_isGif: function() { _isGif: function() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
return (content && content.info && content.info.mimetype === "image/gif"); return (
content &&
content.info &&
content.info.mimetype === "image/gif"
);
}, },
onImageEnter: function(e) { onImageEnter: function(e) {
if (!this._isGif()) { if (!this._isGif() || UserSettingsStore.getSyncedSetting("autoplayGifsAndVideos", false)) {
return; return;
} }
var imgElement = e.target; const imgElement = e.target;
imgElement.src = this._getContentUrl(); imgElement.src = this._getContentUrl();
}, },
onImageLeave: function(e) { onImageLeave: function(e) {
if (!this._isGif()) { if (!this._isGif() || UserSettingsStore.getSyncedSetting("autoplayGifsAndVideos", false)) {
return; return;
} }
var imgElement = e.target; const imgElement = e.target;
imgElement.src = this._getThumbUrl(); imgElement.src = this._getThumbUrl();
}, },
@ -101,6 +106,7 @@ module.exports = React.createClass({
_getThumbUrl: function() { _getThumbUrl: function() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined) { if (content.file !== undefined) {
// Don't use the thumbnail for clients wishing to autoplay gifs.
if (this.state.decryptedThumbnailUrl) { if (this.state.decryptedThumbnailUrl) {
return this.state.decryptedThumbnailUrl; return this.state.decryptedThumbnailUrl;
} }
@ -115,15 +121,15 @@ module.exports = React.createClass({
this.fixupHeight(); this.fixupHeight();
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) { if (content.file !== undefined && this.state.decryptedUrl === null) {
var thumbnailPromise = q(null); let thumbnailPromise = q(null);
if (content.info.thumbnail_file) { if (content.info.thumbnail_file) {
thumbnailPromise = decryptFile( thumbnailPromise = decryptFile(
content.info.thumbnail_file content.info.thumbnail_file,
).then(function(blob) { ).then(function(blob) {
return readBlobAsDataUri(blob); return readBlobAsDataUri(blob);
}); });
} }
var decryptedBlob; let decryptedBlob;
thumbnailPromise.then((thumbnailUrl) => { thumbnailPromise.then((thumbnailUrl) => {
return decryptFile(content.file).then(function(blob) { return decryptFile(content.file).then(function(blob) {
decryptedBlob = blob; decryptedBlob = blob;
@ -168,7 +174,7 @@ module.exports = React.createClass({
// the alternative here would be 600*timelineWidth/800; to scale them down to fit inside a 4:3 bounding box // the alternative here would be 600*timelineWidth/800; to scale them down to fit inside a 4:3 bounding box
//console.log("trying to fit image into timelineWidth of " + this.refs.body.offsetWidth + " or " + this.refs.body.clientWidth); //console.log("trying to fit image into timelineWidth of " + this.refs.body.offsetWidth + " or " + this.refs.body.clientWidth);
var thumbHeight = null; let thumbHeight = null;
if (content.info) { if (content.info) {
thumbHeight = ImageUtils.thumbHeight(content.info.w, content.info.h, timelineWidth, maxHeight); thumbHeight = ImageUtils.thumbHeight(content.info.w, content.info.h, timelineWidth, maxHeight);
} }
@ -190,7 +196,6 @@ module.exports = React.createClass({
} }
if (content.file !== undefined && this.state.decryptedUrl === null) { if (content.file !== undefined && this.state.decryptedUrl === null) {
// Need to decrypt the attachment // Need to decrypt the attachment
// The attachment is decrypted in componentDidMount. // The attachment is decrypted in componentDidMount.
// For now add an img tag with a spinner. // For now add an img tag with a spinner.
@ -210,7 +215,12 @@ module.exports = React.createClass({
} }
const contentUrl = this._getContentUrl(); const contentUrl = this._getContentUrl();
const thumbUrl = this._getThumbUrl(); let thumbUrl;
if (this._isGif() && UserSettingsStore.getSyncedSetting("autoplayGifsAndVideos", false)) {
thumbUrl = contentUrl;
} else {
thumbUrl = this._getThumbUrl();
}
if (thumbUrl) { if (thumbUrl) {
return ( return (

View File

@ -23,6 +23,7 @@ import Model from '../../../Modal';
import sdk from '../../../index'; import sdk from '../../../index';
import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile'; import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
import q from 'q'; import q from 'q';
import UserSettingsStore from '../../../UserSettingsStore';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MVideoBody', displayName: 'MVideoBody',
@ -152,11 +153,11 @@ module.exports = React.createClass({
const contentUrl = this._getContentUrl(); const contentUrl = this._getContentUrl();
const thumbUrl = this._getThumbUrl(); const thumbUrl = this._getThumbUrl();
const autoplay = UserSettingsStore.getSyncedSetting("autoplayGifsAndVideos", false);
var height = null; let height = null;
var width = null; let width = null;
var poster = null; let poster = null;
var preload = "metadata"; let preload = "metadata";
if (content.info) { if (content.info) {
const scale = this.thumbScale(content.info.w, content.info.h, 480, 360); const scale = this.thumbScale(content.info.w, content.info.h, 480, 360);
if (scale) { if (scale) {
@ -169,11 +170,10 @@ module.exports = React.createClass({
preload = "none"; preload = "none";
} }
} }
return ( return (
<span className="mx_MVideoBody"> <span className="mx_MVideoBody">
<video className="mx_MVideoBody" src={contentUrl} alt={content.body} <video className="mx_MVideoBody" src={contentUrl} alt={content.body}
controls preload={preload} autoPlay={false} controls preload={preload} muted={autoplay} autoPlay={autoplay}
height={height} width={width} poster={poster}> height={height} width={width} poster={poster}>
</video> </video>
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} /> <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />

View File

@ -16,17 +16,18 @@ limitations under the License.
'use strict'; 'use strict';
var React = require('react'); import React from 'react';
var ReactDOM = require('react-dom'); import ReactDOM from 'react-dom';
var highlight = require('highlight.js'); import highlight from 'highlight.js';
var HtmlUtils = require('../../../HtmlUtils'); import * as HtmlUtils from '../../../HtmlUtils';
var linkify = require('linkifyjs'); import * as linkify from 'linkifyjs';
var linkifyElement = require('linkifyjs/element'); import linkifyElement from 'linkifyjs/element';
var linkifyMatrix = require('../../../linkify-matrix'); import linkifyMatrix from '../../../linkify-matrix';
var sdk = require('../../../index'); import sdk from '../../../index';
var ScalarAuthClient = require("../../../ScalarAuthClient"); import ScalarAuthClient from '../../../ScalarAuthClient';
var Modal = require("../../../Modal"); import Modal from '../../../Modal';
var SdkConfig = require('../../../SdkConfig'); import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher';
linkifyMatrix(linkify); linkifyMatrix(linkify);
@ -131,7 +132,8 @@ module.exports = React.createClass({
links.push(node); links.push(node);
} }
} }
else if (node.tagName === "PRE" || node.tagName === "CODE") { else if (node.tagName === "PRE" || node.tagName === "CODE" ||
node.tagName === "BLOCKQUOTE") {
continue; continue;
} }
else if (node.children && node.children.length) { else if (node.children && node.children.length) {
@ -187,6 +189,15 @@ module.exports = React.createClass({
this.forceUpdate(); this.forceUpdate();
}, },
onEmoteSenderClick: function(event) {
const mxEvent = this.props.mxEvent;
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
dis.dispatch({
action: 'insert_displayname',
displayname: name.replace(' (IRC)', ''),
});
},
getEventTileOps: function() { getEventTileOps: function() {
var self = this; var self = this;
return { return {
@ -273,7 +284,15 @@ module.exports = React.createClass({
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
return ( return (
<span ref="content" className="mx_MEmoteBody mx_EventTile_content"> <span ref="content" className="mx_MEmoteBody mx_EventTile_content">
* <EmojiText>{name}</EmojiText> { body } *&nbsp;
<EmojiText
className="mx_MEmoteBody_sender"
onClick={this.onEmoteSenderClick}
>
{name}
</EmojiText>
&nbsp;
{ body }
{ widgets } { widgets }
</span> </span>
); );

View File

@ -22,10 +22,10 @@ module.exports = React.createClass({
displayName: 'UnknownBody', displayName: 'UnknownBody',
render: function() { render: function() {
var content = this.props.mxEvent.getContent(); const text = this.props.mxEvent.getContent().body;
return ( return (
<span className="mx_UnknownBody"> <span className="mx_UnknownBody" title="Redacted or unknown message type">
{content.body} {text}
</span> </span>
); );
}, },

View File

@ -25,18 +25,10 @@ var TextForEvent = require('../../../TextForEvent');
import WithMatrixClient from '../../../wrappers/WithMatrixClient'; import WithMatrixClient from '../../../wrappers/WithMatrixClient';
var ContextualMenu = require('../../structures/ContextualMenu'); var ContextualMenu = require('../../structures/ContextualMenu');
var dispatcher = require("../../../dispatcher"); import dis from '../../../dispatcher';
var ObjectUtils = require('../../../ObjectUtils'); var ObjectUtils = require('../../../ObjectUtils');
var bounce = false;
try {
if (global.localStorage) {
bounce = global.localStorage.getItem('avatar_bounce') == 'true';
}
} catch (e) {
}
var eventTileTypes = { var eventTileTypes = {
'm.room.message': 'messages.MessageEvent', 'm.room.message': 'messages.MessageEvent',
'm.room.member' : 'messages.TextualEvent', 'm.room.member' : 'messages.TextualEvent',
@ -48,6 +40,7 @@ var eventTileTypes = {
'm.room.third_party_invite' : 'messages.TextualEvent', 'm.room.third_party_invite' : 'messages.TextualEvent',
'm.room.history_visibility' : 'messages.TextualEvent', 'm.room.history_visibility' : 'messages.TextualEvent',
'm.room.encryption' : 'messages.TextualEvent', 'm.room.encryption' : 'messages.TextualEvent',
'm.room.power_levels' : 'messages.TextualEvent',
}; };
var MAX_READ_AVATARS = 5; var MAX_READ_AVATARS = 5;
@ -73,6 +66,12 @@ module.exports = WithMatrixClient(React.createClass({
/* the MatrixEvent to show */ /* the MatrixEvent to show */
mxEvent: React.PropTypes.object.isRequired, mxEvent: React.PropTypes.object.isRequired,
/* true if mxEvent is redacted. This is a prop because using mxEvent.isRedacted()
* might not be enough when deciding shouldComponentUpdate - prevProps.mxEvent
* references the same this.props.mxEvent.
*/
isRedacted: React.PropTypes.bool,
/* true if this is a continuation of the previous event (which has the /* true if this is a continuation of the previous event (which has the
* effect of not showing another avatar/displayname * effect of not showing another avatar/displayname
*/ */
@ -285,9 +284,10 @@ module.exports = WithMatrixClient(React.createClass({
}, },
getReadAvatars: function() { getReadAvatars: function() {
var ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker'); const ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker');
var avatars = []; const avatars = [];
var left = 0; const receiptOffset = 15;
let left = 0;
// It's possible that the receipt was sent several days AFTER the event. // It's possible that the receipt was sent several days AFTER the event.
// If it is, we want to display the complete date along with the HH:MM:SS, // If it is, we want to display the complete date along with the HH:MM:SS,
@ -307,6 +307,12 @@ module.exports = WithMatrixClient(React.createClass({
if ((i < MAX_READ_AVATARS) || this.state.allReadAvatars) { if ((i < MAX_READ_AVATARS) || this.state.allReadAvatars) {
hidden = false; hidden = false;
} }
// TODO: we keep the extra read avatars in the dom to make animation simpler
// we could optimise this to reduce the dom size.
// If hidden, set offset equal to the offset of the final visible avatar or
// else set it proportional to index
left = (hidden ? MAX_READ_AVATARS - 1 : i) * -receiptOffset;
var userId = receipt.roomMember.userId; var userId = receipt.roomMember.userId;
var readReceiptInfo; var readReceiptInfo;
@ -319,7 +325,6 @@ module.exports = WithMatrixClient(React.createClass({
} }
} }
//console.log("i = " + i + ", MAX_READ_AVATARS = " + MAX_READ_AVATARS + ", allReadAvatars = " + this.state.allReadAvatars + " visibility = " + style.visibility);
// add to the start so the most recent is on the end (ie. ends up rightmost) // add to the start so the most recent is on the end (ie. ends up rightmost)
avatars.unshift( avatars.unshift(
<ReadReceiptMarker key={userId} member={receipt.roomMember} <ReadReceiptMarker key={userId} member={receipt.roomMember}
@ -332,12 +337,6 @@ module.exports = WithMatrixClient(React.createClass({
showFullTimestamp={receipt.ts >= dayAfterEventTime} showFullTimestamp={receipt.ts >= dayAfterEventTime}
/> />
); );
// TODO: we keep the extra read avatars in the dom to make animation simpler
// we could optimise this to reduce the dom size.
if (!hidden) {
left -= 15;
}
} }
var remText; var remText;
if (!this.state.allReadAvatars) { if (!this.state.allReadAvatars) {
@ -345,9 +344,8 @@ module.exports = WithMatrixClient(React.createClass({
if (remainder > 0) { if (remainder > 0) {
remText = <span className="mx_EventTile_readAvatarRemainder" remText = <span className="mx_EventTile_readAvatarRemainder"
onClick={this.toggleAllReadAvatars} onClick={this.toggleAllReadAvatars}
style={{ left: left }}>{ remainder }+ style={{ right: -(left - receiptOffset) }}>{ remainder }+
</span>; </span>;
left -= 15;
} }
} }
@ -359,7 +357,7 @@ module.exports = WithMatrixClient(React.createClass({
onSenderProfileClick: function(event) { onSenderProfileClick: function(event) {
var mxEvent = this.props.mxEvent; var mxEvent = this.props.mxEvent;
dispatcher.dispatch({ dis.dispatch({
action: 'insert_displayname', action: 'insert_displayname',
displayname: (mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()).replace(' (IRC)', ''), displayname: (mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()).replace(' (IRC)', ''),
}); });
@ -375,6 +373,17 @@ module.exports = WithMatrixClient(React.createClass({
}); });
}, },
onPermalinkClicked: function(e) {
// This allows the permalink to be opened in a new tab/window or copied as
// matrix.to, but also for it to enable routing within Riot when clicked.
e.preventDefault();
dis.dispatch({
action: 'view_room',
event_id: this.props.mxEvent.getId(),
room_id: this.props.mxEvent.getRoomId(),
});
},
render: function() { render: function() {
var MessageTimestamp = sdk.getComponent('messages.MessageTimestamp'); var MessageTimestamp = sdk.getComponent('messages.MessageTimestamp');
var SenderProfile = sdk.getComponent('messages.SenderProfile'); var SenderProfile = sdk.getComponent('messages.SenderProfile');
@ -399,6 +408,7 @@ module.exports = WithMatrixClient(React.createClass({
var e2eEnabled = this.props.matrixClient.isRoomEncrypted(this.props.mxEvent.getRoomId()); var e2eEnabled = this.props.matrixClient.isRoomEncrypted(this.props.mxEvent.getRoomId());
var isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1); var isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1);
const isRedacted = (eventType === 'm.room.message') && this.props.isRedacted;
var classes = classNames({ var classes = classNames({
mx_EventTile: true, mx_EventTile: true,
@ -415,8 +425,12 @@ module.exports = WithMatrixClient(React.createClass({
mx_EventTile_verified: this.state.verified == true, mx_EventTile_verified: this.state.verified == true,
mx_EventTile_unverified: this.state.verified == false, mx_EventTile_unverified: this.state.verified == false,
mx_EventTile_bad: this.props.mxEvent.getContent().msgtype === 'm.bad.encrypted', mx_EventTile_bad: this.props.mxEvent.getContent().msgtype === 'm.bad.encrypted',
mx_EventTile_redacted: isRedacted,
}); });
var permalink = "#/room/" + this.props.mxEvent.getRoomId() +"/"+ this.props.mxEvent.getId();
const permalink = "https://matrix.to/#/" +
this.props.mxEvent.getRoomId() + "/" +
this.props.mxEvent.getId();
var readAvatars = this.getReadAvatars(); var readAvatars = this.getReadAvatars();
@ -489,6 +503,8 @@ module.exports = WithMatrixClient(React.createClass({
else if (e2eEnabled) { else if (e2eEnabled) {
e2e = <img onClick={ this.onCryptoClicked } className="mx_EventTile_e2eIcon" src="img/e2e-unencrypted.svg" width="12" height="12"/>; e2e = <img onClick={ this.onCryptoClicked } className="mx_EventTile_e2eIcon" src="img/e2e-unencrypted.svg" width="12" height="12"/>;
} }
const timestamp = this.props.mxEvent.getTs() ?
<MessageTimestamp ts={this.props.mxEvent.getTs()} /> : null;
if (this.props.tileShape === "notif") { if (this.props.tileShape === "notif") {
var room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId()); var room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId());
@ -496,15 +512,15 @@ module.exports = WithMatrixClient(React.createClass({
return ( return (
<div className={classes}> <div className={classes}>
<div className="mx_EventTile_roomName"> <div className="mx_EventTile_roomName">
<a href={ permalink }> <a href={ permalink } onClick={this.onPermalinkClicked}>
{ room ? room.name : '' } { room ? room.name : '' }
</a> </a>
</div> </div>
<div className="mx_EventTile_senderDetails"> <div className="mx_EventTile_senderDetails">
{ avatar } { avatar }
<a href={ permalink }> <a href={ permalink } onClick={this.onPermalinkClicked}>
{ sender } { sender }
<MessageTimestamp ts={this.props.mxEvent.getTs()} /> { timestamp }
</a> </a>
</div> </div>
<div className="mx_EventTile_line" > <div className="mx_EventTile_line" >
@ -530,10 +546,14 @@ module.exports = WithMatrixClient(React.createClass({
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
onWidgetLoad={this.props.onWidgetLoad} /> onWidgetLoad={this.props.onWidgetLoad} />
</div> </div>
<a className="mx_EventTile_senderDetailsLink" href={ permalink }> <a
className="mx_EventTile_senderDetailsLink"
href={ permalink }
onClick={this.onPermalinkClicked}
>
<div className="mx_EventTile_senderDetails"> <div className="mx_EventTile_senderDetails">
{ sender } { sender }
<MessageTimestamp ts={this.props.mxEvent.getTs()} /> { timestamp }
</div> </div>
</a> </a>
</div> </div>
@ -548,8 +568,8 @@ module.exports = WithMatrixClient(React.createClass({
{ avatar } { avatar }
{ sender } { sender }
<div className="mx_EventTile_line"> <div className="mx_EventTile_line">
<a href={ permalink }> <a href={ permalink } onClick={this.onPermalinkClicked}>
<MessageTimestamp ts={this.props.mxEvent.getTs()} /> { timestamp }
</a> </a>
{ e2e } { e2e }
<EventTileType ref="tile" <EventTileType ref="tile"
@ -567,7 +587,8 @@ module.exports = WithMatrixClient(React.createClass({
})); }));
module.exports.haveTileForEvent = function(e) { module.exports.haveTileForEvent = function(e) {
if (e.isRedacted()) return false; // Only messages have a tile (black-rectangle) if redacted
if (e.isRedacted() && e.getType() !== 'm.room.message') return false;
if (eventTileTypes[e.getType()] == undefined) return false; if (eventTileTypes[e.getType()] == undefined) return false;
if (eventTileTypes[e.getType()] == 'messages.TextualEvent') { if (eventTileTypes[e.getType()] == 'messages.TextualEvent') {
return TextForEvent.textForEvent(e) !== ''; return TextForEvent.textForEvent(e) !== '';

View File

@ -218,11 +218,13 @@ module.exports = WithMatrixClient(React.createClass({
}, },
onKick: function() { onKick: function() {
const membership = this.props.member.membership;
const kickLabel = membership === "invite" ? "Disinvite" : "Kick";
const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog"); const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog");
Modal.createDialog(ConfirmUserActionDialog, { Modal.createDialog(ConfirmUserActionDialog, {
member: this.props.member, member: this.props.member,
action: 'Kick', action: kickLabel,
askReason: true, askReason: membership == "join",
danger: true, danger: true,
onFinished: (proceed, reason) => { onFinished: (proceed, reason) => {
if (!proceed) return; if (!proceed) return;
@ -237,9 +239,10 @@ module.exports = WithMatrixClient(React.createClass({
console.log("Kick success"); console.log("Kick success");
}, function(err) { }, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Kick error: " + err);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Kick error", title: "Error",
description: err.message description: "Failed to kick user",
}); });
} }
).finally(()=>{ ).finally(()=>{
@ -278,9 +281,10 @@ module.exports = WithMatrixClient(React.createClass({
console.log("Ban success"); console.log("Ban success");
}, function(err) { }, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Ban error: " + err);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Ban error", title: "Error",
description: err.message, description: "Failed to ban user",
}); });
} }
).finally(()=>{ ).finally(()=>{
@ -327,9 +331,10 @@ module.exports = WithMatrixClient(React.createClass({
// get out of sync if we force setState here! // get out of sync if we force setState here!
console.log("Mute toggle success"); console.log("Mute toggle success");
}, function(err) { }, function(err) {
console.error("Mute error: " + err);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Mute error", title: "Error",
description: err.message description: "Failed to mute user",
}); });
} }
).finally(()=>{ ).finally(()=>{
@ -375,9 +380,10 @@ module.exports = WithMatrixClient(React.createClass({
description: "This action cannot be performed by a guest user. Please register to be able to do this." description: "This action cannot be performed by a guest user. Please register to be able to do this."
}); });
} else { } else {
console.error("Toggle moderator error:" + err);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Moderator toggle error", title: "Error",
description: err.message description: "Failed to toggle moderator status",
}); });
} }
} }
@ -395,9 +401,10 @@ module.exports = WithMatrixClient(React.createClass({
console.log("Power change success"); console.log("Power change success");
}, function(err) { }, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to change power level " + err);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Failure to change power level", title: "Error",
description: err.message description: "Failed to change power level",
}); });
} }
).finally(()=>{ ).finally(()=>{
@ -553,6 +560,13 @@ module.exports = WithMatrixClient(React.createClass({
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
}, },
onRoomTileClick(roomId) {
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
},
_renderDevices: function() { _renderDevices: function() {
if (!this._enableDevices) { if (!this._enableDevices) {
return null; return null;
@ -569,7 +583,7 @@ module.exports = WithMatrixClient(React.createClass({
} else if (devices === null) { } else if (devices === null) {
devComponents = "Unable to load device list"; devComponents = "Unable to load device list";
} else if (devices.length === 0) { } else if (devices.length === 0) {
devComponents = "No registered devices"; devComponents = "No devices with registered encryption keys";
} else { } else {
devComponents = []; devComponents = [];
for (var i = 0; i < devices.length; i++) { for (var i = 0; i < devices.length; i++) {
@ -613,6 +627,7 @@ module.exports = WithMatrixClient(React.createClass({
unread={Unread.doesRoomHaveUnreadMessages(room)} unread={Unread.doesRoomHaveUnreadMessages(room)}
highlight={highlight} highlight={highlight}
isInvite={me.membership == "invite"} isInvite={me.membership == "invite"}
onClick={this.onRoomTileClick}
/> />
); );
} }

View File

@ -91,8 +91,9 @@ export default class MessageComposer extends React.Component {
this.refs.uploadInput.click(); this.refs.uploadInput.click();
} }
onUploadFileSelected(ev) { onUploadFileSelected(files, isPasted) {
let files = ev.target.files; if (!isPasted)
files = files.target.files;
let QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); let QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
let TintableSvg = sdk.getComponent("elements.TintableSvg"); let TintableSvg = sdk.getComponent("elements.TintableSvg");
@ -100,7 +101,7 @@ export default class MessageComposer extends React.Component {
let fileList = []; let fileList = [];
for (let i=0; i<files.length; i++) { for (let i=0; i<files.length; i++) {
fileList.push(<li key={i}> fileList.push(<li key={i}>
<TintableSvg key={i} src="img/files.svg" width="16" height="16" /> {files[i].name} <TintableSvg key={i} src="img/files.svg" width="16" height="16" /> {files[i].name || 'Attachment'}
</li>); </li>);
} }
@ -223,8 +224,8 @@ export default class MessageComposer extends React.Component {
); );
let e2eImg, e2eTitle, e2eClass; let e2eImg, e2eTitle, e2eClass;
const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
if (MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId)) { if (roomIsEncrypted) {
// FIXME: show a /!\ if there are untrusted devices in the room... // FIXME: show a /!\ if there are untrusted devices in the room...
e2eImg = 'img/e2e-verified.svg'; e2eImg = 'img/e2e-verified.svg';
e2eTitle = 'Encrypted room'; e2eTitle = 'Encrypted room';
@ -286,15 +287,20 @@ export default class MessageComposer extends React.Component {
key="controls_formatting" /> key="controls_formatting" />
); );
const placeholderText = roomIsEncrypted ?
"Send an encrypted message…" : "Send a message (unencrypted)…";
controls.push( controls.push(
<MessageComposerInput <MessageComposerInput
ref={c => this.messageComposerInput = c} ref={c => this.messageComposerInput = c}
key="controls_input" key="controls_input"
onResize={this.props.onResize} onResize={this.props.onResize}
room={this.props.room} room={this.props.room}
placeholder={placeholderText}
tryComplete={this._tryComplete} tryComplete={this._tryComplete}
onUpArrow={this.onUpArrow} onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow} onDownArrow={this.onDownArrow}
onUploadFileSelected={this.onUploadFileSelected}
tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete
onContentChanged={this.onInputContentChanged} onContentChanged={this.onInputContentChanged}
onInputStateChanged={this.onInputStateChanged} />, onInputStateChanged={this.onInputStateChanged} />,

View File

@ -84,6 +84,7 @@ export default class MessageComposerInput extends React.Component {
this.onAction = this.onAction.bind(this); this.onAction = this.onAction.bind(this);
this.handleReturn = this.handleReturn.bind(this); this.handleReturn = this.handleReturn.bind(this);
this.handleKeyCommand = this.handleKeyCommand.bind(this); this.handleKeyCommand = this.handleKeyCommand.bind(this);
this.handlePastedFiles = this.handlePastedFiles.bind(this);
this.onEditorContentChanged = this.onEditorContentChanged.bind(this); this.onEditorContentChanged = this.onEditorContentChanged.bind(this);
this.setEditorState = this.setEditorState.bind(this); this.setEditorState = this.setEditorState.bind(this);
this.onUpArrow = this.onUpArrow.bind(this); this.onUpArrow = this.onUpArrow.bind(this);
@ -475,6 +476,10 @@ export default class MessageComposerInput extends React.Component {
return false; return false;
} }
handlePastedFiles(files) {
this.props.onUploadFileSelected(files, true);
}
handleReturn(ev) { handleReturn(ev) {
if (ev.shiftKey) { if (ev.shiftKey) {
this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState)); this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState));
@ -504,7 +509,7 @@ export default class MessageComposerInput extends React.Component {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Server error", title: "Server error",
description: err.message description: "Server unavailable, overloaded, or something else went wrong.",
}); });
}); });
} }
@ -721,13 +726,14 @@ export default class MessageComposerInput extends React.Component {
title={`Markdown is ${this.state.isRichtextEnabled ? 'disabled' : 'enabled'}`} title={`Markdown is ${this.state.isRichtextEnabled ? 'disabled' : 'enabled'}`}
src={`img/button-md-${!this.state.isRichtextEnabled}.png`} /> src={`img/button-md-${!this.state.isRichtextEnabled}.png`} />
<Editor ref="editor" <Editor ref="editor"
placeholder="Type a message…" placeholder={this.props.placeholder}
editorState={this.state.editorState} editorState={this.state.editorState}
onChange={this.onEditorContentChanged} onChange={this.onEditorContentChanged}
blockStyleFn={MessageComposerInput.getBlockStyle} blockStyleFn={MessageComposerInput.getBlockStyle}
keyBindingFn={MessageComposerInput.getKeyBinding} keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand} handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn} handleReturn={this.handleReturn}
handlePastedFiles={this.handlePastedFiles}
stripPastedStyles={!this.state.isRichtextEnabled} stripPastedStyles={!this.state.isRichtextEnabled}
onTab={this.onTab} onTab={this.onTab}
onUpArrow={this.onUpArrow} onUpArrow={this.onUpArrow}
@ -757,6 +763,8 @@ MessageComposerInput.propTypes = {
onDownArrow: React.PropTypes.func, onDownArrow: React.PropTypes.func,
onUploadFileSelected: React.PropTypes.func,
// attempts to confirm currently selected completion, returns whether actually confirmed // attempts to confirm currently selected completion, returns whether actually confirmed
tryComplete: React.PropTypes.func, tryComplete: React.PropTypes.func,

View File

@ -34,16 +34,11 @@ export function onSendMessageFailed(err, room) {
// https://github.com/vector-im/riot-web/issues/3148 // https://github.com/vector-im/riot-web/issues/3148
console.log('MessageComposer got send failure: ' + err.name + '('+err+')'); console.log('MessageComposer got send failure: ' + err.name + '('+err+')');
if (err.name === "UnknownDeviceError") { if (err.name === "UnknownDeviceError") {
const UnknownDeviceDialog = sdk.getComponent("dialogs.UnknownDeviceDialog"); dis.dispatch({
Modal.createDialog(UnknownDeviceDialog, { action: 'unknown_device_error',
devices: err.devices, err: err,
room: room, room: room,
onFinished: (r) => { });
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('UnknownDeviceDialog closed with '+r);
},
}, "mx_Dialog_unknownDevice");
} }
dis.dispatch({ dis.dispatch({
action: 'message_send_failed', action: 'message_send_failed',
@ -70,6 +65,9 @@ export default React.createClass({
// js-sdk Room object // js-sdk Room object
room: React.PropTypes.object.isRequired, room: React.PropTypes.object.isRequired,
// The text to use a placeholder in the input box
placeholder: React.PropTypes.string.isRequired,
}, },
componentWillMount: function() { componentWillMount: function() {
@ -313,7 +311,7 @@ export default React.createClass({
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Server error", title: "Server error",
description: err.message description: "Server unavailable, overloaded, or something else went wrong.",
}); });
}); });
} }
@ -442,7 +440,7 @@ export default React.createClass({
render: function() { render: function() {
return ( return (
<div className="mx_MessageComposer_input" onClick={ this.onInputClick }> <div className="mx_MessageComposer_input" onClick={ this.onInputClick }>
<textarea autoFocus ref="textarea" rows="1" onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} placeholder="Type a message..." /> <textarea autoFocus ref="textarea" rows="1" onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} placeholder={this.props.placeholder} />
</div> </div>
); );
} }

View File

@ -115,9 +115,10 @@ module.exports = React.createClass({
changeAvatar.onFileSelected(ev).catch(function(err) { changeAvatar.onFileSelected(ev).catch(function(err) {
var errMsg = (typeof err === "string") ? err : (err.error || ""); var errMsg = (typeof err === "string") ? err : (err.error || "");
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to set avatar: " + errMsg);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Error", title: "Error",
description: "Failed to set avatar. " + errMsg description: "Failed to set avatar.",
}); });
}).done(); }).done();
}, },

View File

@ -96,6 +96,12 @@ module.exports = React.createClass({
}); });
} }
break; break;
case 'on_room_read':
// Force an update because the notif count state is too deep to cause
// an update. This forces the local echo of reading notifs to be
// reflected by the RoomTiles.
this.forceUpdate();
break;
} }
}, },
@ -485,11 +491,14 @@ module.exports = React.createClass({
<RoomSubList list={ self.state.lists['im.vector.fake.direct'] } <RoomSubList list={ self.state.lists['im.vector.fake.direct'] }
label="People" label="People"
editable={ false } tagName="im.vector.fake.direct"
verb="tag direct chat"
editable={ true }
order="recent" order="recent"
selectedRoom={ self.props.selectedRoom } selectedRoom={ self.props.selectedRoom }
incomingCall={ self.state.incomingCall } incomingCall={ self.state.incomingCall }
collapsed={ self.props.collapsed } collapsed={ self.props.collapsed }
alwaysShowHeader={ true }
searchFilter={ self.props.searchFilter } searchFilter={ self.props.searchFilter }
onHeaderClick={ self.onSubListHeaderClick } onHeaderClick={ self.onSubListHeaderClick }
onShowMoreRooms={ self.onShowMoreRooms } /> onShowMoreRooms={ self.onShowMoreRooms } />

View File

@ -54,9 +54,10 @@ const BannedUser = React.createClass({
this.props.member.roomId, this.props.member.userId, this.props.member.roomId, this.props.member.userId,
).catch((err) => { ).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to unban: " + err);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Failed to unban", title: "Error",
description: err.message, description: "Failed to unban",
}); });
}).done(); }).done();
}, },

View File

@ -19,7 +19,6 @@ limitations under the License.
var React = require('react'); var React = require('react');
var ReactDOM = require("react-dom"); var ReactDOM = require("react-dom");
var classNames = require('classnames'); var classNames = require('classnames');
var dis = require("../../../dispatcher");
var MatrixClientPeg = require('../../../MatrixClientPeg'); var MatrixClientPeg = require('../../../MatrixClientPeg');
import DMRoomMap from '../../../utils/DMRoomMap'; import DMRoomMap from '../../../utils/DMRoomMap';
var sdk = require('../../../index'); var sdk = require('../../../index');
@ -35,6 +34,7 @@ module.exports = React.createClass({
propTypes: { propTypes: {
connectDragSource: React.PropTypes.func, connectDragSource: React.PropTypes.func,
connectDropTarget: React.PropTypes.func, connectDropTarget: React.PropTypes.func,
onClick: React.PropTypes.func,
isDragging: React.PropTypes.bool, isDragging: React.PropTypes.bool,
room: React.PropTypes.object.isRequired, room: React.PropTypes.object.isRequired,
@ -56,8 +56,7 @@ module.exports = React.createClass({
return({ return({
hover : false, hover : false,
badgeHover : false, badgeHover : false,
notificationTagMenu: false, menuDisplayed: false,
roomTagMenu: false,
notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId), notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
}); });
}, },
@ -100,10 +99,9 @@ module.exports = React.createClass({
}, },
onClick: function() { onClick: function() {
dis.dispatch({ if (this.props.onClick) {
action: 'view_room', this.props.onClick(this.props.room.roomId);
room_id: this.props.room.roomId, }
});
}, },
onMouseEnter: function() { onMouseEnter: function() {
@ -137,62 +135,32 @@ module.exports = React.createClass({
this.setState({ hover: false }); this.setState({ hover: false });
} }
var NotificationStateMenu = sdk.getComponent('context_menus.NotificationStateContextMenu'); var RoomTileContextMenu = sdk.getComponent('context_menus.RoomTileContextMenu');
var elementRect = e.target.getBoundingClientRect(); var elementRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page // The window X and Y offsets are to adjust position when zoomed in to page
var x = elementRect.right + window.pageXOffset + 3; const x = elementRect.right + window.pageXOffset + 3;
var y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset) - 53; const chevronOffset = 12;
let y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset);
y = y - (chevronOffset + 8); // where 8 is half the height of the chevron
var self = this; var self = this;
ContextualMenu.createMenu(NotificationStateMenu, { ContextualMenu.createMenu(RoomTileContextMenu, {
menuWidth: 188, chevronOffset: chevronOffset,
menuHeight: 126,
chevronOffset: 45,
left: x, left: x,
top: y, top: y,
room: this.props.room, room: this.props.room,
onFinished: function() { onFinished: function() {
self.setState({ notificationTagMenu: false }); self.setState({ menuDisplayed: false });
self.props.refreshSubList(); self.props.refreshSubList();
} }
}); });
this.setState({ notificationTagMenu: true }); this.setState({ menuDisplayed: true });
} }
// Prevent the RoomTile onClick event firing as well // Prevent the RoomTile onClick event firing as well
e.stopPropagation(); e.stopPropagation();
}, },
onAvatarClicked: function(e) {
// Only allow none guests to access the context menu
if (!MatrixClientPeg.get().isGuest() && !this.props.collapsed) {
// If the badge is clicked, then no longer show tooltip
if (this.props.collapsed) {
this.setState({ hover: false });
}
var RoomTagMenu = sdk.getComponent('context_menus.RoomTagContextMenu');
var elementRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
var x = elementRect.right + window.pageXOffset + 3;
var y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset) - 19;
var self = this;
ContextualMenu.createMenu(RoomTagMenu, {
chevronOffset: 10,
// XXX: fix horrid hardcoding
menuColour: UserSettingsStore.getSyncedSettings().theme === 'dark' ? "#2d2d2d" : "#FFFFFF",
left: x,
top: y,
room: this.props.room,
onFinished: function() {
self.setState({ roomTagMenu: false });
}
});
this.setState({ roomTagMenu: true });
// Prevent the RoomTile onClick event firing as well
e.stopPropagation();
}
},
render: function() { render: function() {
var myUserId = MatrixClientPeg.get().credentials.userId; var myUserId = MatrixClientPeg.get().credentials.userId;
var me = this.props.room.currentState.members[myUserId]; var me = this.props.room.currentState.members[myUserId];
@ -211,7 +179,7 @@ module.exports = React.createClass({
'mx_RoomTile_unreadNotify': notifBadges, 'mx_RoomTile_unreadNotify': notifBadges,
'mx_RoomTile_highlight': mentionBadges, 'mx_RoomTile_highlight': mentionBadges,
'mx_RoomTile_invited': (me && me.membership == 'invite'), 'mx_RoomTile_invited': (me && me.membership == 'invite'),
'mx_RoomTile_notificationTagMenu': this.state.notificationTagMenu, 'mx_RoomTile_menuDisplayed': this.state.menuDisplayed,
'mx_RoomTile_noBadges': !badges, 'mx_RoomTile_noBadges': !badges,
}); });
@ -219,14 +187,9 @@ module.exports = React.createClass({
'mx_RoomTile_avatar': true, 'mx_RoomTile_avatar': true,
}); });
var avatarContainerClasses = classNames({
'mx_RoomTile_avatar_container': true,
'mx_RoomTile_avatar_roomTagMenu': this.state.roomTagMenu,
});
var badgeClasses = classNames({ var badgeClasses = classNames({
'mx_RoomTile_badge': true, 'mx_RoomTile_badge': true,
'mx_RoomTile_badgeButton': this.state.badgeHover || this.state.notificationTagMenu, 'mx_RoomTile_badgeButton': this.state.badgeHover || this.state.menuDisplayed,
}); });
// XXX: We should never display raw room IDs, but sometimes the // XXX: We should never display raw room IDs, but sometimes the
@ -237,7 +200,7 @@ module.exports = React.createClass({
var badge; var badge;
var badgeContent; var badgeContent;
if (this.state.badgeHover || this.state.notificationTagMenu) { if (this.state.badgeHover || this.state.menuDisplayed) {
badgeContent = "\u00B7\u00B7\u00B7"; badgeContent = "\u00B7\u00B7\u00B7";
} else if (badges) { } else if (badges) {
var limitedCount = FormattingUtils.formatCount(notificationCount); var limitedCount = FormattingUtils.formatCount(notificationCount);
@ -255,7 +218,7 @@ module.exports = React.createClass({
var nameClasses = classNames({ var nameClasses = classNames({
'mx_RoomTile_name': true, 'mx_RoomTile_name': true,
'mx_RoomTile_invite': this.props.isInvite, 'mx_RoomTile_invite': this.props.isInvite,
'mx_RoomTile_badgeShown': badges || this.state.badgeHover || this.state.notificationTagMenu, 'mx_RoomTile_badgeShown': badges || this.state.badgeHover || this.state.menuDisplayed,
}); });
if (this.props.selected) { if (this.props.selected) {
@ -294,13 +257,11 @@ module.exports = React.createClass({
<div> { /* Only native elements can be wrapped in a DnD object. */} <div> { /* Only native elements can be wrapped in a DnD object. */}
<AccessibleButton className={classes} tabIndex="0" onClick={this.onClick} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}> <AccessibleButton className={classes} tabIndex="0" onClick={this.onClick} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
<div className={avatarClasses}> <div className={avatarClasses}>
<div className="mx_RoomTile_avatar_menu" onClick={this.onAvatarClicked}> <div className="mx_RoomTile_avatar_container">
<div className={avatarContainerClasses}>
<RoomAvatar room={this.props.room} width={24} height={24} /> <RoomAvatar room={this.props.room} width={24} height={24} />
{directMessageIndicator} {directMessageIndicator}
</div> </div>
</div> </div>
</div>
<div className="mx_RoomTile_nameContainer"> <div className="mx_RoomTile_nameContainer">
{ label } { label }
{ badge } { badge }

View File

@ -32,10 +32,7 @@ module.exports = React.createClass({
<div className="mx_TopUnreadMessagesBar"> <div className="mx_TopUnreadMessagesBar">
<div className="mx_TopUnreadMessagesBar_scrollUp" <div className="mx_TopUnreadMessagesBar_scrollUp"
onClick={this.props.onScrollUpClick}> onClick={this.props.onScrollUpClick}>
<img src="img/scrollup.svg" width="24" height="24" Jump to first unread message. <span style={{ textDecoration: 'underline' }} onClick={this.props.onCloseClick}>Mark all read</span>
alt="Scroll to unread messages"
title="Scroll to unread messages"/>
Unread messages. <span style={{ textDecoration: 'underline' }} onClick={this.props.onCloseClick}>Mark all read</span>
</div> </div>
<img className="mx_TopUnreadMessagesBar_close" <img className="mx_TopUnreadMessagesBar_close"
src="img/cancel.svg" width="18" height="18" src="img/cancel.svg" width="18" height="18"

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 React from 'react';
import sdk from '../../../index';
import AddThreepid from '../../../AddThreepid';
import WithMatrixClient from '../../../wrappers/WithMatrixClient';
import Modal from '../../../Modal';
export default WithMatrixClient(React.createClass({
displayName: 'AddPhoneNumber',
propTypes: {
matrixClient: React.PropTypes.object.isRequired,
onThreepidAdded: React.PropTypes.func,
},
getInitialState: function() {
return {
busy: false,
phoneCountry: null,
phoneNumber: "",
msisdn_add_pending: false,
};
},
componentWillMount: function() {
this._addThreepid = null;
this._addMsisdnInput = null;
this._unmounted = false;
},
componentWillUnmount: function() {
this._unmounted = true;
},
_onPhoneCountryChange: function(phoneCountry) {
this.setState({ phoneCountry: phoneCountry });
},
_onPhoneNumberChange: function(ev) {
this.setState({ phoneNumber: ev.target.value });
},
_onAddMsisdnEditFinished: function(value, shouldSubmit) {
if (!shouldSubmit) return;
this._addMsisdn();
},
_onAddMsisdnSubmit: function(ev) {
ev.preventDefault();
this._addMsisdn();
},
_collectAddMsisdnInput: function(e) {
this._addMsisdnInput = e;
},
_addMsisdn: function() {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
this._addThreepid = new AddThreepid();
// we always bind phone numbers when registering, so let's do the
// same here.
this._addThreepid.addMsisdn(this.state.phoneCountry, this.state.phoneNumber, true).then((resp) => {
this._promptForMsisdnVerificationCode(resp.msisdn);
}).catch((err) => {
console.error("Unable to add phone number: " + err);
let msg = err.message;
Modal.createDialog(ErrorDialog, {
title: "Error",
description: msg,
});
}).finally(() => {
if (this._unmounted) return;
this.setState({msisdn_add_pending: false});
}).done();
this._addMsisdnInput.blur();
this.setState({msisdn_add_pending: true});
},
_promptForMsisdnVerificationCode:function (msisdn, err) {
if (this._unmounted) return;
const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog");
let msgElements = [
<div key="_static" >A text message has been sent to +{msisdn}.
Please enter the verification code it contains</div>
];
if (err) {
let msg = err.error;
if (err.errcode == 'M_THREEPID_AUTH_FAILED') {
msg = "Incorrect verification code";
}
msgElements.push(<div key="_error" className="error">{msg}</div>);
}
Modal.createDialog(TextInputDialog, {
title: "Enter Code",
description: <div>{msgElements}</div>,
button: "Submit",
onFinished: (should_verify, token) => {
if (!should_verify) {
this._addThreepid = null;
return;
}
if (this._unmounted) return;
this.setState({msisdn_add_pending: true});
this._addThreepid.haveMsisdnToken(token).then(() => {
this._addThreepid = null;
this.setState({phoneNumber: ''});
if (this.props.onThreepidAdded) this.props.onThreepidAdded();
}).catch((err) => {
this._promptForMsisdnVerificationCode(msisdn, err);
}).finally(() => {
if (this._unmounted) return;
this.setState({msisdn_add_pending: false});
}).done();
}
});
},
render: function() {
const Loader = sdk.getComponent("elements.Spinner");
if (this.state.msisdn_add_pending) {
return <Loader />;
} else if (this.props.matrixClient.isGuest()) {
return <div />;
}
const CountryDropdown = sdk.getComponent('views.login.CountryDropdown');
// XXX: This CSS relies on the CSS surrounding it in UserSettings as its in
// a tabular format to align the submit buttons
return (
<form className="mx_UserSettings_profileTableRow" onSubmit={this._onAddMsisdnSubmit}>
<div className="mx_UserSettings_profileLabelCell">
</div>
<div className="mx_UserSettings_profileInputCell">
<div className="mx_Login_phoneSection">
<CountryDropdown onOptionChange={this._onPhoneCountryChange}
className="mx_Login_phoneCountry"
value={this.state.phoneCountry}
/>
<input type="text"
ref={this._collectAddMsisdnInput}
className="mx_UserSettings_phoneNumberField"
placeholder="Add phone number"
value={this.state.phoneNumber}
onChange={this._onPhoneNumberChange}
/>
</div>
</div>
<div className="mx_UserSettings_threepidButton mx_filterFlipColor">
<input type="image" value="Add" src="img/plus.svg" width="14" height="14" />
</div>
</form>
);
}
}))

View File

@ -53,6 +53,7 @@ module.exports = React.createClass({
<EditableTextContainer <EditableTextContainer
getInitialValue={this._getDisplayName} getInitialValue={this._getDisplayName}
placeholder="No display name" placeholder="No display name"
blurToSubmit={true}
onSubmit={this._changeDisplayName} /> onSubmit={this._changeDisplayName} />
); );
} }

View File

@ -73,11 +73,17 @@ module.exports = React.createClass({
description: description:
<div> <div>
Changing password will currently reset any end-to-end encryption keys on all devices, Changing password will currently reset any end-to-end encryption keys on all devices,
making encrypted chat history unreadable. making encrypted chat history unreadable, unless you first export your room keys
This will be <a href="https://github.com/vector-im/riot-web/issues/2671">improved shortly</a>, and re-import them afterwards.
but for now be warned. In future this <a href="https://github.com/vector-im/riot-web/issues/2671">will be improved</a>.
</div>, </div>,
button: "Continue", button: "Continue",
extraButtons: [
<button className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
Export E2E room keys
</button>
],
onFinished: (confirmed) => { onFinished: (confirmed) => {
if (confirmed) { if (confirmed) {
var authDict = { var authDict = {
@ -105,6 +111,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(),
}
);
},
onClickChange: function() { onClickChange: function() {
var old_password = this.refs.old_input.value; var old_password = this.refs.old_input.value;
var new_password = this.refs.new_input.value; var new_password = this.refs.new_input.value;

View File

@ -19,6 +19,9 @@ import React from 'react';
import sdk from '../../../index'; import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import DateUtils from '../../../DateUtils';
const AUTH_CACHE_AGE = 5 * 60 * 1000; // 5 minutes
export default class DevicesPanelEntry extends React.Component { export default class DevicesPanelEntry extends React.Component {
constructor(props, context) { constructor(props, context) {
@ -30,7 +33,6 @@ export default class DevicesPanelEntry extends React.Component {
}; };
this._unmounted = false; this._unmounted = false;
this._onDeleteClick = this._onDeleteClick.bind(this); this._onDeleteClick = this._onDeleteClick.bind(this);
this._onDisplayNameChanged = this._onDisplayNameChanged.bind(this); this._onDisplayNameChanged = this._onDisplayNameChanged.bind(this);
this._makeDeleteRequest = this._makeDeleteRequest.bind(this); this._makeDeleteRequest = this._makeDeleteRequest.bind(this);
@ -53,8 +55,12 @@ export default class DevicesPanelEntry extends React.Component {
_onDeleteClick() { _onDeleteClick() {
this.setState({deleting: true}); this.setState({deleting: true});
// try without interactive auth to start off if (this.context.authCache.lastUpdate < Date.now() - AUTH_CACHE_AGE) {
this._makeDeleteRequest(null).catch((error) => { this.context.authCache.auth = null;
}
// try with auth cache (which is null, so no interactive auth, to start off)
this._makeDeleteRequest(this.context.authCache.auth).catch((error) => {
if (this._unmounted) { return; } if (this._unmounted) { return; }
if (error.httpStatus !== 401 || !error.data || !error.data.flows) { if (error.httpStatus !== 401 || !error.data || !error.data.flows) {
// doesn't look like an interactive-auth failure // doesn't look like an interactive-auth failure
@ -65,6 +71,7 @@ export default class DevicesPanelEntry extends React.Component {
var InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); var InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
Modal.createDialog(InteractiveAuthDialog, { Modal.createDialog(InteractiveAuthDialog, {
matrixClient: MatrixClientPeg.get(),
authData: error.data, authData: error.data,
makeRequest: this._makeDeleteRequest, makeRequest: this._makeDeleteRequest,
}); });
@ -83,6 +90,9 @@ export default class DevicesPanelEntry extends React.Component {
} }
_makeDeleteRequest(auth) { _makeDeleteRequest(auth) {
this.context.authCache.auth = auth;
this.context.authCache.lastUpdate = Date.now();
const device = this.props.device; const device = this.props.device;
return MatrixClientPeg.get().deleteDevice(device.device_id, auth).then( return MatrixClientPeg.get().deleteDevice(device.device_id, auth).then(
() => { () => {
@ -110,8 +120,7 @@ export default class DevicesPanelEntry extends React.Component {
let lastSeen = ""; let lastSeen = "";
if (device.last_seen_ts) { if (device.last_seen_ts) {
// todo: format the timestamp as "5 minutes ago" or whatever. const lastSeenDate = DateUtils.formatDate(new Date(device.last_seen_ts));
const lastSeenDate = new Date(device.last_seen_ts);
lastSeen = device.last_seen_ip + " @ " + lastSeen = device.last_seen_ip + " @ " +
lastSeenDate.toLocaleString(); lastSeenDate.toLocaleString();
} }
@ -160,6 +169,10 @@ DevicesPanelEntry.propTypes = {
onDeleted: React.PropTypes.func, onDeleted: React.PropTypes.func,
}; };
DevicesPanelEntry.contextTypes = {
authCache: React.PropTypes.object,
};
DevicesPanelEntry.defaultProps = { DevicesPanelEntry.defaultProps = {
onDeleted: function() {}, onDeleted: function() {},
}; };

View File

@ -102,9 +102,10 @@ function createRoom(opts) {
}); });
return roomId; return roomId;
}, function(err) { }, function(err) {
console.error("Failed to create room " + roomId + " " + err);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Failure to create room", title: "Failure to create room",
description: err.toString() description: "Server may be unavailable, overloaded, or you hit a bug.",
}); });
return null; return null;
}); });

View File

@ -122,7 +122,7 @@ var escapeRegExp = function(string) {
// anyone else really should be using matrix.to. // anyone else really should be using matrix.to.
matrixLinkify.VECTOR_URL_PATTERN = "^(?:https?:\/\/)?(?:" matrixLinkify.VECTOR_URL_PATTERN = "^(?:https?:\/\/)?(?:"
+ escapeRegExp(window.location.host + window.location.pathname) + "|" + escapeRegExp(window.location.host + window.location.pathname) + "|"
+ "(?:www\\.)?vector\\.im/(?:beta|staging|develop)/" + "(?:www\\.)?(riot|vector)\\.im/(?:beta|staging|develop)/"
+ ")(#.*)"; + ")(#.*)";
matrixLinkify.MATRIXTO_URL_PATTERN = "^(?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/((#|@|!).*)"; matrixLinkify.MATRIXTO_URL_PATTERN = "^(?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/((#|@|!).*)";

1273
src/phonenumber.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -57,6 +57,7 @@ describe('InteractiveAuthDialog', function () {
const dlg = ReactDOM.render( const dlg = ReactDOM.render(
<InteractiveAuthDialog <InteractiveAuthDialog
matrixClient={client}
authData={{ authData={{
session: "sess", session: "sess",
flows: [ flows: [
@ -67,8 +68,8 @@ describe('InteractiveAuthDialog', function () {
onFinished={onFinished} onFinished={onFinished}
/>, parentDiv); />, parentDiv);
// at this point there should be a password box and a submit button // wait for a password box and a submit button
const formNode = ReactTestUtils.findRenderedDOMComponentWithTag(dlg, "form"); test_utils.waitForRenderedDOMComponentWithTag(dlg, "form").then((formNode) => {
const inputNodes = ReactTestUtils.scryRenderedDOMComponentsWithTag( const inputNodes = ReactTestUtils.scryRenderedDOMComponentsWithTag(
dlg, "input" dlg, "input"
); );
@ -108,7 +109,8 @@ describe('InteractiveAuthDialog', function () {
); );
// let the request complete // let the request complete
q.delay(1).then(() => { return q.delay(1);
}).then(() => {
expect(onFinished.callCount).toEqual(1); expect(onFinished.callCount).toEqual(1);
expect(onFinished.calledWithExactly(true, {a:1})).toBe(true); expect(onFinished.calledWithExactly(true, {a:1})).toBe(true);
}).done(done, done); }).done(done, done);

View File

@ -1,11 +1,51 @@
"use strict"; "use strict";
var sinon = require('sinon'); import sinon from 'sinon';
var q = require('q'); import q from 'q';
import ReactTestUtils from 'react-addons-test-utils';
var peg = require('../src/MatrixClientPeg.js'); import peg from '../src/MatrixClientPeg.js';
var jssdk = require('matrix-js-sdk'); import jssdk from 'matrix-js-sdk';
var MatrixEvent = jssdk.MatrixEvent; const MatrixEvent = jssdk.MatrixEvent;
/**
* Wrapper around window.requestAnimationFrame that returns a promise
* @private
*/
function _waitForFrame() {
const def = q.defer();
window.requestAnimationFrame(() => {
def.resolve();
});
return def.promise;
}
/**
* Waits a small number of animation frames for a component to appear
* in the DOM. Like findRenderedDOMComponentWithTag(), but allows
* for the element to appear a short time later, eg. if a promise needs
* to resolve first.
* @return a promise that resolves once the component appears, or rejects
* if it doesn't appear after a nominal number of animation frames.
*/
export function waitForRenderedDOMComponentWithTag(tree, tag, attempts) {
if (attempts === undefined) {
// Let's start by assuming we'll only need to wait a single frame, and
// we can try increasing this if necessary.
attempts = 1;
} else if (attempts == 0) {
return q.reject("Gave up waiting for component with tag: " + tag);
}
return _waitForFrame().then(() => {
const result = ReactTestUtils.scryRenderedDOMComponentsWithTag(tree, tag);
if (result.length > 0) {
return result[0];
} else {
return waitForRenderedDOMComponentWithTag(tree, tag, attempts - 1);
}
});
}
/** /**
* Perform common actions before each test case, e.g. printing the test case * Perform common actions before each test case, e.g. printing the test case
@ -92,6 +132,7 @@ export function createTestClient() {
sendTextMessage: () => q({}), sendTextMessage: () => q({}),
sendHtmlMessage: () => q({}), sendHtmlMessage: () => q({}),
getSyncState: () => "SYNCING", getSyncState: () => "SYNCING",
generateClientSecret: () => "t35tcl1Ent5ECr3T",
}; };
} }