Merge branch 'develop' into rte-fixes

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

View file

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

View file

@ -135,17 +135,24 @@ module.exports = function (config) {
}, },
], ],
noParse: [ noParse: [
// for cross platform compatibility use [\\\/] as the path separator
// this ensures that the regex trips on both Windows and *nix
// don't parse the languages within highlight.js. They // don't parse the languages within highlight.js. They
// cause stack overflows // cause stack overflows
// (https://github.com/webpack/webpack/issues/1721), and // (https://github.com/webpack/webpack/issues/1721), and
// there is no need for webpack to parse them - they can // there is no need for webpack to parse them - they can
// just be included as-is. // just be included as-is.
/highlight\.js\/lib\/languages/, /highlight\.js[\\\/]lib[\\\/]languages/,
// olm takes ages for webpack to process, and it's already heavily
// optimised, so there is little to gain by us uglifying it.
/olm[\\\/](javascript[\\\/])?olm\.js$/,
// also disable parsing for sinon, because it // also disable parsing for sinon, because it
// tries to do voodoo with 'require' which upsets // tries to do voodoo with 'require' which upsets
// webpack (https://github.com/webpack/webpack/issues/304) // webpack (https://github.com/webpack/webpack/issues/304)
/sinon\/pkg\/sinon\.js$/, /sinon[\\\/]pkg[\\\/]sinon\.js$/,
], ],
}, },
resolve: { resolve: {

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "0.8.6", "version": "0.8.8",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -32,8 +32,8 @@
}, },
"scripts": { "scripts": {
"reskindex": "scripts/reskindex.js -h header", "reskindex": "scripts/reskindex.js -h header",
"build": "node scripts/babelcheck.js && babel src -d lib --source-maps", "build": "babel src -d lib --source-maps",
"start": "node scripts/babelcheck.js && babel src -w -d lib --source-maps", "start": "babel src -w -d lib --source-maps",
"lint": "eslint src/", "lint": "eslint src/",
"lintall": "eslint src/ test/", "lintall": "eslint src/ test/",
"clean": "rimraf lib", "clean": "rimraf lib",
@ -53,7 +53,7 @@
"draft-js-export-markdown": "^0.2.0", "draft-js-export-markdown": "^0.2.0",
"emojione": "2.2.3", "emojione": "2.2.3",
"file-saver": "^1.3.3", "file-saver": "^1.3.3",
"filesize": "^3.1.2", "filesize": "3.5.6",
"flux": "^2.0.3", "flux": "^2.0.3",
"glob": "^5.0.14", "glob": "^5.0.14",
"highlight.js": "^8.9.1", "highlight.js": "^8.9.1",
@ -63,11 +63,12 @@
"lodash": "^4.13.1", "lodash": "^4.13.1",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
"optimist": "^0.6.1", "optimist": "^0.6.1",
"prop-types": "^15.5.8",
"q": "^1.4.1", "q": "^1.4.1",
"react": "^15.4.0", "react": "^15.4.0",
"react-addons-css-transition-group": "15.3.2", "react-addons-css-transition-group": "15.3.2",
"react-dom": "^15.4.0", "react-dom": "^15.4.0",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#39d858c",
"sanitize-html": "^1.11.1", "sanitize-html": "^1.11.1",
"text-encoding-utf-8": "^1.0.1", "text-encoding-utf-8": "^1.0.1",
"velocity-vector": "vector-im/velocity#059e3b2", "velocity-vector": "vector-im/velocity#059e3b2",

View file

@ -1,22 +0,0 @@
#!/usr/bin/env node
var exec = require('child_process').exec;
// Makes sure the babel executable in the path is babel 6 (or greater), not
// babel 5, which it is if you upgrade from an older version of react-sdk and
// run 'npm install' since the package has changed to babel-cli, so 'babel'
// remains installed and the executable in node_modules/.bin remains as babel
// 5.
exec("babel -V", function (error, stdout, stderr) {
if ((error && error.code) || parseInt(stdout.substr(0,1), 10) < 6) {
console.log("\033[31m\033[1m"+
'*****************************************\n'+
'* matrix-react-sdk has moved to babel 6 *\n'+
'* Please "rm -rf node_modules && npm i" *\n'+
'* then restore links as appropriate *\n'+
'*****************************************\n'+
"\033[91m");
process.exit(1);
}
});

View file

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

View file

@ -22,8 +22,8 @@ module.exports = {
avatarUrlForMember: function(member, width, height, resizeMethod) { avatarUrlForMember: function(member, width, height, resizeMethod) {
var url = member.getAvatarUrl( var url = member.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(), MatrixClientPeg.get().getHomeserverUrl(),
width, Math.floor(width * window.devicePixelRatio),
height, Math.floor(height * window.devicePixelRatio),
resizeMethod, resizeMethod,
false, false,
false false
@ -40,7 +40,9 @@ module.exports = {
avatarUrlForUser: function(user, width, height, resizeMethod) { avatarUrlForUser: function(user, width, height, resizeMethod) {
var url = ContentRepo.getHttpUriForMxc( var url = ContentRepo.getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl, MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl,
width, height, resizeMethod Math.floor(width * window.devicePixelRatio),
Math.floor(height * window.devicePixelRatio),
resizeMethod
); );
if (!url || url.length === 0) { if (!url || url.length === 0) {
return null; return null;
@ -57,4 +59,3 @@ module.exports = {
return 'img/' + images[total % images.length] + '.png'; return 'img/' + images[total % images.length] + '.png';
} }
}; };

View file

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

View file

@ -310,9 +310,10 @@ function _onAction(payload) {
placeCall(call); placeCall(call);
}, function(err) { }, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Conference call failed: " + err);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Failed to set up conference call", title: "Failed to set up conference call",
description: "Conference call failed: " + err, description: "Conference call failed. " + ((err && err.message) ? err.message : ""),
}); });
}); });
} }

View file

@ -0,0 +1,62 @@
/*
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.
*/
// singleton which dispatches invocations of a given type & argument
// rather than just a type (as per EventEmitter and Flux's dispatcher etc)
//
// This means you can have a single point which listens for an EventEmitter event
// and then dispatches out to one of thousands of RoomTiles (for instance) rather than
// having each RoomTile register for the EventEmitter event and having to
// iterate over all of them.
class ConstantTimeDispatcher {
constructor() {
// type -> arg -> [ listener(arg, params) ]
this.listeners = {};
}
register(type, arg, listener) {
if (!this.listeners[type]) this.listeners[type] = {};
if (!this.listeners[type][arg]) this.listeners[type][arg] = [];
this.listeners[type][arg].push(listener);
}
unregister(type, arg, listener) {
if (this.listeners[type] && this.listeners[type][arg]) {
var i = this.listeners[type][arg].indexOf(listener);
if (i > -1) {
this.listeners[type][arg].splice(i, 1);
}
}
else {
console.warn("Unregistering unrecognised listener (type=" + type + ", arg=" + arg + ")");
}
}
dispatch(type, arg, params) {
if (!this.listeners[type] || !this.listeners[type][arg]) {
//console.warn("No registered listeners for dispatch (type=" + type + ", arg=" + arg + ")");
return;
}
this.listeners[type][arg].forEach(listener=>{
listener.call(arg, params);
});
}
}
if (!global.constantTimeDispatcher) {
global.constantTimeDispatcher = new ConstantTimeDispatcher();
}
module.exports = global.constantTimeDispatcher;

View file

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

View file

@ -25,6 +25,9 @@ import emojione from 'emojione';
import classNames from 'classnames'; import classNames from 'classnames';
emojione.imagePathSVG = 'emojione/svg/'; emojione.imagePathSVG = 'emojione/svg/';
// Store PNG path for displaying many flags at once (for increased performance over SVG)
emojione.imagePathPNG = 'emojione/png/';
// Use SVGs for emojis
emojione.imageType = 'svg'; emojione.imageType = 'svg';
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi"); const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi");
@ -58,6 +61,29 @@ export function unicodeToImage(str) {
return str; return str;
} }
/**
* Given one or more unicode characters (represented by unicode
* character number), return an image node with the corresponding
* emoji.
*
* @param alt {string} String to use for the image alt text
* @param useSvg {boolean} Whether to use SVG image src. If False, PNG will be used.
* @param unicode {integer} One or more integers representing unicode characters
* @returns A img node with the corresponding emoji
*/
export function charactersToImageNode(alt, useSvg, ...unicode) {
const fileName = unicode.map((u) => {
return u.toString(16);
}).join('-');
const path = useSvg ? emojione.imagePathSVG : emojione.imagePathPNG;
const fileType = useSvg ? 'svg' : 'png';
return <img
alt={alt}
src={`${path}${fileName}.${fileType}${emojione.cacheBustParam}`}
/>;
}
export function stripParagraphs(html: string): string { export function stripParagraphs(html: string): string {
const contentDiv = document.createElement('div'); const contentDiv = document.createElement('div');
contentDiv.innerHTML = html; contentDiv.innerHTML = html;
@ -85,8 +111,7 @@ var sanitizeHtmlParams = {
allowedTags: [ allowedTags: [
'font', // custom to matrix for IRC-style font coloring 'font', // custom to matrix for IRC-style font coloring
'del', // for markdown 'del', // for markdown
// deliberately no h1/h2 to stop people shouting. 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol',
'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol',
'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span',
], ],
@ -98,6 +123,7 @@ var sanitizeHtmlParams = {
// We don't currently allow img itself by default, but this // We don't currently allow img itself by default, but this
// would make sense if we did // would make sense if we did
img: ['src'], img: ['src'],
ol: ['start'],
}, },
// Lots of these won't come up by default because we don't allow them // Lots of these won't come up by default because we don't allow them
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],

View file

@ -32,4 +32,5 @@ module.exports = {
DELETE: 46, DELETE: 46,
KEY_D: 68, KEY_D: 68,
KEY_E: 69, KEY_E: 69,
KEY_K: 75,
}; };

View file

