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)
===================================================================================================
[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: [
// 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
// cause stack overflows
// (https://github.com/webpack/webpack/issues/1721), and
// there is no need for webpack to parse them - they can
// 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
// tries to do voodoo with 'require' which upsets
// webpack (https://github.com/webpack/webpack/issues/304)
/sinon\/pkg\/sinon\.js$/,
/sinon[\\\/]pkg[\\\/]sinon\.js$/,
],
},
resolve: {

View file

@ -1,6 +1,6 @@
{
"name": "matrix-react-sdk",
"version": "0.8.6",
"version": "0.8.8",
"description": "SDK for matrix.org using React",
"author": "matrix.org",
"repository": {
@ -32,8 +32,8 @@
},
"scripts": {
"reskindex": "scripts/reskindex.js -h header",
"build": "node scripts/babelcheck.js && babel src -d lib --source-maps",
"start": "node scripts/babelcheck.js && babel src -w -d lib --source-maps",
"build": "babel src -d lib --source-maps",
"start": "babel src -w -d lib --source-maps",
"lint": "eslint src/",
"lintall": "eslint src/ test/",
"clean": "rimraf lib",
@ -53,7 +53,7 @@
"draft-js-export-markdown": "^0.2.0",
"emojione": "2.2.3",
"file-saver": "^1.3.3",
"filesize": "^3.1.2",
"filesize": "3.5.6",
"flux": "^2.0.3",
"glob": "^5.0.14",
"highlight.js": "^8.9.1",
@ -63,11 +63,12 @@
"lodash": "^4.13.1",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
"optimist": "^0.6.1",
"prop-types": "^15.5.8",
"q": "^1.4.1",
"react": "^15.4.0",
"react-addons-css-transition-group": "15.3.2",
"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",
"text-encoding-utf-8": "^1.0.1",
"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 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -51,11 +52,36 @@ class AddThreepid {
});
}
/**
* Attempt to add a msisdn threepid. This will trigger a side-effect of
* sending a test message to the provided phone number.
* @param {string} phoneCountry The ISO 2 letter code of the country to resolve phoneNumber in
* @param {string} phoneNumber The national or international formatted phone number to add
* @param {boolean} bind If True, bind this phone number to this mxid on the Identity Server
* @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken().
*/
addMsisdn(phoneCountry, phoneNumber, bind) {
this.bind = bind;
return MatrixClientPeg.get().requestAdd3pidMsisdnToken(
phoneCountry, phoneNumber, this.clientSecret, 1,
).then((res) => {
this.sessionId = res.sid;
return res;
}, function(err) {
if (err.errcode == 'M_THREEPID_IN_USE') {
err.message = "This phone number is already in use";
} else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`;
}
throw err;
});
}
/**
* Checks if the email link has been clicked by attempting to add the threepid
* @return {Promise} Resolves if the password was reset. Rejects with an object
* @return {Promise} Resolves if the email address was added. Rejects with an object
* with a "message" property which contains a human-readable message detailing why
* the reset failed, e.g. "There is no mapped matrix user ID for the given email address".
* the request failed.
*/
checkEmailLinkClicked() {
var identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1];
@ -73,6 +99,29 @@ class AddThreepid {
throw err;
});
}
/**
* Takes a phone number verification code as entered by the user and validates
* it with the ID server, then if successful, adds the phone number.
* @return {Promise} Resolves if the phone number was added. Rejects with an object
* with a "message" property which contains a human-readable message detailing why
* the request failed.
*/
haveMsisdnToken(token) {
return MatrixClientPeg.get().submitMsisdnToken(
this.sessionId, this.clientSecret, token,
).then((result) => {
if (result.errcode) {
throw result;
}
const identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1];
return MatrixClientPeg.get().addThreePid({
sid: this.sessionId,
client_secret: this.clientSecret,
id_server: identityServerDomain
}, this.bind);
});
}
}
module.exports = AddThreepid;

View file

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

View file

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

View file

@ -310,9 +310,10 @@ function _onAction(payload) {
placeCall(call);
}, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Conference call failed: " + err);
Modal.createDialog(ErrorDialog, {
title: "Failed to set up conference call",
description: "Conference call failed: " + err,
description: "Conference call failed. " + ((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) {
const content = {
body: file.name,
body: file.name || 'Attachment',
info: {
size: file.size,
}
@ -316,7 +316,7 @@ class ContentMessages {
}
const upload = {
fileName: file.name,
fileName: file.name || 'Attachment',
roomId: roomId,
total: 0,
loaded: 0,

View file

@ -25,6 +25,9 @@ import emojione from 'emojione';
import classNames from 'classnames';
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';
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi");
@ -58,6 +61,29 @@ export function unicodeToImage(str) {
return str;
}
/**
* Given one or more unicode characters (represented by unicode
* character number), return an image node with the corresponding
* emoji.
*
* @param alt {string} String to use for the image alt text
* @param 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 {
const contentDiv = document.createElement('div');
contentDiv.innerHTML = html;
@ -85,8 +111,7 @@ var sanitizeHtmlParams = {
allowedTags: [
'font', // custom to matrix for IRC-style font coloring
'del', // for markdown
// deliberately no h1/h2 to stop people shouting.
'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol',
'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span',
],
@ -98,6 +123,7 @@ var sanitizeHtmlParams = {
// We don't currently allow img itself by default, but this
// would make sense if we did
img: ['src'],
ol: ['start'],
},
// Lots of these won't come up by default because we don't allow them
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],

View file

@ -32,4 +32,5 @@ module.exports = {
DELETE: 46,
KEY_D: 68,
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
* 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
* 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
* true; defines the IS to use.
*
* @returns {Promise} a promise which resolves when the above process completes.
*/
export function loadSession(opts) {
const realQueryParams = opts.realQueryParams || {};
@ -127,7 +128,7 @@ export function loadSession(opts) {
function _loginWithToken(queryParams, defaultDeviceDisplayName) {
// create a temporary MatrixClient to do the login
var client = Matrix.createClient({
const client = Matrix.createClient({
baseUrl: queryParams.homeserver,
});
@ -159,7 +160,7 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) {
// Not really sure where the right home for it is.
// create a temporary MatrixClient to do the login
var client = Matrix.createClient({
const client = Matrix.createClient({
baseUrl: hsUrl,
});
@ -188,30 +189,30 @@ function _restoreFromLocalStorage() {
if (!localStorage) {
return q(false);
}
const hs_url = localStorage.getItem("mx_hs_url");
const is_url = localStorage.getItem("mx_is_url") || 'https://matrix.org';
const access_token = localStorage.getItem("mx_access_token");
const user_id = localStorage.getItem("mx_user_id");
const device_id = localStorage.getItem("mx_device_id");
const hsUrl = localStorage.getItem("mx_hs_url");
const isUrl = localStorage.getItem("mx_is_url") || 'https://matrix.org';
const accessToken = localStorage.getItem("mx_access_token");
const userId = localStorage.getItem("mx_user_id");
const deviceId = localStorage.getItem("mx_device_id");
let is_guest;
let isGuest;
if (localStorage.getItem("mx_is_guest") !== null) {
is_guest = localStorage.getItem("mx_is_guest") === "true";
isGuest = localStorage.getItem("mx_is_guest") === "true";
} else {
// 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) {
console.log("Restoring session for %s", user_id);
if (accessToken && userId && hsUrl) {
console.log("Restoring session for %s", userId);
try {
setLoggedIn({
userId: user_id,
deviceId: device_id,
accessToken: access_token,
homeserverUrl: hs_url,
identityServerUrl: is_url,
guest: is_guest,
userId: userId,
deviceId: deviceId,
accessToken: accessToken,
homeserverUrl: hsUrl,
identityServerUrl: isUrl,
guest: isGuest,
});
return q(true);
} catch (e) {
@ -273,9 +274,18 @@ export function initRtsClient(url) {
*/
export function setLoggedIn(credentials) {
credentials.guest = Boolean(credentials.guest);
console.log("setLoggedIn => %s (guest=%s) hs=%s",
credentials.userId, credentials.guest,
credentials.homeserverUrl);
console.log(
"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
let teamPromise = Promise.resolve(null);
@ -347,7 +357,7 @@ export function logout() {
return;
}
return MatrixClientPeg.get().logout().then(onLoggedOut,
MatrixClientPeg.get().logout().then(onLoggedOut,
(err) => {
// Just throwing an error here is going to be very unhelpful
// if you're trying to log out because your server's down and
@ -358,8 +368,8 @@ export function logout() {
// change your password).
console.log("Failed to call logout API: token will not be invalidated");
onLoggedOut();
}
);
},
).done();
}
/**
@ -415,7 +425,7 @@ export function stopMatrixClient() {
UserActivity.stop();
Presence.stop();
if (DMRoomMap.shared()) DMRoomMap.shared().stop();
var cli = MatrixClientPeg.get();
const cli = MatrixClientPeg.get();
if (cli) {
cli.stopClient();
cli.removeAllListeners();

View file

@ -105,21 +105,48 @@ export default class Login {
});
}
loginViaPassword(username, pass) {
var self = this;
var isEmail = username.indexOf("@") > 0;
var loginParams = {
password: pass,
initial_device_display_name: this._defaultDeviceDisplayName,
};
if (isEmail) {
loginParams.medium = 'email';
loginParams.address = username;
loginViaPassword(username, phoneCountry, phoneNumber, pass) {
const self = this;
const isEmail = username.indexOf("@") > 0;
let identifier;
let legacyParams; // parameters added to support old HSes
if (phoneCountry && phoneNumber) {
identifier = {
type: 'm.id.phone',
country: phoneCountry,
number: phoneNumber,
};
// No legacy support for phone number login
} else if (isEmail) {
identifier = {
type: 'm.id.thirdparty',
medium: 'email',
address: username,
};
legacyParams = {
medium: 'email',
address: username,
};
} else {
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 q({
homeserverUrl: self._hsUrl,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,9 +15,9 @@ limitations under the License.
*/
'use strict';
var q = require("q");
var MatrixClientPeg = require("./MatrixClientPeg");
var Notifier = require("./Notifier");
import q from 'q';
import MatrixClientPeg from './MatrixClientPeg';
import Notifier from './Notifier';
/*
* TODO: Make things use this. This is all WIP - see UserSettings.js for usage.
@ -33,7 +33,7 @@ module.exports = {
],
loadProfileInfo: function() {
var cli = MatrixClientPeg.get();
const cli = MatrixClientPeg.get();
return cli.getProfileInfo(cli.credentials.userId);
},
@ -44,7 +44,7 @@ module.exports = {
loadThreePids: function() {
if (MatrixClientPeg.get().isGuest()) {
return q({
threepids: []
threepids: [],
}); // guests can't poke 3pid endpoint
}
return MatrixClientPeg.get().getThreePids();
@ -73,19 +73,19 @@ module.exports = {
Notifier.setAudioEnabled(enable);
},
changePassword: function(old_password, new_password) {
var cli = MatrixClientPeg.get();
changePassword: function(oldPassword, newPassword) {
const cli = MatrixClientPeg.get();
var authDict = {
const authDict = {
type: 'm.login.password',
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
* email address. Email pushers all have the same app ID, so since
* pushers are unique over (app ID, pushkey), there will be at most
@ -95,8 +95,8 @@ module.exports = {
if (pushers === undefined) {
return undefined;
}
for (var i = 0; i < pushers.length; ++i) {
if (pushers[i].kind == 'email' && pushers[i].pushkey == address) {
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind === 'email' && pushers[i].pushkey === address) {
return pushers[i];
}
}
@ -110,7 +110,7 @@ module.exports = {
addEmailPusher: function(address, data) {
return MatrixClientPeg.get().setPusher({
kind: 'email',
app_id: "m.email",
app_id: 'm.email',
pushkey: address,
app_display_name: 'Email Notifications',
device_display_name: address,
@ -121,46 +121,46 @@ module.exports = {
},
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);
},
setUrlPreviewsDisabled: function(disabled) {
// FIXME: handle errors
return MatrixClientPeg.get().setAccountData("org.matrix.preview_urls", {
disable: disabled
return MatrixClientPeg.get().setAccountData('org.matrix.preview_urls', {
disable: disabled,
});
},
getSyncedSettings: function() {
var event = MatrixClientPeg.get().getAccountData("im.vector.web.settings");
const event = MatrixClientPeg.get().getAccountData('im.vector.web.settings');
return event ? event.getContent() : {};
},
getSyncedSetting: function(type, defaultValue = null) {
var settings = this.getSyncedSettings();
const settings = this.getSyncedSettings();
return settings.hasOwnProperty(type) ? settings[type] : defaultValue;
},
setSyncedSetting: function(type, value) {
var settings = this.getSyncedSettings();
const settings = this.getSyncedSettings();
settings[type] = value;
// FIXME: handle errors
return MatrixClientPeg.get().setAccountData("im.vector.web.settings", settings);
return MatrixClientPeg.get().setAccountData('im.vector.web.settings', settings);
},
getLocalSettings: function() {
var localSettingsString = localStorage.getItem('mx_local_settings') || '{}';
const localSettingsString = localStorage.getItem('mx_local_settings') || '{}';
return JSON.parse(localSettingsString);
},
getLocalSetting: function(type, defaultValue = null) {
var settings = this.getLocalSettings();
const settings = this.getLocalSettings();
return settings.hasOwnProperty(type) ? settings[type] : defaultValue;
},
setLocalSetting: function(type, value) {
var settings = this.getLocalSettings();
const settings = this.getLocalSettings();
settings[type] = value;
// FIXME: handle errors
localStorage.setItem('mx_local_settings', JSON.stringify(settings));
@ -171,8 +171,8 @@ module.exports = {
if (MatrixClientPeg.get().isGuest()) return false;
if (localStorage.getItem(`mx_labs_feature_${feature}`) === null) {
for (var i = 0; i < this.LABS_FEATURES.length; i++) {
var f = this.LABS_FEATURES[i];
for (let i = 0; i < this.LABS_FEATURES.length; i++) {
const f = this.LABS_FEATURES[i];
if (f.id === feature) {
return f.default;
}
@ -183,5 +183,5 @@ module.exports = {
setFeatureEnabled: function(feature: string, enabled: boolean) {
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);
import views$dialogs$BaseDialog from './components/views/dialogs/BaseDialog';
views$dialogs$BaseDialog && (module.exports.components['views.dialogs.BaseDialog'] = views$dialogs$BaseDialog);
import views$dialogs$ChatCreateOrReuseDialog from './components/views/dialogs/ChatCreateOrReuseDialog';
views$dialogs$ChatCreateOrReuseDialog && (module.exports.components['views.dialogs.ChatCreateOrReuseDialog'] = views$dialogs$ChatCreateOrReuseDialog);
import views$dialogs$ChatInviteDialog from './components/views/dialogs/ChatInviteDialog';
views$dialogs$ChatInviteDialog && (module.exports.components['views.dialogs.ChatInviteDialog'] = views$dialogs$ChatInviteDialog);
import views$dialogs$ConfirmRedactDialog from './components/views/dialogs/ConfirmRedactDialog';
views$dialogs$ConfirmRedactDialog && (module.exports.components['views.dialogs.ConfirmRedactDialog'] = views$dialogs$ConfirmRedactDialog);
import views$dialogs$ConfirmUserActionDialog from './components/views/dialogs/ConfirmUserActionDialog';
views$dialogs$ConfirmUserActionDialog && (module.exports.components['views.dialogs.ConfirmUserActionDialog'] = views$dialogs$ConfirmUserActionDialog);
import views$dialogs$DeactivateAccountDialog from './components/views/dialogs/DeactivateAccountDialog';
@ -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);
import views$elements$AccessibleButton from './components/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';
views$elements$AddressSelector && (module.exports.components['views.elements.AddressSelector'] = views$elements$AddressSelector);
import views$elements$AddressTile from './components/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';
views$elements$DeviceVerifyButtons && (module.exports.components['views.elements.DeviceVerifyButtons'] = views$elements$DeviceVerifyButtons);
import views$elements$DirectorySearchBox from './components/views/elements/DirectorySearchBox';
views$elements$DirectorySearchBox && (module.exports.components['views.elements.DirectorySearchBox'] = views$elements$DirectorySearchBox);
import views$elements$Dropdown from './components/views/elements/Dropdown';
views$elements$Dropdown && (module.exports.components['views.elements.Dropdown'] = views$elements$Dropdown);
import views$elements$EditableText from './components/views/elements/EditableText';
views$elements$EditableText && (module.exports.components['views.elements.EditableText'] = views$elements$EditableText);
import views$elements$EditableTextContainer from './components/views/elements/EditableTextContainer';
views$elements$EditableTextContainer && (module.exports.components['views.elements.EditableTextContainer'] = views$elements$EditableTextContainer);
import views$elements$EmojiText from './components/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';
views$elements$MemberEventListSummary && (module.exports.components['views.elements.MemberEventListSummary'] = views$elements$MemberEventListSummary);
import views$elements$PowerSelector from './components/views/elements/PowerSelector';
views$elements$PowerSelector && (module.exports.components['views.elements.PowerSelector'] = views$elements$PowerSelector);
import views$elements$ProgressBar from './components/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';
views$elements$TintableSvg && (module.exports.components['views.elements.TintableSvg'] = views$elements$TintableSvg);
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);
import views$login$CasLogin from './components/views/login/CasLogin';
views$login$CasLogin && (module.exports.components['views.login.CasLogin'] = views$login$CasLogin);
import views$login$CountryDropdown from './components/views/login/CountryDropdown';
views$login$CountryDropdown && (module.exports.components['views.login.CountryDropdown'] = views$login$CountryDropdown);
import views$login$CustomServerDialog from './components/views/login/CustomServerDialog';
views$login$CustomServerDialog && (module.exports.components['views.login.CustomServerDialog'] = views$login$CustomServerDialog);
import views$login$InteractiveAuthEntryComponents from './components/views/login/InteractiveAuthEntryComponents';
@ -221,6 +241,8 @@ import views$rooms$TopUnreadMessagesBar from './components/views/rooms/TopUnread
views$rooms$TopUnreadMessagesBar && (module.exports.components['views.rooms.TopUnreadMessagesBar'] = views$rooms$TopUnreadMessagesBar);
import views$rooms$UserTile from './components/views/rooms/UserTile';
views$rooms$UserTile && (module.exports.components['views.rooms.UserTile'] = views$rooms$UserTile);
import views$settings$AddPhoneNumber from './components/views/settings/AddPhoneNumber';
views$settings$AddPhoneNumber && (module.exports.components['views.settings.AddPhoneNumber'] = views$settings$AddPhoneNumber);
import views$settings$ChangeAvatar from './components/views/settings/ChangeAvatar';
views$settings$ChangeAvatar && (module.exports.components['views.settings.ChangeAvatar'] = views$settings$ChangeAvatar);
import views$settings$ChangeDisplayName from './components/views/settings/ChangeDisplayName';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -59,7 +59,9 @@ module.exports = React.createClass({
ContentRepo.getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(),
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
this.getRoomAvatarUrl(props),
this.getOneToOneAvatar(props),
@ -74,7 +76,9 @@ module.exports = React.createClass({
return props.room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
props.width, props.height, props.resizeMethod,
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false
);
},
@ -103,14 +107,18 @@ module.exports = React.createClass({
}
return theOtherGuy.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
props.width, props.height, props.resizeMethod,
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false
);
} else if (userIds.length == 1) {
return mlist[userIds[0]].getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
props.width, props.height, props.resizeMethod,
false
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false
);
} else {
return null;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -50,6 +50,12 @@ export default React.createClass({
};
},
componentDidMount: function() {
if (this.props.focus) {
this.refs.button.focus();
}
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
@ -59,7 +65,7 @@ export default React.createClass({
{this.props.description}
</div>
<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}
</button>
</div>

View file

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

View file

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

View file

@ -27,11 +27,13 @@ import React from 'react';
export default function AccessibleButton(props) {
const {element, onClick, children, ...restProps} = props;
restProps.onClick = onClick;
restProps.onKeyDown = function(e) {
if (e.keyCode == 13 || e.keyCode == 32) return onClick();
restProps.onKeyUp = function(e) {
if (e.keyCode == 13 || e.keyCode == 32) return onClick(e);
};
restProps.tabIndex = restProps.tabIndex || "0";
restProps.role = "button";
restProps.className = (restProps.className ? restProps.className + " " : "") +
"mx_AccessibleButton";
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 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.
@ -138,7 +139,7 @@ export default React.createClass({
onClick={this.onClick.bind(this, i)}
onMouseEnter={this.onMouseEnter.bind(this, i)}
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; }}
>
<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) {
if (ev.key == 'Enter') {
if (ev.key == 'Enter' && this.props.showJoinButton) {
if (this.props.onJoinClick) {
this.props.onJoinClick(this.state.value);
}

View file

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

View file

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

View file

@ -0,0 +1,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 url from 'url';
import classnames from 'classnames';
import sdk from '../../../index';
@ -158,6 +160,7 @@ export const RecaptchaAuthEntry = React.createClass({
submitAuthDict: React.PropTypes.func.isRequired,
stageParams: React.PropTypes.object.isRequired,
errorText: React.PropTypes.string,
busy: React.PropTypes.bool,
},
_onCaptchaResponse: function(response) {
@ -168,6 +171,11 @@ export const RecaptchaAuthEntry = React.createClass({
},
render: function() {
if (this.props.busy) {
const Loader = sdk.getComponent("elements.Spinner");
return <Loader />;
}
const CaptchaForm = sdk.getComponent("views.login.CaptchaForm");
var sitePublicKey = this.props.stageParams.public_key;
return (
@ -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({
displayName: 'FallbackAuthEntry',
@ -313,6 +452,7 @@ const AuthEntryComponents = [
PasswordAuthEntry,
RecaptchaAuthEntry,
EmailIdentityAuthEntry,
MsisdnAuthEntry,
];
export function getEntryComponentForLoginType(loginType) {

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,66 +18,164 @@ limitations under the License.
import React from 'react';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
import sdk from '../../../index';
import {field_input_incorrect} from '../../../UiEffects';
/**
* A pure UI component which displays a username/password form.
*/
module.exports = React.createClass({displayName: 'PasswordLogin',
propTypes: {
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,
},
class PasswordLogin extends React.Component {
static defaultProps = {
onUsernameChanged: function() {},
onPasswordChanged: function() {},
onPhoneCountryChanged: function() {},
onPhoneNumberChanged: function() {},
initialUsername: "",
initialPhoneCountry: "",
initialPhoneNumber: "",
initialPassword: "",
loginIncorrect: false,
hsDomain: "",
}
getDefaultProps: function() {
return {
onUsernameChanged: function() {},
onPasswordChanged: function() {},
initialUsername: "",
initialPassword: "",
loginIncorrect: false,
};
},
getInitialState: function() {
return {
constructor(props) {
super(props);
this.state = {
username: this.props.initialUsername,
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;
},
}
componentWillReceiveProps: function(nextProps) {
componentWillReceiveProps(nextProps) {
if (!this.props.loginIncorrect && nextProps.loginIncorrect) {
field_input_incorrect(this._passwordField);
}
},
}
onSubmitForm: function(ev) {
onSubmitForm(ev) {
ev.preventDefault();
this.props.onSubmit(this.state.username, this.state.password);
},
this.props.onSubmit(
this.state.username,
this.state.phoneCountry,
this.state.phoneNumber,
this.state.password,
);
}
onUsernameChanged: function(ev) {
onUsernameChanged(ev) {
this.setState({username: 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.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;
if (this.props.onForgotPasswordClick) {
@ -92,14 +191,25 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
error: this.props.loginIncorrect,
});
const Dropdown = sdk.getComponent('elements.Dropdown');
const loginField = this.renderLoginField(this.state.loginType);
return (
<div>
<form onSubmit={this.onSubmitForm}>
<input className="mx_Login_field" type="text"
name="username" // make it a little easier for browser's remember-password
value={this.state.username} onChange={this.onUsernameChanged}
placeholder="Email or user name" autoFocus />
<br />
<div className="mx_Login_type_container">
<label className="mx_Login_type_label">I want to sign in with my</label>
<Dropdown
className="mx_Login_type_dropdown"
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"
name="password"
value={this.state.password} onChange={this.onPasswordChanged}
@ -111,4 +221,25 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
</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 sdk from '../../../index';
import Email from '../../../email';
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
import Modal from '../../../Modal';
const FIELD_EMAIL = 'field_email';
const FIELD_PHONE_COUNTRY = 'field_phone_country';
const FIELD_PHONE_NUMBER = 'field_phone_number';
const FIELD_USERNAME = 'field_username';
const FIELD_PASSWORD = 'field_password';
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
@ -35,6 +38,8 @@ module.exports = React.createClass({
propTypes: {
// Values pre-filled in the input boxes when the component loads
defaultEmail: React.PropTypes.string,
defaultPhoneCountry: React.PropTypes.string,
defaultPhoneNumber: React.PropTypes.string,
defaultUsername: React.PropTypes.string,
defaultPassword: React.PropTypes.string,
teamsConfig: React.PropTypes.shape({
@ -71,6 +76,8 @@ module.exports = React.createClass({
return {
fieldValid: {},
selectedTeam: null,
// The ISO2 country code selected in the phone number entry
phoneCountry: this.props.defaultPhoneCountry,
};
},
@ -85,6 +92,7 @@ module.exports = React.createClass({
this.validateField(FIELD_PASSWORD_CONFIRM);
this.validateField(FIELD_PASSWORD);
this.validateField(FIELD_USERNAME);
this.validateField(FIELD_PHONE_NUMBER);
this.validateField(FIELD_EMAIL);
var self = this;
@ -118,6 +126,8 @@ module.exports = React.createClass({
username: this.refs.username.value.trim() || this.props.guestUsername,
password: this.refs.password.value.trim(),
email: email,
phoneCountry: this.state.phoneCountry,
phoneNumber: this.refs.phoneNumber.value.trim(),
});
if (promise) {
@ -174,6 +184,11 @@ module.exports = React.createClass({
const emailValid = email === '' || Email.looksValid(email);
this.markFieldValid(field_id, emailValid, "RegistrationForm.ERR_EMAIL_INVALID");
break;
case FIELD_PHONE_NUMBER:
const phoneNumber = this.refs.phoneNumber.value;
const phoneNumberValid = phoneNumber === '' || phoneNumberLooksValid(phoneNumber);
this.markFieldValid(field_id, phoneNumberValid, "RegistrationForm.ERR_PHONE_NUMBER_INVALID");
break;
case FIELD_USERNAME:
// XXX: SPEC-1
var username = this.refs.username.value.trim() || this.props.guestUsername;
@ -233,6 +248,8 @@ module.exports = React.createClass({
switch (field_id) {
case FIELD_EMAIL:
return this.refs.email;
case FIELD_PHONE_NUMBER:
return this.refs.phoneNumber;
case FIELD_USERNAME:
return this.refs.username;
case FIELD_PASSWORD:
@ -251,6 +268,13 @@ module.exports = React.createClass({
return cls;
},
_onPhoneCountryChange(newVal) {
this.setState({
phoneCountry: newVal.iso2,
phonePrefix: newVal.prefix,
});
},
render: function() {
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 = (
<input className="mx_Login_submit" type="submit" value="Register" />
);
@ -300,6 +349,7 @@ module.exports = React.createClass({
<form onSubmit={this.onSubmit}>
{emailSection}
{belowEmailSection}
{phoneSection}
<input type="text" ref="username"
placeholder={ placeholderUserName } defaultValue={this.props.defaultUsername}
className={this._classForField(FIELD_USERNAME, 'mx_Login_field')}

View file

@ -27,8 +27,7 @@ module.exports = React.createClass({
displayName: 'ServerConfig',
propTypes: {
onHsUrlChanged: React.PropTypes.func,
onIsUrlChanged: React.PropTypes.func,
onServerConfigChange: React.PropTypes.func,
// 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.
@ -50,8 +49,7 @@ module.exports = React.createClass({
getDefaultProps: function() {
return {
onHsUrlChanged: function() {},
onIsUrlChanged: function() {},
onServerConfigChange: function() {},
customHsUrl: "",
customIsUrl: "",
withToggleButton: false,
@ -75,7 +73,10 @@ module.exports = React.createClass({
this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, function() {
var hsUrl = this.state.hs_url.trim().replace(/\/$/, "");
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() {
var isUrl = this.state.is_url.trim().replace(/\/$/, "");
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
});
if (!visible) {
this.props.onHsUrlChanged(this.props.defaultHsUrl);
this.props.onIsUrlChanged(this.props.defaultIsUrl);
this.props.onServerConfigChange({
hsUrl : this.props.defaultHsUrl,
isUrl : this.props.defaultIsUrl,
});
}
else {
this.props.onHsUrlChanged(this.state.hs_url);
this.props.onIsUrlChanged(this.state.is_url);
this.props.onServerConfigChange({
hsUrl : this.state.hs_url,
isUrl : this.state.is_url,
});
}
},

View file

@ -346,7 +346,7 @@ module.exports = React.createClass({
return (
<span className="mx_MFileBody">
<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 }
</a>
<div className="mx_MImageBody_size">
@ -360,7 +360,7 @@ module.exports = React.createClass({
return (
<span className="mx_MFileBody">
<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"/>
Download {text}
</a>

View file

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

View file

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

View file

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

View file

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

View file

@ -218,11 +218,13 @@ module.exports = WithMatrixClient(React.createClass({
},
onKick: function() {
const membership = this.props.member.membership;
const kickLabel = membership === "invite" ? "Disinvite" : "Kick";
const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog");
Modal.createDialog(ConfirmUserActionDialog, {
member: this.props.member,
action: 'Kick',
askReason: true,
action: kickLabel,
askReason: membership == "join",
danger: true,
onFinished: (proceed, reason) => {
if (!proceed) return;
@ -237,9 +239,10 @@ module.exports = WithMatrixClient(React.createClass({
console.log("Kick success");
}, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Kick error: " + err);
Modal.createDialog(ErrorDialog, {
title: "Kick error",
description: err.message
title: "Failed to kick",
description: ((err && err.message) ? err.message : "Operation failed"),
});
}
).finally(()=>{
@ -278,9 +281,10 @@ module.exports = WithMatrixClient(React.createClass({
console.log("Ban success");
}, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Ban error: " + err);
Modal.createDialog(ErrorDialog, {
title: "Ban error",
description: err.message,
title: "Error",
description: "Failed to ban user",
});
}
).finally(()=>{
@ -327,9 +331,10 @@ module.exports = WithMatrixClient(React.createClass({
// get out of sync if we force setState here!
console.log("Mute toggle success");
}, function(err) {
console.error("Mute error: " + err);
Modal.createDialog(ErrorDialog, {
title: "Mute error",
description: err.message
title: "Error",
description: "Failed to mute user",
});
}
).finally(()=>{
@ -375,9 +380,10 @@ module.exports = WithMatrixClient(React.createClass({
description: "This action cannot be performed by a guest user. Please register to be able to do this."
});
} else {
console.error("Toggle moderator error:" + err);
Modal.createDialog(ErrorDialog, {
title: "Moderator toggle error",
description: err.message
title: "Error",
description: "Failed to toggle moderator status",
});
}
}
@ -395,9 +401,10 @@ module.exports = WithMatrixClient(React.createClass({
console.log("Power change success");
}, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to change power level " + err);
Modal.createDialog(ErrorDialog, {
title: "Failure to change power level",
description: err.message
title: "Error",
description: "Failed to change power level",
});
}
).finally(()=>{
@ -553,6 +560,13 @@ module.exports = WithMatrixClient(React.createClass({
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
},
onRoomTileClick(roomId) {
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
},
_renderDevices: function() {
if (!this._enableDevices) {
return null;
@ -613,6 +627,7 @@ module.exports = WithMatrixClient(React.createClass({
unread={Unread.doesRoomHaveUnreadMessages(room)}
highlight={highlight}
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.onInputStateChanged = this.onInputStateChanged.bind(this);
this.onEvent = this.onEvent.bind(this);
this.onPageUnload = this.onPageUnload.bind(this);
this.state = {
autocompleteQuery: '',
@ -50,7 +51,7 @@ export default class MessageComposer extends React.Component {
inputState: {
style: [],
blockType: null,
isRichtextEnabled: UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true),
isRichtextEnabled: UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', false),
wordCount: 0,
},
showFormatting: UserSettingsStore.getSyncedSetting('MessageComposer.showFormatting', false),
@ -64,12 +65,21 @@ export default class MessageComposer extends React.Component {
// marked as encrypted.
// XXX: fragile as all hell - fixme somehow, perhaps with a dedicated Room.encryption event or something.
MatrixClientPeg.get().on("event", this.onEvent);
window.addEventListener('beforeunload', this.onPageUnload);
}
componentWillUnmount() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("event", this.onEvent);
}
window.removeEventListener('beforeunload', this.onPageUnload);
}
onPageUnload(event) {
if (this.messageComposerInput) {
this.messageComposerInput.sentHistory.saveLastTextEntry();
}
}
onEvent(event) {
@ -91,8 +101,9 @@ export default class MessageComposer extends React.Component {
this.refs.uploadInput.click();
}
onUploadFileSelected(ev) {
let files = ev.target.files;
onUploadFileSelected(files, isPasted) {
if (!isPasted)
files = files.target.files;
let QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
let TintableSvg = sdk.getComponent("elements.TintableSvg");
@ -100,7 +111,7 @@ export default class MessageComposer extends React.Component {
let fileList = [];
for (let i=0; i<files.length; i++) {
fileList.push(<li key={i}>
<TintableSvg key={i} src="img/files.svg" width="16" height="16" /> {files[i].name}
<TintableSvg key={i} src="img/files.svg" width="16" height="16" /> {files[i].name || 'Attachment'}
</li>);
}
@ -171,7 +182,7 @@ export default class MessageComposer extends React.Component {
}
onUpArrow() {
return this.refs.autocomplete.onUpArrow();
return this.refs.autocomplete.onUpArrow();
}
onDownArrow() {
@ -299,6 +310,7 @@ export default class MessageComposer extends React.Component {
tryComplete={this._tryComplete}
onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow}
onUploadFileSelected={this.onUploadFileSelected}
tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete
onContentChanged={this.onInputContentChanged}
onInputStateChanged={this.onInputStateChanged} />,

View file

@ -96,8 +96,20 @@ export default class MessageComposerInput extends React.Component {
constructor(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 = {
// whether we're in rich text or markdown mode
@ -261,6 +273,7 @@ export default class MessageComposerInput extends React.Component {
}
sendTyping(isTyping) {
if (UserSettingsStore.getSyncedSetting('dontSendTypingNotifications', false)) return;
MatrixClientPeg.get().sendTyping(
this.props.room.roomId,
this.isTyping, TYPING_SERVER_TIMEOUT,
@ -404,10 +417,14 @@ export default class MessageComposerInput extends React.Component {
}
return false;
};
}
handleReturn = (ev) => {
if(ev.shiftKey) {
handlePastedFiles(files) {
this.props.onUploadFileSelected(files, true);
}
handleReturn(ev) {
if (ev.shiftKey) {
this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState));
return true;
}
@ -442,7 +459,7 @@ export default class MessageComposerInput extends React.Component {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Server error",
description: err.message,
description: ((err && err.message) ? err.message : "Server unavailable, overloaded, or something else went wrong."),
});
});
} else if (cmd.error) {
@ -473,9 +490,9 @@ export default class MessageComposerInput extends React.Component {
let sendTextFn = this.client.sendTextMessage;
if (contentText.startsWith('/me')) {
contentText = contentText.replace('/me', '');
contentText = contentText.replace('/me ', '');
// bit of a hack, but the alternative would be quite complicated
if (contentHTML) contentHTML = contentHTML.replace('/me', '');
if (contentHTML) contentHTML = contentHTML.replace('/me ', '');
sendHtmlFn = this.client.sendHtmlEmote;
sendTextFn = this.client.sendEmoteMessage;
}
@ -686,6 +703,7 @@ export default class MessageComposerInput extends React.Component {
keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn}
handlePastedFiles={this.handlePastedFiles}
stripPastedStyles={!this.state.isRichtextEnabled}
onTab={this.onTab}
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 MemberEntry = require("../../../TabCompleteEntries").MemberEntry;
var sdk = require('../../../index');
import UserSettingsStore from "../../../UserSettingsStore";
var dis = require("../../../dispatcher");
var KeyCode = require("../../../KeyCode");
@ -311,7 +312,7 @@ export default React.createClass({
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
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) {
if (UserSettingsStore.getSyncedSetting('dontSendTypingNotifications', false)) return;
MatrixClientPeg.get().sendTyping(
this.props.room.roomId,
this.isTyping, TYPING_SERVER_TIMEOUT

View file

@ -75,7 +75,7 @@ module.exports = React.createClass({
render: function() {
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";
// if (this.props.currentlyActive) ago += " (now?)";
return (

View file

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

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -21,15 +22,23 @@ var GeminiScrollbar = require('react-gemini-scrollbar');
var MatrixClientPeg = require("../../../MatrixClientPeg");
var CallHandler = require('../../../CallHandler');
var RoomListSorter = require("../../../RoomListSorter");
var Unread = require('../../../Unread');
var dis = require("../../../dispatcher");
var sdk = require('../../../index');
var rate_limited_func = require('../../../ratelimitedfunc');
var Rooms = require('../../../Rooms');
import DMRoomMap from '../../../utils/DMRoomMap';
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({
displayName: 'RoomList',
@ -37,13 +46,23 @@ module.exports = React.createClass({
propTypes: {
ConferenceHandler: React.PropTypes.any,
collapsed: React.PropTypes.bool.isRequired,
currentRoom: React.PropTypes.string,
selectedRoom: 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() {
return {
isLoadingLeftRooms: false,
totalRoomCount: null,
lists: {},
incomingCall: null,
};
@ -57,12 +76,21 @@ module.exports = React.createClass({
cli.on("Room.name", this.onRoomName);
cli.on("Room.tags", this.onRoomTags);
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("accountData", this.onAccountData);
var s = this.getRoomLists();
this.setState(s);
// lookup for which lists a given roomId is currently in.
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() {
@ -71,7 +99,22 @@ module.exports = React.createClass({
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
this._updateStickyHeaders(true);
this._repositionIncomingCallBox(undefined, false);
@ -95,6 +138,26 @@ module.exports = React.createClass({
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;
}
},
@ -108,7 +171,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
MatrixClientPeg.get().removeListener("Room.tags", this.onRoomTags);
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("accountData", this.onAccountData);
}
@ -117,10 +180,14 @@ module.exports = React.createClass({
},
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();
},
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();
},
@ -143,6 +210,10 @@ module.exports = React.createClass({
}
},
_onMouseOver: function(ev) {
this._lastMouseOverTs = Date.now();
},
onSubListHeaderClick: function(isHidden, scrollToPosition) {
// The scroll area has expanded or contracted, so re-calculate sticky headers positions
this._updateStickyHeaders(true, scrollToPosition);
@ -152,41 +223,98 @@ module.exports = React.createClass({
if (toStartOfTimeline) return;
if (!room) 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) {
// because if we read a notification, it will affect notification count
// only bother updating if there's a receipt from us
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) {
this._delayedRefreshRoomList();
constantTimeDispatcher.dispatch(
"RoomTile.refresh", room.roomId, {}
);
},
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();
},
onRoomStateEvents: function(ev, state) {
this._delayedRefreshRoomList();
onRoomStateMember: function(ev, state, member) {
if (ev.getStateKey() === MatrixClientPeg.get().credentials.userId &&
ev.getPrevContent() && ev.getPrevContent().membership === "invite")
{
this._delayedRefreshRoomList();
}
else {
constantTimeDispatcher.dispatch(
"RoomTile.refresh", member.roomId, {}
);
}
},
onRoomMemberName: function(ev, member) {
this._delayedRefreshRoomList();
constantTimeDispatcher.dispatch(
"RoomTile.refresh", member.roomId, {}
);
},
onAccountData: function(ev) {
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();
}
},
_delayedRefreshRoomList: new rate_limited_func(function() {
this.refreshRoomList();
// 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._delayedRefreshRoomListLoopCount = 0;
}
else {
this._delayedRefreshRoomListLoopCount++;
this._delayedRefreshRoomList();
}
}, 500),
refreshRoomList: function() {
@ -194,26 +322,36 @@ module.exports = React.createClass({
// (!this._lastRefreshRoomListTs ? "-" : (Date.now() - this._lastRefreshRoomListTs))
// );
// TODO: rather than bluntly regenerating and re-sorting everything
// every time we see any kind of room change from the JS SDK
// we could do incremental updates on our copy of the state
// based on the room which has actually changed. This would stop
// us re-rendering all the sublists every time anything changes anywhere
// in the state of the client.
this.setState(this.getRoomLists());
this._lastRefreshRoomListTs = Date.now();
// TODO: ideally we'd calculate this once at start, and then maintain
// any changes to it incrementally, updating the appropriate sublists
// as needed.
// Alternatively we'd do something magical with Immutable.js or similar.
const lists = this.getRoomLists();
let totalRooms = 0;
for (const l of Object.values(lists)) {
totalRooms += l.length;
}
this.setState({
lists: this.getRoomLists(),
totalRoomCount: totalRooms,
});
// this._lastRefreshRoomListTs = Date.now();
},
getRoomLists: function() {
var self = this;
var s = { lists: {} };
const lists = {};
s.lists["im.vector.fake.invite"] = [];
s.lists["m.favourite"] = [];
s.lists["im.vector.fake.recent"] = [];
s.lists["im.vector.fake.direct"] = [];
s.lists["m.lowpriority"] = [];
s.lists["im.vector.fake.archived"] = [];
lists["im.vector.fake.invite"] = [];
lists["m.favourite"] = [];
lists["im.vector.fake.recent"] = [];
lists["im.vector.fake.direct"] = [];
lists["m.lowpriority"] = [];
lists["im.vector.fake.archived"] = [];
this.listsForRoomId = {};
var otherTagNames = {};
const dmRoomMap = new DMRoomMap(MatrixClientPeg.get());
@ -226,8 +364,13 @@ module.exports = React.createClass({
// ", target = " + me.events.member.getStateKey() +
// ", prevMembership = " + me.events.member.getPrevContent().membership);
if (!self.listsForRoomId[room.roomId]) {
self.listsForRoomId[room.roomId] = [];
}
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)) {
// 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
var tagNames = Object.keys(room.tags);
if (tagNames.length) {
for (var i = 0; i < tagNames.length; i++) {
var tagName = tagNames[i];
s.lists[tagName] = s.lists[tagName] || [];
s.lists[tagNames[i]].push(room);
lists[tagName] = lists[tagName] || [];
lists[tagName].push(room);
self.listsForRoomId[room.roomId].push(tagName);
otherTagNames[tagName] = 1;
}
}
else if (dmRoomMap.getUserIdForRoomId(room.roomId)) {
// "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 {
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") {
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 {
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.
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() {
var panel = ReactDOM.findDOMNode(this);
if (!panel) return null;
if (panel.classList.contains('gm-prevented')) {
return panel;
} else {
return panel.children[2]; // XXX: Fragile!
}
// empirically, if we have gm-prevented for some reason, the scroll node
// is still the 3rd child (i.e. the view child). This looks to be due
// to vdh's improved resize updater logic...?
return panel.children[2]; // XXX: Fragile!
},
_whenScrolling: function(e) {
@ -331,10 +455,11 @@ module.exports = React.createClass({
var incomingCallBox = document.getElementById("incomingCallBox");
if (incomingCallBox && incomingCallBox.parentElement) {
var scrollArea = this._getScrollNode();
if (!scrollArea) return;
// 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
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
var scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height;
@ -354,10 +479,11 @@ module.exports = React.createClass({
// properly through React
_initAndPositionStickyHeaders: function(initialise, scrollToPosition) {
var scrollArea = this._getScrollNode();
if (!scrollArea) return;
// 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
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
var scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height;
@ -451,21 +577,74 @@ module.exports = React.createClass({
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() {
var RoomSubList = sdk.getComponent('structures.RoomSubList');
var self = this;
return (
<GeminiScrollbar className="mx_RoomList_scrollbar"
autoshow={true} onScroll={ self._whenScrolling } ref="gemscroll">
<div className="mx_RoomList">
autoshow={true} onScroll={ self._whenScrolling } onResize={ self._whenScrolling } ref="gemscroll">
<div className="mx_RoomList" onMouseOver={ this._onMouseOver }>
<RoomSubList list={ self.state.lists['im.vector.fake.invite'] }
label="Invites"
tagName="im.vector.fake.invite"
editable={ false }
order="recent"
selectedRoom={ self.props.selectedRoom }
incomingCall={ self.state.incomingCall }
collapsed={ self.props.collapsed }
selectedRoom={ self.props.selectedRoom }
searchFilter={ self.props.searchFilter }
onHeaderClick={ self.onSubListHeaderClick }
onShowMoreRooms={ self.onShowMoreRooms } />
@ -473,51 +652,57 @@ module.exports = React.createClass({
<RoomSubList list={ self.state.lists['m.favourite'] }
label="Favourites"
tagName="m.favourite"
verb="favourite"
emptyContent={this._getEmptyContent('m.favourite')}
editable={ true }
order="manual"
selectedRoom={ self.props.selectedRoom }
incomingCall={ self.state.incomingCall }
collapsed={ self.props.collapsed }
selectedRoom={ self.props.selectedRoom }
searchFilter={ self.props.searchFilter }
onHeaderClick={ self.onSubListHeaderClick }
onShowMoreRooms={ self.onShowMoreRooms } />
<RoomSubList list={ self.state.lists['im.vector.fake.direct'] }
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"
selectedRoom={ self.props.selectedRoom }
incomingCall={ self.state.incomingCall }
collapsed={ self.props.collapsed }
selectedRoom={ self.props.selectedRoom }
alwaysShowHeader={ true }
searchFilter={ self.props.searchFilter }
onHeaderClick={ self.onSubListHeaderClick }
onShowMoreRooms={ self.onShowMoreRooms } />
<RoomSubList list={ self.state.lists['im.vector.fake.recent'] }
label="Rooms"
tagName="im.vector.fake.recent"
editable={ true }
verb="restore"
emptyContent={this._getEmptyContent('im.vector.fake.recent')}
headerItems={this._getHeaderItems('im.vector.fake.recent')}
order="recent"
selectedRoom={ self.props.selectedRoom }
incomingCall={ self.state.incomingCall }
collapsed={ self.props.collapsed }
selectedRoom={ self.props.selectedRoom }
searchFilter={ self.props.searchFilter }
onHeaderClick={ self.onSubListHeaderClick }
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))$/)) {
return <RoomSubList list={ self.state.lists[tagName] }
key={ tagName }
label={ tagName }
tagName={ tagName }
verb={ "tag as " + tagName }
emptyContent={this._getEmptyContent(tagName)}
editable={ true }
order="manual"
selectedRoom={ self.props.selectedRoom }
incomingCall={ self.state.incomingCall }
collapsed={ self.props.collapsed }
selectedRoom={ self.props.selectedRoom }
searchFilter={ self.props.searchFilter }
onHeaderClick={ self.onSubListHeaderClick }
onShowMoreRooms={ self.onShowMoreRooms } />;
@ -528,22 +713,23 @@ module.exports = React.createClass({
<RoomSubList list={ self.state.lists['m.lowpriority'] }
label="Low priority"
tagName="m.lowpriority"
verb="demote"
emptyContent={this._getEmptyContent('m.lowpriority')}
editable={ true }
order="recent"
selectedRoom={ self.props.selectedRoom }
incomingCall={ self.state.incomingCall }
collapsed={ self.props.collapsed }
selectedRoom={ self.props.selectedRoom }
searchFilter={ self.props.searchFilter }
onHeaderClick={ self.onSubListHeaderClick }
onShowMoreRooms={ self.onShowMoreRooms } />
<RoomSubList list={ self.state.lists['im.vector.fake.archived'] }
label="Historical"
tagName="im.vector.fake.archived"
editable={ false }
order="recent"
selectedRoom={ self.props.selectedRoom }
collapsed={ self.props.collapsed }
selectedRoom={ self.props.selectedRoom }
alwaysShowHeader={ true }
startAsHidden={ true }
showSpinner={ self.state.isLoadingLeftRooms }

View file

@ -54,9 +54,10 @@ const BannedUser = React.createClass({
this.props.member.roomId, this.props.member.userId,
).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to unban: " + err);
Modal.createDialog(ErrorDialog, {
title: "Failed to unban",
description: err.message,
title: "Error",
description: "Failed to unban",
});
}).done();
},
@ -128,14 +129,17 @@ module.exports = React.createClass({
console.error("Failed to get room visibility: " + err);
});
this.scalarClient = new ScalarAuthClient();
this.scalarClient.connect().done(() => {
this.forceUpdate();
}, (err) => {
this.setState({
scalar_error: err
this.scalarClient = null;
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
this.scalarClient = new ScalarAuthClient();
this.scalarClient.connect().done(() => {
this.forceUpdate();
}, (err) => {
this.setState({
scalar_error: err
});
});
});
}
dis.dispatch({
action: 'ui_opacity',
@ -489,7 +493,7 @@ module.exports = React.createClass({
ev.preventDefault();
var IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
Modal.createDialog(IntegrationsManager, {
src: this.scalarClient.hasCredentials() ?
src: (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId) :
null,
onFinished: ()=>{
@ -764,36 +768,39 @@ module.exports = React.createClass({
</div>;
}
var integrationsButton;
var integrationsError;
if (this.state.showIntegrationsError && this.state.scalar_error) {
console.error(this.state.scalar_error);
integrationsError = (
<span className="mx_RoomSettings_integrationsButton_errorPopup">
Could not connect to the integration server
</span>
);
}
let integrationsButton;
let integrationsError;
if (this.scalarClient.hasCredentials()) {
integrationsButton = (
if (this.scalarClient !== null) {
if (this.state.showIntegrationsError && this.state.scalar_error) {
console.error(this.state.scalar_error);
integrationsError = (
<span className="mx_RoomSettings_integrationsButton_errorPopup">
Could not connect to the integration server
</span>
);
}
if (this.scalarClient.hasCredentials()) {
integrationsButton = (
<div className="mx_RoomSettings_integrationsButton" onClick={ this.onManageIntegrations }>
Manage Integrations
</div>
);
} else if (this.state.scalar_error) {
integrationsButton = (
Manage Integrations
</div>
);
} else if (this.state.scalar_error) {
integrationsButton = (
<div className="mx_RoomSettings_integrationsButton_error" onClick={ this.onShowIntegrationsError }>
Integrations Error <img src="img/warning.svg" width="17"/>
{ integrationsError }
</div>
);
} else {
integrationsButton = (
<div className="mx_RoomSettings_integrationsButton" style={{ opacity: 0.5 }}>
Manage Integrations
</div>
);
Integrations Error <img src="img/warning.svg" width="17"/>
{ integrationsError }
</div>
);
} else {
integrationsButton = (
<div className="mx_RoomSettings_integrationsButton" style={{opacity: 0.5}}>
Manage Integrations
</div>
);
}
}
return (

View file

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

View file

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

View file

@ -19,6 +19,7 @@ limitations under the License.
import React from 'react';
import dis from '../../../dispatcher';
import AccessibleButton from '../elements/AccessibleButton';
import sdk from '../../../index';
// cancel button which is shared between room header and simple room header
export function CancelButton(props) {
@ -45,6 +46,9 @@ export default React.createClass({
// is the RightPanel collapsed?
collapsedRhs: React.PropTypes.bool,
// `src` to a TintableSvg. Optional.
icon: React.PropTypes.string,
},
onShowRhsClick: function(ev) {
@ -53,9 +57,17 @@ export default React.createClass({
render: function() {
let cancelButton;
let icon;
if (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;
/* // 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_wrapper">
<div className="mx_RoomHeader_simpleHeader">
{ icon }
{ this.props.title }
{ showRhsButton }
{ cancelButton }

View file

@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -32,10 +33,10 @@ module.exports = React.createClass({
<div className="mx_TopUnreadMessagesBar">
<div className="mx_TopUnreadMessagesBar_scrollUp"
onClick={this.props.onScrollUpClick}>
<img src="img/scrollup.svg" width="24" height="24"
<img src="img/scrollto.svg" width="24" height="24"
alt="Scroll to unread messages"
title="Scroll to unread messages"/>
Unread messages. <span style={{ textDecoration: 'underline' }} onClick={this.props.onCloseClick}>Mark all read</span>
Jump to first unread message.
</div>
<img className="mx_TopUnreadMessagesBar_close"
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:
<div>
Changing password will currently reset any end-to-end encryption keys on all devices,
making encrypted chat history unreadable.
This will be <a href="https://github.com/vector-im/riot-web/issues/2671">improved shortly</a>,
but for now be warned.
making encrypted chat history unreadable, unless you first export your room keys
and re-import them afterwards.
In future this <a href="https://github.com/vector-im/riot-web/issues/2671">will be improved</a>.
</div>,
button: "Continue",
extraButtons: [
<button className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
Export E2E room keys
</button>
],
onFinished: (confirmed) => {
if (confirmed) {
var authDict = {
@ -105,6 +111,18 @@ module.exports = React.createClass({
});
},
_onExportE2eKeysClicked: function() {
Modal.createDialogAsync(
(cb) => {
require.ensure(['../../../async-components/views/dialogs/ExportE2eKeysDialog'], () => {
cb(require('../../../async-components/views/dialogs/ExportE2eKeysDialog'));
}, "e2e-export");
}, {
matrixClient: MatrixClientPeg.get(),
}
);
},
onClickChange: function() {
var old_password = this.refs.old_input.value;
var new_password = this.refs.new_input.value;

View file

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

View file

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

1273
src/phonenumber.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -115,7 +115,7 @@ var Tester = React.createClass({
//
// there is an extra 50 pixels of margin at the bottom.
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',
backgroundColor: '#fff8dc' }}>
{key}

View file

@ -68,48 +68,49 @@ describe('InteractiveAuthDialog', function () {
onFinished={onFinished}
/>, parentDiv);
// at this point there should be a password box and a submit button
const formNode = ReactTestUtils.findRenderedDOMComponentWithTag(dlg, "form");
const inputNodes = ReactTestUtils.scryRenderedDOMComponentsWithTag(
dlg, "input"
);
let passwordNode;
let submitNode;
for (const node of inputNodes) {
if (node.type == 'password') {
passwordNode = node;
} else if (node.type == 'submit') {
submitNode = node;
// wait for a password box and a submit button
test_utils.waitForRenderedDOMComponentWithTag(dlg, "form").then((formNode) => {
const inputNodes = ReactTestUtils.scryRenderedDOMComponentsWithTag(
dlg, "input"
);
let passwordNode;
let submitNode;
for (const node of inputNodes) {
if (node.type == 'password') {
passwordNode = node;
} else if (node.type == 'submit') {
submitNode = node;
}
}
}
expect(passwordNode).toExist();
expect(submitNode).toExist();
expect(passwordNode).toExist();
expect(submitNode).toExist();
// submit should be disabled
expect(submitNode.disabled).toBe(true);
// submit should be disabled
expect(submitNode.disabled).toBe(true);
// put something in the password box, and hit enter; that should
// trigger a request
passwordNode.value = "s3kr3t";
ReactTestUtils.Simulate.change(passwordNode);
expect(submitNode.disabled).toBe(false);
ReactTestUtils.Simulate.submit(formNode, {});
// put something in the password box, and hit enter; that should
// trigger a request
passwordNode.value = "s3kr3t";
ReactTestUtils.Simulate.change(passwordNode);
expect(submitNode.disabled).toBe(false);
ReactTestUtils.Simulate.submit(formNode, {});
expect(doRequest.callCount).toEqual(1);
expect(doRequest.calledWithExactly({
session: "sess",
type: "m.login.password",
password: "s3kr3t",
user: "@user:id",
})).toBe(true);
expect(doRequest.callCount).toEqual(1);
expect(doRequest.calledWithExactly({
session: "sess",
type: "m.login.password",
password: "s3kr3t",
user: "@user:id",
})).toBe(true);
// there should now be a spinner
ReactTestUtils.findRenderedComponentWithType(
dlg, sdk.getComponent('elements.Spinner'),
);
// there should now be a spinner
ReactTestUtils.findRenderedComponentWithType(
dlg, sdk.getComponent('elements.Spinner'),
);
// let the request complete
q.delay(1).then(() => {
// let the request complete
return q.delay(1);
}).then(() => {
expect(onFinished.callCount).toEqual(1);
expect(onFinished.calledWithExactly(true, {a:1})).toBe(true);
}).done(done, done);

View file

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