/* Copyright 2015 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 GeminiScrollbar = require('react-gemini-scrollbar'); var q = require("q"); var classNames = require("classnames"); var Matrix = require("matrix-js-sdk"); 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 Resend = require("../../Resend"); var dis = require("../../dispatcher"); var PAGINATE_SIZE = 20; var INITIAL_SIZE = 20; var DEBUG_SCROLL = false; module.exports = React.createClass({ displayName: 'RoomView', propTypes: { ConferenceHandler: React.PropTypes.any }, getInitialState: function() { var room = this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null; return { room: room, messageCap: INITIAL_SIZE, editingRoomSettings: false, uploadingRoomSettings: false, numUnreadMessages: 0, draggingFile: false, searching: false, searchResults: null, syncState: MatrixClientPeg.get().getSyncState(), hasUnsentMessages: this._hasUnsentMessages(room), callState: null, } }, componentWillMount: function() { this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.name", this.onRoomName); 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); this.savedScrollState = {atBottom: true}; }, componentWillUnmount: function() { if (this.refs.messagePanel) { // disconnect the D&D event listeners from the message panel. This // is really just for hygiene - the messagePanel is going to be // deleted anyway, so it doesn't matter if the event listeners // don't get cleaned up. var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); messagePanel.removeEventListener('drop', this.onDrop); messagePanel.removeEventListener('dragover', this.onDragOver); messagePanel.removeEventListener('dragleave', this.onDragLeaveOrEnd); messagePanel.removeEventListener('dragend', this.onDragLeaveOrEnd); } dis.unregister(this.dispatcherRef); if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); 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); }, 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) { // Call state has changed so we may be loading video elements // which will obscure the message log. // scroll to bottom this.scrollToBottom(); 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': this.sendReadReceipt(); break; } }, // get the DOM node which has the scrollTop property we care about for our // message panel. // // If the gemini scrollbar is doing its thing, this will be a div within // the message panel (ie, the gemini container); otherwise it will be the // message panel itself. _getScrollNode: function() { var panel = ReactDOM.findDOMNode(this.refs.messagePanel); if (!panel) return null; if (panel.classList.contains('gm-prevented')) { return panel; } else { return panel.children[2]; // XXX: Fragile! } }, onSyncStateChange: function(state, prevState) { if (state === "SYNCING" && prevState === "SYNCING") { return; } this.setState({ syncState: state }); }, // MatrixRoom still showing the messages from the old room? // Set the key to the room_id. Sadly you can no longer get at // the key from inside the component, or we'd check this in code. /*componentWillReceiveProps: function(props) { },*/ onRoomTimeline: function(ev, room, toStartOfTimeline) { if (!this.isMounted()) return; // ignore anything that comes in whilst paginating: we get one // event for each new matrix event so this would cause a huge // number of UI updates. Just update the UI when the paginate // call returns. if (this.state.paginating) 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 (room.roomId != this.props.roomId) return; var currentUnread = this.state.numUnreadMessages; if (!toStartOfTimeline && (ev.getSender() !== MatrixClientPeg.get().credentials.userId)) { // update unread count when scrolled up if (this.savedScrollState.atBottom) { currentUnread = 0; } else { currentUnread += 1; } } this.setState({ room: MatrixClientPeg.get().getRoom(this.props.roomId), numUnreadMessages: currentUnread }); }, onRoomName: function(room) { if (room.roomId == this.props.roomId) { this.setState({ room: room }); } }, onRoomReceipt: function(receiptEvent, room) { if (room.roomId == this.props.roomId) { this.forceUpdate(); } }, onRoomMemberTyping: function(ev, member) { this.forceUpdate(); }, onRoomStateMember: function(ev, state, member) { 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(); }, _initialiseMessagePanel: function() { var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); this.refs.messagePanel.initialised = true; messagePanel.addEventListener('drop', this.onDrop); messagePanel.addEventListener('dragover', this.onDragOver); messagePanel.addEventListener('dragleave', this.onDragLeaveOrEnd); messagePanel.addEventListener('dragend', this.onDragLeaveOrEnd); this.scrollToBottom(); this.sendReadReceipt(); this.fillSpace(); }, 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(); } // after adding event tiles, we may need to tweak the scroll (either to // keep at the bottom of the timeline, or to maintain the view after // adding events to the top). if (this.state.searchResults) return; this._restoreSavedScrollState(); }, _paginateCompleted: function() { if (DEBUG_SCROLL) console.log("paginate complete"); this.setState({ room: MatrixClientPeg.get().getRoom(this.props.roomId) }); // we might not have got enough results from the pagination // request, so give fillSpace() a chance to set off another. this.setState({paginating: false}); if (!this.state.searchResults) { this.fillSpace(); } }, // check the scroll position, and if we need to, set off a pagination // request. fillSpace: function() { if (!this.refs.messagePanel) return; var messageWrapperScroll = this._getScrollNode(); if (messageWrapperScroll.scrollTop > messageWrapperScroll.clientHeight) { return; } // there's less than a screenful of messages left - try to get some // more messages. if (this.state.searchResults) { if (this.nextSearchBatch) { if (DEBUG_SCROLL) console.log("requesting more search results"); this._getSearchBatch(this.state.searchTerm, this.state.searchScope); } else { if (DEBUG_SCROLL) console.log("no more search results"); } return; } // Either wind back the message cap (if there are enough events in the // timeline to do so), or fire off a pagination request. if (this.state.messageCap < this.state.room.timeline.length) { var cap = Math.min(this.state.messageCap + PAGINATE_SIZE, this.state.room.timeline.length); if (DEBUG_SCROLL) console.log("winding back message cap to", cap); this.setState({messageCap: cap}); } else if(this.state.room.oldState.paginationToken) { var cap = this.state.messageCap + PAGINATE_SIZE; if (DEBUG_SCROLL) console.log("starting paginate to cap", cap); this.setState({messageCap: cap, paginating: true}); MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE).finally(this._paginateCompleted).done(); } }, 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).then(function() { self.setState({ joining: false, room: MatrixClientPeg.get().getRoom(self.props.roomId) }); }, function(error) { self.setState({ joining: false, joinError: error }); }); this.setState({ joining: true }); }, onMessageListScroll: function(ev) { var sn = this._getScrollNode(); if (DEBUG_SCROLL) console.log("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll); // Sometimes we see attempts to write to scrollTop essentially being // ignored. (Or rather, it is successfully written, but on the next // scroll event, it's been reset again). // // This was observed on Chrome 47, when scrolling using the trackpad in OS // X Yosemite. Can't reproduce on El Capitan. Our theory is that this is // due to Chrome not being able to cope with the scroll offset being reset // while a two-finger drag is in progress. // // By way of a workaround, we detect this situation and just keep // resetting scrollTop until we see the scroll node have the right // value. if (this.recentEventScroll !== undefined) { if(sn.scrollTop < this.recentEventScroll-200) { console.log("Working around vector-im/vector-web#528"); this._restoreSavedScrollState(); return; } this.recentEventScroll = undefined; } if (this.refs.messagePanel && !this.state.searchResults) { this.savedScrollState = this._calculateScrollState(); if (DEBUG_SCROLL) console.log("Saved scroll state", this.savedScrollState); if (this.savedScrollState.atBottom && this.state.numUnreadMessages != 0) { this.setState({numUnreadMessages: 0}); } } if (!this.state.paginating && !this.state.searchInProgress) { this.fillSpace(); } }, 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: [], searchCount: null, }); this.nextSearchBatch = null; this._getSearchBatch(term, scope); }, // fire off a request for a batch of search results _getSearchBatch: function(term, scope) { this.setState({ searchInProgress: true, }); // make sure that we don't end up merging results from // different searches by keeping a unique id. // // todo: should cancel any previous search requests. var searchId = this.searchId = new Date().getTime(); var self = this; if (DEBUG_SCROLL) console.log("sending search request"); MatrixClientPeg.get().search({ body: this._getSearchCondition(term, scope), next_batch: this.nextSearchBatch }) .then(function(data) { if (DEBUG_SCROLL) console.log("search complete"); if (!self.state.searching || self.searchId != searchId) { console.error("Discarding stale search results"); return; } var results = data.search_categories.room_events; // postgres on synapse returns us precise details of the // strings which actually got matched for highlighting. // combine the highlight list with our existing list; build an object // to avoid O(N^2) fail var highlights = {}; results.highlights.forEach(function(hl) { highlights[hl] = 1; }); self.state.searchHighlights.forEach(function(hl) { highlights[hl] = 1; }); // turn it back into an ordered list. For overlapping highlights, // favour longer (more specific) terms first highlights = Object.keys(highlights).sort(function(a, b) { b.length - a.length }); // sqlite doesn't give us any highlights, so just try to highlight the literal search term if (highlights.length == 0) { highlights = [ term ]; } // append the new results to our existing results var events = self.state.searchResults.concat(results.results); self.setState({ searchHighlights: highlights, searchResults: events, searchCount: results.count, }); self.nextSearchBatch = results.next_batch; }, function(error) { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { title: "Search failed", description: error.toString() }); }).finally(function() { self.setState({ searchInProgress: false }); }).done(); }, _getSearchCondition: function(term, scope) { 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 ] }; } return { search_categories: { room_events: { search_term: term, filter: filter, order_by: "recent", event_context: { before_limit: 1, after_limit: 1, include_profile: true, } } } } }, getEventTiles: function() { var DateSeparator = sdk.getComponent('messages.DateSeparator'); var cli = MatrixClientPeg.get(); var ret = []; var count = 0; var EventTile = sdk.getComponent('rooms.EventTile'); var self = this; if (this.state.searchResults) { // XXX: todo: merge overlapping results somehow? // XXX: why doesn't searching on name work? var lastRoomId; for (var i = this.state.searchResults.length - 1; i >= 0; i--) { var result = this.state.searchResults[i]; var mxEv = new Matrix.MatrixEvent(result.result); if (self.state.searchScope === 'All') { var roomId = result.result.room_id; if(roomId != lastRoomId) { ret.push(