@ -49,7 +49,7 @@ import sdk from './index';
* If any of steps 1-4 are successful, it will call {setLoggedIn}, which in * If any of steps 1-4 are successful, it will call {setLoggedIn}, which in
* turn will raise on_logged_in and will_start_client events. * turn will raise on_logged_in and will_start_client events.
* *
* It returns a promise which resolves when the above process completes. * @param {object} opts
* *
* @param {object} opts.realQueryParams: string->string map of the * @param {object} opts.realQueryParams: string->string map of the
* query-parameters extracted from the real query-string of the starting * query-parameters extracted from the real query-string of the starting
@ -67,6 +67,7 @@ import sdk from './index';
* @params {string} opts.guestIsUrl: homeserver URL. Only used if enableGuest is * @params {string} opts.guestIsUrl: homeserver URL. Only used if enableGuest is
* true; defines the IS to use. * true; defines the IS to use.
* *
* @returns {Promise} a promise which resolves when the above process completes.
*/ */
export function loadSession(opts) { export function loadSession(opts) {
const realQueryParams = opts.realQueryParams || {}; const realQueryParams = opts.realQueryParams || {};
@ -127,7 +128,7 @@ export function loadSession(opts) {
function _loginWithToken(queryParams, defaultDeviceDisplayName) { function _loginWithToken(queryParams, defaultDeviceDisplayName) {
// create a temporary MatrixClient to do the login // create a temporary MatrixClient to do the login
var client = Matrix.createClient({ const client = Matrix.createClient({
baseUrl: queryParams.homeserver, baseUrl: queryParams.homeserver,
}); });
@ -159,7 +160,7 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
// Not really sure where the right home for it is. // Not really sure where the right home for it is.
// create a temporary MatrixClient to do the login // create a temporary MatrixClient to do the login
var client = Matrix.createClient({ const client = Matrix.createClient({
baseUrl: hsUrl, baseUrl: hsUrl,
}); });
@ -188,30 +189,30 @@ function _restoreFromLocalStorage() {
if (!localStorage) { if (!localStorage) {
return q(false); return q(false);
} }
const hs_url = localStorage.getItem("mx_hs_url"); const hsUrl = localStorage.getItem("mx_hs_url");
const is_url = localStorage.getItem("mx_is_url") || 'https://matrix.org'; const isUrl = localStorage.getItem("mx_is_url") || 'https://matrix.org';
const access_token = localStorage.getItem("mx_access_token"); const accessToken = localStorage.getItem("mx_access_token");
const user_id = localStorage.getItem("mx_user_id"); const userId = localStorage.getItem("mx_user_id");
const device_id = localStorage.getItem("mx_device_id"); const deviceId = localStorage.getItem("mx_device_id");
let is_guest; let isGuest;
if (localStorage.getItem("mx_is_guest") !== null) { if (localStorage.getItem("mx_is_guest") !== null) {
is_guest = localStorage.getItem("mx_is_guest") === "true"; isGuest = localStorage.getItem("mx_is_guest") === "true";
} else { } else {
// legacy key name // legacy key name
is_guest = localStorage.getItem("matrix-is-guest") === "true"; isGuest = localStorage.getItem("matrix-is-guest") === "true";
} }
if (access_token && user_id && hs_url) { if (accessToken && userId && hsUrl) {
console.log("Restoring session for %s", user_id); console.log("Restoring session for %s", userId);
try { try {
setLoggedIn({ setLoggedIn({
userId: user_id, userId: userId,
deviceId: device_id, deviceId: deviceId,
accessToken: access_token, accessToken: accessToken,
homeserverUrl: hs_url, homeserverUrl: hsUrl,
identityServerUrl: is_url, identityServerUrl: isUrl,
guest: is_guest, guest: isGuest,
}); });
return q(true); return q(true);
} catch (e) { } catch (e) {
@ -273,9 +274,18 @@ export function initRtsClient(url) {
*/ */
export function setLoggedIn(credentials) { export function setLoggedIn(credentials) {
credentials.guest = Boolean(credentials.guest); credentials.guest = Boolean(credentials.guest);
console.log("setLoggedIn => %s (guest=%s) hs=%s",
credentials.userId, credentials.guest, console.log(
credentials.homeserverUrl); "setLoggedIn: mxid:", credentials.userId,
"deviceId:", credentials.deviceId,
"guest:", credentials.guest,
"hs:", 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 // Resolves by default
let teamPromise = Promise.resolve(null); let teamPromise = Promise.resolve(null);
@ -347,7 +357,7 @@ export function logout() {
return; return;
} }
return MatrixClientPeg.get().logout().then(onLoggedOut, MatrixClientPeg.get().logout().then(onLoggedOut,
(err) => { (err) => {
// Just throwing an error here is going to be very unhelpful // Just throwing an error here is going to be very unhelpful
// if you're trying to log out because your server's down and // if you're trying to log out because your server's down and
@ -358,8 +368,8 @@ export function logout() {
// change your password). // change your password).
console.log("Failed to call logout API: token will not be invalidated"); console.log("Failed to call logout API: token will not be invalidated");
onLoggedOut(); onLoggedOut();
} },
); ).done();
} }
/** /**
@ -415,7 +425,7 @@ export function stopMatrixClient() {
UserActivity.stop(); UserActivity.stop();
Presence.stop(); Presence.stop();
if (DMRoomMap.shared()) DMRoomMap.shared().stop(); if (DMRoomMap.shared()) DMRoomMap.shared().stop();
var cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (cli) { if (cli) {
cli.stopClient(); cli.stopClient();
cli.removeAllListeners(); cli.removeAllListeners();

View file

@ -105,21 +105,48 @@ export default class Login {
}); });
} }
loginViaPassword(username, pass) { loginViaPassword(username, phoneCountry, phoneNumber, pass) {
var self = this; const self = this;
var isEmail = username.indexOf("@") > 0;
var loginParams = { const isEmail = username.indexOf("@") > 0;
password: pass,
initial_device_display_name: this._defaultDeviceDisplayName, 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,
}; };
if (isEmail) {
loginParams.medium = 'email';
loginParams.address = username;
} else { } else {
loginParams.user = username; identifier = {
type: 'm.id.user',
user: username,
};
legacyParams = {
user: username,
};
} }
var client = this._createTemporaryClient(); 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 client.login('m.login.password', loginParams).then(function(data) {
return q({ return q({
homeserverUrl: self._hsUrl, homeserverUrl: self._hsUrl,

View file

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

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,13 +15,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
'use strict'; import MatrixClientPeg from './MatrixClientPeg';
import PlatformPeg from './PlatformPeg';
var MatrixClientPeg = require("./MatrixClientPeg"); import TextForEvent from './TextForEvent';
var PlatformPeg = require("./PlatformPeg"); import Avatar from './Avatar';
var TextForEvent = require('./TextForEvent'); import dis from './dispatcher';
var Avatar = require('./Avatar'); import sdk from './index';
var dis = require("./dispatcher"); import Modal from './Modal';
/* /*
* Dispatches: * Dispatches:
@ -30,7 +31,7 @@ var dis = require("./dispatcher");
* } * }
*/ */
var Notifier = { const Notifier = {
notifsByRoom: {}, notifsByRoom: {},
notificationMessageForEvent: function(ev) { notificationMessageForEvent: function(ev) {
@ -49,16 +50,16 @@ var Notifier = {
return; return;
} }
var msg = this.notificationMessageForEvent(ev); let msg = this.notificationMessageForEvent(ev);
if (!msg) return; if (!msg) return;
var title; let title;
if (!ev.sender || room.name == ev.sender.name) { if (!ev.sender || room.name === ev.sender.name) {
title = room.name; title = room.name;
// notificationMessageForEvent includes sender, // notificationMessageForEvent includes sender,
// but we already have the sender here // but we already have the sender here
if (ev.getContent().body) msg = ev.getContent().body; if (ev.getContent().body) msg = ev.getContent().body;
} else if (ev.getType() == 'm.room.member') { } else if (ev.getType() === 'm.room.member') {
// context is all in the message here, we don't need // context is all in the message here, we don't need
// to display sender info // to display sender info
title = room.name; title = room.name;
@ -69,7 +70,7 @@ var Notifier = {
if (ev.getContent().body) msg = ev.getContent().body; if (ev.getContent().body) msg = ev.getContent().body;
} }
var avatarUrl = ev.sender ? Avatar.avatarUrlForMember( const avatarUrl = ev.sender ? Avatar.avatarUrlForMember(
ev.sender, 40, 40, 'crop' ev.sender, 40, 40, 'crop'
) : null; ) : null;
@ -84,7 +85,7 @@ var Notifier = {
}, },
_playAudioNotification: function(ev, room) { _playAudioNotification: function(ev, room) {
var e = document.getElementById("messageAudio"); const e = document.getElementById("messageAudio");
if (e) { if (e) {
e.load(); e.load();
e.play(); e.play();
@ -96,19 +97,19 @@ var Notifier = {
this.boundOnSyncStateChange = this.onSyncStateChange.bind(this); this.boundOnSyncStateChange = this.onSyncStateChange.bind(this);
this.boundOnRoomReceipt = this.onRoomReceipt.bind(this); this.boundOnRoomReceipt = this.onRoomReceipt.bind(this);
MatrixClientPeg.get().on('Room.timeline', this.boundOnRoomTimeline); MatrixClientPeg.get().on('Room.timeline', this.boundOnRoomTimeline);
MatrixClientPeg.get().on("Room.receipt", this.boundOnRoomReceipt); MatrixClientPeg.get().on('Room.receipt', this.boundOnRoomReceipt);
MatrixClientPeg.get().on("sync", this.boundOnSyncStateChange); MatrixClientPeg.get().on("sync", this.boundOnSyncStateChange);
this.toolbarHidden = false; this.toolbarHidden = false;
this.isPrepared = false; this.isSyncing = false;
}, },
stop: function() { stop: function() {
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get() && this.boundOnRoomTimeline) {
MatrixClientPeg.get().removeListener('Room.timeline', this.boundOnRoomTimeline); MatrixClientPeg.get().removeListener('Room.timeline', this.boundOnRoomTimeline);
MatrixClientPeg.get().removeListener("Room.receipt", this.boundOnRoomReceipt); MatrixClientPeg.get().removeListener('Room.receipt', this.boundOnRoomReceipt);
MatrixClientPeg.get().removeListener('sync', this.boundOnSyncStateChange); MatrixClientPeg.get().removeListener('sync', this.boundOnSyncStateChange);
} }
this.isPrepared = false; this.isSyncing = false;
}, },
supportsDesktopNotifications: function() { supportsDesktopNotifications: function() {
@ -122,7 +123,7 @@ var Notifier = {
// make sure that we persist the current setting audio_enabled setting // make sure that we persist the current setting audio_enabled setting
// before changing anything // before changing anything
if (global.localStorage) { if (global.localStorage) {
if(global.localStorage.getItem('audio_notifications_enabled') == null) { if (global.localStorage.getItem('audio_notifications_enabled') === null) {
this.setAudioEnabled(this.isEnabled()); this.setAudioEnabled(this.isEnabled());
} }
} }
@ -132,6 +133,16 @@ var Notifier = {
plaf.requestNotificationPermission().done((result) => { plaf.requestNotificationPermission().done((result) => {
if (result !== 'granted') { if (result !== 'granted') {
// The permission request was dismissed or denied // The permission request was dismissed or denied
const description = result === 'denied'
? 'Riot does not have permission to send you notifications'
+ ' - please check your browser settings'
: 'Riot was not given permission to send notifications'
+ ' - please try again';
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createDialog(ErrorDialog, {
title: 'Unable to enable Notifications',
description,
});
return; return;
} }
@ -142,7 +153,7 @@ var Notifier = {
if (callback) callback(); if (callback) callback();
dis.dispatch({ dis.dispatch({
action: "notifier_enabled", action: "notifier_enabled",
value: true value: true,
}); });
}); });
// clear the notifications_hidden flag, so that if notifications are // clear the notifications_hidden flag, so that if notifications are
@ -153,7 +164,7 @@ var Notifier = {
global.localStorage.setItem('notifications_enabled', 'false'); global.localStorage.setItem('notifications_enabled', 'false');
dis.dispatch({ dis.dispatch({
action: "notifier_enabled", action: "notifier_enabled",
value: false value: false,
}); });
} }
}, },
@ -166,7 +177,7 @@ var Notifier = {
if (!global.localStorage) return true; if (!global.localStorage) return true;
var enabled = global.localStorage.getItem('notifications_enabled'); const enabled = global.localStorage.getItem('notifications_enabled');
if (enabled === null) return true; if (enabled === null) return true;
return enabled === 'true'; return enabled === 'true';
}, },
@ -179,7 +190,7 @@ var Notifier = {
isAudioEnabled: function(enable) { isAudioEnabled: function(enable) {
if (!global.localStorage) return true; if (!global.localStorage) return true;
var enabled = global.localStorage.getItem( const enabled = global.localStorage.getItem(
'audio_notifications_enabled'); 'audio_notifications_enabled');
// default to true if the popups are enabled // default to true if the popups are enabled
if (enabled === null) return this.isEnabled(); if (enabled === null) return this.isEnabled();
@ -193,7 +204,7 @@ var Notifier = {
// this is nothing to do with notifier_enabled // this is nothing to do with notifier_enabled
dis.dispatch({ dis.dispatch({
action: "notifier_enabled", action: "notifier_enabled",
value: this.isEnabled() value: this.isEnabled(),
}); });
// update the info to localStorage for persistent settings // update the info to localStorage for persistent settings
@ -214,22 +225,21 @@ var Notifier = {
}, },
onSyncStateChange: function(state) { onSyncStateChange: function(state) {
if (state === "PREPARED" || state === "SYNCING") { if (state === "SYNCING") {
this.isPrepared = true; this.isSyncing = true;
} } else if (state === "STOPPED" || state === "ERROR") {
else if (state === "STOPPED" || state === "ERROR") { this.isSyncing = false;
this.isPrepared = false;
} }
}, },
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
if (toStartOfTimeline) return; if (toStartOfTimeline) return;
if (!room) return; if (!room) return;
if (!this.isPrepared) return; // don't alert for any messages initially if (!this.isSyncing) return; // don't alert for any messages initially
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) return; if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return;
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
var actions = MatrixClientPeg.get().getPushActionsForEvent(ev); const actions = MatrixClientPeg.get().getPushActionsForEvent(ev);
if (actions && actions.notify) { if (actions && actions.notify) {
if (this.isEnabled()) { if (this.isEnabled()) {
this._displayPopupNotification(ev, room); this._displayPopupNotification(ev, room);
@ -241,7 +251,7 @@ var Notifier = {
}, },
onRoomReceipt: function(ev, room) { onRoomReceipt: function(ev, room) {
if (room.getUnreadNotificationCount() == 0) { if (room.getUnreadNotificationCount() === 0) {
// ideally we would clear each notification when it was read, // ideally we would clear each notification when it was read,
// but we have no way, given a read receipt, to know whether // but we have no way, given a read receipt, to know whether
// the receipt comes before or after an event, so we can't // the receipt comes before or after an event, so we can't
@ -256,7 +266,7 @@ var Notifier = {
} }
delete this.notifsByRoom[room.roomId]; delete this.notifsByRoom[room.roomId];
} }
} },
}; };
if (!global.mxNotifier) { if (!global.mxNotifier) {

29
src/Roles.js Normal file
View file

@ -0,0 +1,29 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export const LEVEL_ROLE_MAP = {
undefined: 'Default',
0: 'User',
50: 'Moderator',
100: 'Admin',
};
export function textualPowerLevel(level, userDefault) {
if (LEVEL_ROLE_MAP[level]) {
return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${userDefault})`);
} else {
return level;
}
}

View file

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

View file

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

View file

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

View file

@ -32,7 +32,7 @@ class UserActivity {
start() { start() {
document.onmousedown = this._onUserActivity.bind(this); document.onmousedown = this._onUserActivity.bind(this);
document.onmousemove = this._onUserActivity.bind(this); document.onmousemove = this._onUserActivity.bind(this);
document.onkeypress = this._onUserActivity.bind(this); document.onkeydown = this._onUserActivity.bind(this);
// can't use document.scroll here because that's only the document // can't use document.scroll here because that's only the document
// itself being scrolled. Need to use addEventListener's useCapture. // itself being scrolled. Need to use addEventListener's useCapture.
// also this needs to be the wheel event, not scroll, as scroll is // also this needs to be the wheel event, not scroll, as scroll is
@ -50,7 +50,7 @@ class UserActivity {
stop() { stop() {
document.onmousedown = undefined; document.onmousedown = undefined;
document.onmousemove = undefined; document.onmousemove = undefined;
document.onkeypress = undefined; document.onkeydown = undefined;
window.removeEventListener('wheel', this._onUserActivity.bind(this), window.removeEventListener('wheel', this._onUserActivity.bind(this),
{ passive: true, capture: true }); { passive: true, capture: true });
} }

View file

@ -15,9 +15,9 @@ limitations under the License.
*/ */
'use strict'; 'use strict';
var q = require("q"); import q from 'q';
var MatrixClientPeg = require("./MatrixClientPeg"); import MatrixClientPeg from './MatrixClientPeg';
var Notifier = require("./Notifier"); import Notifier from './Notifier';
/* /*
* TODO: Make things use this. This is all WIP - see UserSettings.js for usage. * TODO: Make things use this. This is all WIP - see UserSettings.js for usage.
@ -33,7 +33,7 @@ module.exports = {
], ],
loadProfileInfo: function() { loadProfileInfo: function() {
var cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
return cli.getProfileInfo(cli.credentials.userId); return cli.getProfileInfo(cli.credentials.userId);
}, },
@ -44,7 +44,7 @@ module.exports = {
loadThreePids: function() { loadThreePids: function() {
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
return q({ return q({
threepids: [] threepids: [],
}); // guests can't poke 3pid endpoint }); // guests can't poke 3pid endpoint
} }
return MatrixClientPeg.get().getThreePids(); return MatrixClientPeg.get().getThreePids();
@ -73,19 +73,19 @@ module.exports = {
Notifier.setAudioEnabled(enable); Notifier.setAudioEnabled(enable);
}, },
changePassword: function(old_password, new_password) { changePassword: function(oldPassword, newPassword) {
var cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
var authDict = { const authDict = {
type: 'm.login.password', type: 'm.login.password',
user: cli.credentials.userId, user: cli.credentials.userId,
password: old_password password: oldPassword,
}; };
return cli.setPassword(authDict, new_password); return cli.setPassword(authDict, newPassword);
}, },
/** /*
* Returns the email pusher (pusher of type 'email') for a given * Returns the email pusher (pusher of type 'email') for a given
* email address. Email pushers all have the same app ID, so since * email address. Email pushers all have the same app ID, so since
* pushers are unique over (app ID, pushkey), there will be at most * pushers are unique over (app ID, pushkey), there will be at most
@ -95,8 +95,8 @@ module.exports = {
if (pushers === undefined) { if (pushers === undefined) {
return undefined; return undefined;
} }
for (var i = 0; i < pushers.length; ++i) { for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind == 'email' && pushers[i].pushkey == address) { if (pushers[i].kind === 'email' && pushers[i].pushkey === address) {
return pushers[i]; return pushers[i];
} }
} }
@ -110,7 +110,7 @@ module.exports = {
addEmailPusher: function(address, data) { addEmailPusher: function(address, data) {
return MatrixClientPeg.get().setPusher({ return MatrixClientPeg.get().setPusher({
kind: 'email', kind: 'email',
app_id: "m.email", app_id: 'm.email',
pushkey: address, pushkey: address,
app_display_name: 'Email Notifications', app_display_name: 'Email Notifications',
device_display_name: address, device_display_name: address,
@ -121,46 +121,46 @@ module.exports = {
}, },
getUrlPreviewsDisabled: function() { getUrlPreviewsDisabled: function() {
var event = MatrixClientPeg.get().getAccountData("org.matrix.preview_urls"); const event = MatrixClientPeg.get().getAccountData('org.matrix.preview_urls');
return (event && event.getContent().disable); return (event && event.getContent().disable);
}, },
setUrlPreviewsDisabled: function(disabled) { setUrlPreviewsDisabled: function(disabled) {
// FIXME: handle errors // FIXME: handle errors
return MatrixClientPeg.get().setAccountData("org.matrix.preview_urls", { return MatrixClientPeg.get().setAccountData('org.matrix.preview_urls', {
disable: disabled disable: disabled,
}); });
}, },
getSyncedSettings: function() { getSyncedSettings: function() {
var event = MatrixClientPeg.get().getAccountData("im.vector.web.settings"); const event = MatrixClientPeg.get().getAccountData('im.vector.web.settings');
return event ? event.getContent() : {}; return event ? event.getContent() : {};
}, },
getSyncedSetting: function(type, defaultValue = null) { getSyncedSetting: function(type, defaultValue = null) {
var settings = this.getSyncedSettings(); const settings = this.getSyncedSettings();
return settings.hasOwnProperty(type) ? settings[type] : defaultValue; return settings.hasOwnProperty(type) ? settings[type] : defaultValue;
}, },
setSyncedSetting: function(type, value) { setSyncedSetting: function(type, value) {
var settings = this.getSyncedSettings(); const settings = this.getSyncedSettings();
settings[type] = value; settings[type] = value;
// FIXME: handle errors // FIXME: handle errors
return MatrixClientPeg.get().setAccountData("im.vector.web.settings", settings); return MatrixClientPeg.get().setAccountData('im.vector.web.settings', settings);
}, },
getLocalSettings: function() { getLocalSettings: function() {
var localSettingsString = localStorage.getItem('mx_local_settings') || '{}'; const localSettingsString = localStorage.getItem('mx_local_settings') || '{}';
return JSON.parse(localSettingsString); return JSON.parse(localSettingsString);
}, },
getLocalSetting: function(type, defaultValue = null) { getLocalSetting: function(type, defaultValue = null) {
var settings = this.getLocalSettings(); const settings = this.getLocalSettings();
return settings.hasOwnProperty(type) ? settings[type] : defaultValue; return settings.hasOwnProperty(type) ? settings[type] : defaultValue;
}, },
setLocalSetting: function(type, value) { setLocalSetting: function(type, value) {
var settings = this.getLocalSettings(); const settings = this.getLocalSettings();
settings[type] = value; settings[type] = value;
// FIXME: handle errors // FIXME: handle errors
localStorage.setItem('mx_local_settings', JSON.stringify(settings)); localStorage.setItem('mx_local_settings', JSON.stringify(settings));
@ -171,8 +171,8 @@ module.exports = {
if (MatrixClientPeg.get().isGuest()) return false; if (MatrixClientPeg.get().isGuest()) return false;
if (localStorage.getItem(`mx_labs_feature_${feature}`) === null) { if (localStorage.getItem(`mx_labs_feature_${feature}`) === null) {
for (var i = 0; i < this.LABS_FEATURES.length; i++) { for (let i = 0; i < this.LABS_FEATURES.length; i++) {
var f = this.LABS_FEATURES[i]; const f = this.LABS_FEATURES[i];
if (f.id === feature) { if (f.id === feature) {
return f.default; return f.default;
} }
@ -183,5 +183,5 @@ module.exports = {
setFeatureEnabled: function(feature: string, enabled: boolean) { setFeatureEnabled: function(feature: string, enabled: boolean) {
localStorage.setItem(`mx_labs_feature_${feature}`, enabled); localStorage.setItem(`mx_labs_feature_${feature}`, enabled);
} },
}; };

View file

@ -75,8 +75,12 @@ import views$create_room$RoomAlias from './components/views/create_room/RoomAlia
views$create_room$RoomAlias && (module.exports.components['views.create_room.RoomAlias'] = views$create_room$RoomAlias); views$create_room$RoomAlias && (module.exports.components['views.create_room.RoomAlias'] = views$create_room$RoomAlias);
import views$dialogs$BaseDialog from './components/views/dialogs/BaseDialog'; import views$dialogs$BaseDialog from './components/views/dialogs/BaseDialog';
views$dialogs$BaseDialog && (module.exports.components['views.dialogs.BaseDialog'] = views$dialogs$BaseDialog); views$dialogs$BaseDialog && (module.exports.components['views.dialogs.BaseDialog'] = views$dialogs$BaseDialog);
import views$dialogs$ChatCreateOrReuseDialog from './components/views/dialogs/ChatCreateOrReuseDialog';
views$dialogs$ChatCreateOrReuseDialog && (module.exports.components['views.dialogs.ChatCreateOrReuseDialog'] = views$dialogs$ChatCreateOrReuseDialog);
import views$dialogs$ChatInviteDialog from './components/views/dialogs/ChatInviteDialog'; import views$dialogs$ChatInviteDialog from './components/views/dialogs/ChatInviteDialog';
views$dialogs$ChatInviteDialog && (module.exports.components['views.dialogs.ChatInviteDialog'] = views$dialogs$ChatInviteDialog); views$dialogs$ChatInviteDialog && (module.exports.components['views.dialogs.ChatInviteDialog'] = views$dialogs$ChatInviteDialog);
import views$dialogs$ConfirmRedactDialog from './components/views/dialogs/ConfirmRedactDialog';
views$dialogs$ConfirmRedactDialog && (module.exports.components['views.dialogs.ConfirmRedactDialog'] = views$dialogs$ConfirmRedactDialog);
import views$dialogs$ConfirmUserActionDialog from './components/views/dialogs/ConfirmUserActionDialog'; import views$dialogs$ConfirmUserActionDialog from './components/views/dialogs/ConfirmUserActionDialog';
views$dialogs$ConfirmUserActionDialog && (module.exports.components['views.dialogs.ConfirmUserActionDialog'] = views$dialogs$ConfirmUserActionDialog); views$dialogs$ConfirmUserActionDialog && (module.exports.components['views.dialogs.ConfirmUserActionDialog'] = views$dialogs$ConfirmUserActionDialog);
import views$dialogs$DeactivateAccountDialog from './components/views/dialogs/DeactivateAccountDialog'; import views$dialogs$DeactivateAccountDialog from './components/views/dialogs/DeactivateAccountDialog';
@ -99,26 +103,40 @@ import views$dialogs$UnknownDeviceDialog from './components/views/dialogs/Unknow
views$dialogs$UnknownDeviceDialog && (module.exports.components['views.dialogs.UnknownDeviceDialog'] = views$dialogs$UnknownDeviceDialog); views$dialogs$UnknownDeviceDialog && (module.exports.components['views.dialogs.UnknownDeviceDialog'] = views$dialogs$UnknownDeviceDialog);
import views$elements$AccessibleButton from './components/views/elements/AccessibleButton'; import views$elements$AccessibleButton from './components/views/elements/AccessibleButton';
views$elements$AccessibleButton && (module.exports.components['views.elements.AccessibleButton'] = views$elements$AccessibleButton); views$elements$AccessibleButton && (module.exports.components['views.elements.AccessibleButton'] = views$elements$AccessibleButton);
import views$elements$ActionButton from './components/views/elements/ActionButton';
views$elements$ActionButton && (module.exports.components['views.elements.ActionButton'] = views$elements$ActionButton);
import views$elements$AddressSelector from './components/views/elements/AddressSelector'; import views$elements$AddressSelector from './components/views/elements/AddressSelector';
views$elements$AddressSelector && (module.exports.components['views.elements.AddressSelector'] = views$elements$AddressSelector); views$elements$AddressSelector && (module.exports.components['views.elements.AddressSelector'] = views$elements$AddressSelector);
import views$elements$AddressTile from './components/views/elements/AddressTile'; import views$elements$AddressTile from './components/views/elements/AddressTile';
views$elements$AddressTile && (module.exports.components['views.elements.AddressTile'] = views$elements$AddressTile); views$elements$AddressTile && (module.exports.components['views.elements.AddressTile'] = views$elements$AddressTile);
import views$elements$CreateRoomButton from './components/views/elements/CreateRoomButton';
views$elements$CreateRoomButton && (module.exports.components['views.elements.CreateRoomButton'] = views$elements$CreateRoomButton);
import views$elements$DeviceVerifyButtons from './components/views/elements/DeviceVerifyButtons'; import views$elements$DeviceVerifyButtons from './components/views/elements/DeviceVerifyButtons';
views$elements$DeviceVerifyButtons && (module.exports.components['views.elements.DeviceVerifyButtons'] = views$elements$DeviceVerifyButtons); views$elements$DeviceVerifyButtons && (module.exports.components['views.elements.DeviceVerifyButtons'] = views$elements$DeviceVerifyButtons);
import views$elements$DirectorySearchBox from './components/views/elements/DirectorySearchBox'; import views$elements$DirectorySearchBox from './components/views/elements/DirectorySearchBox';
views$elements$DirectorySearchBox && (module.exports.components['views.elements.DirectorySearchBox'] = views$elements$DirectorySearchBox); views$elements$DirectorySearchBox && (module.exports.components['views.elements.DirectorySearchBox'] = views$elements$DirectorySearchBox);
import views$elements$Dropdown from './components/views/elements/Dropdown';
views$elements$Dropdown && (module.exports.components['views.elements.Dropdown'] = views$elements$Dropdown);
import views$elements$EditableText from './components/views/elements/EditableText'; import views$elements$EditableText from './components/views/elements/EditableText';
views$elements$EditableText && (module.exports.components['views.elements.EditableText'] = views$elements$EditableText); views$elements$EditableText && (module.exports.components['views.elements.EditableText'] = views$elements$EditableText);
import views$elements$EditableTextContainer from './components/views/elements/EditableTextContainer'; import views$elements$EditableTextContainer from './components/views/elements/EditableTextContainer';
views$elements$EditableTextContainer && (module.exports.components['views.elements.EditableTextContainer'] = views$elements$EditableTextContainer); views$elements$EditableTextContainer && (module.exports.components['views.elements.EditableTextContainer'] = views$elements$EditableTextContainer);
import views$elements$EmojiText from './components/views/elements/EmojiText'; import views$elements$EmojiText from './components/views/elements/EmojiText';
views$elements$EmojiText && (module.exports.components['views.elements.EmojiText'] = views$elements$EmojiText); views$elements$EmojiText && (module.exports.components['views.elements.EmojiText'] = views$elements$EmojiText);
import views$elements$HomeButton from './components/views/elements/HomeButton';
views$elements$HomeButton && (module.exports.components['views.elements.HomeButton'] = views$elements$HomeButton);
import views$elements$MemberEventListSummary from './components/views/elements/MemberEventListSummary'; import views$elements$MemberEventListSummary from './components/views/elements/MemberEventListSummary';
views$elements$MemberEventListSummary && (module.exports.components['views.elements.MemberEventListSummary'] = views$elements$MemberEventListSummary); views$elements$MemberEventListSummary && (module.exports.components['views.elements.MemberEventListSummary'] = views$elements$MemberEventListSummary);
import views$elements$PowerSelector from './components/views/elements/PowerSelector'; import views$elements$PowerSelector from './components/views/elements/PowerSelector';
views$elements$PowerSelector && (module.exports.components['views.elements.PowerSelector'] = views$elements$PowerSelector); views$elements$PowerSelector && (module.exports.components['views.elements.PowerSelector'] = views$elements$PowerSelector);
import views$elements$ProgressBar from './components/views/elements/ProgressBar'; import views$elements$ProgressBar from './components/views/elements/ProgressBar';
views$elements$ProgressBar && (module.exports.components['views.elements.ProgressBar'] = views$elements$ProgressBar); views$elements$ProgressBar && (module.exports.components['views.elements.ProgressBar'] = views$elements$ProgressBar);
import views$elements$RoomDirectoryButton from './components/views/elements/RoomDirectoryButton';
views$elements$RoomDirectoryButton && (module.exports.components['views.elements.RoomDirectoryButton'] = views$elements$RoomDirectoryButton);
import views$elements$SettingsButton from './components/views/elements/SettingsButton';
views$elements$SettingsButton && (module.exports.components['views.elements.SettingsButton'] = views$elements$SettingsButton);
import views$elements$StartChatButton from './components/views/elements/StartChatButton';
views$elements$StartChatButton && (module.exports.components['views.elements.StartChatButton'] = views$elements$StartChatButton);
import views$elements$TintableSvg from './components/views/elements/TintableSvg'; import views$elements$TintableSvg from './components/views/elements/TintableSvg';
views$elements$TintableSvg && (module.exports.components['views.elements.TintableSvg'] = views$elements$TintableSvg); views$elements$TintableSvg && (module.exports.components['views.elements.TintableSvg'] = views$elements$TintableSvg);
import views$elements$TruncatedList from './components/views/elements/TruncatedList'; import views$elements$TruncatedList from './components/views/elements/TruncatedList';
@ -129,6 +147,8 @@ import views$login$CaptchaForm from './components/views/login/CaptchaForm';
views$login$CaptchaForm && (module.exports.components['views.login.CaptchaForm'] = views$login$CaptchaForm); views$login$CaptchaForm && (module.exports.components['views.login.CaptchaForm'] = views$login$CaptchaForm);
import views$login$CasLogin from './components/views/login/CasLogin'; import views$login$CasLogin from './components/views/login/CasLogin';
views$login$CasLogin && (module.exports.components['views.login.CasLogin'] = views$login$CasLogin); views$login$CasLogin && (module.exports.components['views.login.CasLogin'] = views$login$CasLogin);
import views$login$CountryDropdown from './components/views/login/CountryDropdown';
views$login$CountryDropdown && (module.exports.components['views.login.CountryDropdown'] = views$login$CountryDropdown);
import views$login$CustomServerDialog from './components/views/login/CustomServerDialog'; import views$login$CustomServerDialog from './components/views/login/CustomServerDialog';
views$login$CustomServerDialog && (module.exports.components['views.login.CustomServerDialog'] = views$login$CustomServerDialog); views$login$CustomServerDialog && (module.exports.components['views.login.CustomServerDialog'] = views$login$CustomServerDialog);
import views$login$InteractiveAuthEntryComponents from './components/views/login/InteractiveAuthEntryComponents'; import views$login$InteractiveAuthEntryComponents from './components/views/login/InteractiveAuthEntryComponents';
@ -221,6 +241,8 @@ import views$rooms$TopUnreadMessagesBar from './components/views/rooms/TopUnread
views$rooms$TopUnreadMessagesBar && (module.exports.components['views.rooms.TopUnreadMessagesBar'] = views$rooms$TopUnreadMessagesBar); views$rooms$TopUnreadMessagesBar && (module.exports.components['views.rooms.TopUnreadMessagesBar'] = views$rooms$TopUnreadMessagesBar);
import views$rooms$UserTile from './components/views/rooms/UserTile'; import views$rooms$UserTile from './components/views/rooms/UserTile';
views$rooms$UserTile && (module.exports.components['views.rooms.UserTile'] = views$rooms$UserTile); views$rooms$UserTile && (module.exports.components['views.rooms.UserTile'] = views$rooms$UserTile);
import views$settings$AddPhoneNumber from './components/views/settings/AddPhoneNumber';
views$settings$AddPhoneNumber && (module.exports.components['views.settings.AddPhoneNumber'] = views$settings$AddPhoneNumber);
import views$settings$ChangeAvatar from './components/views/settings/ChangeAvatar'; import views$settings$ChangeAvatar from './components/views/settings/ChangeAvatar';
views$settings$ChangeAvatar && (module.exports.components['views.settings.ChangeAvatar'] = views$settings$ChangeAvatar); views$settings$ChangeAvatar && (module.exports.components['views.settings.ChangeAvatar'] = views$settings$ChangeAvatar);
import views$settings$ChangeDisplayName from './components/views/settings/ChangeDisplayName'; import views$settings$ChangeDisplayName from './components/views/settings/ChangeDisplayName';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -59,7 +59,9 @@ module.exports = React.createClass({
ContentRepo.getHttpUriForMxc( ContentRepo.getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), MatrixClientPeg.get().getHomeserverUrl(),
props.oobData.avatarUrl, props.oobData.avatarUrl,
props.width, props.height, props.resizeMethod Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod
), // highest priority ), // highest priority
this.getRoomAvatarUrl(props), this.getRoomAvatarUrl(props),
this.getOneToOneAvatar(props), this.getOneToOneAvatar(props),
@ -74,7 +76,9 @@ module.exports = React.createClass({
return props.room.getAvatarUrl( return props.room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(), MatrixClientPeg.get().getHomeserverUrl(),
props.width, props.height, props.resizeMethod, Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false false
); );
}, },
@ -103,13 +107,17 @@ module.exports = React.createClass({
} }
return theOtherGuy.getAvatarUrl( return theOtherGuy.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(), MatrixClientPeg.get().getHomeserverUrl(),
props.width, props.height, props.resizeMethod, Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false false
); );
} else if (userIds.length == 1) { } else if (userIds.length == 1) {
return mlist[userIds[0]].getAvatarUrl( return mlist[userIds[0]].getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(), MatrixClientPeg.get().getHomeserverUrl(),
props.width, props.height, props.resizeMethod, Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false false
); );
} else { } else {

View file

@ -18,6 +18,7 @@ import React from 'react';
import * as KeyCode from '../../../KeyCode'; import * as KeyCode from '../../../KeyCode';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import sdk from '../../../index';
/** /**
* Basic container for modal dialogs. * Basic container for modal dialogs.
@ -46,7 +47,19 @@ export default React.createClass({
children: React.PropTypes.node, children: React.PropTypes.node,
}, },
_onKeyDown: function(e) { componentWillMount: function() {
this.priorActiveElement = document.activeElement;
},
componentWillUnmount: function() {
if (this.priorActiveElement !== null) {
this.priorActiveElement.focus();
}
},
// Must be when the key is released (and not pressed) otherwise componentWillUnmount
// will focus another element which will receive future key events
_onKeyUp: function(e) {
if (e.keyCode === KeyCode.ESCAPE) { if (e.keyCode === KeyCode.ESCAPE) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -65,15 +78,14 @@ export default React.createClass({
}, },
render: function() { render: function() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
return ( return (
<div onKeyDown={this._onKeyDown} className={this.props.className}> <div onKeyUp={this._onKeyUp} className={this.props.className}>
<AccessibleButton onClick={this._onCancelClick} <AccessibleButton onClick={this._onCancelClick}
className="mx_Dialog_cancelButton" className="mx_Dialog_cancelButton"
> >
<img <TintableSvg src="img/icons-close-button.svg" width="35" height="35" />
src="img/cancel.svg" width="18" height="18"
alt="Cancel" title="Cancel"
/>
</AccessibleButton> </AccessibleButton>
<div className='mx_Dialog_title'> <div className='mx_Dialog_title'>
{ this.props.title } { this.props.title }

View file

@ -0,0 +1,115 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import MatrixClientPeg from '../../../MatrixClientPeg';
import DMRoomMap from '../../../utils/DMRoomMap';
import AccessibleButton from '../elements/AccessibleButton';
import Unread from '../../../Unread';
import classNames from 'classnames';
import createRoom from '../../../createRoom';
export default class ChatCreateOrReuseDialog extends React.Component {
constructor(props) {
super(props);
this.onNewDMClick = this.onNewDMClick.bind(this);
this.onRoomTileClick = this.onRoomTileClick.bind(this);
}
onNewDMClick() {
createRoom({dmUserId: this.props.userId});
this.props.onFinished(true);
}
onRoomTileClick(roomId) {
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
this.props.onFinished(true);
}
render() {
const client = MatrixClientPeg.get();
const dmRoomMap = new DMRoomMap(client);
const dmRooms = dmRoomMap.getDMRoomsForUserId(this.props.userId);
const RoomTile = sdk.getComponent("rooms.RoomTile");
const tiles = [];
for (const roomId of dmRooms) {
const room = client.getRoom(roomId);
if (room) {
const me = room.getMember(client.credentials.userId);
const highlight = (
room.getUnreadNotificationCount('highlight') > 0 ||
me.membership == "invite"
);
tiles.push(
<RoomTile key={room.roomId} room={room}
collapsed={false}
selected={false}
unread={Unread.doesRoomHaveUnreadMessages(room)}
highlight={highlight}
isInvite={me.membership == "invite"}
onClick={this.onRoomTileClick}
/>
);
}
}
const labelClasses = classNames({
mx_MemberInfo_createRoom_label: true,
mx_RoomTile_name: true,
});
const startNewChat = <AccessibleButton
className="mx_MemberInfo_createRoom"
onClick={this.onNewDMClick}
>
<div className="mx_RoomTile_avatar">
<img src="img/create-big.svg" width="26" height="26" />
</div>
<div className={labelClasses}><i>Start new chat</i></div>
</AccessibleButton>;
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<BaseDialog className='mx_ChatCreateOrReuseDialog'
onFinished={() => {
this.props.onFinished(false)
}}
title='Create a new chat or reuse an existing one'
>
<div className="mx_Dialog_content">
You already have existing direct chats with this user:
<div className="mx_ChatCreateOrReuseDialog_tiles">
{tiles}
{startNewChat}
</div>
</div>
</BaseDialog>
);
}
}
ChatCreateOrReuseDialog.propTyps = {
userId: React.PropTypes.string.isRequired,
onFinished: React.PropTypes.func.isRequired,
};

View file

@ -30,15 +30,6 @@ import Fuse from 'fuse.js';
const TRUNCATE_QUERY_LIST = 40; const TRUNCATE_QUERY_LIST = 40;
/*
* Escapes a string so it can be used in a RegExp
* Basically just replaces: \ ^ $ * + ? . ( ) | { } [ ]
* From http://stackoverflow.com/a/6969486
*/
function escapeRegExp(str) {
return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
}
module.exports = React.createClass({ module.exports = React.createClass({
displayName: "ChatInviteDialog", displayName: "ChatInviteDialog",
propTypes: { propTypes: {
@ -111,18 +102,27 @@ module.exports = React.createClass({
if (inviteList === null) return; if (inviteList === null) return;
} }
const addrTexts = inviteList.map(addr => addr.address);
if (inviteList.length > 0) { if (inviteList.length > 0) {
if (this._isDmChat(inviteList)) { if (this._isDmChat(addrTexts)) {
const userId = inviteList[0].address;
// Direct Message chat // Direct Message chat
var room = this._getDirectMessageRoom(inviteList[0]); const rooms = this._getDirectMessageRooms(userId);
if (room) { if (rooms.length > 0) {
// A Direct Message room already exists for this user and you // A Direct Message room already exists for this user, so select a
// so go straight to that room // room from a list that is similar to the one in MemberInfo panel
dis.dispatch({ const ChatCreateOrReuseDialog = sdk.getComponent(
action: 'view_room', "views.dialogs.ChatCreateOrReuseDialog"
room_id: room.roomId, );
}); Modal.createDialog(ChatCreateOrReuseDialog, {
userId: userId,
onFinished: (success) => {
if (success) {
this.props.onFinished(true, inviteList[0]); this.props.onFinished(true, inviteList[0]);
}
// else show this ChatInviteDialog again
}
});
} else { } else {
this._startChat(inviteList); this._startChat(inviteList);
} }
@ -211,23 +211,22 @@ module.exports = React.createClass({
} }
}); });
// If the query isn't a user we know about, but is a // If the query is a valid address, add an entry for that
// valid address, add an entry for that // This is important, otherwise there's no way to invite
if (queryList.length == 0) { // a perfectly valid address if there are close matches.
const addrType = getAddressType(query); const addrType = getAddressType(query);
if (addrType !== null) { if (addrType !== null) {
queryList[0] = { queryList.unshift({
addressType: addrType, addressType: addrType,
address: query, address: query,
isKnown: false, isKnown: false,
}; });
if (this._cancelThreepidLookup) this._cancelThreepidLookup(); if (this._cancelThreepidLookup) this._cancelThreepidLookup();
if (addrType == 'email') { if (addrType == 'email') {
this._lookupThreepid(addrType, query).done(); this._lookupThreepid(addrType, query).done();
} }
} }
} }
}
this.setState({ this.setState({
queryList: queryList, queryList: queryList,
error: false, error: false,
@ -267,22 +266,20 @@ module.exports = React.createClass({
if (this._cancelThreepidLookup) this._cancelThreepidLookup(); if (this._cancelThreepidLookup) this._cancelThreepidLookup();
}, },
_getDirectMessageRoom: function(addr) { _getDirectMessageRooms: function(addr) {
const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); const dmRoomMap = new DMRoomMap(MatrixClientPeg.get());
var dmRooms = dmRoomMap.getDMRoomsForUserId(addr); const dmRooms = dmRoomMap.getDMRoomsForUserId(addr);
if (dmRooms.length > 0) { const rooms = [];
// Cycle through all the DM rooms and find the first non forgotten or parted room dmRooms.forEach(dmRoom => {
for (let i = 0; i < dmRooms.length; i++) { let room = MatrixClientPeg.get().getRoom(dmRoom);
let room = MatrixClientPeg.get().getRoom(dmRooms[i]);
if (room) { if (room) {
const me = room.getMember(MatrixClientPeg.get().credentials.userId); const me = room.getMember(MatrixClientPeg.get().credentials.userId);
if (me.membership == 'join') { if (me.membership == 'join') {
return room; rooms.push(room);
} }
} }
} });
} return rooms;
return null;
}, },
_startChat: function(addrs) { _startChat: function(addrs) {
@ -311,8 +308,8 @@ module.exports = React.createClass({
console.error(err.stack); console.error(err.stack);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Failure to invite", title: "Failed to invite",
description: err.toString() description: ((err && err.message) ? err.message : "Operation failed"),
}); });
return null; return null;
}) })
@ -324,8 +321,8 @@ module.exports = React.createClass({
console.error(err.stack); console.error(err.stack);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Failure to invite user", title: "Failed to invite user",
description: err.toString() description: ((err && err.message) ? err.message : "Operation failed"),
}); });
return null; return null;
}) })
@ -345,8 +342,8 @@ module.exports = React.createClass({
console.error(err.stack); console.error(err.stack);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Failure to invite", title: "Failed to invite",
description: err.toString() description: ((err && err.message) ? err.message : "Operation failed"),
}); });
return null; return null;
}) })
@ -381,8 +378,11 @@ module.exports = React.createClass({
return false; return false;
}, },
_isDmChat: function(addrs) { _isDmChat: function(addrTexts) {
if (addrs.length === 1 && getAddressType(addrs[0]) === "mx" && !this.props.roomId) { if (addrTexts.length === 1 &&
getAddressType(addrTexts[0]) === "mx" &&
!this.props.roomId
) {
return true; return true;
} else { } else {
return false; return false;

View file

@ -0,0 +1,73 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import classnames from 'classnames';
/*
* A dialog for confirming a redaction.
*/
export default React.createClass({
displayName: 'ConfirmRedactDialog',
propTypes: {
onFinished: React.PropTypes.func.isRequired,
},
defaultProps: {
danger: false,
},
onOk: function() {
this.props.onFinished(true);
},
onCancel: function() {
this.props.onFinished(false);
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const title = "Confirm Redaction";
const confirmButtonClass = classnames({
'mx_Dialog_primary': true,
'danger': false,
});
return (
<BaseDialog className="mx_ConfirmUserActionDialog" onFinished={this.props.onFinished}
onEnterPressed={ this.onOk }
title={title}
>
<div className="mx_Dialog_content">
Are you sure you wish to redact (delete) this event?
Note that if you redact a room name or topic change, it could undo the change.
</div>
<div className="mx_Dialog_buttons">
<button className={confirmButtonClass} onClick={this.onOk}>
Redact
</button>
<button onClick={this.onCancel}>
Cancel
</button>
</div>
</BaseDialog>
);
},
});

View file

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

View file

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

View file

@ -50,6 +50,12 @@ export default React.createClass({
}; };
}, },
componentDidMount: function() {
if (this.props.focus) {
this.refs.button.focus();
}
},
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return ( return (
@ -59,7 +65,7 @@ export default React.createClass({
{this.props.description} {this.props.description}
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button className="mx_Dialog_primary" onClick={this.props.onFinished} autoFocus={this.props.focus}> <button ref="button" className="mx_Dialog_primary" onClick={this.props.onFinished}>
{this.props.button} {this.props.button}
</button> </button>
</div> </div>

View file

@ -21,10 +21,8 @@ export default React.createClass({
displayName: 'QuestionDialog', displayName: 'QuestionDialog',
propTypes: { propTypes: {
title: React.PropTypes.string, title: React.PropTypes.string,
description: React.PropTypes.oneOfType([ description: React.PropTypes.node,
React.PropTypes.element, extraButtons: React.PropTypes.node,
React.PropTypes.string,
]),
button: React.PropTypes.string, button: React.PropTypes.string,
focus: React.PropTypes.bool, focus: React.PropTypes.bool,
onFinished: React.PropTypes.func.isRequired, onFinished: React.PropTypes.func.isRequired,
@ -34,6 +32,7 @@ export default React.createClass({
return { return {
title: "", title: "",
description: "", description: "",
extraButtons: null,
button: "OK", button: "OK",
focus: true, focus: true,
hasCancelButton: true, hasCancelButton: true,
@ -48,6 +47,12 @@ export default React.createClass({
this.props.onFinished(false); this.props.onFinished(false);
}, },
componentDidMount: function() {
if (this.props.focus) {
this.refs.button.focus();
}
},
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const cancelButton = this.props.hasCancelButton ? ( const cancelButton = this.props.hasCancelButton ? (
@ -64,9 +69,10 @@ export default React.createClass({
{this.props.description} {this.props.description}
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button className="mx_Dialog_primary" onClick={this.onOk} autoFocus={this.props.focus}> <button ref="button" className="mx_Dialog_primary" onClick={this.onOk}>
{this.props.button} {this.props.button}
</button> </button>
{this.props.extraButtons}
{cancelButton} {cancelButton}
</div> </div>
</BaseDialog> </BaseDialog>

View file

@ -149,7 +149,7 @@ export default React.createClass({
> >
<GeminiScrollbar autoshow={false} className="mx_Dialog_content"> <GeminiScrollbar autoshow={false} className="mx_Dialog_content">
<h4> <h4>
This room contains devices that you haven't seen before. "{this.props.room.name}" contains devices that you haven't seen before.
</h4> </h4>
{ warning } { warning }
Unknown devices: Unknown devices:

View file

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

View file

@ -0,0 +1,80 @@
/*
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 PropTypes from 'prop-types';
import AccessibleButton from './AccessibleButton';
import dis from '../../../dispatcher';
import sdk from '../../../index';
export default React.createClass({
displayName: 'RoleButton',
propTypes: {
size: PropTypes.string,
tooltip: PropTypes.bool,
action: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
iconPath: PropTypes.string.isRequired,
},
getDefaultProps: function() {
return {
size: "25",
tooltip: false,
};
},
getInitialState: function() {
return {
showTooltip: false,
};
},
_onClick: function(ev) {
ev.stopPropagation();
dis.dispatch({action: this.props.action});
},
_onMouseEnter: function() {
if (this.props.tooltip) this.setState({showTooltip: true});
},
_onMouseLeave: function() {
this.setState({showTooltip: false});
},
render: function() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
let tooltip;
if (this.state.showTooltip) {
const RoomTooltip = sdk.getComponent("rooms.RoomTooltip");
tooltip = <RoomTooltip className="mx_RoleButton_tooltip" label={this.props.label} />;
}
return (
<AccessibleButton className="mx_RoleButton"
onClick={this._onClick}
onMouseEnter={this._onMouseEnter}
onMouseLeave={this._onMouseLeave}
>
<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />
{tooltip}
</AccessibleButton>
);
}
});

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -138,7 +139,7 @@ export default React.createClass({
onClick={this.onClick.bind(this, i)} onClick={this.onClick.bind(this, i)}
onMouseEnter={this.onMouseEnter.bind(this, i)} onMouseEnter={this.onMouseEnter.bind(this, i)}
onMouseLeave={this.onMouseLeave} onMouseLeave={this.onMouseLeave}
key={this.props.addressList[i].userId} key={this.props.addressList[i].addressType + "/" + this.props.addressList[i].address}
ref={(ref) => { this.addressListElement = ref; }} ref={(ref) => { this.addressListElement = ref; }}
> >
<AddressTile address={this.props.addressList[i]} justified={true} networkName="vector" networkUrl="img/search-icon-vector.svg" /> <AddressTile address={this.props.addressList[i]} justified={true} networkName="vector" networkUrl="img/search-icon-vector.svg" />

View file

@ -0,0 +1,38 @@
/*
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 PropTypes from 'prop-types';
const CreateRoomButton = function(props) {
const ActionButton = sdk.getComponent('elements.ActionButton');
return (
<ActionButton action="view_create_chat"
label="Create new room"
iconPath="img/icons-create-room.svg"
size={props.size}
tooltip={props.tooltip}
/>
);
};
CreateRoomButton.propTypes = {
size: PropTypes.string,
tooltip: PropTypes.bool,
};
export default CreateRoomButton;

View file

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

View file

@ -0,0 +1,329 @@
/*
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) {
if (!nextProps.children || nextProps.children.length === 0) {
return;
}
this._reindexChildren(nextProps.children);
const firstChild = 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 (options.length === 0) {
return [<div className="mx_Dropdown_option">
No results
</div>];
}
return options;
}
render() {
let currentValue;
const menuStyle = {};
if (this.props.menuWidth) menuStyle.width = this.props.menuWidth;
let menu;
if (this.state.expanded) {
if (this.props.searchEnabled) {
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>;
}
if (!currentValue) {
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,
searchEnabled: React.PropTypes.bool,
// 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

@ -0,0 +1,38 @@
/*
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 PropTypes from 'prop-types';
const HomeButton = function(props) {
const ActionButton = sdk.getComponent('elements.ActionButton');
return (
<ActionButton action="view_home_page"
label="Welcome page"
iconPath="img/icons-home.svg"
size={props.size}
tooltip={props.tooltip}
/>
);
};
HomeButton.propTypes = {
size: PropTypes.string,
tooltip: PropTypes.bool,
};
export default HomeButton;

View file

@ -221,6 +221,8 @@ module.exports = React.createClass({
"banned": beConjugated + " banned", "banned": beConjugated + " banned",
"unbanned": beConjugated + " unbanned", "unbanned": beConjugated + " unbanned",
"kicked": beConjugated + " kicked", "kicked": beConjugated + " kicked",
"changed_name": "changed name",
"changed_avatar": "changed avatar",
}; };
if (Object.keys(map).includes(t)) { if (Object.keys(map).includes(t)) {
@ -289,7 +291,24 @@ module.exports = React.createClass({
switch (e.mxEvent.getContent().membership) { switch (e.mxEvent.getContent().membership) {
case 'invite': return 'invited'; case 'invite': return 'invited';
case 'ban': return 'banned'; case 'ban': return 'banned';
case 'join': return 'joined'; case 'join':
if (e.mxEvent.getPrevContent().membership === 'join') {
if (e.mxEvent.getContent().displayname !==
e.mxEvent.getPrevContent().displayname)
{
return 'changed_name';
}
else if (e.mxEvent.getContent().avatar_url !==
e.mxEvent.getPrevContent().avatar_url)
{
return 'changed_avatar';
}
// console.log("MELS ignoring duplicate membership join event");
return null;
}
else {
return 'joined';
}
case 'leave': case 'leave':
if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) { if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) {
switch (e.mxEvent.getPrevContent().membership) { switch (e.mxEvent.getPrevContent().membership) {
@ -350,6 +369,7 @@ module.exports = React.createClass({
render: function() { render: function() {
const eventsToRender = this.props.events; const eventsToRender = this.props.events;
const eventIds = eventsToRender.map(e => e.getId()).join(',');
const fewEvents = eventsToRender.length < this.props.threshold; const fewEvents = eventsToRender.length < this.props.threshold;
const expanded = this.state.expanded || fewEvents; const expanded = this.state.expanded || fewEvents;
@ -360,7 +380,7 @@ module.exports = React.createClass({
if (fewEvents) { if (fewEvents) {
return ( return (
<div className="mx_MemberEventListSummary"> <div className="mx_MemberEventListSummary" data-scroll-tokens={eventIds}>
{expandedEvents} {expandedEvents}
</div> </div>
); );
@ -418,7 +438,7 @@ module.exports = React.createClass({
); );
return ( return (
<div className="mx_MemberEventListSummary"> <div className="mx_MemberEventListSummary" data-scroll-tokens={eventIds}>
{toggleButton} {toggleButton}
{summaryContainer} {summaryContainer}
{expanded ? <div className="mx_MemberEventListSummary_line">&nbsp;</div> : null} {expanded ? <div className="mx_MemberEventListSummary_line">&nbsp;</div> : null}

View file

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

View file

@ -0,0 +1,38 @@
/*
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 PropTypes from 'prop-types';
const RoomDirectoryButton = function(props) {
const ActionButton = sdk.getComponent('elements.ActionButton');
return (
<ActionButton action="view_room_directory"
label="Room directory"
iconPath="img/icons-directory.svg"
size={props.size}
tooltip={props.tooltip}
/>
);
};
RoomDirectoryButton.propTypes = {
size: PropTypes.string,
tooltip: PropTypes.bool,
};
export default RoomDirectoryButton;

View file

@ -0,0 +1,38 @@
/*
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 PropTypes from 'prop-types';
const SettingsButton = function(props) {
const ActionButton = sdk.getComponent('elements.ActionButton');
return (
<ActionButton action="view_user_settings"
label="Settings"
iconPath="img/icons-settings.svg"
size={props.size}
tooltip={props.tooltip}
/>
);
};
SettingsButton.propTypes = {
size: PropTypes.string,
tooltip: PropTypes.bool,
};
export default SettingsButton;

View file

@ -0,0 +1,38 @@
/*
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 PropTypes from 'prop-types';
const StartChatButton = function(props) {
const ActionButton = sdk.getComponent('elements.ActionButton');
return (
<ActionButton action="view_create_chat"
label="Start chat"
iconPath="img/icons-people.svg"
size={props.size}
tooltip={props.tooltip}
/>
);
};
StartChatButton.propTypes = {
size: PropTypes.string,
tooltip: PropTypes.bool,
};
export default StartChatButton;

View file

@ -0,0 +1,127 @@
/*
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;
}
export default class CountryDropdown extends React.Component {
constructor(props) {
super(props);
this._onSearchChange = this._onSearchChange.bind(this);
this._onOptionChange = this._onOptionChange.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]);
}
}
_onSearchChange(search) {
this.setState({
searchQuery: search,
});
}
_onOptionChange(iso2) {
this.props.onOptionChange(COUNTRIES_BY_ISO2[iso2]);
}
_flagImgForIso2(iso2) {
// Unicode Regional Indicator Symbol letter 'A'
const RIS_A = 0x1F1E6;
const ASCII_A = 65;
return charactersToImageNode(iso2, true,
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;
}
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;
const getShortOption = this.props.isSmall ? this._flagImgForIso2 : undefined;
return <Dropdown className={this.props.className}
onOptionChange={this._onOptionChange} onSearchChange={this._onSearchChange}
menuWidth={298} getShortOption={getShortOption}
value={value} searchEnabled={true}
>
{options}
</Dropdown>
}
}
CountryDropdown.propTypes = {
className: React.PropTypes.string,
isSmall: React.PropTypes.bool,
onOptionChange: React.PropTypes.func.isRequired,
value: React.PropTypes.string,
};

View file

@ -16,6 +16,8 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import url from 'url';
import classnames from 'classnames';
import sdk from '../../../index'; import sdk from '../../../index';
@ -158,6 +160,7 @@ export const RecaptchaAuthEntry = React.createClass({
submitAuthDict: React.PropTypes.func.isRequired, submitAuthDict: React.PropTypes.func.isRequired,
stageParams: React.PropTypes.object.isRequired, stageParams: React.PropTypes.object.isRequired,
errorText: React.PropTypes.string, errorText: React.PropTypes.string,
busy: React.PropTypes.bool,
}, },
_onCaptchaResponse: function(response) { _onCaptchaResponse: function(response) {
@ -168,6 +171,11 @@ export const RecaptchaAuthEntry = React.createClass({
}, },
render: function() { render: function() {
if (this.props.busy) {
const Loader = sdk.getComponent("elements.Spinner");
return <Loader />;
}
const CaptchaForm = sdk.getComponent("views.login.CaptchaForm"); const CaptchaForm = sdk.getComponent("views.login.CaptchaForm");
var sitePublicKey = this.props.stageParams.public_key; var sitePublicKey = this.props.stageParams.public_key;
return ( return (
@ -255,6 +263,137 @@ export const EmailIdentityAuthEntry = React.createClass({
}, },
}); });
export const MsisdnAuthEntry = React.createClass({
displayName: 'MsisdnAuthEntry',
statics: {
LOGIN_TYPE: "m.login.msisdn",
},
propTypes: {
inputs: React.PropTypes.shape({
phoneCountry: React.PropTypes.string,
phoneNumber: React.PropTypes.string,
}),
fail: React.PropTypes.func,
clientSecret: React.PropTypes.func,
submitAuthDict: React.PropTypes.func.isRequired,
matrixClient: React.PropTypes.object,
submitAuthDict: React.PropTypes.func,
},
getInitialState: function() {
return {
token: '',
requestingToken: false,
};
},
componentWillMount: function() {
this._sid = null;
this._msisdn = null;
this._tokenBox = null;
this.setState({requestingToken: true});
this._requestMsisdnToken().catch((e) => {
this.props.fail(e);
}).finally(() => {
this.setState({requestingToken: false});
}).done();
},
/*
* Requests a verification token by SMS.
*/
_requestMsisdnToken: function() {
return this.props.matrixClient.requestRegisterMsisdnToken(
this.props.inputs.phoneCountry,
this.props.inputs.phoneNumber,
this.props.clientSecret,
1, // TODO: Multiple send attempts?
).then((result) => {
this._sid = result.sid;
this._msisdn = result.msisdn;
});
},
_onTokenChange: function(e) {
this.setState({
token: e.target.value,
});
},
_onFormSubmit: function(e) {
e.preventDefault();
if (this.state.token == '') return;
this.setState({
errorText: null,
});
this.props.matrixClient.submitMsisdnToken(
this._sid, this.props.clientSecret, this.state.token
).then((result) => {
if (result.success) {
const idServerParsedUrl = url.parse(
this.props.matrixClient.getIdentityServerUrl(),
)
this.props.submitAuthDict({
type: MsisdnAuthEntry.LOGIN_TYPE,
threepid_creds: {
sid: this._sid,
client_secret: this.props.clientSecret,
id_server: idServerParsedUrl.host,
},
});
} else {
this.setState({
errorText: "Token incorrect",
});
}
}).catch((e) => {
this.props.fail(e);
console.log("Failed to submit msisdn token");
}).done();
},
render: function() {
if (this.state.requestingToken) {
const Loader = sdk.getComponent("elements.Spinner");
return <Loader />;
} else {
const enableSubmit = Boolean(this.state.token);
const submitClasses = classnames({
mx_InteractiveAuthEntryComponents_msisdnSubmit: true,
mx_UserSettings_button: true, // XXX button classes
});
return (
<div>
<p>A text message has been sent to +<i>{this._msisdn}</i></p>
<p>Please enter the code it contains:</p>
<div className="mx_InteractiveAuthEntryComponents_msisdnWrapper">
<form onSubmit={this._onFormSubmit}>
<input type="text"
className="mx_InteractiveAuthEntryComponents_msisdnEntry"
value={this.state.token}
onChange={this._onTokenChange}
/>
<br />
<input type="submit" value="Submit"
className={submitClasses}
disabled={!enableSubmit}
/>
</form>
<div className="error">
{this.state.errorText}
</div>
</div>
</div>
);
}
},
});
export const FallbackAuthEntry = React.createClass({ export const FallbackAuthEntry = React.createClass({
displayName: 'FallbackAuthEntry', displayName: 'FallbackAuthEntry',
@ -313,6 +452,7 @@ const AuthEntryComponents = [
PasswordAuthEntry, PasswordAuthEntry,
RecaptchaAuthEntry, RecaptchaAuthEntry,
EmailIdentityAuthEntry, EmailIdentityAuthEntry,
MsisdnAuthEntry,
]; ];
export function getEntryComponentForLoginType(loginType) { export function getEntryComponentForLoginType(loginType) {

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -17,66 +18,164 @@ limitations under the License.
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import sdk from '../../../index';
import {field_input_incorrect} from '../../../UiEffects'; import {field_input_incorrect} from '../../../UiEffects';
/** /**
* A pure UI component which displays a username/password form. * A pure UI component which displays a username/password form.
*/ */
module.exports = React.createClass({displayName: 'PasswordLogin', class PasswordLogin extends React.Component {
propTypes: { static defaultProps = {
onSubmit: React.PropTypes.func.isRequired, // fn(username, password)
onForgotPasswordClick: React.PropTypes.func, // fn()
initialUsername: React.PropTypes.string,
initialPassword: React.PropTypes.string,
onUsernameChanged: React.PropTypes.func,
onPasswordChanged: React.PropTypes.func,
loginIncorrect: React.PropTypes.bool,
},
getDefaultProps: function() {
return {
onUsernameChanged: function() {}, onUsernameChanged: function() {},
onPasswordChanged: function() {}, onPasswordChanged: function() {},
onPhoneCountryChanged: function() {},
onPhoneNumberChanged: function() {},
initialUsername: "", initialUsername: "",
initialPhoneCountry: "",
initialPhoneNumber: "",
initialPassword: "", initialPassword: "",
loginIncorrect: false, loginIncorrect: false,
}; hsDomain: "",
}, }
getInitialState: function() { constructor(props) {
return { super(props);
this.state = {
username: this.props.initialUsername, username: this.props.initialUsername,
password: this.props.initialPassword, password: this.props.initialPassword,
phoneCountry: this.props.initialPhoneCountry,
phoneNumber: this.props.initialPhoneNumber,
loginType: PasswordLogin.LOGIN_FIELD_MXID,
}; };
},
componentWillMount: function() { this.onSubmitForm = this.onSubmitForm.bind(this);
this.onUsernameChanged = this.onUsernameChanged.bind(this);
this.onLoginTypeChange = this.onLoginTypeChange.bind(this);
this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this);
this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this);
this.onPasswordChanged = this.onPasswordChanged.bind(this);
}
componentWillMount() {
this._passwordField = null; this._passwordField = null;
}, }
componentWillReceiveProps: function(nextProps) { componentWillReceiveProps(nextProps) {
if (!this.props.loginIncorrect && nextProps.loginIncorrect) { if (!this.props.loginIncorrect && nextProps.loginIncorrect) {
field_input_incorrect(this._passwordField); field_input_incorrect(this._passwordField);
} }
}, }
onSubmitForm: function(ev) { onSubmitForm(ev) {
ev.preventDefault(); ev.preventDefault();
this.props.onSubmit(this.state.username, this.state.password); this.props.onSubmit(
}, this.state.username,
this.state.phoneCountry,
this.state.phoneNumber,
this.state.password,
);
}
onUsernameChanged: function(ev) { onUsernameChanged(ev) {
this.setState({username: ev.target.value}); this.setState({username: ev.target.value});
this.props.onUsernameChanged(ev.target.value); this.props.onUsernameChanged(ev.target.value);
}, }
onPasswordChanged: function(ev) { onLoginTypeChange(loginType) {
this.setState({
loginType: loginType,
username: "" // Reset because email and username use the same state
});
}
onPhoneCountryChanged(country) {
this.setState({
phoneCountry: country.iso2,
phonePrefix: country.prefix,
});
this.props.onPhoneCountryChanged(country.iso2);
}
onPhoneNumberChanged(ev) {
this.setState({phoneNumber: ev.target.value});
this.props.onPhoneNumberChanged(ev.target.value);
}
onPasswordChanged(ev) {
this.setState({password: ev.target.value}); this.setState({password: ev.target.value});
this.props.onPasswordChanged(ev.target.value); this.props.onPasswordChanged(ev.target.value);
}, }
render: function() { renderLoginField(loginType) {
switch(loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
return <input
className="mx_Login_field mx_Login_email"
key="email_input"
type="text"
name="username" // make it a little easier for browser's remember-password
onChange={this.onUsernameChanged}
placeholder="joe@example.com"
value={this.state.username}
autoFocus
/>;
case PasswordLogin.LOGIN_FIELD_MXID:
const mxidInputClasses = classNames({
"mx_Login_field": true,
"mx_Login_username": true,
"mx_Login_field_has_prefix": true,
"mx_Login_field_has_suffix": Boolean(this.props.hsDomain),
});
let suffix = null;
if (this.props.hsDomain) {
suffix = <div className="mx_Login_field_suffix">
:{this.props.hsDomain}
</div>;
}
return <div className="mx_Login_field_group">
<div className="mx_Login_field_prefix">@</div>
<input
className={mxidInputClasses}
key="username_input"
type="text"
name="username" // make it a little easier for browser's remember-password
onChange={this.onUsernameChanged}
placeholder="username"
value={this.state.username}
autoFocus
/>
{suffix}
</div>;
case PasswordLogin.LOGIN_FIELD_PHONE:
const CountryDropdown = sdk.getComponent('views.login.CountryDropdown');
const prefix = this.state.phonePrefix;
return <div className="mx_Login_phoneSection">
<CountryDropdown
className="mx_Login_phoneCountry"
ref="phone_country"
onOptionChange={this.onPhoneCountryChanged}
value={this.state.phoneCountry}
/>
<div className="mx_Login_field_group">
<div className="mx_Login_field_prefix">+{prefix}</div>
<input
className="mx_Login_phoneNumberField mx_Login_field mx_Login_field_has_prefix"
ref="phoneNumber"
key="phone_input"
type="text"
name="phoneNumber"
onChange={this.onPhoneNumberChanged}
placeholder="Mobile phone number"
value={this.state.phoneNumber}
autoFocus
/>
</div>
</div>;
}
}
render() {
var forgotPasswordJsx; var forgotPasswordJsx;
if (this.props.onForgotPasswordClick) { if (this.props.onForgotPasswordClick) {
@ -92,14 +191,25 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
error: this.props.loginIncorrect, error: this.props.loginIncorrect,
}); });
const Dropdown = sdk.getComponent('elements.Dropdown');
const loginField = this.renderLoginField(this.state.loginType);
return ( return (
<div> <div>
<form onSubmit={this.onSubmitForm}> <form onSubmit={this.onSubmitForm}>
<input className="mx_Login_field" type="text" <div className="mx_Login_type_container">
name="username" // make it a little easier for browser's remember-password <label className="mx_Login_type_label">I want to sign in with my</label>
value={this.state.username} onChange={this.onUsernameChanged} <Dropdown
placeholder="Email or user name" autoFocus /> className="mx_Login_type_dropdown"
<br /> value={this.state.loginType}
onOptionChange={this.onLoginTypeChange}>
<span key={PasswordLogin.LOGIN_FIELD_MXID}>Matrix ID</span>
<span key={PasswordLogin.LOGIN_FIELD_EMAIL}>Email Address</span>
<span key={PasswordLogin.LOGIN_FIELD_PHONE}>Phone</span>
</Dropdown>
</div>
{loginField}
<input className={pwFieldClass} ref={(e) => {this._passwordField = e;}} type="password" <input className={pwFieldClass} ref={(e) => {this._passwordField = e;}} type="password"
name="password" name="password"
value={this.state.password} onChange={this.onPasswordChanged} value={this.state.password} onChange={this.onPasswordChanged}
@ -111,4 +221,25 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
</div> </div>
); );
} }
}); }
PasswordLogin.LOGIN_FIELD_EMAIL = "login_field_email";
PasswordLogin.LOGIN_FIELD_MXID = "login_field_mxid";
PasswordLogin.LOGIN_FIELD_PHONE = "login_field_phone";
PasswordLogin.propTypes = {
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,
hsDomain: React.PropTypes.string,
};
module.exports = PasswordLogin;

View file

@ -19,9 +19,12 @@ import React from 'react';
import { field_input_incorrect } from '../../../UiEffects'; import { field_input_incorrect } from '../../../UiEffects';
import sdk from '../../../index'; import sdk from '../../../index';
import Email from '../../../email'; import Email from '../../../email';
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
const FIELD_EMAIL = 'field_email'; 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_USERNAME = 'field_username';
const FIELD_PASSWORD = 'field_password'; const FIELD_PASSWORD = 'field_password';
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
@ -35,6 +38,8 @@ module.exports = React.createClass({
propTypes: { propTypes: {
// Values pre-filled in the input boxes when the component loads // Values pre-filled in the input boxes when the component loads
defaultEmail: React.PropTypes.string, defaultEmail: React.PropTypes.string,
defaultPhoneCountry: React.PropTypes.string,
defaultPhoneNumber: React.PropTypes.string,
defaultUsername: React.PropTypes.string, defaultUsername: React.PropTypes.string,
defaultPassword: React.PropTypes.string, defaultPassword: React.PropTypes.string,
teamsConfig: React.PropTypes.shape({ teamsConfig: React.PropTypes.shape({
@ -71,6 +76,8 @@ module.exports = React.createClass({
return { return {
fieldValid: {}, fieldValid: {},
selectedTeam: null, selectedTeam: null,
// The ISO2 country code selected in the phone number entry
phoneCountry: this.props.defaultPhoneCountry,
}; };
}, },
@ -85,6 +92,7 @@ module.exports = React.createClass({
this.validateField(FIELD_PASSWORD_CONFIRM); this.validateField(FIELD_PASSWORD_CONFIRM);
this.validateField(FIELD_PASSWORD); this.validateField(FIELD_PASSWORD);
this.validateField(FIELD_USERNAME); this.validateField(FIELD_USERNAME);
this.validateField(FIELD_PHONE_NUMBER);
this.validateField(FIELD_EMAIL); this.validateField(FIELD_EMAIL);
var self = this; var self = this;
@ -118,6 +126,8 @@ module.exports = React.createClass({
username: this.refs.username.value.trim() || this.props.guestUsername, username: this.refs.username.value.trim() || this.props.guestUsername,
password: this.refs.password.value.trim(), password: this.refs.password.value.trim(),
email: email, email: email,
phoneCountry: this.state.phoneCountry,
phoneNumber: this.refs.phoneNumber.value.trim(),
}); });
if (promise) { if (promise) {
@ -174,6 +184,11 @@ module.exports = React.createClass({
const emailValid = email === '' || Email.looksValid(email); const emailValid = email === '' || Email.looksValid(email);
this.markFieldValid(field_id, emailValid, "RegistrationForm.ERR_EMAIL_INVALID"); this.markFieldValid(field_id, emailValid, "RegistrationForm.ERR_EMAIL_INVALID");
break; 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: case FIELD_USERNAME:
// XXX: SPEC-1 // XXX: SPEC-1
var username = this.refs.username.value.trim() || this.props.guestUsername; var username = this.refs.username.value.trim() || this.props.guestUsername;
@ -233,6 +248,8 @@ module.exports = React.createClass({
switch (field_id) { switch (field_id) {
case FIELD_EMAIL: case FIELD_EMAIL:
return this.refs.email; return this.refs.email;
case FIELD_PHONE_NUMBER:
return this.refs.phoneNumber;
case FIELD_USERNAME: case FIELD_USERNAME:
return this.refs.username; return this.refs.username;
case FIELD_PASSWORD: case FIELD_PASSWORD:
@ -251,6 +268,13 @@ module.exports = React.createClass({
return cls; return cls;
}, },
_onPhoneCountryChange(newVal) {
this.setState({
phoneCountry: newVal.iso2,
phonePrefix: newVal.prefix,
});
},
render: function() { render: function() {
var self = this; var self = this;
@ -286,6 +310,31 @@ module.exports = React.createClass({
} }
} }
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}
/>
<div className="mx_Login_field_group">
<div className="mx_Login_field_prefix">+{this.state.phonePrefix}</div>
<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',
'mx_Login_field_has_prefix'
)}
onBlur={function() {self.validateField(FIELD_PHONE_NUMBER);}}
value={self.state.phoneNumber}
/>
</div>
</div>
);
const registerButton = ( const registerButton = (
<input className="mx_Login_submit" type="submit" value="Register" /> <input className="mx_Login_submit" type="submit" value="Register" />
); );
@ -300,6 +349,7 @@ module.exports = React.createClass({
<form onSubmit={this.onSubmit}> <form onSubmit={this.onSubmit}>
{emailSection} {emailSection}
{belowEmailSection} {belowEmailSection}
{phoneSection}
<input type="text" ref="username" <input type="text" ref="username"
placeholder={ placeholderUserName } defaultValue={this.props.defaultUsername} placeholder={ placeholderUserName } defaultValue={this.props.defaultUsername}
className={this._classForField(FIELD_USERNAME, 'mx_Login_field')} className={this._classForField(FIELD_USERNAME, 'mx_Login_field')}

View file

@ -27,8 +27,7 @@ module.exports = React.createClass({
displayName: 'ServerConfig', displayName: 'ServerConfig',
propTypes: { propTypes: {
onHsUrlChanged: React.PropTypes.func, onServerConfigChange: React.PropTypes.func,
onIsUrlChanged: React.PropTypes.func,
// default URLs are defined in config.json (or the hardcoded defaults) // default URLs are defined in config.json (or the hardcoded defaults)
// they are used if the user has not overridden them with a custom URL. // they are used if the user has not overridden them with a custom URL.
@ -50,8 +49,7 @@ module.exports = React.createClass({
getDefaultProps: function() { getDefaultProps: function() {
return { return {
onHsUrlChanged: function() {}, onServerConfigChange: function() {},
onIsUrlChanged: function() {},
customHsUrl: "", customHsUrl: "",
customIsUrl: "", customIsUrl: "",
withToggleButton: false, withToggleButton: false,
@ -75,7 +73,10 @@ module.exports = React.createClass({
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, function() { this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, function() {
var hsUrl = this.state.hs_url.trim().replace(/\/$/, ""); var hsUrl = this.state.hs_url.trim().replace(/\/$/, "");
if (hsUrl === "") hsUrl = this.props.defaultHsUrl; if (hsUrl === "") hsUrl = this.props.defaultHsUrl;
this.props.onHsUrlChanged(hsUrl); this.props.onServerConfigChange({
hsUrl : this.state.hs_url,
isUrl : this.state.is_url,
});
}); });
}); });
}, },
@ -85,7 +86,10 @@ module.exports = React.createClass({
this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, function() { this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, function() {
var isUrl = this.state.is_url.trim().replace(/\/$/, ""); var isUrl = this.state.is_url.trim().replace(/\/$/, "");
if (isUrl === "") isUrl = this.props.defaultIsUrl; if (isUrl === "") isUrl = this.props.defaultIsUrl;
this.props.onIsUrlChanged(isUrl); this.props.onServerConfigChange({
hsUrl : this.state.hs_url,
isUrl : this.state.is_url,
});
}); });
}); });
}, },
@ -102,12 +106,16 @@ module.exports = React.createClass({
configVisible: visible configVisible: visible
}); });
if (!visible) { if (!visible) {
this.props.onHsUrlChanged(this.props.defaultHsUrl); this.props.onServerConfigChange({
this.props.onIsUrlChanged(this.props.defaultIsUrl); hsUrl : this.props.defaultHsUrl,
isUrl : this.props.defaultIsUrl,
});
} }
else { else {
this.props.onHsUrlChanged(this.state.hs_url); this.props.onServerConfigChange({
this.props.onIsUrlChanged(this.state.is_url); hsUrl : this.state.hs_url,
isUrl : this.state.is_url,
});
} }
}, },

View file

@ -346,7 +346,7 @@ module.exports = React.createClass({
return ( return (
<span className="mx_MFileBody"> <span className="mx_MFileBody">
<div className="mx_MImageBody_download"> <div className="mx_MImageBody_download">
<a className="mx_ImageBody_downloadLink" href={contentUrl} target="_blank"> <a className="mx_ImageBody_downloadLink" href={contentUrl} download={fileName} target="_blank">
{ fileName } { fileName }
</a> </a>
<div className="mx_MImageBody_size"> <div className="mx_MImageBody_size">
@ -360,7 +360,7 @@ module.exports = React.createClass({
return ( return (
<span className="mx_MFileBody"> <span className="mx_MFileBody">
<div className="mx_MImageBody_download"> <div className="mx_MImageBody_download">
<a href={contentUrl} target="_blank" rel="noopener"> <a href={contentUrl} download={fileName} target="_blank" rel="noopener">
<img src={tintedDownloadImageURL} width="12" height="14" ref="downloadImage"/> <img src={tintedDownloadImageURL} width="12" height="14" ref="downloadImage"/>
Download {text} Download {text}
</a> </a>

View file

@ -56,6 +56,7 @@ module.exports = React.createClass({
const ImageView = sdk.getComponent("elements.ImageView"); const ImageView = sdk.getComponent("elements.ImageView");
const params = { const params = {
src: httpUrl, src: httpUrl,
name: content.body && content.body.length > 0 ? content.body : 'Attachment',
mxEvent: this.props.mxEvent, mxEvent: this.props.mxEvent,
}; };

View file

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

View file

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

View file

@ -25,18 +25,10 @@ var TextForEvent = require('../../../TextForEvent');
import WithMatrixClient from '../../../wrappers/WithMatrixClient'; import WithMatrixClient from '../../../wrappers/WithMatrixClient';
var ContextualMenu = require('../../structures/ContextualMenu'); var ContextualMenu = require('../../structures/ContextualMenu');
var dispatcher = require("../../../dispatcher"); import dis from '../../../dispatcher';
var ObjectUtils = require('../../../ObjectUtils'); var ObjectUtils = require('../../../ObjectUtils');
var bounce = false;
try {
if (global.localStorage) {
bounce = global.localStorage.getItem('avatar_bounce') == 'true';
}
} catch (e) {
}
var eventTileTypes = { var eventTileTypes = {
'm.room.message': 'messages.MessageEvent', 'm.room.message': 'messages.MessageEvent',
'm.room.member' : 'messages.TextualEvent', 'm.room.member' : 'messages.TextualEvent',
@ -48,6 +40,7 @@ var eventTileTypes = {
'm.room.third_party_invite' : 'messages.TextualEvent', 'm.room.third_party_invite' : 'messages.TextualEvent',
'm.room.history_visibility' : 'messages.TextualEvent', 'm.room.history_visibility' : 'messages.TextualEvent',
'm.room.encryption' : 'messages.TextualEvent', 'm.room.encryption' : 'messages.TextualEvent',
'm.room.power_levels' : 'messages.TextualEvent',
}; };
var MAX_READ_AVATARS = 5; var MAX_READ_AVATARS = 5;
@ -73,6 +66,12 @@ module.exports = WithMatrixClient(React.createClass({
/* the MatrixEvent to show */ /* the MatrixEvent to show */
mxEvent: React.PropTypes.object.isRequired, mxEvent: React.PropTypes.object.isRequired,
/* true if mxEvent is redacted. This is a prop because using mxEvent.isRedacted()
* might not be enough when deciding shouldComponentUpdate - prevProps.mxEvent
* references the same this.props.mxEvent.
*/
isRedacted: React.PropTypes.bool,
/* true if this is a continuation of the previous event (which has the /* true if this is a continuation of the previous event (which has the
* effect of not showing another avatar/displayname * effect of not showing another avatar/displayname
*/ */
@ -285,9 +284,16 @@ module.exports = WithMatrixClient(React.createClass({
}, },
getReadAvatars: function() { getReadAvatars: function() {
var ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker');
var avatars = []; // return early if there are no read receipts
var left = 0; if (!this.props.readReceipts || this.props.readReceipts.length === 0) {
return (<span className="mx_EventTile_readAvatars"></span>);
}
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. // It's possible that the receipt was sent several days AFTER the event.
// If it is, we want to display the complete date along with the HH:MM:SS, // If it is, we want to display the complete date along with the HH:MM:SS,
@ -307,6 +313,12 @@ module.exports = WithMatrixClient(React.createClass({
if ((i < MAX_READ_AVATARS) || this.state.allReadAvatars) { if ((i < MAX_READ_AVATARS) || this.state.allReadAvatars) {
hidden = false; hidden = false;
} }
// TODO: we keep the extra read avatars in the dom to make animation simpler
// we could optimise this to reduce the dom size.
// If hidden, set offset equal to the offset of the final visible avatar or
// else set it proportional to index
left = (hidden ? MAX_READ_AVATARS - 1 : i) * -receiptOffset;
var userId = receipt.roomMember.userId; var userId = receipt.roomMember.userId;
var readReceiptInfo; var readReceiptInfo;
@ -318,11 +330,6 @@ module.exports = WithMatrixClient(React.createClass({
this.props.readReceiptMap[userId] = readReceiptInfo; this.props.readReceiptMap[userId] = readReceiptInfo;
} }
} }
// 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;
}
// add to the start so the most recent is on the end (ie. ends up rightmost) // add to the start so the most recent is on the end (ie. ends up rightmost)
avatars.unshift( avatars.unshift(
@ -343,7 +350,7 @@ module.exports = WithMatrixClient(React.createClass({
if (remainder > 0) { if (remainder > 0) {
remText = <span className="mx_EventTile_readAvatarRemainder" remText = <span className="mx_EventTile_readAvatarRemainder"
onClick={this.toggleAllReadAvatars} onClick={this.toggleAllReadAvatars}
style={{ right: -(left - 15) }}>{ remainder }+ style={{ right: -(left - receiptOffset) }}>{ remainder }+
</span>; </span>;
} }
} }
@ -356,7 +363,7 @@ module.exports = WithMatrixClient(React.createClass({
onSenderProfileClick: function(event) { onSenderProfileClick: function(event) {
var mxEvent = this.props.mxEvent; var mxEvent = this.props.mxEvent;
dispatcher.dispatch({ dis.dispatch({
action: 'insert_displayname', action: 'insert_displayname',
displayname: (mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()).replace(' (IRC)', ''), displayname: (mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender()).replace(' (IRC)', ''),
}); });
@ -372,6 +379,17 @@ module.exports = WithMatrixClient(React.createClass({
}); });
}, },
onPermalinkClicked: function(e) {
// This allows the permalink to be opened in a new tab/window or copied as
// matrix.to, but also for it to enable routing within Riot when clicked.
e.preventDefault();
dis.dispatch({
action: 'view_room',
event_id: this.props.mxEvent.getId(),
room_id: this.props.mxEvent.getRoomId(),
});
},
render: function() { render: function() {
var MessageTimestamp = sdk.getComponent('messages.MessageTimestamp'); var MessageTimestamp = sdk.getComponent('messages.MessageTimestamp');
var SenderProfile = sdk.getComponent('messages.SenderProfile'); var SenderProfile = sdk.getComponent('messages.SenderProfile');
@ -383,8 +401,7 @@ module.exports = WithMatrixClient(React.createClass({
var msgtype = content.msgtype; var msgtype = content.msgtype;
var eventType = this.props.mxEvent.getType(); var eventType = this.props.mxEvent.getType();
// Info messages are basically information about commands processed on a // Info messages are basically information about commands processed on a room
// room, or emote messages
var isInfoMessage = (eventType !== 'm.room.message'); var isInfoMessage = (eventType !== 'm.room.message');
var EventTileType = sdk.getComponent(eventTileTypes[eventType]); var EventTileType = sdk.getComponent(eventTileTypes[eventType]);
@ -396,6 +413,7 @@ module.exports = WithMatrixClient(React.createClass({
var e2eEnabled = this.props.matrixClient.isRoomEncrypted(this.props.mxEvent.getRoomId()); var e2eEnabled = this.props.matrixClient.isRoomEncrypted(this.props.mxEvent.getRoomId());
var isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1); var isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1);
const isRedacted = (eventType === 'm.room.message') && this.props.isRedacted;
var classes = classNames({ var classes = classNames({
mx_EventTile: true, mx_EventTile: true,
@ -411,9 +429,14 @@ module.exports = WithMatrixClient(React.createClass({
menu: this.state.menu, menu: this.state.menu,
mx_EventTile_verified: this.state.verified == true, mx_EventTile_verified: this.state.verified == true,
mx_EventTile_unverified: this.state.verified == false, mx_EventTile_unverified: this.state.verified == false,
mx_EventTile_bad: this.props.mxEvent.getContent().msgtype === 'm.bad.encrypted', mx_EventTile_bad: msgtype === 'm.bad.encrypted',
mx_EventTile_emote: msgtype === 'm.emote',
mx_EventTile_redacted: isRedacted,
}); });
var permalink = "https://matrix.to/#/" + this.props.mxEvent.getRoomId() +"/"+ this.props.mxEvent.getId();
const permalink = "https://matrix.to/#/" +
this.props.mxEvent.getRoomId() + "/" +
this.props.mxEvent.getId();
var readAvatars = this.getReadAvatars(); var readAvatars = this.getReadAvatars();
@ -486,6 +509,8 @@ module.exports = WithMatrixClient(React.createClass({
else if (e2eEnabled) { else if (e2eEnabled) {
e2e = <img onClick={ this.onCryptoClicked } className="mx_EventTile_e2eIcon" src="img/e2e-unencrypted.svg" width="12" height="12"/>; e2e = <img onClick={ this.onCryptoClicked } className="mx_EventTile_e2eIcon" src="img/e2e-unencrypted.svg" width="12" height="12"/>;
} }
const timestamp = this.props.mxEvent.getTs() ?
<MessageTimestamp ts={this.props.mxEvent.getTs()} /> : null;
if (this.props.tileShape === "notif") { if (this.props.tileShape === "notif") {
var room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId()); var room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId());
@ -493,15 +518,15 @@ module.exports = WithMatrixClient(React.createClass({
return ( return (
<div className={classes}> <div className={classes}>
<div className="mx_EventTile_roomName"> <div className="mx_EventTile_roomName">
<a href={ permalink }> <a href={ permalink } onClick={this.onPermalinkClicked}>
{ room ? room.name : '' } { room ? room.name : '' }
</a> </a>
</div> </div>
<div className="mx_EventTile_senderDetails"> <div className="mx_EventTile_senderDetails">
{ avatar } { avatar }
<a href={ permalink }> <a href={ permalink } onClick={this.onPermalinkClicked}>
{ sender } { sender }
<MessageTimestamp ts={this.props.mxEvent.getTs()} /> { timestamp }
</a> </a>
</div> </div>
<div className="mx_EventTile_line" > <div className="mx_EventTile_line" >
@ -527,10 +552,14 @@ module.exports = WithMatrixClient(React.createClass({
tileShape={this.props.tileShape} tileShape={this.props.tileShape}
onWidgetLoad={this.props.onWidgetLoad} /> onWidgetLoad={this.props.onWidgetLoad} />
</div> </div>
<a className="mx_EventTile_senderDetailsLink" href={ permalink }> <a
className="mx_EventTile_senderDetailsLink"
href={ permalink }
onClick={this.onPermalinkClicked}
>
<div className="mx_EventTile_senderDetails"> <div className="mx_EventTile_senderDetails">
{ sender } { sender }
<MessageTimestamp ts={this.props.mxEvent.getTs()} /> { timestamp }
</div> </div>
</a> </a>
</div> </div>
@ -545,8 +574,8 @@ module.exports = WithMatrixClient(React.createClass({
{ avatar } { avatar }
{ sender } { sender }
<div className="mx_EventTile_line"> <div className="mx_EventTile_line">
<a href={ permalink }> <a href={ permalink } onClick={this.onPermalinkClicked}>
<MessageTimestamp ts={this.props.mxEvent.getTs()} /> { timestamp }
</a> </a>
{ e2e } { e2e }
<EventTileType ref="tile" <EventTileType ref="tile"
@ -564,7 +593,8 @@ module.exports = WithMatrixClient(React.createClass({
})); }));
module.exports.haveTileForEvent = function(e) { module.exports.haveTileForEvent = function(e) {
if (e.isRedacted()) return false; // Only messages have a tile (black-rectangle) if redacted
if (e.isRedacted() && e.getType() !== 'm.room.message') return false;
if (eventTileTypes[e.getType()] == undefined) return false; if (eventTileTypes[e.getType()] == undefined) return false;
if (eventTileTypes[e.getType()] == 'messages.TextualEvent') { if (eventTileTypes[e.getType()] == 'messages.TextualEvent') {
return TextForEvent.textForEvent(e) !== ''; return TextForEvent.textForEvent(e) !== '';

View file

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

View file

@ -43,6 +43,7 @@ export default class MessageComposer extends React.Component {
this.onToggleMarkdownClicked = this.onToggleMarkdownClicked.bind(this); this.onToggleMarkdownClicked = this.onToggleMarkdownClicked.bind(this);
this.onInputStateChanged = this.onInputStateChanged.bind(this); this.onInputStateChanged = this.onInputStateChanged.bind(this);
this.onEvent = this.onEvent.bind(this); this.onEvent = this.onEvent.bind(this);
this.onPageUnload = this.onPageUnload.bind(this);
this.state = { this.state = {
autocompleteQuery: '', autocompleteQuery: '',
@ -50,7 +51,7 @@ export default class MessageComposer extends React.Component {
inputState: { inputState: {
style: [], style: [],
blockType: null, blockType: null,
isRichtextEnabled: UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true), isRichtextEnabled: UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', false),
wordCount: 0, wordCount: 0,
}, },
showFormatting: UserSettingsStore.getSyncedSetting('MessageComposer.showFormatting', false), showFormatting: UserSettingsStore.getSyncedSetting('MessageComposer.showFormatting', false),
@ -64,12 +65,21 @@ export default class MessageComposer extends React.Component {
// marked as encrypted. // marked as encrypted.
// XXX: fragile as all hell - fixme somehow, perhaps with a dedicated Room.encryption event or something. // XXX: fragile as all hell - fixme somehow, perhaps with a dedicated Room.encryption event or something.
MatrixClientPeg.get().on("event", this.onEvent); MatrixClientPeg.get().on("event", this.onEvent);
window.addEventListener('beforeunload', this.onPageUnload);
} }
componentWillUnmount() { componentWillUnmount() {
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("event", this.onEvent); MatrixClientPeg.get().removeListener("event", this.onEvent);
} }
window.removeEventListener('beforeunload', this.onPageUnload);
}
onPageUnload(event) {
if (this.messageComposerInput) {
this.messageComposerInput.sentHistory.saveLastTextEntry();
}
} }
onEvent(event) { onEvent(event) {
@ -91,8 +101,9 @@ export default class MessageComposer extends React.Component {
this.refs.uploadInput.click(); this.refs.uploadInput.click();
} }
onUploadFileSelected(ev) { onUploadFileSelected(files, isPasted) {
let files = ev.target.files; if (!isPasted)
files = files.target.files;
let QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); let QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
let TintableSvg = sdk.getComponent("elements.TintableSvg"); let TintableSvg = sdk.getComponent("elements.TintableSvg");
@ -100,7 +111,7 @@ export default class MessageComposer extends React.Component {
let fileList = []; let fileList = [];
for (let i=0; i<files.length; i++) { for (let i=0; i<files.length; i++) {
fileList.push(<li key={i}> fileList.push(<li key={i}>
<TintableSvg key={i} src="img/files.svg" width="16" height="16" /> {files[i].name} <TintableSvg key={i} src="img/files.svg" width="16" height="16" /> {files[i].name || 'Attachment'}
</li>); </li>);
} }
@ -299,6 +310,7 @@ export default class MessageComposer extends React.Component {
tryComplete={this._tryComplete} tryComplete={this._tryComplete}
onUpArrow={this.onUpArrow} onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow} onDownArrow={this.onDownArrow}
onUploadFileSelected={this.onUploadFileSelected}
tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete
onContentChanged={this.onInputContentChanged} onContentChanged={this.onInputContentChanged}
onInputStateChanged={this.onInputStateChanged} />, onInputStateChanged={this.onInputStateChanged} />,

View file

@ -96,8 +96,20 @@ export default class MessageComposerInput extends React.Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
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);
this.onDownArrow = this.onDownArrow.bind(this);
this.onTab = this.onTab.bind(this);
this.onEscape = this.onEscape.bind(this);
this.setDisplayedCompletion = this.setDisplayedCompletion.bind(this);
this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this);
const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true); const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', false);
this.state = { this.state = {
// whether we're in rich text or markdown mode // whether we're in rich text or markdown mode
@ -261,6 +273,7 @@ export default class MessageComposerInput extends React.Component {
} }
sendTyping(isTyping) { sendTyping(isTyping) {
if (UserSettingsStore.getSyncedSetting('dontSendTypingNotifications', false)) return;
MatrixClientPeg.get().sendTyping( MatrixClientPeg.get().sendTyping(
this.props.room.roomId, this.props.room.roomId,
this.isTyping, TYPING_SERVER_TIMEOUT, this.isTyping, TYPING_SERVER_TIMEOUT,
@ -404,10 +417,14 @@ export default class MessageComposerInput extends React.Component {
} }
return false; return false;
}; }
handleReturn = (ev) => { handlePastedFiles(files) {
if(ev.shiftKey) { this.props.onUploadFileSelected(files, true);
}
handleReturn(ev) {
if (ev.shiftKey) {
this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState)); this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState));
return true; return true;
} }
@ -442,7 +459,7 @@ export default class MessageComposerInput extends React.Component {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Server error", title: "Server error",
description: err.message, description: ((err && err.message) ? err.message : "Server unavailable, overloaded, or something else went wrong."),
}); });
}); });
} else if (cmd.error) { } else if (cmd.error) {
@ -473,9 +490,9 @@ export default class MessageComposerInput extends React.Component {
let sendTextFn = this.client.sendTextMessage; let sendTextFn = this.client.sendTextMessage;
if (contentText.startsWith('/me')) { if (contentText.startsWith('/me')) {
contentText = contentText.replace('/me', ''); contentText = contentText.replace('/me ', '');
// bit of a hack, but the alternative would be quite complicated // 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; sendHtmlFn = this.client.sendHtmlEmote;
sendTextFn = this.client.sendEmoteMessage; sendTextFn = this.client.sendEmoteMessage;
} }
@ -686,6 +703,7 @@ export default class MessageComposerInput extends React.Component {
keyBindingFn={MessageComposerInput.getKeyBinding} keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand} handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn} handleReturn={this.handleReturn}
handlePastedFiles={this.handlePastedFiles}
stripPastedStyles={!this.state.isRichtextEnabled} stripPastedStyles={!this.state.isRichtextEnabled}
onTab={this.onTab} onTab={this.onTab}
onUpArrow={this.onUpArrow} onUpArrow={this.onUpArrow}
@ -697,3 +715,28 @@ export default class MessageComposerInput extends React.Component {
); );
} }
} }
MessageComposerInput.propTypes = {
tabComplete: React.PropTypes.any,
// a callback which is called when the height of the composer is
// changed due to a change in content.
onResize: React.PropTypes.func,
// js-sdk Room object
room: React.PropTypes.object.isRequired,
// called with current plaintext content (as a string) whenever it changes
onContentChanged: React.PropTypes.func,
onUpArrow: React.PropTypes.func,
onDownArrow: React.PropTypes.func,
onUploadFileSelected: React.PropTypes.func,
// attempts to confirm currently selected completion, returns whether actually confirmed
tryComplete: React.PropTypes.func,
onInputStateChanged: React.PropTypes.func,
};

View file

@ -20,6 +20,7 @@ var SlashCommands = require("../../../SlashCommands");
var Modal = require("../../../Modal"); var Modal = require("../../../Modal");
var MemberEntry = require("../../../TabCompleteEntries").MemberEntry; var MemberEntry = require("../../../TabCompleteEntries").MemberEntry;
var sdk = require('../../../index'); var sdk = require('../../../index');
import UserSettingsStore from "../../../UserSettingsStore";
var dis = require("../../../dispatcher"); var dis = require("../../../dispatcher");
var KeyCode = require("../../../KeyCode"); var KeyCode = require("../../../KeyCode");
@ -311,7 +312,7 @@ export default React.createClass({
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Server error", title: "Server error",
description: err.message description: ((err && err.message) ? err.message : "Server unavailable, overloaded, or something else went wrong."),
}); });
}); });
} }
@ -420,6 +421,7 @@ export default React.createClass({
}, },
sendTyping: function(isTyping) { sendTyping: function(isTyping) {
if (UserSettingsStore.getSyncedSetting('dontSendTypingNotifications', false)) return;
MatrixClientPeg.get().sendTyping( MatrixClientPeg.get().sendTyping(
this.props.room.roomId, this.props.room.roomId,
this.isTyping, TYPING_SERVER_TIMEOUT this.isTyping, TYPING_SERVER_TIMEOUT

View file

@ -75,7 +75,7 @@ module.exports = React.createClass({
render: function() { render: function() {
if (this.props.activeAgo >= 0) { if (this.props.activeAgo >= 0) {
var ago = this.props.currentlyActive ? "now" : (this.getDuration(this.props.activeAgo) + " ago"); var ago = this.props.currentlyActive ? "" : "for " + (this.getDuration(this.props.activeAgo));
// var ago = this.getDuration(this.props.activeAgo) + " ago"; // var ago = this.getDuration(this.props.activeAgo) + " ago";
// if (this.props.currentlyActive) ago += " (now?)"; // if (this.props.currentlyActive) ago += " (now?)";
return ( return (

View file

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

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -21,15 +22,23 @@ var GeminiScrollbar = require('react-gemini-scrollbar');
var MatrixClientPeg = require("../../../MatrixClientPeg"); var MatrixClientPeg = require("../../../MatrixClientPeg");
var CallHandler = require('../../../CallHandler'); var CallHandler = require('../../../CallHandler');
var RoomListSorter = require("../../../RoomListSorter"); var RoomListSorter = require("../../../RoomListSorter");
var Unread = require('../../../Unread');
var dis = require("../../../dispatcher"); var dis = require("../../../dispatcher");
var sdk = require('../../../index'); var sdk = require('../../../index');
var rate_limited_func = require('../../../ratelimitedfunc'); var rate_limited_func = require('../../../ratelimitedfunc');
var Rooms = require('../../../Rooms'); var Rooms = require('../../../Rooms');
import DMRoomMap from '../../../utils/DMRoomMap'; import DMRoomMap from '../../../utils/DMRoomMap';
var Receipt = require('../../../utils/Receipt'); var Receipt = require('../../../utils/Receipt');
var constantTimeDispatcher = require('../../../ConstantTimeDispatcher');
import AccessibleButton from '../elements/AccessibleButton';
var HIDE_CONFERENCE_CHANS = true; const HIDE_CONFERENCE_CHANS = true;
const VERBS = {
'm.favourite': 'favourite',
'im.vector.fake.direct': 'tag direct chat',
'im.vector.fake.recent': 'restore',
'm.lowpriority': 'demote',
};
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'RoomList', displayName: 'RoomList',
@ -37,13 +46,23 @@ module.exports = React.createClass({
propTypes: { propTypes: {
ConferenceHandler: React.PropTypes.any, ConferenceHandler: React.PropTypes.any,
collapsed: React.PropTypes.bool.isRequired, collapsed: React.PropTypes.bool.isRequired,
currentRoom: React.PropTypes.string, selectedRoom: React.PropTypes.string,
searchFilter: React.PropTypes.string, searchFilter: React.PropTypes.string,
}, },
shouldComponentUpdate: function(nextProps, nextState) {
if (nextProps.collapsed !== this.props.collapsed) return true;
if (nextProps.searchFilter !== this.props.searchFilter) return true;
if (nextState.lists !== this.state.lists ||
nextState.isLoadingLeftRooms !== this.state.isLoadingLeftRooms ||
nextState.incomingCall !== this.state.incomingCall) return true;
return false;
},
getInitialState: function() { getInitialState: function() {
return { return {
isLoadingLeftRooms: false, isLoadingLeftRooms: false,
totalRoomCount: null,
lists: {}, lists: {},
incomingCall: null, incomingCall: null,
}; };
@ -57,12 +76,21 @@ module.exports = React.createClass({
cli.on("Room.name", this.onRoomName); cli.on("Room.name", this.onRoomName);
cli.on("Room.tags", this.onRoomTags); cli.on("Room.tags", this.onRoomTags);
cli.on("Room.receipt", this.onRoomReceipt); cli.on("Room.receipt", this.onRoomReceipt);
cli.on("RoomState.events", this.onRoomStateEvents); cli.on("RoomState.members", this.onRoomStateMember);
cli.on("RoomMember.name", this.onRoomMemberName); cli.on("RoomMember.name", this.onRoomMemberName);
cli.on("accountData", this.onAccountData); cli.on("accountData", this.onAccountData);
var s = this.getRoomLists(); // lookup for which lists a given roomId is currently in.
this.setState(s); this.listsForRoomId = {};
this.refreshRoomList();
// order of the sublists
//this.listOrder = [];
// loop count to stop a stack overflow if the user keeps waggling the
// mouse for >30s in a row, or if running under mocha
this._delayedRefreshRoomListLoopCount = 0
}, },
componentDidMount: function() { componentDidMount: function() {
@ -71,7 +99,22 @@ module.exports = React.createClass({
this._updateStickyHeaders(true); this._updateStickyHeaders(true);
}, },
componentDidUpdate: function() { componentWillReceiveProps: function(nextProps) {
// short-circuit react when the room changes
// to avoid rerendering all the sublists everywhere
if (nextProps.selectedRoom !== this.props.selectedRoom) {
if (this.props.selectedRoom) {
constantTimeDispatcher.dispatch(
"RoomTile.select", this.props.selectedRoom, {}
);
}
constantTimeDispatcher.dispatch(
"RoomTile.select", nextProps.selectedRoom, { selected: true }
);
}
},
componentDidUpdate: function(prevProps, prevState) {
// Reinitialise the stickyHeaders when the component is updated // Reinitialise the stickyHeaders when the component is updated
this._updateStickyHeaders(true); this._updateStickyHeaders(true);
this._repositionIncomingCallBox(undefined, false); this._repositionIncomingCallBox(undefined, false);
@ -95,6 +138,26 @@ module.exports = React.createClass({
incomingCall: null incomingCall: null
}); });
} }
break;
case 'on_room_read':
// poke the right RoomTile to refresh, using the constantTimeDispatcher
// to avoid each and every RoomTile registering to the 'on_room_read' event
// XXX: if we like the constantTimeDispatcher we might want to dispatch
// directly from TimelinePanel rather than needlessly bouncing via here.
constantTimeDispatcher.dispatch(
"RoomTile.refresh", payload.room.roomId, {}
);
// also have to poke the right list(s)
var lists = this.listsForRoomId[payload.room.roomId];
if (lists) {
lists.forEach(list=>{
constantTimeDispatcher.dispatch(
"RoomSubList.refreshHeader", list, { room: payload.room }
);
});
}
break; break;
} }
}, },
@ -108,7 +171,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
MatrixClientPeg.get().removeListener("Room.tags", this.onRoomTags); MatrixClientPeg.get().removeListener("Room.tags", this.onRoomTags);
MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().removeListener("RoomMember.name", this.onRoomMemberName); MatrixClientPeg.get().removeListener("RoomMember.name", this.onRoomMemberName);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData); MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
} }
@ -117,10 +180,14 @@ module.exports = React.createClass({
}, },
onRoom: function(room) { onRoom: function(room) {
// XXX: this happens rarely; ideally we should only update the correct
// sublists when it does (e.g. via a constantTimeDispatch to the right sublist)
this._delayedRefreshRoomList(); this._delayedRefreshRoomList();
}, },
onDeleteRoom: function(roomId) { onDeleteRoom: function(roomId) {
// XXX: this happens rarely; ideally we should only update the correct
// sublists when it does (e.g. via a constantTimeDispatch to the right sublist)
this._delayedRefreshRoomList(); this._delayedRefreshRoomList();
}, },
@ -143,6 +210,10 @@ module.exports = React.createClass({
} }
}, },
_onMouseOver: function(ev) {
this._lastMouseOverTs = Date.now();
},
onSubListHeaderClick: function(isHidden, scrollToPosition) { onSubListHeaderClick: function(isHidden, scrollToPosition) {
// The scroll area has expanded or contracted, so re-calculate sticky headers positions // The scroll area has expanded or contracted, so re-calculate sticky headers positions
this._updateStickyHeaders(true, scrollToPosition); this._updateStickyHeaders(true, scrollToPosition);
@ -152,41 +223,98 @@ module.exports = React.createClass({
if (toStartOfTimeline) return; if (toStartOfTimeline) return;
if (!room) return; if (!room) return;
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
this._delayedRefreshRoomList();
// rather than regenerate our full roomlists, which is very heavy, we poke the
// correct sublists to just re-sort themselves. This isn't enormously reacty,
// but is much faster than the default react reconciler, or having to do voodoo
// with shouldComponentUpdate and a pleaseRefresh property or similar.
var lists = this.listsForRoomId[room.roomId];
if (lists) {
lists.forEach(list=>{
constantTimeDispatcher.dispatch("RoomSubList.sort", list, { room: room });
});
}
// we have to explicitly hit the roomtile which just changed
constantTimeDispatcher.dispatch(
"RoomTile.refresh", room.roomId, {}
);
}, },
onRoomReceipt: function(receiptEvent, room) { onRoomReceipt: function(receiptEvent, room) {
// because if we read a notification, it will affect notification count // because if we read a notification, it will affect notification count
// only bother updating if there's a receipt from us // only bother updating if there's a receipt from us
if (Receipt.findReadReceiptFromUserId(receiptEvent, MatrixClientPeg.get().credentials.userId)) { if (Receipt.findReadReceiptFromUserId(receiptEvent, MatrixClientPeg.get().credentials.userId)) {
this._delayedRefreshRoomList(); var lists = this.listsForRoomId[room.roomId];
if (lists) {
lists.forEach(list=>{
constantTimeDispatcher.dispatch(
"RoomSubList.refreshHeader", list, { room: room }
);
});
}
// we have to explicitly hit the roomtile which just changed
constantTimeDispatcher.dispatch(
"RoomTile.refresh", room.roomId, {}
);
} }
}, },
onRoomName: function(room) { onRoomName: function(room) {
this._delayedRefreshRoomList(); constantTimeDispatcher.dispatch(
"RoomTile.refresh", room.roomId, {}
);
}, },
onRoomTags: function(event, room) { onRoomTags: function(event, room) {
// XXX: this happens rarely; ideally we should only update the correct
// sublists when it does (e.g. via a constantTimeDispatch to the right sublist)
this._delayedRefreshRoomList(); this._delayedRefreshRoomList();
}, },
onRoomStateEvents: function(ev, state) { onRoomStateMember: function(ev, state, member) {
if (ev.getStateKey() === MatrixClientPeg.get().credentials.userId &&
ev.getPrevContent() && ev.getPrevContent().membership === "invite")
{
this._delayedRefreshRoomList(); this._delayedRefreshRoomList();
}
else {
constantTimeDispatcher.dispatch(
"RoomTile.refresh", member.roomId, {}
);
}
}, },
onRoomMemberName: function(ev, member) { onRoomMemberName: function(ev, member) {
this._delayedRefreshRoomList(); constantTimeDispatcher.dispatch(
"RoomTile.refresh", member.roomId, {}
);
}, },
onAccountData: function(ev) { onAccountData: function(ev) {
if (ev.getType() == 'm.direct') { if (ev.getType() == 'm.direct') {
// XXX: this happens rarely; ideally we should only update the correct
// sublists when it does (e.g. via a constantTimeDispatch to the right sublist)
this._delayedRefreshRoomList();
}
else if (ev.getType() == 'm.push_rules') {
this._delayedRefreshRoomList(); this._delayedRefreshRoomList();
} }
}, },
_delayedRefreshRoomList: new rate_limited_func(function() { _delayedRefreshRoomList: new rate_limited_func(function() {
// if the mouse has been moving over the RoomList in the last 500ms
// then delay the refresh further to avoid bouncing around under the
// cursor
if (Date.now() - this._lastMouseOverTs > 500 || this._delayedRefreshRoomListLoopCount > 60) {
this.refreshRoomList(); this.refreshRoomList();
this._delayedRefreshRoomListLoopCount = 0;
}
else {
this._delayedRefreshRoomListLoopCount++;
this._delayedRefreshRoomList();
}
}, 500), }, 500),
refreshRoomList: function() { refreshRoomList: function() {
@ -194,26 +322,36 @@ module.exports = React.createClass({
// (!this._lastRefreshRoomListTs ? "-" : (Date.now() - this._lastRefreshRoomListTs)) // (!this._lastRefreshRoomListTs ? "-" : (Date.now() - this._lastRefreshRoomListTs))
// ); // );
// TODO: rather than bluntly regenerating and re-sorting everything // TODO: ideally we'd calculate this once at start, and then maintain
// every time we see any kind of room change from the JS SDK // any changes to it incrementally, updating the appropriate sublists
// we could do incremental updates on our copy of the state // as needed.
// based on the room which has actually changed. This would stop // Alternatively we'd do something magical with Immutable.js or similar.
// us re-rendering all the sublists every time anything changes anywhere const lists = this.getRoomLists();
// in the state of the client. let totalRooms = 0;
this.setState(this.getRoomLists()); for (const l of Object.values(lists)) {
this._lastRefreshRoomListTs = Date.now(); totalRooms += l.length;
}
this.setState({
lists: this.getRoomLists(),
totalRoomCount: totalRooms,
});
// this._lastRefreshRoomListTs = Date.now();
}, },
getRoomLists: function() { getRoomLists: function() {
var self = this; var self = this;
var s = { lists: {} }; const lists = {};
s.lists["im.vector.fake.invite"] = []; lists["im.vector.fake.invite"] = [];
s.lists["m.favourite"] = []; lists["m.favourite"] = [];
s.lists["im.vector.fake.recent"] = []; lists["im.vector.fake.recent"] = [];
s.lists["im.vector.fake.direct"] = []; lists["im.vector.fake.direct"] = [];
s.lists["m.lowpriority"] = []; lists["m.lowpriority"] = [];
s.lists["im.vector.fake.archived"] = []; lists["im.vector.fake.archived"] = [];
this.listsForRoomId = {};
var otherTagNames = {};
const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); const dmRoomMap = new DMRoomMap(MatrixClientPeg.get());
@ -226,8 +364,13 @@ module.exports = React.createClass({
// ", target = " + me.events.member.getStateKey() + // ", target = " + me.events.member.getStateKey() +
// ", prevMembership = " + me.events.member.getPrevContent().membership); // ", prevMembership = " + me.events.member.getPrevContent().membership);
if (!self.listsForRoomId[room.roomId]) {
self.listsForRoomId[room.roomId] = [];
}
if (me.membership == "invite") { if (me.membership == "invite") {
s.lists["im.vector.fake.invite"].push(room); self.listsForRoomId[room.roomId].push("im.vector.fake.invite");
lists["im.vector.fake.invite"].push(room);
} }
else if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(room, me, self.props.ConferenceHandler)) { else if (HIDE_CONFERENCE_CHANS && Rooms.isConfCallRoom(room, me, self.props.ConferenceHandler)) {
// skip past this room & don't put it in any lists // skip past this room & don't put it in any lists
@ -237,81 +380,62 @@ module.exports = React.createClass({
{ {
// Used to split rooms via tags // Used to split rooms via tags
var tagNames = Object.keys(room.tags); var tagNames = Object.keys(room.tags);
if (tagNames.length) { if (tagNames.length) {
for (var i = 0; i < tagNames.length; i++) { for (var i = 0; i < tagNames.length; i++) {
var tagName = tagNames[i]; var tagName = tagNames[i];
s.lists[tagName] = s.lists[tagName] || []; lists[tagName] = lists[tagName] || [];
s.lists[tagNames[i]].push(room); lists[tagName].push(room);
self.listsForRoomId[room.roomId].push(tagName);
otherTagNames[tagName] = 1;
} }
} }
else if (dmRoomMap.getUserIdForRoomId(room.roomId)) { else if (dmRoomMap.getUserIdForRoomId(room.roomId)) {
// "Direct Message" rooms (that we're still in and that aren't otherwise tagged) // "Direct Message" rooms (that we're still in and that aren't otherwise tagged)
s.lists["im.vector.fake.direct"].push(room); self.listsForRoomId[room.roomId].push("im.vector.fake.direct");
lists["im.vector.fake.direct"].push(room);
} }
else { else {
s.lists["im.vector.fake.recent"].push(room); self.listsForRoomId[room.roomId].push("im.vector.fake.recent");
lists["im.vector.fake.recent"].push(room);
} }
} }
else if (me.membership === "leave") { else if (me.membership === "leave") {
s.lists["im.vector.fake.archived"].push(room); self.listsForRoomId[room.roomId].push("im.vector.fake.archived");
lists["im.vector.fake.archived"].push(room);
} }
else { else {
console.error("unrecognised membership: " + me.membership + " - this should never happen"); console.error("unrecognised membership: " + me.membership + " - this should never happen");
} }
}); });
if (s.lists["im.vector.fake.direct"].length == 0 &&
MatrixClientPeg.get().getAccountData('m.direct') === undefined &&
!MatrixClientPeg.get().isGuest())
{
// scan through the 'recents' list for any rooms which look like DM rooms
// and make them DM rooms
const oldRecents = s.lists["im.vector.fake.recent"];
s.lists["im.vector.fake.recent"] = [];
for (const room of oldRecents) {
const me = room.getMember(MatrixClientPeg.get().credentials.userId);
if (me && Rooms.looksLikeDirectMessageRoom(room, me)) {
s.lists["im.vector.fake.direct"].push(room);
} else {
s.lists["im.vector.fake.recent"].push(room);
}
}
// save these new guessed DM rooms into the account data
const newMDirectEvent = {};
for (const room of s.lists["im.vector.fake.direct"]) {
const me = room.getMember(MatrixClientPeg.get().credentials.userId);
const otherPerson = Rooms.getOnlyOtherMember(room, me);
if (!otherPerson) continue;
const roomList = newMDirectEvent[otherPerson.userId] || [];
roomList.push(room.roomId);
newMDirectEvent[otherPerson.userId] = roomList;
}
// if this fails, fine, we'll just do the same thing next time we get the room lists
MatrixClientPeg.get().setAccountData('m.direct', newMDirectEvent).done();
}
//console.log("calculated new roomLists; im.vector.fake.recent = " + s.lists["im.vector.fake.recent"]);
// we actually apply the sorting to this when receiving the prop in RoomSubLists. // we actually apply the sorting to this when receiving the prop in RoomSubLists.
return s; // we'll need this when we get to iterating through lists programatically - e.g. ctrl-shift-up/down
/*
this.listOrder = [
"im.vector.fake.invite",
"m.favourite",
"im.vector.fake.recent",
"im.vector.fake.direct",
Object.keys(otherTagNames).filter(tagName=>{
return (!tagName.match(/^m\.(favourite|lowpriority)$/));
}).sort(),
"m.lowpriority",
"im.vector.fake.archived"
];
*/
return lists;
}, },
_getScrollNode: function() { _getScrollNode: function() {
var panel = ReactDOM.findDOMNode(this); var panel = ReactDOM.findDOMNode(this);
if (!panel) return null; if (!panel) return null;
if (panel.classList.contains('gm-prevented')) { // empirically, if we have gm-prevented for some reason, the scroll node
return panel; // is still the 3rd child (i.e. the view child). This looks to be due
} else { // to vdh's improved resize updater logic...?
return panel.children[2]; // XXX: Fragile! return panel.children[2]; // XXX: Fragile!
}
}, },
_whenScrolling: function(e) { _whenScrolling: function(e) {
@ -331,10 +455,11 @@ module.exports = React.createClass({
var incomingCallBox = document.getElementById("incomingCallBox"); var incomingCallBox = document.getElementById("incomingCallBox");
if (incomingCallBox && incomingCallBox.parentElement) { if (incomingCallBox && incomingCallBox.parentElement) {
var scrollArea = this._getScrollNode(); var scrollArea = this._getScrollNode();
if (!scrollArea) return;
// Use the offset of the top of the scroll area from the window // Use the offset of the top of the scroll area from the window
// as this is used to calculate the CSS fixed top position for the stickies // as this is used to calculate the CSS fixed top position for the stickies
var scrollAreaOffset = scrollArea.getBoundingClientRect().top + window.pageYOffset; var scrollAreaOffset = scrollArea.getBoundingClientRect().top + window.pageYOffset;
// Use the offset of the top of the componet from the window // Use the offset of the top of the component from the window
// as this is used to calculate the CSS fixed top position for the stickies // as this is used to calculate the CSS fixed top position for the stickies
var scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height; var scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height;
@ -354,10 +479,11 @@ module.exports = React.createClass({
// properly through React // properly through React
_initAndPositionStickyHeaders: function(initialise, scrollToPosition) { _initAndPositionStickyHeaders: function(initialise, scrollToPosition) {
var scrollArea = this._getScrollNode(); var scrollArea = this._getScrollNode();
if (!scrollArea) return;
// Use the offset of the top of the scroll area from the window // Use the offset of the top of the scroll area from the window
// as this is used to calculate the CSS fixed top position for the stickies // as this is used to calculate the CSS fixed top position for the stickies
var scrollAreaOffset = scrollArea.getBoundingClientRect().top + window.pageYOffset; var scrollAreaOffset = scrollArea.getBoundingClientRect().top + window.pageYOffset;
// Use the offset of the top of the componet from the window // Use the offset of the top of the component from the window
// as this is used to calculate the CSS fixed top position for the stickies // as this is used to calculate the CSS fixed top position for the stickies
var scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height; var scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height;
@ -451,21 +577,74 @@ module.exports = React.createClass({
this.refs.gemscroll.forceUpdate(); this.refs.gemscroll.forceUpdate();
}, },
_getEmptyContent: function(section) {
const RoomDropTarget = sdk.getComponent('rooms.RoomDropTarget');
if (this.props.collapsed) {
return <RoomDropTarget label="" />;
}
const StartChatButton = sdk.getComponent('elements.StartChatButton');
const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton');
const CreateRoomButton = sdk.getComponent('elements.CreateRoomButton');
if (this.state.totalRoomCount === 0) {
const TintableSvg = sdk.getComponent('elements.TintableSvg');
switch (section) {
case 'im.vector.fake.direct':
return <div className="mx_RoomList_emptySubListTip">
Press
<StartChatButton size="16" />
to start a chat with someone
</div>;
case 'im.vector.fake.recent':
return <div className="mx_RoomList_emptySubListTip">
You're not in any rooms yet! Press
<CreateRoomButton size="16" />
to make a room or
<RoomDirectoryButton size="16" />
to browse the directory
</div>;
}
}
const labelText = 'Drop here to ' + (VERBS[section] || 'tag ' + section);
return <RoomDropTarget label={labelText} />;
},
_getHeaderItems: function(section) {
const StartChatButton = sdk.getComponent('elements.StartChatButton');
const RoomDirectoryButton = sdk.getComponent('elements.RoomDirectoryButton');
const CreateRoomButton = sdk.getComponent('elements.CreateRoomButton');
switch (section) {
case 'im.vector.fake.direct':
return <span className="mx_RoomList_headerButtons">
<StartChatButton size="16" />
</span>;
case 'im.vector.fake.recent':
return <span className="mx_RoomList_headerButtons">
<RoomDirectoryButton size="16" />
<CreateRoomButton size="16" />
</span>;
}
},
render: function() { render: function() {
var RoomSubList = sdk.getComponent('structures.RoomSubList'); var RoomSubList = sdk.getComponent('structures.RoomSubList');
var self = this; var self = this;
return ( return (
<GeminiScrollbar className="mx_RoomList_scrollbar" <GeminiScrollbar className="mx_RoomList_scrollbar"
autoshow={true} onScroll={ self._whenScrolling } ref="gemscroll"> autoshow={true} onScroll={ self._whenScrolling } onResize={ self._whenScrolling } ref="gemscroll">
<div className="mx_RoomList"> <div className="mx_RoomList" onMouseOver={ this._onMouseOver }>
<RoomSubList list={ self.state.lists['im.vector.fake.invite'] } <RoomSubList list={ self.state.lists['im.vector.fake.invite'] }
label="Invites" label="Invites"
tagName="im.vector.fake.invite"
editable={ false } editable={ false }
order="recent" order="recent"
selectedRoom={ self.props.selectedRoom }
incomingCall={ self.state.incomingCall } incomingCall={ self.state.incomingCall }
collapsed={ self.props.collapsed } collapsed={ self.props.collapsed }
selectedRoom={ self.props.selectedRoom }
searchFilter={ self.props.searchFilter } searchFilter={ self.props.searchFilter }
onHeaderClick={ self.onSubListHeaderClick } onHeaderClick={ self.onSubListHeaderClick }
onShowMoreRooms={ self.onShowMoreRooms } /> onShowMoreRooms={ self.onShowMoreRooms } />
@ -473,51 +652,57 @@ module.exports = React.createClass({
<RoomSubList list={ self.state.lists['m.favourite'] } <RoomSubList list={ self.state.lists['m.favourite'] }
label="Favourites" label="Favourites"
tagName="m.favourite" tagName="m.favourite"
verb="favourite" emptyContent={this._getEmptyContent('m.favourite')}
editable={ true } editable={ true }
order="manual" order="manual"
selectedRoom={ self.props.selectedRoom }
incomingCall={ self.state.incomingCall } incomingCall={ self.state.incomingCall }
collapsed={ self.props.collapsed } collapsed={ self.props.collapsed }
selectedRoom={ self.props.selectedRoom }
searchFilter={ self.props.searchFilter } searchFilter={ self.props.searchFilter }
onHeaderClick={ self.onSubListHeaderClick } onHeaderClick={ self.onSubListHeaderClick }
onShowMoreRooms={ self.onShowMoreRooms } /> onShowMoreRooms={ self.onShowMoreRooms } />
<RoomSubList list={ self.state.lists['im.vector.fake.direct'] } <RoomSubList list={ self.state.lists['im.vector.fake.direct'] }
label="People" label="People"
editable={ false } tagName="im.vector.fake.direct"
emptyContent={this._getEmptyContent('im.vector.fake.direct')}
headerItems={this._getHeaderItems('im.vector.fake.direct')}
editable={ true }
order="recent" order="recent"
selectedRoom={ self.props.selectedRoom }
incomingCall={ self.state.incomingCall } incomingCall={ self.state.incomingCall }
collapsed={ self.props.collapsed } collapsed={ self.props.collapsed }
selectedRoom={ self.props.selectedRoom }
alwaysShowHeader={ true }
searchFilter={ self.props.searchFilter } searchFilter={ self.props.searchFilter }
onHeaderClick={ self.onSubListHeaderClick } onHeaderClick={ self.onSubListHeaderClick }
onShowMoreRooms={ self.onShowMoreRooms } /> onShowMoreRooms={ self.onShowMoreRooms } />
<RoomSubList list={ self.state.lists['im.vector.fake.recent'] } <RoomSubList list={ self.state.lists['im.vector.fake.recent'] }
label="Rooms" label="Rooms"
tagName="im.vector.fake.recent"
editable={ true } editable={ true }
verb="restore" emptyContent={this._getEmptyContent('im.vector.fake.recent')}
headerItems={this._getHeaderItems('im.vector.fake.recent')}
order="recent" order="recent"
selectedRoom={ self.props.selectedRoom }
incomingCall={ self.state.incomingCall } incomingCall={ self.state.incomingCall }
collapsed={ self.props.collapsed } collapsed={ self.props.collapsed }
selectedRoom={ self.props.selectedRoom }
searchFilter={ self.props.searchFilter } searchFilter={ self.props.searchFilter }
onHeaderClick={ self.onSubListHeaderClick } onHeaderClick={ self.onSubListHeaderClick }
onShowMoreRooms={ self.onShowMoreRooms } /> onShowMoreRooms={ self.onShowMoreRooms } />
{ Object.keys(self.state.lists).map(function(tagName) { { Object.keys(self.state.lists).sort().map(function(tagName) {
if (!tagName.match(/^(m\.(favourite|lowpriority)|im\.vector\.fake\.(invite|recent|direct|archived))$/)) { if (!tagName.match(/^(m\.(favourite|lowpriority)|im\.vector\.fake\.(invite|recent|direct|archived))$/)) {
return <RoomSubList list={ self.state.lists[tagName] } return <RoomSubList list={ self.state.lists[tagName] }
key={ tagName } key={ tagName }
label={ tagName } label={ tagName }
tagName={ tagName } tagName={ tagName }
verb={ "tag as " + tagName } emptyContent={this._getEmptyContent(tagName)}
editable={ true } editable={ true }
order="manual" order="manual"
selectedRoom={ self.props.selectedRoom }
incomingCall={ self.state.incomingCall } incomingCall={ self.state.incomingCall }
collapsed={ self.props.collapsed } collapsed={ self.props.collapsed }
selectedRoom={ self.props.selectedRoom }
searchFilter={ self.props.searchFilter } searchFilter={ self.props.searchFilter }
onHeaderClick={ self.onSubListHeaderClick } onHeaderClick={ self.onSubListHeaderClick }
onShowMoreRooms={ self.onShowMoreRooms } />; onShowMoreRooms={ self.onShowMoreRooms } />;
@ -528,22 +713,23 @@ module.exports = React.createClass({
<RoomSubList list={ self.state.lists['m.lowpriority'] } <RoomSubList list={ self.state.lists['m.lowpriority'] }
label="Low priority" label="Low priority"
tagName="m.lowpriority" tagName="m.lowpriority"
verb="demote" emptyContent={this._getEmptyContent('m.lowpriority')}
editable={ true } editable={ true }
order="recent" order="recent"
selectedRoom={ self.props.selectedRoom }
incomingCall={ self.state.incomingCall } incomingCall={ self.state.incomingCall }
collapsed={ self.props.collapsed } collapsed={ self.props.collapsed }
selectedRoom={ self.props.selectedRoom }
searchFilter={ self.props.searchFilter } searchFilter={ self.props.searchFilter }
onHeaderClick={ self.onSubListHeaderClick } onHeaderClick={ self.onSubListHeaderClick }
onShowMoreRooms={ self.onShowMoreRooms } /> onShowMoreRooms={ self.onShowMoreRooms } />
<RoomSubList list={ self.state.lists['im.vector.fake.archived'] } <RoomSubList list={ self.state.lists['im.vector.fake.archived'] }
label="Historical" label="Historical"
tagName="im.vector.fake.archived"
editable={ false } editable={ false }
order="recent" order="recent"
selectedRoom={ self.props.selectedRoom }
collapsed={ self.props.collapsed } collapsed={ self.props.collapsed }
selectedRoom={ self.props.selectedRoom }
alwaysShowHeader={ true } alwaysShowHeader={ true }
startAsHidden={ true } startAsHidden={ true }
showSpinner={ self.state.isLoadingLeftRooms } showSpinner={ self.state.isLoadingLeftRooms }

View file

@ -54,9 +54,10 @@ const BannedUser = React.createClass({
this.props.member.roomId, this.props.member.userId, this.props.member.roomId, this.props.member.userId,
).catch((err) => { ).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to unban: " + err);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Failed to unban", title: "Error",
description: err.message, description: "Failed to unban",
}); });
}).done(); }).done();
}, },
@ -128,6 +129,8 @@ module.exports = React.createClass({
console.error("Failed to get room visibility: " + err); console.error("Failed to get room visibility: " + err);
}); });
this.scalarClient = null;
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
this.scalarClient = new ScalarAuthClient(); this.scalarClient = new ScalarAuthClient();
this.scalarClient.connect().done(() => { this.scalarClient.connect().done(() => {
this.forceUpdate(); this.forceUpdate();
@ -136,6 +139,7 @@ module.exports = React.createClass({
scalar_error: err scalar_error: err
}); });
}); });
}
dis.dispatch({ dis.dispatch({
action: 'ui_opacity', action: 'ui_opacity',
@ -489,7 +493,7 @@ module.exports = React.createClass({
ev.preventDefault(); ev.preventDefault();
var IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); var IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
Modal.createDialog(IntegrationsManager, { Modal.createDialog(IntegrationsManager, {
src: this.scalarClient.hasCredentials() ? src: (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId) : this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId) :
null, null,
onFinished: ()=>{ onFinished: ()=>{
@ -764,8 +768,10 @@ module.exports = React.createClass({
</div>; </div>;
} }
var integrationsButton; let integrationsButton;
var integrationsError; let integrationsError;
if (this.scalarClient !== null) {
if (this.state.showIntegrationsError && this.state.scalar_error) { if (this.state.showIntegrationsError && this.state.scalar_error) {
console.error(this.state.scalar_error); console.error(this.state.scalar_error);
integrationsError = ( integrationsError = (
@ -790,11 +796,12 @@ module.exports = React.createClass({
); );
} else { } else {
integrationsButton = ( integrationsButton = (
<div className="mx_RoomSettings_integrationsButton" style={{ opacity: 0.5 }}> <div className="mx_RoomSettings_integrationsButton" style={{opacity: 0.5}}>
Manage Integrations Manage Integrations
</div> </div>
); );
} }
}
return ( return (
<div className="mx_RoomSettings"> <div className="mx_RoomSettings">

View file

@ -19,7 +19,6 @@ limitations under the License.
var React = require('react'); var React = require('react');
var ReactDOM = require("react-dom"); var ReactDOM = require("react-dom");
var classNames = require('classnames'); var classNames = require('classnames');
var dis = require("../../../dispatcher");
var MatrixClientPeg = require('../../../MatrixClientPeg'); var MatrixClientPeg = require('../../../MatrixClientPeg');
import DMRoomMap from '../../../utils/DMRoomMap'; import DMRoomMap from '../../../utils/DMRoomMap';
var sdk = require('../../../index'); var sdk = require('../../../index');
@ -28,6 +27,8 @@ var RoomNotifs = require('../../../RoomNotifs');
var FormattingUtils = require('../../../utils/FormattingUtils'); var FormattingUtils = require('../../../utils/FormattingUtils');
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
var UserSettingsStore = require('../../../UserSettingsStore'); var UserSettingsStore = require('../../../UserSettingsStore');
var constantTimeDispatcher = require('../../../ConstantTimeDispatcher');
var Unread = require('../../../Unread');
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'RoomTile', displayName: 'RoomTile',
@ -35,13 +36,12 @@ module.exports = React.createClass({
propTypes: { propTypes: {
connectDragSource: React.PropTypes.func, connectDragSource: React.PropTypes.func,
connectDropTarget: React.PropTypes.func, connectDropTarget: React.PropTypes.func,
onClick: React.PropTypes.func,
isDragging: React.PropTypes.bool, isDragging: React.PropTypes.bool,
selectedRoom: React.PropTypes.string,
room: React.PropTypes.object.isRequired, room: React.PropTypes.object.isRequired,
collapsed: React.PropTypes.bool.isRequired, collapsed: React.PropTypes.bool.isRequired,
selected: React.PropTypes.bool.isRequired,
unread: React.PropTypes.bool.isRequired,
highlight: React.PropTypes.bool.isRequired,
isInvite: React.PropTypes.bool.isRequired, isInvite: React.PropTypes.bool.isRequired,
incomingCall: React.PropTypes.object, incomingCall: React.PropTypes.object,
}, },
@ -54,11 +54,11 @@ module.exports = React.createClass({
getInitialState: function() { getInitialState: function() {
return({ return({
hover : false, hover: false,
badgeHover : false, badgeHover: false,
notificationTagMenu: false, menuDisplayed: false,
roomTagMenu: false,
notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId), notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
selected: this.props.room ? (this.props.selectedRoom === this.props.room.roomId) : false,
}); });
}, },
@ -80,32 +80,40 @@ module.exports = React.createClass({
} }
}, },
onAccountData: function(accountDataEvent) {
if (accountDataEvent.getType() == 'm.push_rules') {
this.setState({
notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId),
});
}
},
componentWillMount: function() { componentWillMount: function() {
MatrixClientPeg.get().on("accountData", this.onAccountData); constantTimeDispatcher.register("RoomTile.refresh", this.props.room.roomId, this.onRefresh);
constantTimeDispatcher.register("RoomTile.select", this.props.room.roomId, this.onSelect);
this.onRefresh();
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
var cli = MatrixClientPeg.get(); constantTimeDispatcher.unregister("RoomTile.refresh", this.props.room.roomId, this.onRefresh);
if (cli) { constantTimeDispatcher.unregister("RoomTile.select", this.props.room.roomId, this.onSelect);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
}
}, },
onClick: function() { componentWillReceiveProps: function(nextProps) {
dis.dispatch({ this.onRefresh();
action: 'view_room', },
room_id: this.props.room.roomId,
onRefresh: function(params) {
this.setState({
unread: Unread.doesRoomHaveUnreadMessages(this.props.room),
highlight: this.props.room.getUnreadNotificationCount('highlight') > 0 || this.props.isInvite,
}); });
}, },
onSelect: function(params) {
this.setState({
selected: params.selected,
});
},
onClick: function(ev) {
if (this.props.onClick) {
this.props.onClick(this.props.room.roomId, ev);
}
},
onMouseEnter: function() { onMouseEnter: function() {
this.setState( { hover : true }); this.setState( { hover : true });
this.badgeOnMouseEnter(); this.badgeOnMouseEnter();
@ -137,62 +145,32 @@ module.exports = React.createClass({
this.setState({ hover: false }); this.setState({ hover: false });
} }
var NotificationStateMenu = sdk.getComponent('context_menus.NotificationStateContextMenu'); var RoomTileContextMenu = sdk.getComponent('context_menus.RoomTileContextMenu');
var elementRect = e.target.getBoundingClientRect(); var elementRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page // The window X and Y offsets are to adjust position when zoomed in to page
var x = elementRect.right + window.pageXOffset + 3; const x = elementRect.right + window.pageXOffset + 3;
var y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset) - 53; const chevronOffset = 12;
let y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset);
y = y - (chevronOffset + 8); // where 8 is half the height of the chevron
var self = this; var self = this;
ContextualMenu.createMenu(NotificationStateMenu, { ContextualMenu.createMenu(RoomTileContextMenu, {
menuWidth: 188, chevronOffset: chevronOffset,
menuHeight: 126,
chevronOffset: 45,
left: x, left: x,
top: y, top: y,
room: this.props.room, room: this.props.room,
onFinished: function() { onFinished: function() {
self.setState({ notificationTagMenu: false }); self.setState({ menuDisplayed: false });
self.props.refreshSubList(); self.props.refreshSubList();
} }
}); });
this.setState({ notificationTagMenu: true }); this.setState({ menuDisplayed: true });
} }
// Prevent the RoomTile onClick event firing as well // Prevent the RoomTile onClick event firing as well
e.stopPropagation(); e.stopPropagation();
}, },
onAvatarClicked: function(e) {
// Only allow none guests to access the context menu
if (!MatrixClientPeg.get().isGuest() && !this.props.collapsed) {
// If the badge is clicked, then no longer show tooltip
if (this.props.collapsed) {
this.setState({ hover: false });
}
var RoomTagMenu = sdk.getComponent('context_menus.RoomTagContextMenu');
var elementRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
var x = elementRect.right + window.pageXOffset + 3;
var y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset) - 19;
var self = this;
ContextualMenu.createMenu(RoomTagMenu, {
chevronOffset: 10,
// XXX: fix horrid hardcoding
menuColour: UserSettingsStore.getSyncedSettings().theme === 'dark' ? "#2d2d2d" : "#FFFFFF",
left: x,
top: y,
room: this.props.room,
onFinished: function() {
self.setState({ roomTagMenu: false });
}
});
this.setState({ roomTagMenu: true });
// Prevent the RoomTile onClick event firing as well
e.stopPropagation();
}
},
render: function() { render: function() {
var myUserId = MatrixClientPeg.get().credentials.userId; var myUserId = MatrixClientPeg.get().credentials.userId;
var me = this.props.room.currentState.members[myUserId]; var me = this.props.room.currentState.members[myUserId];
@ -201,17 +179,17 @@ module.exports = React.createClass({
// var highlightCount = this.props.room.getUnreadNotificationCount("highlight"); // var highlightCount = this.props.room.getUnreadNotificationCount("highlight");
const notifBadges = notificationCount > 0 && this._shouldShowNotifBadge(); const notifBadges = notificationCount > 0 && this._shouldShowNotifBadge();
const mentionBadges = this.props.highlight && this._shouldShowMentionBadge(); const mentionBadges = this.state.highlight && this._shouldShowMentionBadge();
const badges = notifBadges || mentionBadges; const badges = notifBadges || mentionBadges;
var classes = classNames({ var classes = classNames({
'mx_RoomTile': true, 'mx_RoomTile': true,
'mx_RoomTile_selected': this.props.selected, 'mx_RoomTile_selected': this.state.selected,
'mx_RoomTile_unread': this.props.unread, 'mx_RoomTile_unread': this.state.unread,
'mx_RoomTile_unreadNotify': notifBadges, 'mx_RoomTile_unreadNotify': notifBadges,
'mx_RoomTile_highlight': mentionBadges, 'mx_RoomTile_highlight': mentionBadges,
'mx_RoomTile_invited': (me && me.membership == 'invite'), 'mx_RoomTile_invited': (me && me.membership == 'invite'),
'mx_RoomTile_notificationTagMenu': this.state.notificationTagMenu, 'mx_RoomTile_menuDisplayed': this.state.menuDisplayed,
'mx_RoomTile_noBadges': !badges, 'mx_RoomTile_noBadges': !badges,
}); });
@ -219,14 +197,9 @@ module.exports = React.createClass({
'mx_RoomTile_avatar': true, 'mx_RoomTile_avatar': true,
}); });
var avatarContainerClasses = classNames({
'mx_RoomTile_avatar_container': true,
'mx_RoomTile_avatar_roomTagMenu': this.state.roomTagMenu,
});
var badgeClasses = classNames({ var badgeClasses = classNames({
'mx_RoomTile_badge': true, 'mx_RoomTile_badge': true,
'mx_RoomTile_badgeButton': this.state.badgeHover || this.state.notificationTagMenu, 'mx_RoomTile_badgeButton': this.state.badgeHover || this.state.menuDisplayed,
}); });
// XXX: We should never display raw room IDs, but sometimes the // XXX: We should never display raw room IDs, but sometimes the
@ -237,7 +210,7 @@ module.exports = React.createClass({
var badge; var badge;
var badgeContent; var badgeContent;
if (this.state.badgeHover || this.state.notificationTagMenu) { if (this.state.badgeHover || this.state.menuDisplayed) {
badgeContent = "\u00B7\u00B7\u00B7"; badgeContent = "\u00B7\u00B7\u00B7";
} else if (badges) { } else if (badges) {
var limitedCount = FormattingUtils.formatCount(notificationCount); var limitedCount = FormattingUtils.formatCount(notificationCount);
@ -255,10 +228,10 @@ module.exports = React.createClass({
var nameClasses = classNames({ var nameClasses = classNames({
'mx_RoomTile_name': true, 'mx_RoomTile_name': true,
'mx_RoomTile_invite': this.props.isInvite, 'mx_RoomTile_invite': this.props.isInvite,
'mx_RoomTile_badgeShown': badges || this.state.badgeHover || this.state.notificationTagMenu, 'mx_RoomTile_badgeShown': badges || this.state.badgeHover || this.state.menuDisplayed,
}); });
if (this.props.selected) { if (this.state.selected) {
let nameSelected = <EmojiText>{name}</EmojiText>; let nameSelected = <EmojiText>{name}</EmojiText>;
label = <div title={ name } className={ nameClasses }>{ nameSelected }</div>; label = <div title={ name } className={ nameClasses }>{ nameSelected }</div>;
@ -292,15 +265,14 @@ module.exports = React.createClass({
let ret = ( let ret = (
<div> { /* Only native elements can be wrapped in a DnD object. */} <div> { /* Only native elements can be wrapped in a DnD object. */}
<AccessibleButton className={classes} tabIndex="0" onClick={this.onClick} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}> <AccessibleButton className={classes} tabIndex="0" onClick={this.onClick}
onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
<div className={avatarClasses}> <div className={avatarClasses}>
<div className="mx_RoomTile_avatar_menu" onClick={this.onAvatarClicked}> <div className="mx_RoomTile_avatar_container">
<div className={avatarContainerClasses}>
<RoomAvatar room={this.props.room} width={24} height={24} /> <RoomAvatar room={this.props.room} width={24} height={24} />
{directMessageIndicator} {directMessageIndicator}
</div> </div>
</div> </div>
</div>
<div className="mx_RoomTile_nameContainer"> <div className="mx_RoomTile_nameContainer">
{ label } { label }
{ badge } { badge }

View file

@ -60,7 +60,7 @@ module.exports = React.createClass({
} }
} }
return ( return (
<li data-scroll-token={eventId+"+"+j}> <li data-scroll-tokens={eventId+"+"+j}>
{ret} {ret}
</li>); </li>);
}, },

View file

@ -19,6 +19,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import sdk from '../../../index';
// cancel button which is shared between room header and simple room header // cancel button which is shared between room header and simple room header
export function CancelButton(props) { export function CancelButton(props) {
@ -45,6 +46,9 @@ export default React.createClass({
// is the RightPanel collapsed? // is the RightPanel collapsed?
collapsedRhs: React.PropTypes.bool, collapsedRhs: React.PropTypes.bool,
// `src` to a TintableSvg. Optional.
icon: React.PropTypes.string,
}, },
onShowRhsClick: function(ev) { onShowRhsClick: function(ev) {
@ -53,9 +57,17 @@ export default React.createClass({
render: function() { render: function() {
let cancelButton; let cancelButton;
let icon;
if (this.props.onCancelClick) { if (this.props.onCancelClick) {
cancelButton = <CancelButton onClick={this.props.onCancelClick} />; cancelButton = <CancelButton onClick={this.props.onCancelClick} />;
} }
if (this.props.icon) {
const TintableSvg = sdk.getComponent('elements.TintableSvg');
icon = <TintableSvg
className="mx_RoomHeader_icon" src={this.props.icon}
width="25" height="25"
/>;
}
let showRhsButton; let showRhsButton;
/* // don't bother cluttering things up with this for now. /* // don't bother cluttering things up with this for now.
@ -73,6 +85,7 @@ export default React.createClass({
<div className="mx_RoomHeader" > <div className="mx_RoomHeader" >
<div className="mx_RoomHeader_wrapper"> <div className="mx_RoomHeader_wrapper">
<div className="mx_RoomHeader_simpleHeader"> <div className="mx_RoomHeader_simpleHeader">
{ icon }
{ this.props.title } { this.props.title }
{ showRhsButton } { showRhsButton }
{ cancelButton } { cancelButton }

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -32,10 +33,10 @@ module.exports = React.createClass({
<div className="mx_TopUnreadMessagesBar"> <div className="mx_TopUnreadMessagesBar">
<div className="mx_TopUnreadMessagesBar_scrollUp" <div className="mx_TopUnreadMessagesBar_scrollUp"
onClick={this.props.onScrollUpClick}> onClick={this.props.onScrollUpClick}>
<img src="img/scrollup.svg" width="24" height="24" <img src="img/scrollto.svg" width="24" height="24"
alt="Scroll to unread messages" alt="Scroll to unread messages"
title="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.
</div> </div>
<img className="mx_TopUnreadMessagesBar_close" <img className="mx_TopUnreadMessagesBar_close"
src="img/cancel.svg" width="18" height="18" src="img/cancel.svg" width="18" height="18"

View file

@ -0,0 +1,173 @@
/*
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.iso2 });
},
_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_UserSettings_phoneSection">
<CountryDropdown onOptionChange={this._onPhoneCountryChange}
className="mx_UserSettings_phoneCountry"
value={this.state.phoneCountry}
isSmall={true}
/>
<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

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

View file

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

View file

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

1273
src/phonenumber.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -115,7 +115,7 @@ var Tester = React.createClass({
// //
// there is an extra 50 pixels of margin at the bottom. // there is an extra 50 pixels of margin at the bottom.
return ( return (
<li key={key} data-scroll-token={key}> <li key={key} data-scroll-tokens={key}>
<div style={{height: '98px', margin: '50px', border: '1px solid black', <div style={{height: '98px', margin: '50px', border: '1px solid black',
backgroundColor: '#fff8dc' }}> backgroundColor: '#fff8dc' }}>
{key} {key}

View file

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

View file

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