diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index e559a21e1a..5022b983f0 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -40,7 +40,6 @@ export default React.createClass({ propTypes: { matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient).isRequired, page_type: React.PropTypes.string.isRequired, - onRoomIdResolved: React.PropTypes.func, onRoomCreated: React.PropTypes.func, onUserSettingsClose: React.PropTypes.func, @@ -190,16 +189,14 @@ export default React.createClass({ case PageTypes.RoomView: page_element = { modal.close(); - if (this.currentRoomId === roomId) { + if (this.state.currentRoomId === roomId) { dis.dispatch({action: 'view_next_room'}); } }, (err) => { @@ -807,8 +808,12 @@ module.exports = React.createClass({ this._teamToken = teamToken; dis.dispatch({action: 'view_home_page'}); } else if (this._is_registered) { + this._is_registered = false; if (this.props.config.welcomeUserId) { - createRoom({dmUserId: this.props.config.welcomeUserId}); + createRoom({ + dmUserId: this.props.config.welcomeUserId, + andView: false, + }); return; } // The user has just logged in after registering @@ -853,7 +858,6 @@ module.exports = React.createClass({ ready: false, collapse_lhs: false, collapse_rhs: false, - currentRoomAlias: null, currentRoomId: null, page_type: PageTypes.RoomDirectory, }); @@ -891,6 +895,12 @@ module.exports = React.createClass({ }); cli.on('sync', function(state, prevState) { + // LifecycleStore and others cannot directly subscribe to matrix client for + // events because flux only allows store state changes during flux dispatches. + // So dispatch directly from here. Ideally we'd use a SyncStateStore that + // would do this dispatch and expose the sync state itself (by listening to + // its own dispatch). + dis.dispatch({action: 'sync_state', prevState, state}); self.updateStatusIndicator(state, prevState); if (state === "SYNCING" && prevState === "SYNCING") { return; @@ -1102,6 +1112,8 @@ module.exports = React.createClass({ }, onRegistered: function(credentials, teamToken) { + // XXX: These both should be in state or ideally store(s) because we risk not + // rendering the most up-to-date view of state otherwise. // teamToken may not be truthy this._teamToken = teamToken; this._is_registered = true; @@ -1163,13 +1175,6 @@ module.exports = React.createClass({ } }, - onRoomIdResolved: function(roomId) { - // It's the RoomView's resposibility to look up room aliases, but we need the - // ID to pass into things like the Member List, so the Room View tells us when - // its done that resolution so we can display things that take a room ID. - this.setState({currentRoomId: roomId}); - }, - _makeRegistrationUrl: function(params) { if (this.props.startingFragmentQueryParams.referrer) { params.referrer = this.props.startingFragmentQueryParams.referrer; @@ -1211,10 +1216,10 @@ module.exports = React.createClass({ const LoggedInView = sdk.getComponent('structures.LoggedInView'); return ( { this.forceUpdate(); - } + }, }); - if (this.props.roomAddress[0] == '#') { - // we always look up the alias from the directory server: - // we want the room that the given alias is pointing to - // right now. We may have joined that alias before but there's - // no guarantee the alias hasn't subsequently been remapped. - MatrixClientPeg.get().getRoomIdForAlias(this.props.roomAddress).done((result) => { - if (this.props.onRoomIdResolved) { - this.props.onRoomIdResolved(result.room_id); - } - var room = MatrixClientPeg.get().getRoom(result.room_id); - this.setState({ - room: room, - roomId: result.room_id, - roomLoading: !room, - unsentMessageError: this._getUnsentMessageError(room), - }, this._onHaveRoom); - }, (err) => { - this.setState({ - roomLoading: false, - roomLoadError: err, - }); - }); - } else { - var room = MatrixClientPeg.get().getRoom(this.props.roomAddress); - this.setState({ - roomId: this.props.roomAddress, - room: room, - roomLoading: !room, - unsentMessageError: this._getUnsentMessageError(room), - }, this._onHaveRoom); + // Start listening for RoomViewStore updates + RoomViewStore.addListener(this._onRoomViewStoreUpdate); + this._onRoomViewStoreUpdate(true); + }, + + _onRoomViewStoreUpdate: function(initial) { + if (this.unmounted) { + return; } + this.setState({ + roomId: RoomViewStore.getRoomId(), + roomAlias: RoomViewStore.getRoomAlias(), + joining: RoomViewStore.isJoining(), + joinError: RoomViewStore.getJoinError(), + }, () => { + this._onHaveRoom(); + this.onRoom(MatrixClientPeg.get().getRoom(this.state.roomId)); + }); }, _onHaveRoom: function() { @@ -224,17 +202,17 @@ module.exports = React.createClass({ // NB. We peek if we are not in the room, although if we try to peek into // a room in which we have a member event (ie. we've left) synapse will just // send us the same data as we get in the sync (ie. the last events we saw). - var user_is_in_room = null; - if (this.state.room) { - user_is_in_room = this.state.room.hasMembershipState( - MatrixClientPeg.get().credentials.userId, 'join' + const room = MatrixClientPeg.get().getRoom(this.state.roomId); + let isUserJoined = null; + if (room) { + isUserJoined = room.hasMembershipState( + MatrixClientPeg.get().credentials.userId, 'join', ); - this._updateAutoComplete(); - this.tabComplete.loadEntries(this.state.room); + this._updateAutoComplete(room); + this.tabComplete.loadEntries(room); } - - if (!user_is_in_room && this.state.roomId) { + if (!isUserJoined && !this.state.joining && this.state.roomId) { if (this.props.autoJoin) { this.onJoinButtonClicked(); } else if (this.state.roomId) { @@ -260,9 +238,12 @@ module.exports = React.createClass({ } }).done(); } - } else if (user_is_in_room) { + } else if (isUserJoined) { MatrixClientPeg.get().stopPeeking(); - this._onRoomLoaded(this.state.room); + this.setState({ + unsentMessageError: this._getUnsentMessageError(room), + }); + this._onRoomLoaded(room); } }, @@ -299,10 +280,6 @@ module.exports = React.createClass({ }, componentWillReceiveProps: function(newProps) { - if (newProps.roomAddress != this.props.roomAddress) { - throw new Error("changing room on a RoomView is not supported"); - } - if (newProps.eventId != this.props.eventId) { // when we change focussed event id, hide the search results. this.setState({searchResults: null}); @@ -523,7 +500,7 @@ module.exports = React.createClass({ this._updatePreviewUrlVisibility(room); }, - _warnAboutEncryption: function (room) { + _warnAboutEncryption: function(room) { if (!MatrixClientPeg.get().isRoomEncrypted(room.roomId)) { return; } @@ -604,20 +581,14 @@ module.exports = React.createClass({ }, onRoom: function(room) { - // This event is fired when the room is 'stored' by the JS SDK, which - // means it's now a fully-fledged room object ready to be used, so - // set it in our state and start using it (ie. init the timeline) - // This will happen if we start off viewing a room we're not joined, - // then join it whilst RoomView is looking at that room. - if (!this.state.room && room.roomId == this._joiningRoomId) { - this._joiningRoomId = undefined; - this.setState({ - room: room, - joining: false, - }); - - this._onRoomLoaded(room); + if (!room || room.roomId !== this.state.roomId) { + return; } + this.setState({ + room: room, + }, () => { + this._onRoomLoaded(room); + }); }, updateTint: function() { @@ -683,7 +654,7 @@ module.exports = React.createClass({ // refresh the tab complete list this.tabComplete.loadEntries(this.state.room); - this._updateAutoComplete(); + this._updateAutoComplete(this.state.room); // if we are now a member of the room, where we were not before, that // means we have finished joining a room we were previously peeking @@ -778,37 +749,43 @@ module.exports = React.createClass({ }, onJoinButtonClicked: function(ev) { - var self = this; - - var cli = MatrixClientPeg.get(); - var mxIdPromise = q(); + const cli = MatrixClientPeg.get(); // If the user is a ROU, allow them to transition to a PWLU if (cli && cli.isGuest()) { + // Join this room once the user has registered and logged in + dis.dispatch({ + action: 'do_after_sync_prepared', + deferred_action: { + action: 'join_room', + room_id: this.state.roomId, + }, + }); + const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog'); - const defered = q.defer(); - mxIdPromise = defered.promise; const close = Modal.createDialog(SetMxIdDialog, { homeserverUrl: cli.getHomeserverUrl(), onFinished: (submitted, credentials) => { - if (!submitted) { - defered.reject(); - return; + if (submitted) { + this.props.onRegistered(credentials); } - this.props.onRegistered(credentials); - defered.resolve(); }, onDifferentServerClicked: (ev) => { dis.dispatch({action: 'start_registration'}); close(); }, }).close; + return; } - mxIdPromise.then(() => { - this.setState({ - joining: true + q().then(() => { + const signUrl = this.props.thirdPartyInvite ? + this.props.thirdPartyInvite.inviteSignUrl : undefined; + dis.dispatch({ + action: 'join_room', + opts: { inviteSignUrl: signUrl }, }); + // if this is an invite and has the 'direct' hint set, mark it as a DM room now. if (this.state.room) { const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId); @@ -820,65 +797,8 @@ module.exports = React.createClass({ } } } - return q(); - }).then(() => { - var sign_url = this.props.thirdPartyInvite ? this.props.thirdPartyInvite.inviteSignUrl : undefined; - return MatrixClientPeg.get().joinRoom(this.props.roomAddress, - { inviteSignUrl: sign_url } ); - }).then(function(resp) { - var roomId = resp.roomId; - - // It is possible that there is no Room yet if state hasn't come down - // from /sync - joinRoom will resolve when the HTTP request to join succeeds, - // NOT when it comes down /sync. If there is no room, we'll keep the - // joining flag set until we see it. - - // We'll need to initialise the timeline when joining, but due to - // the above, we can't do it here: we do it in onRoom instead, - // once we have a useable room object. - var room = MatrixClientPeg.get().getRoom(roomId); - if (!room) { - // wait for the room to turn up in onRoom. - self._joiningRoomId = roomId; - } else { - // we've got a valid room, but that might also just mean that - // it was peekable (so we had one before anyway). If we are - // not yet a member of the room, we will need to wait for that - // to happen, in onRoomStateMember. - var me = MatrixClientPeg.get().credentials.userId; - self.setState({ - joining: !room.hasMembershipState(me, "join"), - room: room - }); - } - }).catch(function(error) { - self.setState({ - joining: false, - joinError: error - }); - - if (!error) return; - - // https://matrix.org/jira/browse/SYN-659 - // Need specific error message if joining a room is refused because the user is a guest and guest access is not allowed - if ( - error.errcode == 'M_GUEST_ACCESS_FORBIDDEN' || - ( - error.errcode == 'M_FORBIDDEN' && - MatrixClientPeg.get().isGuest() - ) - ) { - dis.dispatch({action: 'view_set_mxid'}); - } else { - var msg = error.message ? error.message : JSON.stringify(error); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Failed to join room", - description: msg - }); - } - }).done(); + }); }, onMessageListScroll: function(ev) { @@ -1451,9 +1371,9 @@ module.exports = React.createClass({ } }, - _updateAutoComplete: function() { + _updateAutoComplete: function(room) { const myUserId = MatrixClientPeg.get().credentials.userId; - const members = this.state.room.getJoinedMembers().filter(function(member) { + const members = room.getJoinedMembers().filter(function(member) { if (member.userId !== myUserId) return true; }); UserProvider.getInstance().setUserList(members); @@ -1491,7 +1411,7 @@ module.exports = React.createClass({ // We have no room object for this room, only the ID. // We've got to this room by following a link, possibly a third party invite. - var room_alias = this.props.roomAddress[0] == '#' ? this.props.roomAddress : null; + var room_alias = this.state.room_alias; return (
{ + * this.setState({ cachedPassword: lifecycleStore.getCachedPassword() }) + * }) + * ``` + */ +class LifecycleStore extends Store { + constructor() { + super(dis); + + // Initialise state + this._state = { + deferred_action: null, + }; + } + + _setState(newState) { + this._state = Object.assign(this._state, newState); + this.__emitChange(); + } + + __onDispatch(payload) { + switch (payload.action) { + case 'do_after_sync_prepared': + this._setState({ + deferred_action: payload.deferred_action, + }); + break; + case 'sync_state': + if (payload.state !== 'PREPARED') { + break; + } + if (!this._state.deferred_action) break; + const deferredAction = Object.assign({}, this._state.deferred_action); + this._setState({ + deferred_action: null, + }); + dis.dispatch(deferredAction); + break; + } + } +} + +let singletonLifecycleStore = null; +if (!singletonLifecycleStore) { + singletonLifecycleStore = new LifecycleStore(); +} +module.exports = singletonLifecycleStore; diff --git a/src/stores/RoomViewStore.js b/src/stores/RoomViewStore.js new file mode 100644 index 0000000000..fe57079859 --- /dev/null +++ b/src/stores/RoomViewStore.js @@ -0,0 +1,145 @@ +/* +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 {Store} from 'flux/utils'; +import MatrixClientPeg from '../MatrixClientPeg'; + +const INITIAL_STATE = { + // Whether we're joining the currently viewed room + joining: false, + // Any error occurred during joining + joinError: null, + // The room ID of the room + roomId: null, + // The room alias of the room (or null if not originally specified in view_room) + roomAlias: null, + // Whether the current room is loading + roomLoading: false, + // Any error that has occurred during loading + roomLoadError: null, +}; + +/** + * A class for storing application state for RoomView. This is the RoomView's interface +* with a subset of the js-sdk. + * ``` + */ +class RoomViewStore extends Store { + constructor() { + super(dis); + + // Initialise state + this._state = INITIAL_STATE; + } + + _setState(newState) { + this._state = Object.assign(this._state, newState); + this.__emitChange(); + } + + __onDispatch(payload) { + switch (payload.action) { + // view_room: + // - room_alias: '#somealias:matrix.org' + // - room_id: '!roomid123:matrix.org' + case 'view_room': + this._viewRoom(payload); + break; + + // join_room: + // - opts: options for joinRoom + case 'join_room': + this._joinRoom(payload); + break; + } + } + + _viewRoom(payload) { + const address = payload.room_alias || payload.room_id; + if (address[0] == '#') { + this._setState({ + roomLoading: true, + }); + MatrixClientPeg.get().getRoomIdForAlias(address).then( + (result) => { + this._setState({ + roomId: result.room_id, + roomAlias: address, + roomLoading: false, + roomLoadError: null, + }); + }, (err) => { + console.error(err); + this._setState({ + roomLoading: false, + roomLoadError: err, + }); + }); + } else { + this._setState({ + roomId: address, + }); + } + } + + _joinRoom(payload) { + this._setState({ + joining: true, + }); + MatrixClientPeg.get().joinRoom(this._state.roomId, payload.opts).then( + () => { + this._setState({ + joining: false, + }); + }, (err) => { + this._setState({ + joining: false, + joinError: err, + }); + }); + } + + reset() { + this._state = Object.assign({}, INITIAL_STATE); + } + + getRoomId() { + return this._state.roomId; + } + + getRoomAlias() { + return this._state.roomAlias; + } + + isRoomLoading() { + return this._state.roomLoading; + } + + isJoining() { + return this._state.joining; + } + + getJoinError() { + return this._state.joinError; + } + +} + +let singletonRoomViewStore = null; +if (!singletonRoomViewStore) { + singletonRoomViewStore = new RoomViewStore(); +} +module.exports = singletonRoomViewStore; diff --git a/src/stores/SessionStore.js b/src/stores/SessionStore.js index 1570f58688..2fd35ce40a 100644 --- a/src/stores/SessionStore.js +++ b/src/stores/SessionStore.js @@ -1,3 +1,18 @@ +/* +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 {Store} from 'flux/utils'; diff --git a/test/components/structures/RoomView-test.js b/test/components/structures/RoomView-test.js deleted file mode 100644 index 8e7c8160b8..0000000000 --- a/test/components/structures/RoomView-test.js +++ /dev/null @@ -1,67 +0,0 @@ -var React = require('react'); -var expect = require('expect'); -var sinon = require('sinon'); -var ReactDOM = require("react-dom"); - -var sdk = require('matrix-react-sdk'); -var RoomView = sdk.getComponent('structures.RoomView'); -var peg = require('../../../src/MatrixClientPeg'); - -var test_utils = require('../../test-utils'); -var q = require('q'); - -var Skinner = require("../../../src/Skinner"); -var stubComponent = require('../../components/stub-component.js'); - -describe('RoomView', function () { - var sandbox; - var parentDiv; - - beforeEach(function() { - test_utils.beforeEach(this); - sandbox = test_utils.stubClient(); - parentDiv = document.createElement('div'); - - this.oldTimelinePanel = Skinner.getComponent('structures.TimelinePanel'); - this.oldRoomHeader = Skinner.getComponent('views.rooms.RoomHeader'); - Skinner.addComponent('structures.TimelinePanel', stubComponent()); - Skinner.addComponent('views.rooms.RoomHeader', stubComponent()); - - peg.get().credentials = { userId: "@test:example.com" }; - }); - - afterEach(function() { - sandbox.restore(); - - ReactDOM.unmountComponentAtNode(parentDiv); - - Skinner.addComponent('structures.TimelinePanel', this.oldTimelinePanel); - Skinner.addComponent('views.rooms.RoomHeader', this.oldRoomHeader); - }); - - it('resolves a room alias to a room id', function (done) { - peg.get().getRoomIdForAlias.returns(q({room_id: "!randomcharacters:aser.ver"})); - - function onRoomIdResolved(room_id) { - expect(room_id).toEqual("!randomcharacters:aser.ver"); - done(); - } - - ReactDOM.render(, parentDiv); - }); - - it('joins by alias if given an alias', function (done) { - peg.get().getRoomIdForAlias.returns(q({room_id: "!randomcharacters:aser.ver"})); - peg.get().getProfileInfo.returns(q({displayname: "foo"})); - var roomView = ReactDOM.render(, parentDiv); - - peg.get().joinRoom = function(x) { - expect(x).toEqual('#alias:ser.ver'); - done(); - }; - - process.nextTick(function() { - roomView.onJoinButtonClicked(); - }); - }); -}); diff --git a/test/stores/RoomViewStore-test.js b/test/stores/RoomViewStore-test.js new file mode 100644 index 0000000000..7100dced19 --- /dev/null +++ b/test/stores/RoomViewStore-test.js @@ -0,0 +1,56 @@ +import expect from 'expect'; + +import dis from '../../src/dispatcher'; +import RoomViewStore from '../../src/stores/RoomViewStore'; + + +import peg from '../../src/MatrixClientPeg'; + +import * as testUtils from '../test-utils'; +import q from 'q'; + +const dispatch = testUtils.getDispatchForStore(RoomViewStore); + +describe('RoomViewStore', function() { + let sandbox; + + beforeEach(function() { + testUtils.beforeEach(this); + sandbox = testUtils.stubClient(); + peg.get().credentials = { userId: "@test:example.com" }; + + // Reset the state of the store + RoomViewStore.reset(); + }); + + afterEach(function() { + sandbox.restore(); + }); + + it('can be used to view a room by ID and join', function(done) { + peg.get().joinRoom = (roomId) => { + expect(roomId).toBe("!randomcharacters:aser.ver"); + done(); + }; + + dispatch({ action: 'view_room', room_id: '!randomcharacters:aser.ver' }); + dispatch({ action: 'join_room' }); + expect(RoomViewStore.isJoining()).toBe(true); + }); + + it('can be used to view a room by alias and join', function(done) { + peg.get().getRoomIdForAlias.returns(q({room_id: "!randomcharacters:aser.ver"})); + peg.get().joinRoom = (roomId) => { + expect(roomId).toBe("!randomcharacters:aser.ver"); + done(); + }; + + dispatch({ action: 'view_room', room_alias: '#somealias2:aser.ver' }); + + // Wait for the next event loop to allow for room alias resolution + setTimeout(() => { + dispatch({ action: 'join_room' }); + expect(RoomViewStore.isJoining()).toBe(true); + }, 0); + }); +}); diff --git a/test/test-utils.js b/test/test-utils.js index 2c866d345c..569208b355 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -4,7 +4,8 @@ import sinon from 'sinon'; import q from 'q'; import ReactTestUtils from 'react-addons-test-utils'; -import peg from '../src/MatrixClientPeg.js'; +import peg from '../src/MatrixClientPeg'; +import dis from '../src/dispatcher'; import jssdk from 'matrix-js-sdk'; const MatrixEvent = jssdk.MatrixEvent; @@ -290,3 +291,13 @@ export function mkStubRoom(roomId = null) { }, }; } + +export function getDispatchForStore(store) { + // Mock the dispatcher by gut-wrenching. Stores can only __emitChange whilst a + // dispatcher `_isDispatching` is true. + return (payload) => { + dis._isDispatching = true; + dis._callbacks[store._dispatchToken](payload); + dis._isDispatching = false; + }; +}