/* Copyright 2015, 2016 OpenMarket 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. */ // TODO: This component is enormous! There's several things which could stand-alone: // - Search results component // - Drag and drop // - File uploading - uploadFile() var React = require("react"); var ReactDOM = require("react-dom"); var q = require("q"); var classNames = require("classnames"); var Matrix = require("matrix-js-sdk"); var MatrixClientPeg = require("../../MatrixClientPeg"); var ContentMessages = require("../../ContentMessages"); var Modal = require("../../Modal"); var sdk = require('../../index'); var CallHandler = require('../../CallHandler'); var TabComplete = require("../../TabComplete"); var MemberEntry = require("../../TabCompleteEntries").MemberEntry; var CommandEntry = require("../../TabCompleteEntries").CommandEntry; var Resend = require("../../Resend"); var SlashCommands = require("../../SlashCommands"); var dis = require("../../dispatcher"); var Tinter = require("../../Tinter"); var rate_limited_func = require('../../ratelimitedfunc'); var ObjectUtils = require('../../ObjectUtils'); var DEBUG = false; if (DEBUG) { // using bind means that we get to keep useful line numbers in the console var debuglog = console.log.bind(console); } else { var debuglog = function () {}; } module.exports = React.createClass({ displayName: 'RoomView', propTypes: { ConferenceHandler: React.PropTypes.any, roomId: React.PropTypes.string.isRequired, // if we are referring to this room by a given alias (e.g. in the URL), track it. // useful for joining rooms by alias correctly (and fixing https://github.com/vector-im/vector-web/issues/819) roomAlias: React.PropTypes.string, // An object representing a third party invite to join this room // Fields: // * inviteSignUrl (string) The URL used to join this room from an email invite // (given as part of the link in the invite email) // * invitedEmail (string) The email address that was invited to this room thirdPartyInvite: React.PropTypes.object, // Any data about the room that would normally come from the Home Server // but has been passed out-of-band, eg. the room name and avatar URL // from an email invite (a workaround for the fact that we can't // get this information from the HS using an email invite). // Fields: // * name (string) The room's name // * avatarUrl (string) The mxc:// avatar URL for the room // * inviterName (string) The display name of the person who // * invited us tovthe room oobData: React.PropTypes.object, // id of an event to jump to. If not given, will go to the end of the // live timeline. eventId: React.PropTypes.string, // where to position the event given by eventId, in pixels from the // bottom of the viewport. If not given, will try to put the event // 1/3 of the way down the viewport. eventPixelOffset: React.PropTypes.number, // ID of an event to highlight. If undefined, no event will be highlighted. // Typically this will either be the same as 'eventId', or undefined. highlightedEventId: React.PropTypes.string, }, getInitialState: function() { var room = this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null; return { room: room, roomLoading: !room, editingRoomSettings: false, uploadingRoomSettings: false, numUnreadMessages: 0, draggingFile: false, searching: false, searchResults: null, hasUnsentMessages: this._hasUnsentMessages(room), callState: null, guestsCanJoin: false, canPeek: false, // this is true if we are fully scrolled-down, and are looking at // the end of the live timeline. It has the effect of hiding the // 'scroll to bottom' knob, among a couple of other things. atEndOfLiveTimeline: true, showTopUnreadMessagesBar: false, auxPanelMaxHeight: undefined, } }, componentWillMount: function() { this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("Room", this.onRoom); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.name", this.onRoomName); MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); // xchat-style tab complete, add a colon if tab // completing at the start of the text this.tabComplete = new TabComplete({ allowLooping: false, autoEnterTabComplete: true, onClickCompletes: true, onStateChange: (isCompleting) => { this.forceUpdate(); } }); // if this is an unknown room then we're in one of three states: // - This is a room we can peek into (search engine) (we can /peek) // - This is a room we can publicly join or were invited to. (we can /join) // - This is a room we cannot join at all. (no action can help us) // We can't try to /join because this may implicitly accept invites (!) // We can /peek though. If it fails then we present the join UI. If it // succeeds then great, show the preview (but we still may be able to /join!). if (!this.state.room) { console.log("Attempting to peek into room %s", this.props.roomId); MatrixClientPeg.get().peekInRoom(this.props.roomId).then((room) => { this.setState({ room: room, roomLoading: false, }); this._onRoomLoaded(room); }, (err) => { // This won't necessarily be a MatrixError, but we duck-type // here and say if it's got an 'errcode' key with the right value, // it means we can't peek. if (err.errcode == "M_GUEST_ACCESS_FORBIDDEN") { // This is fine: the room just isn't peekable (we assume). this.setState({ roomLoading: false, }); } else { throw err; } }).done(); } else { MatrixClientPeg.get().stopPeeking(); this._onRoomLoaded(this.state.room); } }, shouldComponentUpdate: function(nextProps, nextState) { return (!ObjectUtils.shallowEqual(this.props, nextProps) || !ObjectUtils.shallowEqual(this.state, nextState)); }, componentWillUnmount: function() { // set a boolean to say we've been unmounted, which any pending // promises can use to throw away their results. // // (We could use isMounted, but facebook have deprecated that.) this.unmounted = true; if (this.refs.roomView) { // disconnect the D&D event listeners from the room view. This // is really just for hygiene - we're going to be // deleted anyway, so it doesn't matter if the event listeners // don't get cleaned up. var roomView = ReactDOM.findDOMNode(this.refs.roomView); roomView.removeEventListener('drop', this.onDrop); roomView.removeEventListener('dragover', this.onDragOver); roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd); roomView.removeEventListener('dragend', this.onDragLeaveOrEnd); } dis.unregister(this.dispatcherRef); if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener("Room", this.onRoom); MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); } window.removeEventListener('resize', this.onResize); Tinter.tint(); // reset colourscheme }, onAction: function(payload) { switch (payload.action) { case 'message_send_failed': case 'message_sent': case 'message_send_cancelled': this.setState({ hasUnsentMessages: this._hasUnsentMessages(this.state.room) }); break; case 'notifier_enabled': case 'upload_failed': case 'upload_started': case 'upload_finished': this.forceUpdate(); break; case 'call_state': // don't filter out payloads for room IDs other than props.room because // we may be interested in the conf 1:1 room if (!payload.room_id) { return; } var call = CallHandler.getCallForRoom(payload.room_id); var callState; if (call) { callState = call.call_state; } else { callState = "ended"; } // possibly remove the conf call notification if we're now in // the conf this._updateConfCallNotification(); this.setState({ callState: callState }); break; } }, componentWillReceiveProps: function(newProps) { if (newProps.roomId != this.props.roomId) { 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}); } }, onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { if (this.unmounted) return; // ignore events for other rooms if (room.roomId != this.props.roomId) return; // ignore anything but real-time updates at the end of the room: // updates from pagination will happen when the paginate completes. if (toStartOfTimeline || !data || !data.liveEvent) return; // no point handling anything while we're waiting for the join to finish: // we'll only be showing a spinner. if (this.state.joining) return; if (ev.getSender() !== MatrixClientPeg.get().credentials.userId) { // update unread count when scrolled up if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { // no change } else { this.setState((state, props) => { return {numUnreadMessages: state.numUnreadMessages + 1}; }); } } }, // called when state.room is first initialised (either at initial load, // after a successful peek, or after we join the room). _onRoomLoaded: function(room) { this._calculatePeekRules(room); }, _calculatePeekRules: function(room) { var guestAccessEvent = room.currentState.getStateEvents("m.room.guest_access", ""); if (guestAccessEvent && guestAccessEvent.getContent().guest_access === "can_join") { this.setState({ guestsCanJoin: true }); } var historyVisibility = room.currentState.getStateEvents("m.room.history_visibility", ""); if (historyVisibility && historyVisibility.getContent().history_visibility === "world_readable") { this.setState({ canPeek: true }); } }, 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 (room.roomId == this.props.roomId && !this.state.room) { this.setState({ room: room }); this._onRoomLoaded(room); } }, onRoomName: function(room) { // NB don't set state.room here. // // When peeking, this event lands *before* the timeline is correctly // synced; if we set state.room here, the TimelinePanel will be // instantiated, and it will initialise its scroll state, with *no // events*. In short, the scroll state will be all messed up. // // There's no need to set state.room here anyway. if (room.roomId == this.props.roomId) { this.forceUpdate(); } }, updateTint: function() { var room = MatrixClientPeg.get().getRoom(this.props.roomId); if (!room) return; 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 } Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); }, onRoomAccountData: function(room, event) { if (room.roomId == this.props.roomId) { if (event.getType === "org.matrix.room.color_scheme") { var color_scheme = event.getContent(); // XXX: we should validate the event Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); } } }, onRoomStateMember: function(ev, state, member) { if (member.roomId === this.props.roomId) { // a member state changed in this room, refresh the tab complete list this._updateTabCompleteList(); var room = MatrixClientPeg.get().getRoom(this.props.roomId); if (!room) return; var me = MatrixClientPeg.get().credentials.userId; if (this.state.joining && room.hasMembershipState(me, "join")) { this.setState({ joining: false }); } } if (!this.props.ConferenceHandler) { return; } if (member.roomId !== this.props.roomId || member.userId !== this.props.ConferenceHandler.getConferenceUserIdForRoom(member.roomId)) { return; } this._updateConfCallNotification(); }, _hasUnsentMessages: function(room) { return this._getUnsentMessages(room).length > 0; }, _getUnsentMessages: function(room) { if (!room) { return []; } return room.getPendingEvents().filter(function(ev) { return ev.status === Matrix.EventStatus.NOT_SENT; }); }, _updateConfCallNotification: function() { var room = MatrixClientPeg.get().getRoom(this.props.roomId); if (!room || !this.props.ConferenceHandler) { return; } var confMember = room.getMember( this.props.ConferenceHandler.getConferenceUserIdForRoom(this.props.roomId) ); if (!confMember) { return; } var confCall = this.props.ConferenceHandler.getConferenceCallForRoom(confMember.roomId); // A conf call notification should be displayed if there is an ongoing // conf call but this cilent isn't a part of it. this.setState({ displayConfCallNotification: ( (!confCall || confCall.call_state === "ended") && confMember.membership === "join" ) }); }, componentDidMount: function() { var call = CallHandler.getCallForRoom(this.props.roomId); var callState = call ? call.call_state : "ended"; this.setState({ callState: callState }); this._updateConfCallNotification(); window.addEventListener('resize', this.onResize); this.onResize(); this._updateTabCompleteList(); // XXX: EVIL HACK to autofocus inviting on empty rooms. // We use the setTimeout to avoid racing with focus_composer. if (this.state.room && this.state.room.getJoinedMembers().length == 1) { var inviteBox = document.getElementById("mx_SearchableEntityList_query"); setTimeout(function() { if (inviteBox) { inviteBox.focus(); } }, 50); } }, _updateTabCompleteList: new rate_limited_func(function() { var cli = MatrixClientPeg.get(); if (!this.state.room || !this.tabComplete) { return; } var members = this.state.room.getJoinedMembers().filter(function(member) { if (member.userId !== cli.credentials.userId) return true; }); this.tabComplete.setCompletionList( MemberEntry.fromMemberList(members).concat( CommandEntry.fromCommands(SlashCommands.getCommandList()) ) ); }, 500), componentDidUpdate: function() { if (this.refs.roomView) { var roomView = ReactDOM.findDOMNode(this.refs.roomView); if (!roomView.ondrop) { roomView.addEventListener('drop', this.onDrop); roomView.addEventListener('dragover', this.onDragOver); roomView.addEventListener('dragleave', this.onDragLeaveOrEnd); roomView.addEventListener('dragend', this.onDragLeaveOrEnd); } } }, onSearchResultsFillRequest: function(backwards) { if (!backwards) return q(false); if (this.state.searchResults.next_batch) { debuglog("requesting more search results"); var searchPromise = MatrixClientPeg.get().backPaginateRoomEventsSearch( this.state.searchResults); return this._handleSearchResult(searchPromise); } else { debuglog("no more search results"); return q(false); } }, onResendAllClick: function() { var eventsToResend = this._getUnsentMessages(this.state.room); eventsToResend.forEach(function(event) { Resend.resend(event); }); }, onCancelAllClick: function() { var eventsToResend = this._getUnsentMessages(this.state.room); eventsToResend.forEach(function(event) { Resend.removeFromQueue(event); }); }, onJoinButtonClicked: function(ev) { var self = this; var cli = MatrixClientPeg.get(); var display_name_promise = q(); // if this is the first room we're joining, check the user has a display name // and if they don't, prompt them to set one. // NB. This unfortunately does not re-use the ChangeDisplayName component because // it doesn't behave quite as desired here (we want an input field here rather than // content-editable, and we want a default). if (cli.getRooms().filter((r) => { return r.hasMembershipState(cli.credentials.userId, "join"); })) { display_name_promise = cli.getProfileInfo(cli.credentials.userId).then((result) => { if (!result.displayname) { var SetDisplayNameDialog = sdk.getComponent('views.dialogs.SetDisplayNameDialog'); var dialog_defer = q.defer(); var dialog_ref; Modal.createDialog(SetDisplayNameDialog, { currentDisplayName: result.displayname, ref: (r) => { dialog_ref = r; }, onFinished: (submitted) => { if (submitted) { cli.setDisplayName(dialog_ref.getValue()).done(() => { dialog_defer.resolve(); }); } else { dialog_defer.reject(); } } }); return dialog_defer.promise; } }); } display_name_promise.then(() => { var sign_url = this.props.thirdPartyInvite ? this.props.thirdPartyInvite.inviteSignUrl : undefined; return MatrixClientPeg.get().joinRoom(this.props.roomAlias || this.props.roomId, { inviteSignUrl: sign_url } ) }).done(function() { // 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. Likewise, if our state is not // "join" we'll keep this flag set until it comes down /sync. // 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(self.props.roomId); var me = MatrixClientPeg.get().credentials.userId; self.setState({ joining: room ? !room.hasMembershipState(me, "join") : true, room: room }); }, function(error) { self.setState({ joining: false, joinError: error }); if (!error) return; // https://matrix.org/jira/browse/SYN-659 if ( error.errcode == 'M_GUEST_ACCESS_FORBIDDEN' || ( error.errcode == 'M_FORBIDDEN' && MatrixClientPeg.get().isGuest() ) ) { var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); Modal.createDialog(NeedToRegisterDialog, { title: "Failed to join the room", description: "This room is private or inaccessible to guests. You may be able to join if you register." }); } 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 }); } }); this.setState({ joining: true }); }, onMessageListScroll: function(ev) { if (this.refs.messagePanel.isAtEndOfLiveTimeline()) { this.setState({ numUnreadMessages: 0, atEndOfLiveTimeline: true, }); } else { this.setState({ atEndOfLiveTimeline: false, }); } this._updateTopUnreadMessagesBar(); }, onDragOver: function(ev) { ev.stopPropagation(); ev.preventDefault(); ev.dataTransfer.dropEffect = 'none'; var items = ev.dataTransfer.items; if (items.length == 1) { if (items[0].kind == 'file') { this.setState({ draggingFile : true }); ev.dataTransfer.dropEffect = 'copy'; } } }, onDrop: function(ev) { ev.stopPropagation(); ev.preventDefault(); this.setState({ draggingFile : false }); var files = ev.dataTransfer.files; if (files.length == 1) { this.uploadFile(files[0]); } }, onDragLeaveOrEnd: function(ev) { ev.stopPropagation(); ev.preventDefault(); this.setState({ draggingFile : false }); }, uploadFile: function(file) { var self = this; ContentMessages.sendContentToRoom( file, this.props.roomId, MatrixClientPeg.get() ).done(undefined, function(error) { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { title: "Failed to upload file", description: error.toString() }); }); }, onSearch: function(term, scope) { this.setState({ searchTerm: term, searchScope: scope, searchResults: {}, searchHighlights: [], }); // if we already have a search panel, we need to tell it to forget // about its scroll state. if (this.refs.searchResultsPanel) { this.refs.searchResultsPanel.resetScrollState(); } // make sure that we don't end up showing results from // an aborted search by keeping a unique id. // // todo: should cancel any previous search requests. this.searchId = new Date().getTime(); var filter; if (scope === "Room") { filter = { // XXX: it's unintuitive that the filter for searching doesn't have the same shape as the v2 filter API :( rooms: [ this.props.roomId ] }; } debuglog("sending search request"); var searchPromise = MatrixClientPeg.get().searchRoomEvents({ filter: filter, term: term, }); this._handleSearchResult(searchPromise).done(); }, _handleSearchResult: function(searchPromise) { var self = this; // keep a record of the current search id, so that if the search terms // change before we get a response, we can ignore the results. var localSearchId = this.searchId; this.setState({ searchInProgress: true, }); return searchPromise.then(function(results) { debuglog("search complete"); if (self.unmounted || !self.state.searching || self.searchId != localSearchId) { console.error("Discarding stale search results"); return; } // postgres on synapse returns us precise details of the strings // which actually got matched for highlighting. // // In either case, we want to highlight the literal search term // whether it was used by the search engine or not. var highlights = results.highlights; if (highlights.indexOf(self.state.searchTerm) < 0) { highlights = highlights.concat(self.state.searchTerm); } // For overlapping highlights, // favour longer (more specific) terms first highlights = highlights.sort(function(a, b) { return b.length - a.length }); self.setState({ searchHighlights: highlights, searchResults: results, }); }, function(error) { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { title: "Search failed", description: error.toString() }); }).finally(function() { self.setState({ searchInProgress: false }); }); }, getSearchResultTiles: function() { var EventTile = sdk.getComponent('rooms.EventTile'); var SearchResultTile = sdk.getComponent('rooms.SearchResultTile'); var Spinner = sdk.getComponent("elements.Spinner"); var cli = MatrixClientPeg.get(); // XXX: todo: merge overlapping results somehow? // XXX: why doesn't searching on name work? if (this.state.searchResults.results === undefined) { // awaiting results return []; } var ret = []; if (this.state.searchInProgress) { ret.push(