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

pull/21833/head
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)
===================================================================================================
[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",
"version": "0.8.6",
"version": "0.8.7",
"description": "SDK for matrix.org using React",
"author": "matrix.org",
"repository": {

View File

@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -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
* @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
* the reset failed, e.g. "There is no mapped matrix user ID for the given email address".
* the request failed.
*/
checkEmailLinkClicked() {
var identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1];
@ -73,6 +99,29 @@ class AddThreepid {
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;

View File

@ -82,4 +82,12 @@ export default class BasePlatform {
screenCaptureErrorString() {
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();
_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() {
_setCallState(undefined, call.roomId, "ended");
});
@ -301,9 +310,10 @@ function _onAction(payload) {
placeCall(call);
}, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Conference call failed: " + err);
Modal.createDialog(ErrorDialog, {
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) {
const content = {
body: file.name,
body: file.name || 'Attachment',
info: {
size: file.size,
}
@ -316,7 +316,7 @@ class ContentMessages {
}
const upload = {
fileName: file.name,
fileName: file.name || 'Attachment',
roomId: roomId,
total: 0,
loaded: 0,

View File

@ -28,6 +28,7 @@ emojione.imagePathSVG = 'emojione/svg/';
emojione.imageType = 'svg';
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
* because we want to include emoji shortnames in title text
@ -57,6 +58,22 @@ export function unicodeToImage(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 {
const contentDiv = document.createElement('div');
contentDiv.innerHTML = html;
@ -87,15 +104,17 @@ var sanitizeHtmlParams = {
// deliberately no h1/h2 to stop people shouting.
'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol',
'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: {
// 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
// We don't currently allow img itself by default, but this
// would make sense if we did
img: ['src'],
ol: ['start'],
},
// 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'],
@ -136,6 +155,38 @@ var sanitizeHtmlParams = {
attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/
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+$/;
// We allow localhost for mxids to avoid confusion
const mxidRegex = /^@\S+:(?:\S+\.\S+|localhost)$/
const mxidRegex = /^@\S+:\S+$/
export function getAddressType(inputText) {
const isEmailAddress = emailRegex.test(inputText);

View File

@ -155,7 +155,7 @@ function _loginWithToken(queryParams, defaultDeviceDisplayName) {
function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
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.
// create a temporary MatrixClient to do the login
@ -276,6 +276,14 @@ export function setLoggedIn(credentials) {
console.log("setLoggedIn => %s (guest=%s) hs=%s",
credentials.userId, credentials.guest,
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
if (localStorage) {
@ -300,25 +308,29 @@ export function setLoggedIn(credentials) {
console.warn("Error using local storage: can't persist session!", e);
}
if (rtsClient) {
rtsClient.login(credentials.userId).then((body) => {
if (rtsClient && !credentials.guest) {
teamPromise = rtsClient.login(credentials.userId).then((body) => {
if (body.team_token) {
localStorage.setItem("mx_team_token", body.team_token);
}
}, (err) =>{
console.error(
"Failed to get team token on login, not persisting to localStorage",
err
);
return body.team_token;
});
}
} else {
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);
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();
}

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() {
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;
renderer.paragraph = function(node, entering) {

View File

@ -50,6 +50,18 @@ class MatrixClientPeg {
this.opts = {
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 {
@ -122,12 +134,15 @@ class MatrixClientPeg {
opts.sessionStore = new Matrix.WebStorageSessionStore(localStorage);
}
if (window.indexedDB && localStorage) {
opts.store = new Matrix.IndexedDBStore(
new Matrix.IndexedDBStoreBackend(window.indexedDB),
new Matrix.SyncAccumulator(), {
localStorage: localStorage,
}
);
// FIXME: bodge to remove old database. Remove this after a few weeks.
window.indexedDB.deleteDatabase("matrix-js-sdk:default");
opts.store = new Matrix.IndexedDBStore({
indexedDB: window.indexedDB,
dbName: "riot-web-sync",
localStorage: localStorage,
workerScript: this.indexedDbWorkerScript,
});
}
this.matrixClient = Matrix.createClient(opts);

View File

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

View File

@ -18,11 +18,27 @@ var MatrixClientPeg = require('./MatrixClientPeg');
var dis = require('./dispatcher');
var sdk = require('./index');
var Modal = require('./Modal');
import { EventStatus } from 'matrix-js-sdk';
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) {
const room = MatrixClientPeg.get().getRoom(event.getRoomId());
MatrixClientPeg.get().resendEvent(
event, MatrixClientPeg.get().getRoom(event.getRoomId())
event, room
).done(function(res) {
dis.dispatch({
action: 'message_sent',
@ -33,16 +49,11 @@ module.exports = {
// https://github.com/vector-im/riot-web/issues/3148
console.log('Resend got send failure: ' + err.name + '('+err+')');
if (err.name === "UnknownDeviceError") {
var UnknownDeviceDialog = sdk.getComponent("dialogs.UnknownDeviceDialog");
Modal.createDialog(UnknownDeviceDialog, {
devices: err.devices,
room: MatrixClientPeg.get().getRoom(event.getRoomId()),
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({
action: 'unknown_device_error',
err: err,
room: room,
});
}
dis.dispatch({
@ -51,7 +62,6 @@ module.exports = {
});
});
},
removeFromQueue: function(event) {
MatrixClientPeg.get().cancelPendingEvent(event);
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;
}
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.
* @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 CallHandler = require("./CallHandler");
import * as Roles from './Roles';
function textForMemberEvent(ev) {
// XXX: SYJS-16 "sender is sometimes null for join messages"
var senderName = ev.sender ? ev.sender.name : ev.getSender();
@ -116,7 +118,6 @@ function textForRoomNameEvent(ev) {
function textForMessageEvent(ev) {
var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
var message = senderDisplayName + ': ' + ev.getContent().body;
if (ev.getContent().msgtype === "m.emote") {
message = "* " + senderDisplayName + " " + message;
@ -183,6 +184,45 @@ function textForEncryptionEvent(event) {
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 = {
'm.room.message': textForMessageEvent,
'm.room.name': textForRoomNameEvent,
@ -194,6 +234,7 @@ var handlers = {
'm.room.third_party_invite': textForThreePidInviteEvent,
'm.room.history_visibility': textForHistoryVisibilityEvent,
'm.room.encryption': textForEncryptionEvent,
'm.room.power_levels': textForPowerEvent,
};
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);
import views$dialogs$BaseDialog from './components/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';
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';
views$dialogs$ConfirmUserActionDialog && (module.exports.components['views.dialogs.ConfirmUserActionDialog'] = views$dialogs$ConfirmUserActionDialog);
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);
import views$elements$DirectorySearchBox from './components/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';
views$elements$EditableText && (module.exports.components['views.elements.EditableText'] = views$elements$EditableText);
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);
import views$login$CasLogin from './components/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';
views$login$CustomServerDialog && (module.exports.components['views.login.CustomServerDialog'] = views$login$CustomServerDialog);
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);
import views$rooms$UserTile from './components/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';
views$settings$ChangeAvatar && (module.exports.components['views.settings.ChangeAvatar'] = views$settings$ChangeAvatar);
import views$settings$ChangeDisplayName from './components/views/settings/ChangeDisplayName';

View File

@ -27,6 +27,9 @@ export default React.createClass({
displayName: 'InteractiveAuth',
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
// mount.
authData: React.PropTypes.shape({
@ -38,11 +41,34 @@ export default React.createClass({
// callback
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
// auth was completed sucessfully, false if canceled.
// @param result The result of the authenticated call
onFinished: React.PropTypes.func.isRequired,
// @param {object} result The result of the authenticated call
// 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() {
@ -60,12 +86,22 @@ export default React.createClass({
this._authLogic = new InteractiveAuth({
authData: this.props.authData,
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.props.onFinished(true, result);
const extra = {
emailSid: this._authLogic.getEmailSid(),
clientSecret: this._authLogic.getClientSecret(),
};
this.props.onAuthFinished(true, result, extra);
}).catch((error) => {
this.props.onAuthFinished(false, error);
console.error("Error during user-interactive auth:", error);
if (this._unmounted) {
return;
@ -76,26 +112,48 @@ export default React.createClass({
errorText: msg
});
}).done();
this._intervalId = null;
if (this.props.poll) {
this._intervalId = setInterval(() => {
this._authLogic.poll();
}, 2000);
}
},
componentWillUnmount: function() {
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({
authStage: stageType,
errorText: error ? error.error : null,
}, this._setFocus);
stageState: stageState,
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({
busy: true,
errorText: null,
stageErrorText: null,
});
return this.props.makeRequest(auth).finally(() => {
return makeRequestPromise.finally(() => {
if (this._unmounted) {
return;
}
@ -117,19 +175,35 @@ export default React.createClass({
_renderCurrentStage: function() {
const stage = this.state.authStage;
var StageComponent = getEntryComponentForLoginType(stage);
if (!stage) return null;
const StageComponent = getEntryComponentForLoginType(stage);
return (
<StageComponent ref="stageComponent"
loginType={stage}
matrixClient={this.props.matrixClient}
authSessionId={this._authLogic.getSessionId()}
clientSecret={this._authLogic.getClientSecret()}
stageParams={this._authLogic.getStageParams(stage)}
submitAuthDict={this._submitAuthDict}
errorText={this.state.stageErrorText}
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() {
let error = null;
if (this.state.errorText) {

View File

@ -49,11 +49,16 @@ export default React.createClass({
childContextTypes: {
matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient),
authCache: React.PropTypes.object,
},
getChildContext: function() {
return {
matrixClient: this._matrixClient,
authCache: {
auth: {},
lastUpdate: 0,
},
};
},
@ -76,6 +81,13 @@ export default React.createClass({
return this._scrollStateMap[roomId];
},
canResetTimelineInRoom: function(roomId) {
if (!this.refs.roomView) {
return true;
}
return this.refs.roomView.canResetTimeline();
},
_onKeyDown: function(ev) {
/*
// Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers
@ -94,6 +106,17 @@ export default React.createClass({
var handled = false;
switch (ev.keyCode) {
case KeyCode.ESCAPE:
// Implemented this way so possible handling for other pages is neater
switch (this.props.page_type) {
case PageTypes.UserSettings:
this.props.onUserSettingsClose();
handled = true;
break;
}
break;
case KeyCode.UP:
case KeyCode.DOWN:
if (ev.altKey) {

View File

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -28,10 +29,6 @@ var UserActivity = require("../../UserActivity");
var Presence = require("../../Presence");
var dis = require("../../dispatcher");
var Login = require("./login/Login");
var Registration = require("./login/Registration");
var PostRegistration = require("./login/PostRegistration");
var Modal = require("../../Modal");
var Tinter = require("../../Tinter");
var sdk = require('../../index');
@ -41,6 +38,7 @@ var Lifecycle = require('../../Lifecycle');
var PageTypes = require('../../PageTypes');
var createRoom = require("../../createRoom");
import * as UDEHandler from '../../UnknownDeviceErrorHandler';
module.exports = React.createClass({
displayName: 'MatrixChat',
@ -61,9 +59,19 @@ module.exports = React.createClass({
// called when the session load completes
onLoadCompleted: React.PropTypes.func,
// Represents the screen to display as a result of parsing the initial
// window.location
initialScreenAfterLogin: React.PropTypes.shape({
screen: React.PropTypes.string.isRequired,
params: React.PropTypes.object,
}),
// displayname, if any, to set on the device when logging
// in/registering.
defaultDeviceDisplayName: React.PropTypes.string,
// A function that makes a registration URL
makeRegistrationUrl: React.PropTypes.func.isRequired,
},
childContextTypes: {
@ -84,6 +92,12 @@ module.exports = React.createClass({
var s = {
loading: true,
screen: undefined,
screenAfterLogin: this.props.initialScreenAfterLogin,
// Stashed guest credentials if the user logs out
// whilst logged in as a guest user (so they can change
// their mind & log back in)
guestCreds: null,
// What the LoggedInView would be showing if visible
page_type: null,
@ -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
viewUserId: null,
logged_in: false,
loggedIn: false,
loggingIn: false,
collapse_lhs: false,
collapse_rhs: false,
ready: false,
@ -179,13 +194,9 @@ module.exports = React.createClass({
componentWillMount: function() {
SdkConfig.put(this.props.config);
// Stashed guest credentials if the user logs out
// whilst logged in as a guest user (so they can change
// their mind & log back in)
this.guestCreds = null;
// if the automatic session load failed, the error
this.sessionLoadError = null;
// Used by _viewRoom before getting state from sync
this.firstSyncComplete = false;
this.firstSyncPromise = q.defer();
if (this.props.config.sync_timeline_limit) {
MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit;
@ -226,10 +237,20 @@ module.exports = React.createClass({
if (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() {
this.dispatcherRef = dis.register(this.onAction);
UDEHandler.startListening();
this.focusComposer = false;
window.addEventListener("focus", this.onFocus);
@ -265,7 +286,6 @@ module.exports = React.createClass({
});
}).catch((e) => {
console.error("Unable to load session", e);
this.sessionLoadError = e.message;
}).done(()=>{
// stuff this through the dispatcher so that it happens
// after the on_logged_in action.
@ -276,6 +296,7 @@ module.exports = React.createClass({
componentWillUnmount: function() {
Lifecycle.stopMatrixClient();
dis.unregister(this.dispatcherRef);
UDEHandler.stopListening();
window.removeEventListener("focus", this.onFocus);
window.removeEventListener('resize', this.handleResize);
},
@ -291,7 +312,7 @@ module.exports = React.createClass({
const newState = {
screen: undefined,
viewUserId: null,
logged_in: false,
loggedIn: false,
ready: false,
upgradeUsername: null,
guestAccessToken: null,
@ -301,88 +322,123 @@ module.exports = React.createClass({
},
onAction: function(payload) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
var roomIndexDelta = 1;
var self = this;
switch (payload.action) {
case 'logout':
if (MatrixClientPeg.get().isGuest()) {
this.guestCreds = MatrixClientPeg.getCredentials();
}
Lifecycle.logout();
break;
case 'start_registration':
var newState = payload.params || {};
newState.screen = 'register';
if (
payload.params &&
payload.params.client_secret &&
payload.params.session_id &&
payload.params.hs_url &&
payload.params.is_url &&
payload.params.sid
) {
newState.register_client_secret = payload.params.client_secret;
newState.register_session_id = payload.params.session_id;
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);
const params = payload.params || {};
this.setStateForNewScreen({
screen: 'register',
// these params may be undefined, but if they are,
// unset them from our state: we don't want to
// resume a previous registration session if the
// user just clicked 'register'
register_client_secret: params.client_secret,
register_session_id: params.session_id,
register_hs_url: params.hs_url,
register_is_url: params.is_url,
register_id_sid: params.sid,
});
this.notifyNewScreen('register');
break;
case 'start_login':
if (this.state.logged_in) return;
if (MatrixClientPeg.get() &&
MatrixClientPeg.get().isGuest()
) {
this.setState({
guestCreds: MatrixClientPeg.getCredentials(),
});
}
this.setStateForNewScreen({
screen: 'login',
});
this.notifyNewScreen('login');
break;
case 'start_post_registration':
this.setState({ // don't clobber logged_in status
this.setState({ // don't clobber loggedIn status
screen: 'post_registration'
});
break;
case 'start_upgrade_registration':
// stash our guest creds so we can backout if needed
this.guestCreds = MatrixClientPeg.getCredentials();
// also stash our credentials, then if we restore the session,
// we can just do it the same way whether we started upgrade
// registration or explicitly logged out
this.setStateForNewScreen({
guestCreds: MatrixClientPeg.getCredentials(),
screen: "register",
upgradeUsername: MatrixClientPeg.get().getUserIdLocalpart(),
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');
break;
case 'start_password_recovery':
if (this.state.logged_in) return;
if (this.state.loggedIn) return;
this.setStateForNewScreen({
screen: 'forgot_password',
});
this.notifyNewScreen('forgot_password');
break;
case 'leave_room':
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
var roomId = payload.room_id;
Modal.createDialog(QuestionDialog, {
title: "Leave room",
description: "Are you sure you want to leave the room?",
onFinished: function(should_leave) {
onFinished: (should_leave) => {
if (should_leave) {
var d = MatrixClientPeg.get().leave(roomId);
const d = MatrixClientPeg.get().leave(payload.room_id);
// FIXME: controller shouldn't be loading a view :(
var Loader = sdk.getComponent("elements.Spinner");
var modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
const Loader = sdk.getComponent("elements.Spinner");
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
d.then(function() {
d.then(() => {
modal.close();
dis.dispatch({action: 'view_next_room'});
}, function(err) {
if (this.currentRoomId === payload.room_id) {
dis.dispatch({action: 'view_next_room'});
}
}, (err) => {
modal.close();
console.error("Failed to leave room " + payload.room_id + " " + err);
Modal.createDialog(ErrorDialog, {
title: "Failed to leave room",
description: "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()
});
});
@ -509,8 +565,11 @@ module.exports = React.createClass({
case 'set_theme':
this._onSetTheme(payload.value);
break;
case 'on_logging_in':
this.setState({loggingIn: true});
break;
case 'on_logged_in':
this._onLoggedIn();
this._onLoggedIn(payload.teamToken);
break;
case 'on_logged_out':
this._onLoggedOut();
@ -582,36 +641,38 @@ module.exports = React.createClass({
}
}
if (this.sdkReady) {
// if the SDK is not ready yet, remember what room
// we're supposed to be on but don't notify about
// the new screen yet (we won't be showing it yet)
// The normal case where this happens is navigating
// to the room in the URL bar on page load.
var presentedId = room_info.room_alias || room_info.room_id;
var room = MatrixClientPeg.get().getRoom(room_info.room_id);
// Wait for the first sync to complete so that if a room does have an alias,
// it would have been retrieved.
let waitFor = q(null);
if (!this.firstSyncComplete) {
if (!this.firstSyncPromise) {
console.warn('Cannot view a room before first sync. room_id:', room_info.room_id);
return;
}
waitFor = this.firstSyncPromise.promise;
}
waitFor.done(() => {
let presentedId = room_info.room_alias || room_info.room_id;
const room = MatrixClientPeg.get().getRoom(room_info.room_id);
if (room) {
var theAlias = Rooms.getDisplayAliasForRoom(room);
const theAlias = Rooms.getDisplayAliasForRoom(room);
if (theAlias) presentedId = theAlias;
// No need to do this given RoomView triggers it itself...
// var color_scheme_event = room.getAccountData("org.matrix.room.color_scheme");
// var color_scheme = {};
// if (color_scheme_event) {
// color_scheme = color_scheme_event.getContent();
// // XXX: we should validate the event
// }
// console.log("Tinter.tint from _viewRoom");
// Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
// Store this as the ID of the last room accessed. This is so that we can
// persist which room is being stored across refreshes and browser quits.
if (localStorage) {
localStorage.setItem('mx_last_room_id', room.roomId);
}
}
if (room_info.event_id) {
presentedId += "/"+room_info.event_id;
presentedId += "/" + room_info.event_id;
}
this.notifyNewScreen('room/'+presentedId);
this.notifyNewScreen('room/' + presentedId);
newState.ready = true;
}
this.setState(newState);
this.setState(newState);
});
},
_createChat: function() {
@ -637,6 +698,14 @@ module.exports = React.createClass({
_onLoadCompleted: function() {
this.props.onLoadCompleted();
this.setState({loading: false});
// Show screens (like 'register') that need to be shown without _onLoggedIn
// being called. 'register' needs to be routed here when the email confirmation
// link is clicked on.
if (this.state.screenAfterLogin &&
['register'].indexOf(this.state.screenAfterLogin.screen) !== -1) {
this._showScreenAfterLogin();
}
},
/**
@ -686,13 +755,48 @@ module.exports = React.createClass({
/**
* Called when a new logged in session has started
*/
_onLoggedIn: function(credentials) {
this.guestCreds = null;
this.notifyNewScreen('');
_onLoggedIn: function(teamToken) {
this.setState({
screen: undefined,
logged_in: true,
guestCreds: null,
loggedIn: true,
loggingIn: false,
});
if (teamToken) {
// A team member has logged in, not a guest
this._teamToken = teamToken;
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() {
this.notifyNewScreen('login');
this.setStateForNewScreen({
logged_in: false,
loggedIn: false,
ready: false,
collapse_lhs: false,
collapse_rhs: false,
@ -709,6 +813,7 @@ module.exports = React.createClass({
currentRoomId: null,
page_type: PageTypes.RoomDirectory,
});
this._teamToken = null;
},
/**
@ -716,9 +821,31 @@ module.exports = React.createClass({
* (useful for setting listeners)
*/
_onWillStartClient() {
var self = this;
var cli = MatrixClientPeg.get();
var self = this;
// Allow the JS SDK to reap timeline events. This reduces the amount of
// memory consumed as the JS SDK stores multiple distinct copies of room
// state (each of which can be 10s of MBs) for each DISJOINT timeline. This is
// particularly noticeable when there are lots of 'limited' /sync responses
// such as when laptops unsleep.
// https://github.com/vector-im/riot-web/issues/3307#issuecomment-282895568
cli.setCanResetTimelineCallback(function(roomId) {
console.log("Request to reset timeline in room ", roomId, " viewing:", self.state.currentRoomId);
if (roomId !== self.state.currentRoomId) {
// It is safe to remove events from rooms we are not viewing.
return true;
}
// We are viewing the room which we want to reset. It is only safe to do
// this if we are not scrolled up in the view. To find out, delegate to
// the timeline panel. If the timeline panel doesn't exist, then we assume
// it is safe to reset the timeline.
if (!self.refs.loggedInView) {
return true;
}
return self.refs.loggedInView.canResetTimelineInRoom(roomId);
});
cli.on('sync', function(state, prevState) {
self.updateStatusIndicator(state, prevState);
if (state === "SYNCING" && prevState === "SYNCING") {
@ -726,55 +853,12 @@ module.exports = React.createClass({
}
console.log("MatrixClient sync state => %s", state);
if (state !== "PREPARED") { return; }
self.sdkReady = true;
if (self.starting_room_alias_payload) {
dis.dispatch(self.starting_room_alias_payload);
delete self.starting_room_alias_payload;
} else if (!self.state.page_type) {
if (!self.state.currentRoomId) {
var firstRoom = null;
if (cli.getRooms() && cli.getRooms().length) {
firstRoom = RoomListSorter.mostRecentActivityFirst(
cli.getRooms()
)[0].roomId;
self.setState({ready: true, currentRoomId: firstRoom, page_type: PageTypes.RoomView});
} else {
if (self._teamToken) {
self.setState({ready: true, page_type: PageTypes.HomePage});
} else {
self.setState({ready: true, page_type: PageTypes.RoomDirectory});
}
}
} else {
self.setState({ready: true, page_type: PageTypes.RoomView});
}
self.firstSyncComplete = true;
self.firstSyncPromise.resolve();
// we notifyNewScreen now because now the room will actually be displayed,
// and (mostly) now we can get the correct alias.
var presentedId = self.state.currentRoomId;
var room = MatrixClientPeg.get().getRoom(self.state.currentRoomId);
if (room) {
var theAlias = Rooms.getDisplayAliasForRoom(room);
if (theAlias) presentedId = theAlias;
}
if (presentedId != undefined) {
self.notifyNewScreen('room/'+presentedId);
} else {
// There is no information on presentedId
// so point user to fallback like /directory
if (self._teamToken) {
self.notifyNewScreen('home');
} else {
self.notifyNewScreen('directory');
}
}
dis.dispatch({action: 'focus_composer'});
} else {
self.setState({ready: true});
}
dis.dispatch({action: 'focus_composer'});
self.setState({ready: true});
});
cli.on('Call.incoming', function(call) {
dis.dispatch({
@ -874,12 +958,7 @@ module.exports = React.createClass({
// we can't view a room unless we're logged in
// (a guest account is fine)
if (!this.state.logged_in) {
// we may still be loading (ie, trying to register a guest
// session); otherwise we're (probably) already showing a login
// screen. Either way, we'll show the room once the client starts.
this.starting_room_alias_payload = payload;
} else {
if (this.state.loggedIn) {
dis.dispatch(payload);
}
} else if (screen.indexOf('user/') == 0) {
@ -973,29 +1052,17 @@ module.exports = React.createClass({
onReturnToGuestClick: function() {
// reanimate our guest login
if (this.guestCreds) {
Lifecycle.setLoggedIn(this.guestCreds);
this.guestCreds = null;
if (this.state.guestCreds) {
Lifecycle.setLoggedIn(this.state.guestCreds);
this.setState({guestCreds: null});
}
},
onRegistered: function(credentials) {
Lifecycle.setLoggedIn(credentials);
// 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) {
onRegistered: function(credentials, teamToken) {
// teamToken may not be truthy
this._teamToken = teamToken;
this._setPage(PageTypes.HomePage);
this._is_registered = true;
Lifecycle.setLoggedIn(credentials);
},
onFinishPostRegistration: function() {
@ -1061,15 +1128,20 @@ module.exports = React.createClass({
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() {
var ForgotPassword = sdk.getComponent('structures.login.ForgotPassword');
var LoggedInView = sdk.getComponent('structures.LoggedInView');
// console.log("rendering; loading="+this.state.loading+"; screen="+this.state.screen +
// "; logged_in="+this.state.logged_in+"; ready="+this.state.ready);
if (this.state.loading) {
var Spinner = sdk.getComponent('elements.Spinner');
// `loading` might be set to false before `loggedIn = true`, causing the default
// (`<Login>`) to be visible for a few MS (say, whilst a request is in-flight to
// the RTS). So in the meantime, use `loggingIn`, which is true between
// actions `on_logging_in` and `on_logged_in`.
if (this.state.loading || this.state.loggingIn) {
const Spinner = sdk.getComponent('elements.Spinner');
return (
<div className="mx_MatrixChat_splash">
<Spinner />
@ -1078,15 +1150,17 @@ module.exports = React.createClass({
}
// needs to be before normal PageTypes as you are logged in technically
else if (this.state.screen == 'post_registration') {
const PostRegistration = sdk.getComponent('structures.login.PostRegistration');
return (
<PostRegistration
onComplete={this.onFinishPostRegistration} />
);
} else if (this.state.logged_in && this.state.ready) {
} else if (this.state.loggedIn && this.state.ready) {
/* for now, we stuff the entirety of our props and state into the LoggedInView.
* we should go through and figure out what we actually need to pass down, as well
* as using something like redux to avoid having a billion bits of state kicking around.
*/
const LoggedInView = sdk.getComponent('structures.LoggedInView');
return (
<LoggedInView ref="loggedInView" matrixClient={MatrixClientPeg.get()}
onRoomIdResolved={this.onRoomIdResolved}
@ -1097,9 +1171,9 @@ module.exports = React.createClass({
{...this.state}
/>
);
} else if (this.state.logged_in) {
} else if (this.state.loggedIn) {
// we think we are logged in, but are still waiting for the /sync to complete
var Spinner = sdk.getComponent('elements.Spinner');
const Spinner = sdk.getComponent('elements.Spinner');
return (
<div className="mx_MatrixChat_splash">
<Spinner />
@ -1109,6 +1183,7 @@ module.exports = React.createClass({
</div>
);
} else if (this.state.screen == 'register') {
const Registration = sdk.getComponent('structures.login.Registration');
return (
<Registration
clientSecret={this.state.register_client_secret}
@ -1124,16 +1199,16 @@ module.exports = React.createClass({
teamServerConfig={this.props.config.teamServerConfig}
customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()}
registrationUrl={this.props.registrationUrl}
makeRegistrationUrl={this._makeRegistrationUrl}
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
onTeamMemberRegistered={this.onTeamMemberRegistered}
onLoggedIn={this.onRegistered}
onLoginClick={this.onLoginClick}
onRegisterClick={this.onRegisterClick}
onCancelClick={this.guestCreds ? this.onReturnToGuestClick : null}
onCancelClick={this.state.guestCreds ? this.onReturnToGuestClick : null}
/>
);
} else if (this.state.screen == 'forgot_password') {
const ForgotPassword = sdk.getComponent('structures.login.ForgotPassword');
return (
<ForgotPassword
defaultHsUrl={this.getDefaultHsUrl()}
@ -1145,7 +1220,8 @@ module.exports = React.createClass({
onLoginClick={this.onLoginClick} />
);
} else {
var r = (
const Login = sdk.getComponent('structures.login.Login');
return (
<Login
onLoggedIn={Lifecycle.setLoggedIn}
onRegisterClick={this.onRegisterClick}
@ -1157,17 +1233,9 @@ module.exports = React.createClass({
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
onForgotPasswordClick={this.onForgotPasswordClick}
enableGuest={this.props.enableGuest}
onCancelClick={this.guestCreds ? this.onReturnToGuestClick : null}
initialErrorText={this.sessionLoadError}
onCancelClick={this.state.guestCreds ? this.onReturnToGuestClick : null}
/>
);
// we only want to show the session load error the first time the
// Login component is rendered. This is pretty hacky but I can't
// think of another way to achieve it.
this.sessionLoadError = null;
return r;
}
}
});

View File

@ -295,7 +295,10 @@ module.exports = React.createClass({
var last = (i == lastShownEventIndex);
// Wrap consecutive member events in a ListSummary, ignore if redacted
if (isMembershipChange(mxEv) && EventTile.haveTileForEvent(mxEv)) {
if (isMembershipChange(mxEv) &&
EventTile.haveTileForEvent(mxEv) &&
!mxEv.isRedacted()
) {
let ts1 = mxEv.getTs();
// Ensure that the key of the MemberEventListSummary does not change with new
// member events. This will prevent it from being re-created unnecessarily, and
@ -349,7 +352,9 @@ module.exports = React.createClass({
<MemberEventListSummary
key={key}
events={summarisedEvents}
data-scroll-token={eventId}>
data-scroll-token={eventId}
onToggle={this._onWidgetLoad} // Update scroll state
>
{eventTiles}
</MemberEventListSummary>
);
@ -362,10 +367,6 @@ module.exports = React.createClass({
// replacing all of the DOM elements every time we paginate.
ret.push(...this._getTilesForEvent(prevEvent, mxEv, last));
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;
@ -410,7 +411,9 @@ module.exports = React.createClass({
// is this a continuation of the previous message?
var continuation = false;
if (prevEvent !== null && prevEvent.sender && mxEv.sender
if (prevEvent !== null
&& prevEvent.sender && mxEv.sender
&& mxEv.sender.userId === prevEvent.sender.userId
&& mxEv.getType() == prevEvent.getType()) {
continuation = true;
@ -463,6 +466,7 @@ module.exports = React.createClass({
ref={this._collectEventNode.bind(this, eventId)}
data-scroll-token={scrollToken}>
<EventTile mxEvent={mxEv} continuation={continuation}
isRedacted={mxEv.isRedacted()}
onWidgetLoad={this._onWidgetLoad}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
@ -483,13 +487,17 @@ module.exports = React.createClass({
// here.
return !this.props.suppressFirstDateSeparator;
}
const prevEventDate = prevEvent.getDate();
if (!nextEventDate || !prevEventDate) {
return false;
}
// Return early for events that are > 24h apart
if (Math.abs(prevEvent.getTs() - nextEventDate.getTime()) > MILLIS_IN_DAY) {
return true;
}
// Compare weekdays
return prevEvent.getDate().getDay() !== nextEventDate.getDay();
return prevEventDate.getDay() !== nextEventDate.getDay();
},
// get a list of read receipts that should be shown next to this event

View File

@ -96,26 +96,12 @@ module.exports = React.createClass({
componentWillMount: function() {
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
this._checkSize();
},
componentDidUpdate: function(prevProps, prevState) {
if(this.props.onResize && this._checkForResize(prevProps, prevState)) {
this.props.onResize();
}
const size = this._getSize(this.props, this.state);
if (size > 0) {
this.props.onVisible();
} else {
if (this.hideDebouncer) {
clearTimeout(this.hideDebouncer);
}
this.hideDebouncer = setTimeout(() => {
// temporarily stop hiding the statusbar as per
// https://github.com/vector-im/riot-web/issues/1991#issuecomment-276953915
// this.props.onHidden();
}, HIDE_DEBOUNCE_MS);
}
componentDidUpdate: function() {
this._checkSize();
},
componentWillUnmount: function() {
@ -142,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
// changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes.
_getSize: function(props, state) {
if (state.syncState === "ERROR" ||
(state.usersTyping.length > 0) ||
props.numUnreadMessages ||
!props.atEndOfLiveTimeline ||
props.hasActiveCall) {
_getSize: function() {
if (this.state.syncState === "ERROR" ||
(this.state.usersTyping.length > 0) ||
this.props.numUnreadMessages ||
!this.props.atEndOfLiveTimeline ||
this.props.hasActiveCall ||
this.props.tabComplete.isTabCompleting()
) {
return STATUS_BAR_EXPANDED;
} else if (props.tabCompleteEntries) {
} else if (this.props.tabCompleteEntries) {
return STATUS_BAR_HIDDEN;
} else if (props.unsentMessageError) {
} else if (this.props.unsentMessageError) {
return STATUS_BAR_EXPANDED_LARGE;
}
return STATUS_BAR_HIDDEN;
},
// determine if we need to call onResize
_checkForResize: function(prevProps, prevState) {
// figure out the old height and the new height of the status bar.
return this._getSize(prevProps, prevState)
!== this._getSize(this.props, this.state);
},
// return suitable content for the image on the left of the status bar.
//
// if wantPlaceholder is true, we include a "..." placeholder if
@ -194,8 +182,9 @@ module.exports = React.createClass({
}
if (this.props.hasActiveCall) {
var TintableSvg = sdk.getComponent("elements.TintableSvg");
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,
// after a successful peek, or after we join the room).
_onRoomLoaded: function(room) {
this._warnAboutEncryption(room);
this._calculatePeekRules(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) {
var guestAccessEvent = room.currentState.getStateEvents("m.room.guest_access", "");
if (guestAccessEvent && guestAccessEvent.getContent().guest_access === "can_join") {
@ -716,17 +752,11 @@ module.exports = React.createClass({
},
onResendAllClick: function() {
var eventsToResend = this._getUnsentMessages(this.state.room);
eventsToResend.forEach(function(event) {
Resend.resend(event);
});
Resend.resendUnsentEvents(this.state.room);
},
onCancelAllClick: function() {
var eventsToResend = this._getUnsentMessages(this.state.room);
eventsToResend.forEach(function(event) {
Resend.removeFromQueue(event);
});
Resend.cancelUnsentEvents(this.state.room);
},
onJoinButtonClicked: function(ev) {
@ -892,8 +922,6 @@ module.exports = React.createClass({
},
uploadFile: function(file) {
var self = this;
if (MatrixClientPeg.get().isGuest()) {
var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog");
Modal.createDialog(NeedToRegisterDialog, {
@ -905,11 +933,20 @@ module.exports = React.createClass({
ContentMessages.sendContentToRoom(
file, this.state.room.roomId, MatrixClientPeg.get()
).done(undefined, function(error) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
).done(undefined, (error) => {
if (error.name === "UnknownDeviceError") {
dis.dispatch({
action: 'unknown_device_error',
err: error,
room: this.state.room,
});
return;
}
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to upload file " + file + " " + error);
Modal.createDialog(ErrorDialog, {
title: "Failed to upload file",
description: error.toString()
description: "Server may be unavailable, overloaded, or the file too big",
});
});
},
@ -993,9 +1030,10 @@ module.exports = React.createClass({
});
}, function(error) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Search failed: " + error);
Modal.createDialog(ErrorDialog, {
title: "Search failed",
description: error.toString()
description: "Server may be unavailable, overloaded, or search timed out :("
});
}).finally(function() {
self.setState({
@ -1639,14 +1677,14 @@ module.exports = React.createClass({
videoMuteButton =
<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"}
width="31" height="27"/>
</div>;
}
voiceMuteButton =
<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"}
width="21" height="26"/>
</div>;

View File

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

View File

@ -251,10 +251,12 @@ var TimelinePanel = React.createClass({
},
onMessageListUnfillRequest: function(backwards, scrollToken) {
// If backwards, unpaginate from the back (i.e. the start of the timeline)
let dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
debuglog("TimelinePanel: unpaginating events in direction", dir);
// All tiles are inserted by MessagePanel to have a scrollToken === eventId
// All tiles are inserted by MessagePanel to have a scrollToken === eventId, and
// this particular event should be the first or last to be unpaginated.
let eventId = scrollToken;
let marker = this.state.events.findIndex(
@ -431,6 +433,10 @@ var TimelinePanel = React.createClass({
}
},
canResetTimeline: function() {
return this.refs.messagePanel && this.refs.messagePanel.isAtBottom();
},
onRoomRedaction: function(ev, room) {
if (this.unmounted) return;
@ -469,14 +475,6 @@ var TimelinePanel = React.createClass({
// we still have a client.
if (!MatrixClientPeg.get()) return;
// if we are scrolled to the bottom, do a quick-reset of our unreadNotificationCount
// to avoid having to wait from the remote echo from the homeserver.
if (this.isAtEndOfLiveTimeline()) {
this.props.timelineSet.room.setUnreadNotificationCount('total', 0);
this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0);
// XXX: i'm a bit surprised we don't have to emit an event or dispatch to get this picked up
}
var currentReadUpToEventId = this._getCurrentReadReceipt(true);
var currentReadUpToEventIndex = this._indexForEventId(currentReadUpToEventId);
@ -514,6 +512,19 @@ var TimelinePanel = React.createClass({
// it failed, so allow retries next time the user is active
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
dis.dispatch({
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() {
dis.register(this.onAction);
this.dispatcherRef = dis.register(this.onAction);
this.mounted = true;
},
componentWillUnmount: function() {
this.mounted = false;
dis.unregister(this.dispatcherRef);
},
onAction: function(payload) {

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,25 +15,21 @@ See the License for the specific language governing permissions and
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');
var dis = require('../../../dispatcher');
var Signup = require("../../../Signup");
var ServerConfig = require("../../views/login/ServerConfig");
var MatrixClientPeg = require("../../../MatrixClientPeg");
var RegistrationForm = require("../../views/login/RegistrationForm");
var CaptchaForm = require("../../views/login/CaptchaForm");
var RtsClient = require("../../../RtsClient");
import sdk from '../../../index';
import dis from '../../../dispatcher';
import ServerConfig from '../../views/login/ServerConfig';
import MatrixClientPeg from '../../../MatrixClientPeg';
import RegistrationForm from '../../views/login/RegistrationForm';
import CaptchaForm from '../../views/login/CaptchaForm';
import RtsClient from '../../../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({
displayName: 'Registration',
@ -40,7 +37,7 @@ module.exports = React.createClass({
onLoggedIn: React.PropTypes.func.isRequired,
clientSecret: React.PropTypes.string,
sessionId: React.PropTypes.string,
registrationUrl: React.PropTypes.string,
makeRegistrationUrl: React.PropTypes.func.isRequired,
idSid: React.PropTypes.string,
customHsUrl: React.PropTypes.string,
customIsUrl: React.PropTypes.string,
@ -58,7 +55,6 @@ module.exports = React.createClass({
teamServerURL: React.PropTypes.string.isRequired,
}),
teamSelected: React.PropTypes.object,
onTeamMemberRegistered: React.PropTypes.func.isRequired,
defaultDeviceDisplayName: React.PropTypes.string,
@ -82,27 +78,20 @@ module.exports = React.createClass({
formVals: {
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() {
this._unmounted = false;
this.dispatcherRef = dis.register(this.onAction);
// attach this to the instance rather than this.state since it isn't UI
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();
this._replaceClient();
if (
this.props.teamServerConfig &&
@ -134,154 +123,137 @@ 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) {
this.registerLogic.setHomeserverUrl(newHsUrl);
this.setState({
hsUrl: newHsUrl,
});
this._replaceClient();
},
onIsUrlChanged: function(newIsUrl) {
this.registerLogic.setIdentityServerUrl(newIsUrl);
this.setState({
isUrl: newIsUrl,
});
this._replaceClient();
},
onAction: function(payload) {
if (payload.action !== "registration_step_update") {
return;
}
// 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
_replaceClient: function() {
this._matrixClient = Matrix.createClient({
baseUrl: this.state.hsUrl,
idBaseUrl: this.state.isUrl,
});
},
onFormSubmit: function(formVals) {
var self = this;
this.setState({
errorText: "",
busy: true,
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
onProcessingRegistration: function(promise) {
var self = this;
promise.done(function(response) {
self.setState({
busy: false
});
if (!response || !response.access_token) {
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();
_onUIAuthFinished: function(success, response, extra) {
if (!success) {
let msg = response.message || response.toString();
// can we give a better error message?
if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
let msisdn_available = false;
for (const flow of response.available_flows) {
msisdn_available |= flow.stages.indexOf('m.login.msisdn') > -1;
}
if (!msisdn_available) {
msg = "This server does not support authentication with a phone number";
}
}
if (!response || !response.user_id || !response.access_token) {
console.error("Final response is missing keys.");
self.setState({
errorText: "Registration failed on server"
});
return;
}
self.props.onLoggedIn({
userId: response.user_id,
deviceId: response.device_id,
homeserverUrl: self.registerLogic.getHomeserverUrl(),
identityServerUrl: self.registerLogic.getIdentityServerUrl(),
accessToken: response.access_token
this.setState({
busy: false,
doingUIAuth: false,
errorText: msg,
});
return;
}
// 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
// that the user registered with at this stage (depending on whether this
// is the client they initiated registration).
if (self._rtsClient) {
// Track referral if self.props.referrer set, get team_token in order to
// retrieve team config and see welcome page etc.
self._rtsClient.trackReferral(
self.props.referrer || '', // Default to empty string = not referred
self.registerLogic.params.idSid,
self.registerLogic.params.clientSecret
).then((data) => {
const teamToken = data.team_token;
// Store for use /w welcome pages
window.localStorage.setItem('mx_team_token', teamToken);
self.props.onTeamMemberRegistered(teamToken);
this.setState({
// we're still busy until we get unmounted: don't show the registration form again
busy: true,
doingUIAuth: false,
});
self._rtsClient.getTeam(teamToken).then((team) => {
console.log(
`User successfully registered with team ${team.name}`
);
if (!team.rooms) {
return;
// 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
// that the user registered with at this stage (depending on whether this
// is the client they initiated registration).
let trackPromise = q(null);
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.
trackPromise = this._rtsClient.trackReferral(
this.props.referrer || '', // Default to empty string = not referred
extra.emailSid,
extra.clientSecret,
).then((data) => {
const teamToken = data.team_token;
// Store for use /w welcome pages
window.localStorage.setItem('mx_team_token', teamToken);
this._rtsClient.getTeam(teamToken).then((team) => {
console.log(
`User successfully registered with team ${team.name}`
);
if (!team.rooms) {
return;
}
// Auto-join rooms
team.rooms.forEach((room) => {
if (room.auto_join && room.room_id) {
console.log(`Auto-joining ${room.room_id}`);
MatrixClientPeg.get().joinRoom(room.room_id);
}
// Auto-join rooms
team.rooms.forEach((room) => {
if (room.auto_join && room.room_id) {
console.log(`Auto-joining ${room.room_id}`);
MatrixClientPeg.get().joinRoom(room.room_id);
}
});
}, (err) => {
console.error('Error getting team config', err);
});
}, (err) => {
console.error('Error tracking referral', err);
console.error('Error getting team config', err);
});
}
if (self.props.brand) {
MatrixClientPeg.get().getPushers().done((resp)=>{
var pushers = resp.pushers;
for (var i = 0; i < pushers.length; ++i) {
if (pushers[i].kind == 'email') {
var emailPusher = pushers[i];
emailPusher.data = { brand: self.props.brand };
MatrixClientPeg.get().setPusher(emailPusher).done(() => {
console.log("Set email branding to " + self.props.brand);
}, (error) => {
console.error("Couldn't set email branding: " + error);
});
}
}
}, (error) => {
console.error("Couldn't get pushers: " + error);
});
}
}, function(err) {
if (err.message) {
self.setState({
errorText: err.message
});
}
self.setState({
busy: false
return teamToken;
}, (err) => {
console.error('Error tracking referral', err);
});
console.log(err);
}
trackPromise.then((teamToken) => {
console.info('Team token promise',teamToken);
this.props.onLoggedIn({
userId: response.user_id,
deviceId: response.device_id,
homeserverUrl: this._matrixClient.getHomeserverUrl(),
identityServerUrl: this._matrixClient.getIdentityServerUrl(),
accessToken: response.access_token
}, teamToken);
}).then(() => {
return this._setupPushers();
});
},
_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') {
const emailPusher = pushers[i];
emailPusher.data = { brand: this.props.brand };
MatrixClientPeg.get().setPusher(emailPusher).done(() => {
console.log("Set email branding to " + this.props.brand);
}, (error) => {
console.error("Couldn't set email branding: " + error);
});
}
}
}, (error) => {
console.error("Couldn't get pushers: " + error);
});
},
@ -300,6 +272,9 @@ module.exports = React.createClass({
case "RegistrationForm.ERR_EMAIL_INVALID":
errMsg = "This doesn't look like a valid email address";
break;
case "RegistrationForm.ERR_PHONE_NUMBER_INVALID":
errMsg = "This doesn't look like a valid phone number";
break;
case "RegistrationForm.ERR_USERNAME_INVALID":
errMsg = "User names may only contain letters, numbers, dots, hyphens and underscores.";
break;
@ -316,116 +291,121 @@ module.exports = React.createClass({
});
},
onCaptchaResponse: function(response) {
this.registerLogic.tellStage("m.login.recaptcha", {
response: response
});
},
onTeamSelected: function(teamSelected) {
if (!this._unmounted) {
this.setState({ teamSelected });
}
},
_getRegisterContentJsx: function() {
const Spinner = sdk.getComponent("elements.Spinner");
_makeRegisterRequest: function(auth) {
let guestAccessToken = this.props.guestAccessToken;
var currStep = this.registerLogic.getStep();
var registerStep;
switch (currStep) {
case "Register.COMPLETE":
break; // NOP
case "Register.START":
case "Register.STEP_m.login.dummy":
// NB. Our 'username' prop is specifically for upgrading
// a guest account
if (this.state.teamServerBusy) {
registerStep = <Spinner />;
break;
}
registerStep = (
if (
this.state.formVals.username !== this.props.username ||
this.state.hsUrl != this.props.defaultHsUrl
) {
// don't try to upgrade if we changed our username
// or are registering on a different HS
guestAccessToken = null;
}
// Only send the bind params if we're sending username / pw params
// (Since we need to send no params at all to use the ones saved in the
// session).
const bindThreepids = this.state.formVals.password ? {
email: true,
msisdn: true,
} : {};
return this._matrixClient.register(
this.state.formVals.username,
this.state.formVals.password,
undefined, // session id: included in the auth dict already
auth,
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
showEmail={true}
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPhoneCountry={this.state.formVals.phoneCountry}
defaultPhoneNumber={this.state.formVals.phoneNumber}
defaultPassword={this.state.formVals.password}
teamsConfig={this.state.teamsConfig}
guestUsername={this.props.username}
guestUsername={guestUsername}
minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit}
onTeamSelected={this.onTeamSelected}
/>
);
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}
{errorSection}
<ServerConfig ref="serverConfig"
withToggleButton={true}
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onHsUrlChanged={this.onHsUrlChanged}
onIsUrlChanged={this.onIsUrlChanged}
delayTimeMs={1000}
/>
);
break;
default:
console.error("Unknown register state: %s", currStep);
break;
}
var busySpinner;
if (this.state.busy) {
busySpinner = (
<Spinner />
</div>
);
}
var returnToAppJsx;
let returnToAppJsx;
if (this.props.onCancelClick) {
returnToAppJsx =
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"
withToggleButton={ this.registerLogic.getStep() === "Register.START" }
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onHsUrlChanged={this.onHsUrlChanged}
onIsUrlChanged={this.onIsUrlChanged}
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>
);
},
render: function() {
var LoginHeader = sdk.getComponent('login.LoginHeader');
var LoginFooter = sdk.getComponent('login.LoginFooter');
);
}
return (
<div className="mx_Login">
<div className="mx_Login_box">
@ -435,7 +415,12 @@ module.exports = React.createClass({
this.state.teamSelected.domain + "/icon.png" :
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 />
</div>
</div>

View File

@ -18,6 +18,7 @@ import React from 'react';
import * as KeyCode from '../../../KeyCode';
import AccessibleButton from '../elements/AccessibleButton';
import sdk from '../../../index';
/**
* Basic container for modal dialogs.
@ -65,15 +66,14 @@ export default React.createClass({
},
render: function() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
return (
<div onKeyDown={this._onKeyDown} className={this.props.className}>
<AccessibleButton onClick={this._onCancelClick}
className="mx_Dialog_cancelButton"
>
<img
src="img/cancel.svg" width="18" height="18"
alt="Cancel" title="Cancel"
/>
<TintableSvg src="img/icons-close-button.svg" width="35" height="35" />
</AccessibleButton>
<div className='mx_Dialog_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 AccessibleButton from '../elements/AccessibleButton';
import q from 'q';
import Fuse from 'fuse.js';
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({
displayName: "ChatInviteDialog",
propTypes: {
@ -85,6 +77,19 @@ module.exports = React.createClass({
// Set the cursor at the end of the text input
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();
},
@ -97,18 +102,27 @@ module.exports = React.createClass({
if (inviteList === null) return;
}
const addrTexts = inviteList.map(addr => addr.address);
if (inviteList.length > 0) {
if (this._isDmChat(inviteList)) {
if (this._isDmChat(addrTexts)) {
const userId = inviteList[0].address;
// Direct Message chat
var room = this._getDirectMessageRoom(inviteList[0]);
if (room) {
// A Direct Message room already exists for this user and you
// so go straight to that room
dis.dispatch({
action: 'view_room',
room_id: room.roomId,
const rooms = this._getDirectMessageRooms(userId);
if (rooms.length > 0) {
// A Direct Message room already exists for this user, so select a
// room from a list that is similar to the one in MemberInfo panel
const ChatCreateOrReuseDialog = sdk.getComponent(
"views.dialogs.ChatCreateOrReuseDialog"
);
Modal.createDialog(ChatCreateOrReuseDialog, {
userId: userId,
onFinished: (success) => {
if (success) {
this.props.onFinished(true, inviteList[0]);
}
// else show this ChatInviteDialog again
}
});
this.props.onFinished(true, inviteList[0]);
} else {
this._startChat(inviteList);
}
@ -167,45 +181,59 @@ module.exports = React.createClass({
const query = ev.target.value;
let queryList = [];
// Only do search if there is something to search
if (query.length > 0 && query != '@') {
// filter the known users list
queryList = this._userList.filter((user) => {
return this._matches(query, user);
}).map((user) => {
// Return objects, structure of which is defined
// by InviteAddressType
return {
addressType: 'mx',
address: user.userId,
displayName: user.displayName,
avatarMxc: user.avatarUrl,
isKnown: true,
}
});
if (query.length < 2) {
return;
}
// If the query isn't a user we know about, but is a
// valid address, add an entry for that
if (queryList.length == 0) {
if (this.queryChangedDebouncer) {
clearTimeout(this.queryChangedDebouncer);
}
this.queryChangedDebouncer = setTimeout(() => {
// Only do search if there is something to search
if (query.length > 0 && query != '@') {
// Weighted keys prefer to match userIds when first char is @
this._fuse.options.keys = [{
name: 'displayName',
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
// by InviteAddressType
return {
addressType: 'mx',
address: user.userId,
displayName: user.displayName,
avatarMxc: user.avatarUrl,
isKnown: true,
}
});
// If the query is a valid address, add an entry for that
// This is important, otherwise there's no way to invite
// a perfectly valid address if there are close matches.
const addrType = getAddressType(query);
if (addrType !== null) {
queryList[0] = {
queryList.unshift({
addressType: addrType,
address: query,
isKnown: false,
};
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
if (addrType == 'email') {
this._lookupThreepid(addrType, query).done();
}
}
}
}
this.setState({
queryList: queryList,
error: false,
});
this.setState({
queryList: queryList,
error: false,
}, () => {
this.addressSelector.moveSelectionTop();
});
}, 200);
},
onDismissed: function(index) {
@ -238,22 +266,20 @@ module.exports = React.createClass({
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
},
_getDirectMessageRoom: function(addr) {
_getDirectMessageRooms: function(addr) {
const dmRoomMap = new DMRoomMap(MatrixClientPeg.get());
var dmRooms = dmRoomMap.getDMRoomsForUserId(addr);
if (dmRooms.length > 0) {
// Cycle through all the DM rooms and find the first non forgotten or parted room
for (let i = 0; i < dmRooms.length; i++) {
let room = MatrixClientPeg.get().getRoom(dmRooms[i]);
if (room) {
const me = room.getMember(MatrixClientPeg.get().credentials.userId);
if (me.membership == 'join') {
return room;
}
const dmRooms = dmRoomMap.getDMRoomsForUserId(addr);
const rooms = [];
dmRooms.forEach(dmRoom => {
let room = MatrixClientPeg.get().getRoom(dmRoom);
if (room) {
const me = room.getMember(MatrixClientPeg.get().credentials.userId);
if (me.membership == 'join') {
rooms.push(room);
}
}
}
return null;
});
return rooms;
},
_startChat: function(addrs) {
@ -282,8 +308,8 @@ module.exports = React.createClass({
console.error(err.stack);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Failure to invite",
description: err.toString()
title: "Error",
description: "Failed to invite",
});
return null;
})
@ -295,8 +321,8 @@ module.exports = React.createClass({
console.error(err.stack);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Failure to invite user",
description: err.toString()
title: "Error",
description: "Failed to invite user",
});
return null;
})
@ -316,8 +342,8 @@ module.exports = React.createClass({
console.error(err.stack);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Failure to invite",
description: err.toString()
title: "Error",
description: "Failed to invite",
});
return null;
})
@ -331,49 +357,15 @@ module.exports = React.createClass({
_updateUserList: new rate_limited_func(function() {
// Get all the users
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),
// 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) {
for (let i = 0; i < this.state.inviteList.length; i++) {
if (
@ -386,8 +378,11 @@ module.exports = React.createClass({
return false;
},
_isDmChat: function(addrs) {
if (addrs.length === 1 && getAddressType(addrs[0]) === "mx" && !this.props.roomId) {
_isDmChat: function(addrTexts) {
if (addrTexts.length === 1 &&
getAddressType(addrTexts[0]) === "mx" &&
!this.props.roomId
) {
return true;
} else {
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_ConfirmUserActionDialog_avatar">
<MemberAvatar member={this.props.member} width={72} height={72} />
<MemberAvatar member={this.props.member} width={48} height={48} />
</div>
<div className="mx_ConfirmUserActionDialog_name">{this.props.member.name}</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 MatrixClientPeg from '../../../MatrixClientPeg';
import Lifecycle from '../../../Lifecycle';
import * as Lifecycle from '../../../Lifecycle';
import Velocity from 'velocity-vector';
export default class DeactivateAccountDialog extends React.Component {

View File

@ -27,6 +27,9 @@ export default React.createClass({
displayName: 'InteractiveAuthDialog',
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
// mount.
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() {
const InteractiveAuth = sdk.getComponent("structures.InteractiveAuth");
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 (
<BaseDialog className="mx_InteractiveAuthDialog"
onFinished={this.props.onFinished}
title={this.props.title}
title={this.state.authError ? 'Error' : this.props.title}
>
<div>
<InteractiveAuth ref={this._collectInteractiveAuth}
authData={this.props.authData}
makeRequest={this.props.makeRequest}
onFinished={this.props.onFinished}
/>
</div>
{content}
</BaseDialog>
);
},

View File

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

View File

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

View File

@ -27,8 +27,8 @@ import React from 'react';
export default function AccessibleButton(props) {
const {element, onClick, children, ...restProps} = props;
restProps.onClick = onClick;
restProps.onKeyDown = function(e) {
if (e.keyCode == 13 || e.keyCode == 32) return onClick();
restProps.onKeyUp = function(e) {
if (e.keyCode == 13 || e.keyCode == 32) return onClick(e);
};
restProps.tabIndex = restProps.tabIndex || "0";
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() {
if (this.state.selected > 0) {
this.setState({
@ -124,7 +133,14 @@ export default React.createClass({
// Saving the addressListElement so we can use it to work out, in the componentDidUpdate
// method, how far to scroll when using the arrow keys
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" />
</div>
);

View File

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

View File

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

View File

@ -116,6 +116,7 @@ export default class EditableTextContainer extends React.Component {
<EditableText initialValue={this.state.value}
placeholder={this.props.placeholder}
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
* value. */
onSubmit: React.PropTypes.func,
/* should the input submit when focus is lost? */
blurToSubmit: React.PropTypes.bool,
};
EditableTextContainer.defaultProps = {
initialValue: "",
placeholder: "",
blurToSubmit: false,
onSubmit: function(v) {return q(); },
};

View File

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

View File

@ -16,17 +16,12 @@ limitations under the License.
'use strict';
var React = require('react');
var roles = {
0: 'User',
50: 'Moderator',
100: 'Admin',
};
import React from 'react';
import * as Roles from '../../../Roles';
var reverseRoles = {};
Object.keys(roles).forEach(function(key) {
reverseRoles[roles[key]] = key;
Object.keys(Roles.LEVEL_ROLE_MAP).forEach(function(key) {
reverseRoles[Roles.LEVEL_ROLE_MAP[key]] = key;
});
module.exports = React.createClass({
@ -49,7 +44,7 @@ module.exports = React.createClass({
getInitialState: function() {
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";
}
else {
selectValue = roles[this.props.value] || "Custom";
selectValue = Roles.LEVEL_ROLE_MAP[this.props.value] || "Custom";
}
var select;
if (this.props.disabled) {
select = <span>{ selectValue }</span>;
}
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 ref="select"
value={ this.props.controlled ? selectValue : undefined }
defaultValue={ !this.props.controlled ? selectValue : undefined }
onChange={ this.onSelectChange }>
<option value="User">User (0)</option>
<option value="Moderator">Moderator (50)</option>
<option value="Admin">Admin (100)</option>
<option value="Custom">Custom level</option>
{ options }
</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 url from 'url';
import classnames from 'classnames';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
/* This file contains a collection of components which are used by the
* 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
* 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
* 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
* errorText: error message from a previous attempt to authenticate
* submitAuthDict: a function which will be called with the new auth dict
* busy: a boolean indicating whether the auth logic is doing something
* 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):
* focus: set the input focus appropriately in the form.
@ -48,6 +68,7 @@ export const PasswordAuthEntry = React.createClass({
},
propTypes: {
matrixClient: React.PropTypes.object.isRequired,
submitAuthDict: React.PropTypes.func.isRequired,
errorText: React.PropTypes.string,
// is the auth logic currently waiting for something to
@ -73,7 +94,7 @@ export const PasswordAuthEntry = React.createClass({
this.props.submitAuthDict({
type: PasswordAuthEntry.LOGIN_TYPE,
user: MatrixClientPeg.get().credentials.userId,
user: this.props.matrixClient.credentials.userId,
password: this.refs.passwordField.value,
});
},
@ -139,6 +160,7 @@ export const RecaptchaAuthEntry = React.createClass({
submitAuthDict: React.PropTypes.func.isRequired,
stageParams: React.PropTypes.object.isRequired,
errorText: React.PropTypes.string,
busy: React.PropTypes.bool,
},
_onCaptchaResponse: function(response) {
@ -149,6 +171,11 @@ export const RecaptchaAuthEntry = React.createClass({
},
render: function() {
if (this.props.busy) {
const Loader = sdk.getComponent("elements.Spinner");
return <Loader />;
}
const CaptchaForm = sdk.getComponent("views.login.CaptchaForm");
var sitePublicKey = this.props.stageParams.public_key;
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({
displayName: 'FallbackAuthEntry',
propTypes: {
matrixClient: React.PropTypes.object.isRequired,
authSessionId: React.PropTypes.string.isRequired,
loginType: React.PropTypes.string.isRequired,
submitAuthDict: React.PropTypes.func.isRequired,
@ -189,7 +420,7 @@ export const FallbackAuthEntry = React.createClass({
},
_onShowFallbackClick: function() {
var url = MatrixClientPeg.get().getFallbackAuthUrl(
var url = this.props.matrixClient.getFallbackAuthUrl(
this.props.loginType,
this.props.authSessionId
);
@ -199,7 +430,7 @@ export const FallbackAuthEntry = React.createClass({
_onReceiveMessage: function(event) {
if (
event.data === "authDone" &&
event.origin === MatrixClientPeg.get().getHomeserverUrl()
event.origin === this.props.matrixClient.getHomeserverUrl()
) {
this.props.submitAuthDict({});
}
@ -220,6 +451,8 @@ export const FallbackAuthEntry = React.createClass({
const AuthEntryComponents = [
PasswordAuthEntry,
RecaptchaAuthEntry,
EmailIdentityAuthEntry,
MsisdnAuthEntry,
];
export function getEntryComponentForLoginType(loginType) {

View File

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,6 +18,7 @@ limitations under the License.
import React from 'react';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
import sdk from '../../../index';
import {field_input_incorrect} from '../../../UiEffects';
@ -28,8 +30,12 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
onSubmit: React.PropTypes.func.isRequired, // fn(username, password)
onForgotPasswordClick: React.PropTypes.func, // fn()
initialUsername: React.PropTypes.string,
initialPhoneCountry: React.PropTypes.string,
initialPhoneNumber: React.PropTypes.string,
initialPassword: React.PropTypes.string,
onUsernameChanged: React.PropTypes.func,
onPhoneCountryChanged: React.PropTypes.func,
onPhoneNumberChanged: React.PropTypes.func,
onPasswordChanged: React.PropTypes.func,
loginIncorrect: React.PropTypes.bool,
},
@ -38,7 +44,11 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
return {
onUsernameChanged: function() {},
onPasswordChanged: function() {},
onPhoneCountryChanged: function() {},
onPhoneNumberChanged: function() {},
initialUsername: "",
initialPhoneCountry: "",
initialPhoneNumber: "",
initialPassword: "",
loginIncorrect: false,
};
@ -48,6 +58,8 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
return {
username: this.props.initialUsername,
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) {
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) {
@ -71,6 +88,16 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
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) {
this.setState({password: ev.target.value});
this.props.onPasswordChanged(ev.target.value);
@ -92,13 +119,28 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
error: this.props.loginIncorrect,
});
const CountryDropdown = sdk.getComponent('views.login.CountryDropdown');
return (
<div>
<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
value={this.state.username} onChange={this.onUsernameChanged}
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 />
<input className={pwFieldClass} ref={(e) => {this._passwordField = e;}} type="password"
name="password"

View File

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,18 +15,19 @@ See the License for the specific language governing permissions and
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');
var UiEffects = require('../../../UiEffects');
var sdk = require('../../../index');
var Email = require('../../../email');
var Modal = require("../../../Modal");
var FIELD_EMAIL = 'field_email';
var FIELD_USERNAME = 'field_username';
var FIELD_PASSWORD = 'field_password';
var FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
const FIELD_EMAIL = 'field_email';
const FIELD_PHONE_COUNTRY = 'field_phone_country';
const FIELD_PHONE_NUMBER = 'field_phone_number';
const FIELD_USERNAME = 'field_username';
const FIELD_PASSWORD = 'field_password';
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
/**
* A pure UI component which displays a registration form.
@ -36,6 +38,8 @@ module.exports = React.createClass({
propTypes: {
// Values pre-filled in the input boxes when the component loads
defaultEmail: React.PropTypes.string,
defaultPhoneCountry: React.PropTypes.string,
defaultPhoneNumber: React.PropTypes.string,
defaultUsername: React.PropTypes.string,
defaultPassword: React.PropTypes.string,
teamsConfig: React.PropTypes.shape({
@ -54,15 +58,13 @@ module.exports = React.createClass({
// a different username will cause a fresh account to be generated.
guestUsername: React.PropTypes.string,
showEmail: React.PropTypes.bool,
minPasswordLength: React.PropTypes.number,
onError: React.PropTypes.func,
onRegisterClick: React.PropTypes.func // onRegisterClick(Object) => ?Promise
onRegisterClick: React.PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
},
getDefaultProps: function() {
return {
showEmail: false,
minPasswordLength: 6,
onError: function(e) {
console.error(e);
@ -74,6 +76,8 @@ module.exports = React.createClass({
return {
fieldValid: {},
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);
this.validateField(FIELD_USERNAME);
this.validateField(FIELD_PHONE_NUMBER);
this.validateField(FIELD_EMAIL);
var self = this;
@ -121,6 +126,8 @@ module.exports = React.createClass({
username: this.refs.username.value.trim() || this.props.guestUsername,
password: this.refs.password.value.trim(),
email: email,
phoneCountry: this.state.phoneCountry,
phoneNumber: this.refs.phoneNumber.value.trim(),
});
if (promise) {
@ -174,8 +181,13 @@ module.exports = React.createClass({
showSupportEmail: false,
});
}
const valid = email === '' || Email.looksValid(email);
this.markFieldValid(field_id, valid, "RegistrationForm.ERR_EMAIL_INVALID");
const emailValid = email === '' || Email.looksValid(email);
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;
case FIELD_USERNAME:
// XXX: SPEC-1
@ -227,7 +239,7 @@ module.exports = React.createClass({
fieldValid[field_id] = val;
this.setState({fieldValid: fieldValid});
if (!val) {
UiEffects.field_input_incorrect(this.fieldElementById(field_id));
field_input_incorrect(this.fieldElementById(field_id));
this.props.onError(error_code);
}
},
@ -236,6 +248,8 @@ module.exports = React.createClass({
switch (field_id) {
case FIELD_EMAIL:
return this.refs.email;
case FIELD_PHONE_NUMBER:
return this.refs.phoneNumber;
case FIELD_USERNAME:
return this.refs.username;
case FIELD_PASSWORD:
@ -245,8 +259,8 @@ module.exports = React.createClass({
}
},
_classForField: function(field_id, baseClass) {
let cls = baseClass || '';
_classForField: function(field_id, ...baseClasses) {
let cls = baseClasses.join(' ');
if (this.state.fieldValid[field_id] === false) {
if (cls) cls += ' ';
cls += 'error';
@ -254,46 +268,71 @@ module.exports = React.createClass({
return cls;
},
_onPhoneCountryChange(newVal) {
this.setState({
phoneCountry: newVal,
});
},
render: function() {
var self = this;
var emailSection, belowEmailSection, registerButton;
if (this.props.showEmail) {
emailSection = (
const emailSection = (
<div>
<input type="text" ref="email"
autoFocus={true} placeholder="Email address (optional)"
defaultValue={this.props.defaultEmail}
className={this._classForField(FIELD_EMAIL, 'mx_Login_field')}
onBlur={function() {self.validateField(FIELD_EMAIL);}}
value={self.state.email}/>
);
if (this.props.teamsConfig) {
if (this.props.teamsConfig.supportEmail && this.state.showSupportEmail) {
belowEmailSection = (
<p className="mx_Login_support">
Sorry, but your university is not registered with us just yet.&nbsp;
Email us on&nbsp;
<a href={"mailto:" + this.props.teamsConfig.supportEmail}>
{this.props.teamsConfig.supportEmail}
</a>&nbsp;
to get your university signed up. Or continue to register with Riot to enjoy our open source platform.
</p>
);
} else if (this.state.selectedTeam) {
belowEmailSection = (
<p className="mx_Login_support">
You are registering with {this.state.selectedTeam.name}
</p>
);
}
</div>
);
let belowEmailSection;
if (this.props.teamsConfig) {
if (this.props.teamsConfig.supportEmail && this.state.showSupportEmail) {
belowEmailSection = (
<p className="mx_Login_support">
Sorry, but your university is not registered with us just yet.&nbsp;
Email us on&nbsp;
<a href={"mailto:" + this.props.teamsConfig.supportEmail}>
{this.props.teamsConfig.supportEmail}
</a>&nbsp;
to get your university signed up. Or continue to register with Riot to enjoy our open source platform.
</p>
);
} else if (this.state.selectedTeam) {
belowEmailSection = (
<p className="mx_Login_support">
You are registering with {this.state.selectedTeam.name}
</p>
);
}
}
if (this.props.onRegisterClick) {
registerButton = (
<input className="mx_Login_submit" type="submit" value="Register" />
);
}
var placeholderUserName = "User name";
const CountryDropdown = sdk.getComponent('views.login.CountryDropdown');
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" />
);
let placeholderUserName = "User name";
if (this.props.guestUsername) {
placeholderUserName += " (default: " + this.props.guestUsername + ")";
}
@ -303,6 +342,7 @@ module.exports = React.createClass({
<form onSubmit={this.onSubmit}>
{emailSection}
{belowEmailSection}
{phoneSection}
<input type="text" ref="username"
placeholder={ placeholderUserName } defaultValue={this.props.defaultUsername}
className={this._classForField(FIELD_USERNAME, 'mx_Login_field')}

View File

@ -25,6 +25,7 @@ import sdk from '../../../index';
import dis from '../../../dispatcher';
import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
import q from 'q';
import UserSettingsStore from '../../../UserSettingsStore';
module.exports = React.createClass({
displayName: 'MImageBody',
@ -42,7 +43,7 @@ module.exports = React.createClass({
decryptedUrl: null,
decryptedThumbnailUrl: null,
decryptedBlob: null,
error: null
error: null,
};
},
@ -55,7 +56,7 @@ module.exports = React.createClass({
const ImageView = sdk.getComponent("elements.ImageView");
const params = {
src: httpUrl,
mxEvent: this.props.mxEvent
mxEvent: this.props.mxEvent,
};
if (content.info) {
@ -70,22 +71,26 @@ module.exports = React.createClass({
_isGif: function() {
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) {
if (!this._isGif()) {
if (!this._isGif() || UserSettingsStore.getSyncedSetting("autoplayGifsAndVideos", false)) {
return;
}
var imgElement = e.target;
const imgElement = e.target;
imgElement.src = this._getContentUrl();
},
onImageLeave: function(e) {
if (!this._isGif()) {
if (!this._isGif() || UserSettingsStore.getSyncedSetting("autoplayGifsAndVideos", false)) {
return;
}
var imgElement = e.target;
const imgElement = e.target;
imgElement.src = this._getThumbUrl();
},
@ -101,6 +106,7 @@ module.exports = React.createClass({
_getThumbUrl: function() {
const content = this.props.mxEvent.getContent();
if (content.file !== undefined) {
// Don't use the thumbnail for clients wishing to autoplay gifs.
if (this.state.decryptedThumbnailUrl) {
return this.state.decryptedThumbnailUrl;
}
@ -115,15 +121,15 @@ module.exports = React.createClass({
this.fixupHeight();
const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
var thumbnailPromise = q(null);
let thumbnailPromise = q(null);
if (content.info.thumbnail_file) {
thumbnailPromise = decryptFile(
content.info.thumbnail_file
content.info.thumbnail_file,
).then(function(blob) {
return readBlobAsDataUri(blob);
});
}
var decryptedBlob;
let decryptedBlob;
thumbnailPromise.then((thumbnailUrl) => {
return decryptFile(content.file).then(function(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
//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) {
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) {
// Need to decrypt the attachment
// The attachment is decrypted in componentDidMount.
// For now add an img tag with a spinner.
@ -210,7 +215,12 @@ module.exports = React.createClass({
}
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) {
return (

View File

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

View File

@ -16,17 +16,18 @@ limitations under the License.
'use strict';
var React = require('react');
var ReactDOM = require('react-dom');
var highlight = require('highlight.js');
var HtmlUtils = require('../../../HtmlUtils');
var linkify = require('linkifyjs');
var linkifyElement = require('linkifyjs/element');
var linkifyMatrix = require('../../../linkify-matrix');
var sdk = require('../../../index');
var ScalarAuthClient = require("../../../ScalarAuthClient");
var Modal = require("../../../Modal");
var SdkConfig = require('../../../SdkConfig');
import React from 'react';
import ReactDOM from 'react-dom';
import highlight from 'highlight.js';
import * as HtmlUtils from '../../../HtmlUtils';
import * as linkify from 'linkifyjs';
import linkifyElement from 'linkifyjs/element';
import linkifyMatrix from '../../../linkify-matrix';
import sdk from '../../../index';
import ScalarAuthClient from '../../../ScalarAuthClient';
import Modal from '../../../Modal';
import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher';
linkifyMatrix(linkify);
@ -131,7 +132,8 @@ module.exports = React.createClass({
links.push(node);
}
}
else if (node.tagName === "PRE" || node.tagName === "CODE") {
else if (node.tagName === "PRE" || node.tagName === "CODE" ||
node.tagName === "BLOCKQUOTE") {
continue;
}
else if (node.children && node.children.length) {
@ -187,6 +189,15 @@ module.exports = React.createClass({
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() {
var self = this;
return {
@ -273,7 +284,15 @@ module.exports = React.createClass({
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
return (
<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 }
</span>
);

View File

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

View File

@ -25,18 +25,10 @@ var TextForEvent = require('../../../TextForEvent');
import WithMatrixClient from '../../../wrappers/WithMatrixClient';
var ContextualMenu = require('../../structures/ContextualMenu');
var dispatcher = require("../../../dispatcher");
import dis from '../../../dispatcher';
var ObjectUtils = require('../../../ObjectUtils');
var bounce = false;
try {
if (global.localStorage) {
bounce = global.localStorage.getItem('avatar_bounce') == 'true';
}
} catch (e) {
}
var eventTileTypes = {
'm.room.message': 'messages.MessageEvent',
'm.room.member' : 'messages.TextualEvent',
@ -48,6 +40,7 @@ var eventTileTypes = {
'm.room.third_party_invite' : 'messages.TextualEvent',
'm.room.history_visibility' : 'messages.TextualEvent',
'm.room.encryption' : 'messages.TextualEvent',
'm.room.power_levels' : 'messages.TextualEvent',
};
var MAX_READ_AVATARS = 5;
@ -73,6 +66,12 @@ module.exports = WithMatrixClient(React.createClass({
/* the MatrixEvent to show */
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
* effect of not showing another avatar/displayname
*/
@ -285,9 +284,10 @@ module.exports = WithMatrixClient(React.createClass({
},
getReadAvatars: function() {
var ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker');
var avatars = [];
var left = 0;
const ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker');
const avatars = [];
const receiptOffset = 15;
let left = 0;
// 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,
@ -307,6 +307,12 @@ module.exports = WithMatrixClient(React.createClass({
if ((i < MAX_READ_AVATARS) || this.state.allReadAvatars) {
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 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)
avatars.unshift(
<ReadReceiptMarker key={userId} member={receipt.roomMember}
@ -332,12 +337,6 @@ module.exports = WithMatrixClient(React.createClass({
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;
if (!this.state.allReadAvatars) {
@ -345,9 +344,8 @@ module.exports = WithMatrixClient(React.createClass({
if (remainder > 0) {
remText = <span className="mx_EventTile_readAvatarRemainder"
onClick={this.toggleAllReadAvatars}
style={{ left: left }}>{ remainder }+
style={{ right: -(left - receiptOffset) }}>{ remainder }+
</span>;
left -= 15;
}
}
@ -359,7 +357,7 @@ module.exports = WithMatrixClient(React.createClass({
onSenderProfileClick: function(event) {
var mxEvent = this.props.mxEvent;
dispatcher.dispatch({
dis.dispatch({
action: 'insert_displayname',
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() {
var MessageTimestamp = sdk.getComponent('messages.MessageTimestamp');
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 isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1);
const isRedacted = (eventType === 'm.room.message') && this.props.isRedacted;
var classes = classNames({
mx_EventTile: true,
@ -415,8 +425,12 @@ module.exports = WithMatrixClient(React.createClass({
mx_EventTile_verified: this.state.verified == true,
mx_EventTile_unverified: this.state.verified == false,
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();
@ -489,6 +503,8 @@ module.exports = WithMatrixClient(React.createClass({
else if (e2eEnabled) {
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") {
var room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId());
@ -496,15 +512,15 @@ module.exports = WithMatrixClient(React.createClass({
return (
<div className={classes}>
<div className="mx_EventTile_roomName">
<a href={ permalink }>
<a href={ permalink } onClick={this.onPermalinkClicked}>
{ room ? room.name : '' }
</a>
</div>
<div className="mx_EventTile_senderDetails">
{ avatar }
<a href={ permalink }>
<a href={ permalink } onClick={this.onPermalinkClicked}>
{ sender }
<MessageTimestamp ts={this.props.mxEvent.getTs()} />
{ timestamp }
</a>
</div>
<div className="mx_EventTile_line" >
@ -530,10 +546,14 @@ module.exports = WithMatrixClient(React.createClass({
tileShape={this.props.tileShape}
onWidgetLoad={this.props.onWidgetLoad} />
</div>
<a className="mx_EventTile_senderDetailsLink" href={ permalink }>
<a
className="mx_EventTile_senderDetailsLink"
href={ permalink }
onClick={this.onPermalinkClicked}
>
<div className="mx_EventTile_senderDetails">
{ sender }
<MessageTimestamp ts={this.props.mxEvent.getTs()} />
{ timestamp }
</div>
</a>
</div>
@ -548,8 +568,8 @@ module.exports = WithMatrixClient(React.createClass({
{ avatar }
{ sender }
<div className="mx_EventTile_line">
<a href={ permalink }>
<MessageTimestamp ts={this.props.mxEvent.getTs()} />
<a href={ permalink } onClick={this.onPermalinkClicked}>
{ timestamp }
</a>
{ e2e }
<EventTileType ref="tile"
@ -567,7 +587,8 @@ module.exports = WithMatrixClient(React.createClass({
}));
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()] == 'messages.TextualEvent') {
return TextForEvent.textForEvent(e) !== '';

View File

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

View File

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

View File

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

View File

@ -34,16 +34,11 @@ export function onSendMessageFailed(err, room) {
// https://github.com/vector-im/riot-web/issues/3148
console.log('MessageComposer got send failure: ' + err.name + '('+err+')');
if (err.name === "UnknownDeviceError") {
const UnknownDeviceDialog = sdk.getComponent("dialogs.UnknownDeviceDialog");
Modal.createDialog(UnknownDeviceDialog, {
devices: err.devices,
dis.dispatch({
action: 'unknown_device_error',
err: err,
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({
action: 'message_send_failed',
@ -70,6 +65,9 @@ export default React.createClass({
// js-sdk Room object
room: React.PropTypes.object.isRequired,
// The text to use a placeholder in the input box
placeholder: React.PropTypes.string.isRequired,
},
componentWillMount: function() {
@ -313,7 +311,7 @@ export default React.createClass({
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
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() {
return (
<div className="mx_MessageComposer_input" onClick={ this.onInputClick }>
<textarea autoFocus ref="textarea" rows="1" onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} placeholder="Type a message..." />
<textarea autoFocus ref="textarea" rows="1" onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} placeholder={this.props.placeholder} />
</div>
);
}

View File

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

View File

@ -96,6 +96,12 @@ module.exports = React.createClass({
});
}
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'] }
label="People"
editable={ false }
tagName="im.vector.fake.direct"
verb="tag direct chat"
editable={ true }
order="recent"
selectedRoom={ self.props.selectedRoom }
incomingCall={ self.state.incomingCall }
collapsed={ self.props.collapsed }
alwaysShowHeader={ true }
searchFilter={ self.props.searchFilter }
onHeaderClick={ self.onSubListHeaderClick }
onShowMoreRooms={ self.onShowMoreRooms } />

View File

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

View File

@ -19,7 +19,6 @@ limitations under the License.
var React = require('react');
var ReactDOM = require("react-dom");
var classNames = require('classnames');
var dis = require("../../../dispatcher");
var MatrixClientPeg = require('../../../MatrixClientPeg');
import DMRoomMap from '../../../utils/DMRoomMap';
var sdk = require('../../../index');
@ -35,6 +34,7 @@ module.exports = React.createClass({
propTypes: {
connectDragSource: React.PropTypes.func,
connectDropTarget: React.PropTypes.func,
onClick: React.PropTypes.func,
isDragging: React.PropTypes.bool,
room: React.PropTypes.object.isRequired,
@ -56,8 +56,7 @@ module.exports = React.createClass({
return({
hover : false,
badgeHover : false,
notificationTagMenu: false,
roomTagMenu: false,
menuDisplayed: false,
notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
});
},
@ -100,10 +99,9 @@ module.exports = React.createClass({
},
onClick: function() {
dis.dispatch({
action: 'view_room',
room_id: this.props.room.roomId,
});
if (this.props.onClick) {
this.props.onClick(this.props.room.roomId);
}
},
onMouseEnter: function() {
@ -137,62 +135,32 @@ module.exports = React.createClass({
this.setState({ hover: false });
}
var NotificationStateMenu = sdk.getComponent('context_menus.NotificationStateContextMenu');
var RoomTileContextMenu = sdk.getComponent('context_menus.RoomTileContextMenu');
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) - 53;
const x = elementRect.right + window.pageXOffset + 3;
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;
ContextualMenu.createMenu(NotificationStateMenu, {
menuWidth: 188,
menuHeight: 126,
chevronOffset: 45,
ContextualMenu.createMenu(RoomTileContextMenu, {
chevronOffset: chevronOffset,
left: x,
top: y,
room: this.props.room,
onFinished: function() {
self.setState({ notificationTagMenu: false });
self.setState({ menuDisplayed: false });
self.props.refreshSubList();
}
});
this.setState({ notificationTagMenu: true });
this.setState({ menuDisplayed: true });
}
// Prevent the RoomTile onClick event firing as well
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() {
var myUserId = MatrixClientPeg.get().credentials.userId;
var me = this.props.room.currentState.members[myUserId];
@ -211,7 +179,7 @@ module.exports = React.createClass({
'mx_RoomTile_unreadNotify': notifBadges,
'mx_RoomTile_highlight': mentionBadges,
'mx_RoomTile_invited': (me && me.membership == 'invite'),
'mx_RoomTile_notificationTagMenu': this.state.notificationTagMenu,
'mx_RoomTile_menuDisplayed': this.state.menuDisplayed,
'mx_RoomTile_noBadges': !badges,
});
@ -219,14 +187,9 @@ module.exports = React.createClass({
'mx_RoomTile_avatar': true,
});
var avatarContainerClasses = classNames({
'mx_RoomTile_avatar_container': true,
'mx_RoomTile_avatar_roomTagMenu': this.state.roomTagMenu,
});
var badgeClasses = classNames({
'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
@ -237,7 +200,7 @@ module.exports = React.createClass({
var badge;
var badgeContent;
if (this.state.badgeHover || this.state.notificationTagMenu) {
if (this.state.badgeHover || this.state.menuDisplayed) {
badgeContent = "\u00B7\u00B7\u00B7";
} else if (badges) {
var limitedCount = FormattingUtils.formatCount(notificationCount);
@ -255,7 +218,7 @@ module.exports = React.createClass({
var nameClasses = classNames({
'mx_RoomTile_name': true,
'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) {
@ -294,11 +257,9 @@ module.exports = React.createClass({
<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}>
<div className={avatarClasses}>
<div className="mx_RoomTile_avatar_menu" onClick={this.onAvatarClicked}>
<div className={avatarContainerClasses}>
<RoomAvatar room={this.props.room} width={24} height={24} />
{directMessageIndicator}
</div>
<div className="mx_RoomTile_avatar_container">
<RoomAvatar room={this.props.room} width={24} height={24} />
{directMessageIndicator}
</div>
</div>
<div className="mx_RoomTile_nameContainer">

View File

@ -32,10 +32,7 @@ module.exports = React.createClass({
<div className="mx_TopUnreadMessagesBar">
<div className="mx_TopUnreadMessagesBar_scrollUp"
onClick={this.props.onScrollUpClick}>
<img src="img/scrollup.svg" width="24" height="24"
alt="Scroll to unread messages"
title="Scroll to unread messages"/>
Unread messages. <span style={{ textDecoration: 'underline' }} onClick={this.props.onCloseClick}>Mark all read</span>
Jump to first unread message. <span style={{ textDecoration: 'underline' }} onClick={this.props.onCloseClick}>Mark all read</span>
</div>
<img className="mx_TopUnreadMessagesBar_close"
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
getInitialValue={this._getDisplayName}
placeholder="No display name"
blurToSubmit={true}
onSubmit={this._changeDisplayName} />
);
}

View File

@ -73,11 +73,17 @@ module.exports = React.createClass({
description:
<div>
Changing password will currently reset any end-to-end encryption keys on all devices,
making encrypted chat history unreadable.
This will be <a href="https://github.com/vector-im/riot-web/issues/2671">improved shortly</a>,
but for now be warned.
making encrypted chat history unreadable, unless you first export your room keys
and re-import them afterwards.
In future this <a href="https://github.com/vector-im/riot-web/issues/2671">will be improved</a>.
</div>,
button: "Continue",
extraButtons: [
<button className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
Export E2E room keys
</button>
],
onFinished: (confirmed) => {
if (confirmed) {
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() {
var old_password = this.refs.old_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 MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import DateUtils from '../../../DateUtils';
const AUTH_CACHE_AGE = 5 * 60 * 1000; // 5 minutes
export default class DevicesPanelEntry extends React.Component {
constructor(props, context) {
@ -30,7 +33,6 @@ export default class DevicesPanelEntry extends React.Component {
};
this._unmounted = false;
this._onDeleteClick = this._onDeleteClick.bind(this);
this._onDisplayNameChanged = this._onDisplayNameChanged.bind(this);
this._makeDeleteRequest = this._makeDeleteRequest.bind(this);
@ -53,8 +55,12 @@ export default class DevicesPanelEntry extends React.Component {
_onDeleteClick() {
this.setState({deleting: true});
// try without interactive auth to start off
this._makeDeleteRequest(null).catch((error) => {
if (this.context.authCache.lastUpdate < Date.now() - AUTH_CACHE_AGE) {
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 (error.httpStatus !== 401 || !error.data || !error.data.flows) {
// 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");
Modal.createDialog(InteractiveAuthDialog, {
matrixClient: MatrixClientPeg.get(),
authData: error.data,
makeRequest: this._makeDeleteRequest,
});
@ -83,6 +90,9 @@ export default class DevicesPanelEntry extends React.Component {
}
_makeDeleteRequest(auth) {
this.context.authCache.auth = auth;
this.context.authCache.lastUpdate = Date.now();
const device = this.props.device;
return MatrixClientPeg.get().deleteDevice(device.device_id, auth).then(
() => {
@ -110,8 +120,7 @@ export default class DevicesPanelEntry extends React.Component {
let lastSeen = "";
if (device.last_seen_ts) {
// todo: format the timestamp as "5 minutes ago" or whatever.
const lastSeenDate = new Date(device.last_seen_ts);
const lastSeenDate = DateUtils.formatDate(new Date(device.last_seen_ts));
lastSeen = device.last_seen_ip + " @ " +
lastSeenDate.toLocaleString();
}
@ -160,6 +169,10 @@ DevicesPanelEntry.propTypes = {
onDeleted: React.PropTypes.func,
};
DevicesPanelEntry.contextTypes = {
authCache: React.PropTypes.object,
};
DevicesPanelEntry.defaultProps = {
onDeleted: function() {},
};

View File

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

View File

@ -122,7 +122,7 @@ var escapeRegExp = function(string) {
// anyone else really should be using matrix.to.
matrixLinkify.VECTOR_URL_PATTERN = "^(?:https?:\/\/)?(?:"
+ escapeRegExp(window.location.host + window.location.pathname) + "|"
+ "(?:www\\.)?vector\\.im/(?:beta|staging|develop)/"
+ "(?:www\\.)?(riot|vector)\\.im/(?:beta|staging|develop)/"
+ ")(#.*)";
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(
<InteractiveAuthDialog
matrixClient={client}
authData={{
session: "sess",
flows: [
@ -67,48 +68,49 @@ describe('InteractiveAuthDialog', function () {
onFinished={onFinished}
/>, parentDiv);
// at this point there should be a password box and a submit button
const formNode = ReactTestUtils.findRenderedDOMComponentWithTag(dlg, "form");
const inputNodes = ReactTestUtils.scryRenderedDOMComponentsWithTag(
dlg, "input"
);
let passwordNode;
let submitNode;
for (const node of inputNodes) {
if (node.type == 'password') {
passwordNode = node;
} else if (node.type == 'submit') {
submitNode = node;
// wait for a password box and a submit button
test_utils.waitForRenderedDOMComponentWithTag(dlg, "form").then((formNode) => {
const inputNodes = ReactTestUtils.scryRenderedDOMComponentsWithTag(
dlg, "input"
);
let passwordNode;
let submitNode;
for (const node of inputNodes) {
if (node.type == 'password') {
passwordNode = node;
} else if (node.type == 'submit') {
submitNode = node;
}
}
}
expect(passwordNode).toExist();
expect(submitNode).toExist();
expect(passwordNode).toExist();
expect(submitNode).toExist();
// submit should be disabled
expect(submitNode.disabled).toBe(true);
// submit should be disabled
expect(submitNode.disabled).toBe(true);
// put something in the password box, and hit enter; that should
// trigger a request
passwordNode.value = "s3kr3t";
ReactTestUtils.Simulate.change(passwordNode);
expect(submitNode.disabled).toBe(false);
ReactTestUtils.Simulate.submit(formNode, {});
// put something in the password box, and hit enter; that should
// trigger a request
passwordNode.value = "s3kr3t";
ReactTestUtils.Simulate.change(passwordNode);
expect(submitNode.disabled).toBe(false);
ReactTestUtils.Simulate.submit(formNode, {});
expect(doRequest.callCount).toEqual(1);
expect(doRequest.calledWithExactly({
session: "sess",
type: "m.login.password",
password: "s3kr3t",
user: "@user:id",
})).toBe(true);
expect(doRequest.callCount).toEqual(1);
expect(doRequest.calledWithExactly({
session: "sess",
type: "m.login.password",
password: "s3kr3t",
user: "@user:id",
})).toBe(true);
// there should now be a spinner
ReactTestUtils.findRenderedComponentWithType(
dlg, sdk.getComponent('elements.Spinner'),
);
// there should now be a spinner
ReactTestUtils.findRenderedComponentWithType(
dlg, sdk.getComponent('elements.Spinner'),
);
// let the request complete
q.delay(1).then(() => {
// let the request complete
return q.delay(1);
}).then(() => {
expect(onFinished.callCount).toEqual(1);
expect(onFinished.calledWithExactly(true, {a:1})).toBe(true);
}).done(done, done);

View File

@ -1,11 +1,51 @@
"use strict";
var sinon = require('sinon');
var q = require('q');
import sinon from 'sinon';
import q from 'q';
import ReactTestUtils from 'react-addons-test-utils';
var peg = require('../src/MatrixClientPeg.js');
var jssdk = require('matrix-js-sdk');
var MatrixEvent = jssdk.MatrixEvent;
import peg from '../src/MatrixClientPeg.js';
import jssdk from 'matrix-js-sdk';
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
@ -92,6 +132,7 @@ export function createTestClient() {
sendTextMessage: () => q({}),
sendHtmlMessage: () => q({}),
getSyncState: () => "SYNCING",
generateClientSecret: () => "t35tcl1Ent5ECr3T",
};
}