/* 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: // - Aux component // - Search results component // - Drag and drop // - File uploading - uploadFile() // - Timeline component (alllll the logic in getEventTiles()) var React = require("react"); var ReactDOM = require("react-dom"); var q = require("q"); var classNames = require("classnames"); var Matrix = require("matrix-js-sdk"); var EventTimeline = Matrix.EventTimeline; var MatrixClientPeg = require("../../MatrixClientPeg"); var ContentMessages = require("../../ContentMessages"); var WhoIsTyping = require("../../WhoIsTyping"); 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 PAGINATE_SIZE = 20; var INITIAL_SIZE = 20; var SEND_READ_RECEIPT_DELAY = 2000; var TIMELINE_CAP = 1000; // the most events to show in a timeline var DEBUG_SCROLL = false; if (DEBUG_SCROLL) { // 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, autoPeek: React.PropTypes.bool, // Now unused, left here temporarily to avoid merge conflicts with @richvdh's branch. roomId: React.PropTypes.string.isRequired, // id of an event to jump to. If not given, will use the read-up-to-marker. 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 in the // middle of the viewprt. 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, autoPeek: React.PropTypes.bool, // should we try to peek the room on mount, or has whoever invoked us already initiated a peek? }, getDefaultProps: function() { return { autoPeek: true, } }, /* properties in RoomView objects include: * * eventNodes: a map from event id to DOM node representing that event */ getInitialState: function() { var room = this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null; return { room: room, events: [], canBackPaginate: true, paginating: room != null, editingRoomSettings: false, uploadingRoomSettings: false, numUnreadMessages: 0, draggingFile: false, searching: false, searchResults: null, syncState: MatrixClientPeg.get().getSyncState(), hasUnsentMessages: this._hasUnsentMessages(room), callState: null, timelineLoading: true, // track whether our room timeline is loading guestsCanJoin: false, canPeek: false, readMarkerEventId: room ? room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId) : null, readMarkerGhostEventId: undefined, // 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, } }, componentWillMount: function() { this.last_rr_sent_event_id = undefined; 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("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().on("sync", this.onSyncStateChange); // 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(); } }); // to make the timeline load work correctly, build up a chain of promises which // take us through the necessary steps. // First of all, we may need to load the room. Construct a promise // which resolves to the Room object. var roomProm; // 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) { if (!this.props.autoPeek) { console.log("No room loaded, and autopeek disabled"); return; } console.log("Attempting to peek into room %s", this.props.roomId); roomProm = MatrixClientPeg.get().peekInRoom(this.props.roomId).then((room) => { this.setState({ room: room }); return room; }); } else { roomProm = q(this.state.room); } // Next, load the timeline. roomProm.then((room) => { this._calculatePeekRules(room); return this._initTimeline(this.props); }).catch((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({ timelineLoading: false, }); } else { throw err; } }).done(); }, _initTimeline: function(props) { var initialEvent = props.eventId; if (!initialEvent) { // go to the 'read-up-to' mark if no explicit event given initialEvent = this.state.readMarkerEventId; } var pixelOffset = props.eventPixelOffset; return this._loadTimeline(initialEvent, pixelOffset); }, /** * (re)-load the event timeline, and initialise the scroll state, centered * around the given event. * * @param {string?} eventId the event to focus on. If undefined, will * scroll to the bottom of the room. * * @param {number?} pixelOffset offset to position the given event at * (pixels from the bottom of the view). If undefined, will put the * event in the middle of the view. * * returns a promise which will resolve when the load completes. */ _loadTimeline: function(eventId, pixelOffset) { // TODO: we could optimise this, by not resetting the window if the // event is in the current window (though it's not obvious how we can // tell if the current window is on the live event stream) this.setState({ events: [], timelineLoading: true, }); this._timelineWindow = new Matrix.TimelineWindow( MatrixClientPeg.get(), this.state.room, {windowLimit: TIMELINE_CAP}); return this._timelineWindow.load(eventId, INITIAL_SIZE).then(() => { debuglog("RoomView: timeline loaded"); this._onTimelineUpdated(true); }).finally(() => { this.setState({ timelineLoading: false, }, () => { // initialise the scroll state of the message panel if (!this.refs.messagePanel) { // this shouldn't happen. console.log("can't initialise scroll state because " + "messagePanel didn't load"); return; } if (eventId) { this.refs.messagePanel.scrollToToken(eventId, pixelOffset); } else { this.refs.messagePanel.scrollToBottom(); } }); }); }, 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("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().removeListener("sync", this.onSyncStateChange); } window.removeEventListener('resize', this.onResize); Tinter.tint(); // reset colourscheme }, onAction: function(payload) { switch (payload.action) { case 'message_send_failed': case 'message_sent': this.setState({ hasUnsentMessages: this._hasUnsentMessages(this.state.room) }); case 'message_resend_started': this.setState({ room: MatrixClientPeg.get().getRoom(this.props.roomId) }); this.forceUpdate(); 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; case 'user_activity': case 'user_activity_end': // we could treat user_activity_end differently and not // send receipts for messages that have arrived between // the actual user activity and the time they stopped // being active, but let's see if this is actually // necessary. this.sendReadReceipt(); break; } }, onSyncStateChange: function(state, prevState) { if (state === "SYNCING" && prevState === "SYNCING") { return; } this.setState({ syncState: state }); }, 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) { console.log("RoomView switching to eventId " + newProps.eventId + " (was " + this.props.eventId + ")"); return this._initTimeline(newProps); } }, 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}; }); } } // tell the messagepanel to go paginate itself. This in turn will cause // onMessageListFillRequest to be called, which will call // _onTimelineUpdated, which will update the state with the new event - // so there is no need update the state here. // if (this.refs.messagePanel) { this.refs.messagePanel.checkFillState(); } }, _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) { if (room.roomId == this.props.roomId) { this.setState({ room: room }); this._initTimeline(this.props).done(); } }, onRoomName: function(room) { if (room.roomId == this.props.roomId) { this.setState({ room: room }); } }, 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); } } }, onRoomReceipt: function(receiptEvent, room) { if (room.roomId == this.props.roomId) { var readMarkerEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId); var readMarkerGhostEventId = this.state.readMarkerGhostEventId; if (this.state.readMarkerEventId !== undefined && this.state.readMarkerEventId != readMarkerEventId) { readMarkerGhostEventId = this.state.readMarkerEventId; } // if the event after the one referenced in the read receipt if sent by us, do nothing since // this is a temporary period before the synthesized receipt for our own message arrives var readMarkerGhostEventIndex; for (var i = 0; i < this.state.events.length; ++i) { if (this.state.events[i].getId() == readMarkerGhostEventId) { readMarkerGhostEventIndex = i; break; } } if (readMarkerGhostEventIndex + 1 < this.state.events.length) { var nextEvent = this.state.events[readMarkerGhostEventIndex + 1]; if (nextEvent.sender && nextEvent.sender.userId == MatrixClientPeg.get().credentials.userId) { readMarkerGhostEventId = undefined; } } this.setState({ readMarkerEventId: readMarkerEventId, readMarkerGhostEventId: readMarkerGhostEventId, }); // if the scrollpanel is following the timeline, attempt to scroll // it to bring the read message up to the middle of the panel. This // will have no immediate effect (since we are already at the // bottom), but will ensure that if there is no further user // activity, but room activity continues, the read message will // scroll up to the middle of the window, but no further. // // we do this here as well as in sendReadReceipt to deal with // people using two clients at once. if (this.refs.messagePanel && this.state.atEndOfLiveTimeline) { this.refs.messagePanel.scrollToToken(readMarkerEventId); } } }, onRoomMemberTyping: function(ev, member) { this.forceUpdate(); }, 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(this.state.room); 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 []; } // TODO: It would be nice if the JS SDK provided nicer constant-time // constructs rather than O(N) (N=num msgs) on this. return room.timeline.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() { if (this.refs.messagePanel) { this._initialiseMessagePanel(); } 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(); if (this.refs.roomView) { var roomView = ReactDOM.findDOMNode(this.refs.roomView); roomView.addEventListener('drop', this.onDrop); roomView.addEventListener('dragover', this.onDragOver); roomView.addEventListener('dragleave', this.onDragLeaveOrEnd); roomView.addEventListener('dragend', this.onDragLeaveOrEnd); } this._updateTabCompleteList(this.state.room); // 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: function(room) { if (!room || !this.tabComplete) { return; } this.tabComplete.setCompletionList( MemberEntry.fromMemberList(room.getJoinedMembers()).concat( CommandEntry.fromCommands(SlashCommands.getCommandList()) ) ); }, _initialiseMessagePanel: function() { var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); this.refs.messagePanel.initialised = true; this.sendReadReceipt(); this.updateTint(); }, componentDidUpdate: function() { // we need to initialise the messagepanel if we've just joined the // room. TODO: we really really ought to factor out messagepanel to a // separate component to avoid this ridiculous dance. if (!this.refs.messagePanel) return; if (!this.refs.messagePanel.initialised) { this._initialiseMessagePanel(); } }, _onTimelineUpdated: function(gotResults) { // we might have switched rooms since the load started - just bin // the results if so. if (this.unmounted) return; this.setState({ paginating: false, }); if (gotResults) { this.setState({ events: this._timelineWindow.getEvents(), canBackPaginate: this._timelineWindow.canPaginate(EventTimeline.BACKWARDS), }); } }, 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); } }, // set off a pagination request. onMessageListFillRequest: function(backwards) { var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; if(!this._timelineWindow.canPaginate(dir)) { debuglog("RoomView: can't paginate at this time; backwards:"+backwards); return q(false); } this.setState({paginating: true}); debuglog("RoomView: Initiating paginate; backwards:"+backwards); return this._timelineWindow.paginate(dir, PAGINATE_SIZE).then((r) => { debuglog("RoomView: paginate complete backwards:"+backwards+"; success:"+r); this._onTimelineUpdated(r); return r; }); }, onResendAllClick: function() { var eventsToResend = this._getUnsentMessages(this.state.room); eventsToResend.forEach(function(event) { Resend.resend(event); }); }, onJoinButtonClicked: function(ev) { var self = this; MatrixClientPeg.get().joinRoom(this.props.roomId).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. 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 }); 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.isAtBottom() && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { if (this.state.numUnreadMessages != 0) { this.setState({ numUnreadMessages: 0 }); } if (!this.state.atEndOfLiveTimeline) { this.setState({ atEndOfLiveTimeline: true }); } } else { if (this.state.atEndOfLiveTimeline) { this.setState({ atEndOfLiveTimeline: false }); } } }, 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() }); }); }, getWhoIsTypingString: function() { return WhoIsTyping.whoIsTypingString(this.state.room); }, 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 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.searchResults.next_batch) { if (this.state.searchResults.results.length == 0) { ret.push(