UNPKG

matrix-react-sdk

Version:
1,321 lines (1,041 loc) 188 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard"); Object.defineProperty(exports, "__esModule", { value: true }); exports.default = void 0; var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty")); var _SettingsStore = _interopRequireDefault(require("../../settings/SettingsStore")); var _Layout = require("../../settings/Layout"); var _react = _interopRequireWildcard(require("react")); var _reactDom = _interopRequireDefault(require("react-dom")); var _propTypes = _interopRequireDefault(require("prop-types")); var _eventTimeline = require("matrix-js-sdk/src/models/event-timeline"); var _timelineWindow = require("matrix-js-sdk/src/timeline-window"); var _languageHandler = require("../../languageHandler"); var _MatrixClientPeg = require("../../MatrixClientPeg"); var _UserActivity = _interopRequireDefault(require("../../UserActivity")); var _Modal = _interopRequireDefault(require("../../Modal")); var _dispatcher = _interopRequireDefault(require("../../dispatcher/dispatcher")); var sdk = _interopRequireWildcard(require("../../index")); var _Keyboard = require("../../Keyboard"); var _Timer = _interopRequireDefault(require("../../utils/Timer")); var _shouldHideEvent = _interopRequireDefault(require("../../shouldHideEvent")); var _EditorStateTransfer = _interopRequireDefault(require("../../utils/EditorStateTransfer")); var _EventTile = require("../views/rooms/EventTile"); var _UIFeature = require("../../settings/UIFeature"); var _objects = require("../../utils/objects"); var _replaceableComponent = require("../../utils/replaceableComponent"); var _arrays = require("../../utils/arrays"); var _dec, _class, _class2, _temp; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; const READ_RECEIPT_INTERVAL_MS = 500; const DEBUG = false; let debuglog = function () {}; if (DEBUG) { // using bind means that we get to keep useful line numbers in the console debuglog = console.log.bind(console); } /* * Component which shows the event timeline in a room view. * * Also responsible for handling and sending read receipts. */ let TimelinePanel = (_dec = (0, _replaceableComponent.replaceableComponent)("structures.TimelinePanel"), _dec(_class = (_temp = _class2 = class TimelinePanel extends _react.default.Component { // a map from room id to read marker event timestamp constructor(props) { super(props); (0, _defineProperty2.default)(this, "onMessageListUnfillRequest", (backwards, scrollToken) => { // If backwards, unpaginate from the back (i.e. the start of the timeline) const dir = backwards ? _eventTimeline.EventTimeline.BACKWARDS : _eventTimeline.EventTimeline.FORWARDS; debuglog("TimelinePanel: unpaginating events in direction", dir); // All tiles are inserted by MessagePanel to have a scrollToken === eventId, and // this particular event should be the first or last to be unpaginated. const eventId = scrollToken; const marker = this.state.events.findIndex(ev => { return ev.getId() === eventId; }); const count = backwards ? marker + 1 : this.state.events.length - marker; if (count > 0) { debuglog("TimelinePanel: Unpaginating", count, "in direction", dir); this._timelineWindow.unpaginate(count, backwards); // We can now paginate in the unpaginated direction const canPaginateKey = backwards ? 'canBackPaginate' : 'canForwardPaginate'; const { events, liveEvents, firstVisibleEventIndex } = this._getEvents(); this.setState({ [canPaginateKey]: true, events, liveEvents, firstVisibleEventIndex }); } }); (0, _defineProperty2.default)(this, "onPaginationRequest", (timelineWindow, direction, size) => { if (this.props.onPaginationRequest) { return this.props.onPaginationRequest(timelineWindow, direction, size); } else { return timelineWindow.paginate(direction, size); } }); (0, _defineProperty2.default)(this, "onMessageListFillRequest", backwards => { if (!this._shouldPaginate()) return Promise.resolve(false); const dir = backwards ? _eventTimeline.EventTimeline.BACKWARDS : _eventTimeline.EventTimeline.FORWARDS; const canPaginateKey = backwards ? 'canBackPaginate' : 'canForwardPaginate'; const paginatingKey = backwards ? 'backPaginating' : 'forwardPaginating'; if (!this.state[canPaginateKey]) { debuglog("TimelinePanel: have given up", dir, "paginating this timeline"); return Promise.resolve(false); } if (!this._timelineWindow.canPaginate(dir)) { debuglog("TimelinePanel: can't", dir, "paginate any further"); this.setState({ [canPaginateKey]: false }); return Promise.resolve(false); } if (backwards && this.state.firstVisibleEventIndex !== 0) { debuglog("TimelinePanel: won't", dir, "paginate past first visible event"); return Promise.resolve(false); } debuglog("TimelinePanel: Initiating paginate; backwards:" + backwards); this.setState({ [paginatingKey]: true }); return this.onPaginationRequest(this._timelineWindow, dir, PAGINATE_SIZE).then(r => { if (this.unmounted) { return; } debuglog("TimelinePanel: paginate complete backwards:" + backwards + "; success:" + r); const { events, liveEvents, firstVisibleEventIndex } = this._getEvents(); const newState = { [paginatingKey]: false, [canPaginateKey]: r, events, liveEvents, firstVisibleEventIndex }; // moving the window in this direction may mean that we can now // paginate in the other where we previously could not. const otherDirection = backwards ? _eventTimeline.EventTimeline.FORWARDS : _eventTimeline.EventTimeline.BACKWARDS; const canPaginateOtherWayKey = backwards ? 'canForwardPaginate' : 'canBackPaginate'; if (!this.state[canPaginateOtherWayKey] && this._timelineWindow.canPaginate(otherDirection)) { debuglog('TimelinePanel: can now', otherDirection, 'paginate again'); newState[canPaginateOtherWayKey] = true; } // Don't resolve until the setState has completed: we need to let // the component update before we consider the pagination completed, // otherwise we'll end up paginating in all the history the js-sdk // has in memory because we never gave the component a chance to scroll // itself into the right place return new Promise(resolve => { this.setState(newState, () => { // we can continue paginating in the given direction if: // - _timelineWindow.paginate says we can // - we're paginating forwards, or we won't be trying to // paginate backwards past the first visible event resolve(r && (!backwards || firstVisibleEventIndex === 0)); }); }); }); }); (0, _defineProperty2.default)(this, "onMessageListScroll", e => { if (this.props.onScroll) { this.props.onScroll(e); } if (this.props.manageReadMarkers) { const rmPosition = this.getReadMarkerPosition(); // we hide the read marker when it first comes onto the screen, but if // it goes back off the top of the screen (presumably because the user // clicks on the 'jump to bottom' button), we need to re-enable it. if (rmPosition < 0) { this.setState({ readMarkerVisible: true }); } // if read marker position goes between 0 and -1/1, // (and user is active), switch timeout const timeout = this._readMarkerTimeout(rmPosition); // NO-OP when timeout already has set to the given value this._readMarkerActivityTimer.changeTimeout(timeout); } }); (0, _defineProperty2.default)(this, "onAction", payload => { if (payload.action === 'ignore_state_changed') { this.forceUpdate(); } if (payload.action === "edit_event") { const editState = payload.event ? new _EditorStateTransfer.default(payload.event) : null; this.setState({ editState }, () => { if (payload.event && this._messagePanel.current) { this._messagePanel.current.scrollToEventIfNeeded(payload.event.getId()); } }); } if (payload.action === "scroll_to_bottom") { this.jumpToLiveTimeline(); } }); (0, _defineProperty2.default)(this, "onRoomTimeline", (ev, room, toStartOfTimeline, removed, data) => { // ignore events for other timeline sets if (data.timeline.getTimelineSet() !== this.props.timelineSet) 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; if (!this._messagePanel.current) return; if (!this._messagePanel.current.getScrollState().stuckAtBottom) { // we won't load this event now, because we don't want to push any // events off the other end of the timeline. But we need to note // that we can now paginate. this.setState({ canForwardPaginate: true }); return; } // tell the timeline window to try to advance itself, but not to make // an http request to do so. // // we deliberately avoid going via the ScrollPanel for this call - the // ScrollPanel might already have an active pagination promise, which // will fail, but would stop us passing the pagination request to the // timeline window. // // see https://github.com/vector-im/vector-web/issues/1035 this._timelineWindow.paginate(_eventTimeline.EventTimeline.FORWARDS, 1, false).then(() => { if (this.unmounted) { return; } const { events, liveEvents, firstVisibleEventIndex } = this._getEvents(); const lastLiveEvent = liveEvents[liveEvents.length - 1]; const updatedState = { events, liveEvents, firstVisibleEventIndex }; let callRMUpdated; if (this.props.manageReadMarkers) { // when a new event arrives when the user is not watching the // window, but the window is in its auto-scroll mode, make sure the // read marker is visible. // // We ignore events we have sent ourselves; we don't want to see the // read-marker when a remote echo of an event we have just sent takes // more than the timeout on userActiveRecently. // const myUserId = _MatrixClientPeg.MatrixClientPeg.get().credentials.userId; const sender = ev.sender ? ev.sender.userId : null; callRMUpdated = false; if (sender != myUserId && !_UserActivity.default.sharedInstance().userActiveRecently()) { updatedState.readMarkerVisible = true; } else if (lastLiveEvent && this.getReadMarkerPosition() === 0) { // we know we're stuckAtBottom, so we can advance the RM // immediately, to save a later render cycle this._setReadMarker(lastLiveEvent.getId(), lastLiveEvent.getTs(), true); updatedState.readMarkerVisible = false; updatedState.readMarkerEventId = lastLiveEvent.getId(); callRMUpdated = true; } } this.setState(updatedState, () => { this._messagePanel.current.updateTimelineMinHeight(); if (callRMUpdated) { this.props.onReadMarkerUpdated(); } }); }); }); (0, _defineProperty2.default)(this, "onRoomTimelineReset", (room, timelineSet) => { if (timelineSet !== this.props.timelineSet) return; if (this._messagePanel.current && this._messagePanel.current.isAtBottom()) { this._loadTimeline(); } }); (0, _defineProperty2.default)(this, "canResetTimeline", () => this._messagePanel.current && this._messagePanel.current.isAtBottom()); (0, _defineProperty2.default)(this, "onRoomRedaction", (ev, room) => { if (this.unmounted) return; // ignore events for other rooms if (room !== this.props.timelineSet.room) return; // we could skip an update if the event isn't in our timeline, // but that's probably an early optimisation. this.forceUpdate(); }); (0, _defineProperty2.default)(this, "onEventReplaced", (replacedEvent, room) => { if (this.unmounted) return; // ignore events for other rooms if (room !== this.props.timelineSet.room) return; // we could skip an update if the event isn't in our timeline, // but that's probably an early optimisation. this.forceUpdate(); }); (0, _defineProperty2.default)(this, "onRoomReceipt", (ev, room) => { if (this.unmounted) return; // ignore events for other rooms if (room !== this.props.timelineSet.room) return; this.forceUpdate(); }); (0, _defineProperty2.default)(this, "onLocalEchoUpdated", (ev, room, oldEventId) => { if (this.unmounted) return; // ignore events for other rooms if (room !== this.props.timelineSet.room) return; this._reloadEvents(); }); (0, _defineProperty2.default)(this, "onAccountData", (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); }); (0, _defineProperty2.default)(this, "onEventDecrypted", ev => { // Can be null for the notification timeline, etc. if (!this.props.timelineSet.room) return; // Need to update as we don't display event tiles for events that // haven't yet been decrypted. The event will have just been updated // in place so we just need to re-render. // TODO: We should restrict this to only events in our timeline, // but possibly the event tile itself should just update when this // happens to save us re-rendering the whole timeline. if (ev.getRoomId() === this.props.timelineSet.room.roomId) { this.forceUpdate(); } }); (0, _defineProperty2.default)(this, "onSync", (state, prevState, data) => { this.setState({ clientSyncState: state }); }); (0, _defineProperty2.default)(this, "sendReadReceipt", () => { if (_SettingsStore.default.getValue("lowBandwidth")) return; if (!this._messagePanel.current) return; if (!this.props.manageReadReceipts) return; // 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. const cli = _MatrixClientPeg.MatrixClientPeg.get(); // if no client or client is guest don't send RR or RM if (!cli || cli.isGuest()) return; let shouldSendRR = true; const currentRREventId = this._getCurrentReadReceipt(true); const currentRREventIndex = this._indexForEventId(currentRREventId); // We want to avoid sending out read receipts when we are looking at // events in the past which are before the latest RR. // // For now, let's apply a heuristic: if (a) the event corresponding to // the latest RR (either from the server, or sent by ourselves) doesn't // appear in our timeline, and (b) we could forward-paginate the event // timeline, then don't send any more RRs. // // This isn't watertight, as we could be looking at a section of // timeline which is *after* the latest RR (so we should actually send // RRs) - but that is a bit of a niche case. It will sort itself out when // the user eventually hits the live timeline. // if (currentRREventId && currentRREventIndex === null && this._timelineWindow.canPaginate(_eventTimeline.EventTimeline.FORWARDS)) { shouldSendRR = false; } const lastReadEventIndex = this._getLastDisplayedEventIndex({ ignoreOwn: true }); if (lastReadEventIndex === null) { shouldSendRR = false; } let lastReadEvent = this.state.events[lastReadEventIndex]; shouldSendRR = shouldSendRR && // Only send a RR if the last read event is ahead in the timeline relative to // the current RR event. lastReadEventIndex > currentRREventIndex && // Only send a RR if the last RR set != the one we would send this.lastRRSentEventId != lastReadEvent.getId(); // Only send a RM if the last RM sent != the one we would send const shouldSendRM = this.lastRMSentEventId != this.state.readMarkerEventId; // we also remember the last read receipt we sent to avoid spamming the // same one at the server repeatedly if (shouldSendRR || shouldSendRM) { if (shouldSendRR) { this.lastRRSentEventId = lastReadEvent.getId(); } else { lastReadEvent = null; } this.lastRMSentEventId = this.state.readMarkerEventId; debuglog('TimelinePanel: Sending Read Markers for ', this.props.timelineSet.room.roomId, 'rm', this.state.readMarkerEventId, lastReadEvent ? 'rr ' + lastReadEvent.getId() : ''); _MatrixClientPeg.MatrixClientPeg.get().setRoomReadMarkers(this.props.timelineSet.room.roomId, this.state.readMarkerEventId, lastReadEvent, // Could be null, in which case no RR is sent {}).catch(e => { // /read_markers API is not implemented on this HS, fallback to just RR if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) { return _MatrixClientPeg.MatrixClientPeg.get().sendReadReceipt(lastReadEvent, {}).catch(e => { console.error(e); this.lastRRSentEventId = undefined; }); } else { console.error(e); } // it failed, so allow retries next time the user is active this.lastRRSentEventId = undefined; this.lastRMSentEventId = 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); _dispatcher.default.dispatch({ action: 'on_room_read', roomId: this.props.timelineSet.room.roomId }); } } }); (0, _defineProperty2.default)(this, "updateReadMarker", () => { if (!this.props.manageReadMarkers) return; if (this.getReadMarkerPosition() === 1) { // the read marker is at an event below the viewport, // we don't want to rewind it. return; } // move the RM to *after* the message at the bottom of the screen. This // avoids a problem whereby we never advance the RM if there is a huge // message which doesn't fit on the screen. const lastDisplayedIndex = this._getLastDisplayedEventIndex({ allowPartial: true }); if (lastDisplayedIndex === null) { return; } const lastDisplayedEvent = this.state.events[lastDisplayedIndex]; this._setReadMarker(lastDisplayedEvent.getId(), lastDisplayedEvent.getTs()); // the read-marker should become invisible, so that if the user scrolls // down, they don't see it. if (this.state.readMarkerVisible) { this.setState({ readMarkerVisible: false }); } // Send the updated read marker (along with read receipt) to the server this.sendReadReceipt(); }); (0, _defineProperty2.default)(this, "jumpToLiveTimeline", () => { // if we can't forward-paginate the existing timeline, then there // is no point reloading it - just jump straight to the bottom. // // Otherwise, reload the timeline rather than trying to paginate // through all of space-time. if (this._timelineWindow.canPaginate(_eventTimeline.EventTimeline.FORWARDS)) { this._loadTimeline(); } else { if (this._messagePanel.current) { this._messagePanel.current.scrollToBottom(); } } }); (0, _defineProperty2.default)(this, "jumpToReadMarker", () => { if (!this.props.manageReadMarkers) return; if (!this._messagePanel.current) return; if (!this.state.readMarkerEventId) return; // we may not have loaded the event corresponding to the read-marker // into the _timelineWindow. In that case, attempts to scroll to it // will fail. // // a quick way to figure out if we've loaded the relevant event is // simply to check if the messagepanel knows where the read-marker is. const ret = this._messagePanel.current.getReadMarkerPosition(); if (ret !== null) { // The messagepanel knows where the RM is, so we must have loaded // the relevant event. this._messagePanel.current.scrollToEvent(this.state.readMarkerEventId, 0, 1 / 3); return; } // Looks like we haven't loaded the event corresponding to the read-marker. // As with jumpToLiveTimeline, we want to reload the timeline around the // read-marker. this._loadTimeline(this.state.readMarkerEventId, 0, 1 / 3); }); (0, _defineProperty2.default)(this, "forgetReadMarker", () => { if (!this.props.manageReadMarkers) return; const rmId = this._getCurrentReadReceipt(); // see if we know the timestamp for the rr event const tl = this.props.timelineSet.getTimelineForEvent(rmId); let rmTs; if (tl) { const event = tl.getEvents().find(e => { return e.getId() == rmId; }); if (event) { rmTs = event.getTs(); } } this._setReadMarker(rmId, rmTs); }); (0, _defineProperty2.default)(this, "isAtEndOfLiveTimeline", () => { return this._messagePanel.current && this._messagePanel.current.isAtBottom() && this._timelineWindow && !this._timelineWindow.canPaginate(_eventTimeline.EventTimeline.FORWARDS); }); (0, _defineProperty2.default)(this, "getScrollState", () => { if (!this._messagePanel.current) { return null; } return this._messagePanel.current.getScrollState(); }); (0, _defineProperty2.default)(this, "getReadMarkerPosition", () => { if (!this.props.manageReadMarkers) return null; if (!this._messagePanel.current) return null; const ret = this._messagePanel.current.getReadMarkerPosition(); if (ret !== null) { return ret; } // 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. 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; } else { return 1; } } return null; }); (0, _defineProperty2.default)(this, "canJumpToReadMarker", () => { // 1. Do not show jump bar if neither the RM nor the RR are set. // 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(); const ret = this.state.readMarkerEventId !== null && ( // 1. pos < 0 || pos === null); // 3., 4. return ret; }); (0, _defineProperty2.default)(this, "handleScrollKey", ev => { if (!this._messagePanel.current) { return; } // jump to the live timeline on ctrl-end, rather than the end of the // timeline window. if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.key === _Keyboard.Key.END) { this.jumpToLiveTimeline(); } else { this._messagePanel.current.handleScrollKey(ev); } }); (0, _defineProperty2.default)(this, "getRelationsForEvent", (...args) => this.props.timelineSet.getRelationsForEvent(...args)); debuglog("TimelinePanel: mounting"); this.lastRRSentEventId = undefined; this.lastRMSentEventId = undefined; this._messagePanel = /*#__PURE__*/(0, _react.createRef)(); // 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) { const readmarker = this.props.timelineSet.room.getAccountData('m.fully_read'); if (readmarker) { initialReadMarker = readmarker.getContent().event_id; } else { initialReadMarker = this._getCurrentReadReceipt(); } } this.state = { events: [], liveEvents: [], timelineLoading: true, // track whether our room timeline is loading // the index of the first event that is to be shown firstVisibleEventIndex: 0, // canBackPaginate == false may mean: // // * we haven't (successfully) loaded the timeline yet, or: // // * we have got to the point where the room was created, or: // // * the server indicated that there were no more visible events // (normally implying we got to the start of the room), or: // // * we gave up asking the server for more events canBackPaginate: false, // canForwardPaginate == false may mean: // // * we haven't (successfully) loaded the timeline yet // // * we have got to the end of time and are now tracking the live // timeline, or: // // * the server indicated that there were no more visible events // (not sure if this ever happens when we're not at the live // timeline), or: // // * we are looking at some historical point, but gave up asking // the server for more events canForwardPaginate: false, // start with the read-marker visible, so that we see its animated // disappearance when switching into the room. readMarkerVisible: true, readMarkerEventId: initialReadMarker, backPaginating: false, forwardPaginating: false, // cache of matrixClient.getSyncState() (but from the 'sync' event) clientSyncState: _MatrixClientPeg.MatrixClientPeg.get().getSyncState(), // should the event tiles have twelve hour times isTwelveHour: _SettingsStore.default.getValue("showTwelveHourTimestamps"), // always show timestamps on event tiles? alwaysShowTimestamps: _SettingsStore.default.getValue("alwaysShowTimestamps"), // how long to show the RM for when it's visible in the window readMarkerInViewThresholdMs: _SettingsStore.default.getValue("readMarkerInViewThresholdMs"), // how long to show the RM for when it's scrolled off-screen readMarkerOutOfViewThresholdMs: _SettingsStore.default.getValue("readMarkerOutOfViewThresholdMs") }; this.dispatcherRef = _dispatcher.default.register(this.onAction); _MatrixClientPeg.MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); _MatrixClientPeg.MatrixClientPeg.get().on("Room.timelineReset", this.onRoomTimelineReset); _MatrixClientPeg.MatrixClientPeg.get().on("Room.redaction", this.onRoomRedaction); // same event handler as Room.redaction as for both we just do forceUpdate _MatrixClientPeg.MatrixClientPeg.get().on("Room.redactionCancelled", this.onRoomRedaction); _MatrixClientPeg.MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt); _MatrixClientPeg.MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated); _MatrixClientPeg.MatrixClientPeg.get().on("Room.accountData", this.onAccountData); _MatrixClientPeg.MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted); _MatrixClientPeg.MatrixClientPeg.get().on("Event.replaced", this.onEventReplaced); _MatrixClientPeg.MatrixClientPeg.get().on("sync", this.onSync); } // TODO: [REACT-WARNING] Move into constructor // eslint-disable-next-line camelcase UNSAFE_componentWillMount() { if (this.props.manageReadReceipts) { this.updateReadReceiptOnUserActivity(); } if (this.props.manageReadMarkers) { this.updateReadMarkerOnUserActivity(); } this._initTimeline(this.props); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event // eslint-disable-next-line camelcase UNSAFE_componentWillReceiveProps(newProps) { if (newProps.timelineSet !== this.props.timelineSet) { // throw new Error("changing timelineSet on a TimelinePanel is not supported"); // regrettably, this does happen; in particular, when joining a // room with /join. In that case, there are two Rooms in // circulation - one which is created by the MatrixClient.joinRoom // call and used to create the RoomView, and a second which is // created by the sync loop once the room comes back down the /sync // pipe. Once the latter happens, our room is replaced with the new one. // // for now, just warn about this. But we're going to end up paginating // both rooms separately, and it's all bad. console.warn("Replacing timelineSet on a TimelinePanel - confusion may ensue"); } if (newProps.eventId != this.props.eventId) { console.log("TimelinePanel switching to eventId " + newProps.eventId + " (was " + this.props.eventId + ")"); return this._initTimeline(newProps); } } shouldComponentUpdate(nextProps, nextState) { if ((0, _objects.objectHasDiff)(this.props, nextProps)) { if (DEBUG) { console.group("Timeline.shouldComponentUpdate: props change"); console.log("props before:", this.props); console.log("props after:", nextProps); console.groupEnd(); } return true; } if ((0, _objects.objectHasDiff)(this.state, nextState)) { if (DEBUG) { console.group("Timeline.shouldComponentUpdate: state change"); console.log("state before:", this.state); console.log("state after:", nextState); console.groupEnd(); } return true; } return false; } componentWillUnmount() { // 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._readReceiptActivityTimer) { this._readReceiptActivityTimer.abort(); this._readReceiptActivityTimer = null; } if (this._readMarkerActivityTimer) { this._readMarkerActivityTimer.abort(); this._readMarkerActivityTimer = null; } _dispatcher.default.unregister(this.dispatcherRef); const client = _MatrixClientPeg.MatrixClientPeg.get(); if (client) { client.removeListener("Room.timeline", this.onRoomTimeline); client.removeListener("Room.timelineReset", this.onRoomTimelineReset); client.removeListener("Room.redaction", this.onRoomRedaction); client.removeListener("Room.redactionCancelled", this.onRoomRedaction); client.removeListener("Room.receipt", this.onRoomReceipt); client.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated); client.removeListener("Room.accountData", this.onAccountData); client.removeListener("Event.decrypted", this.onEventDecrypted); client.removeListener("Event.replaced", this.onEventReplaced); client.removeListener("sync", this.onSync); } } _readMarkerTimeout(readMarkerPosition) { return readMarkerPosition === 0 ? this.state.readMarkerInViewThresholdMs : this.state.readMarkerOutOfViewThresholdMs; } async updateReadMarkerOnUserActivity() { const initialTimeout = this._readMarkerTimeout(this.getReadMarkerPosition()); this._readMarkerActivityTimer = new _Timer.default(initialTimeout); while (this._readMarkerActivityTimer) { //unset on unmount _UserActivity.default.sharedInstance().timeWhileActiveRecently(this._readMarkerActivityTimer); try { await this._readMarkerActivityTimer.finished(); } catch (e) { continue; /* aborted */ } // outside of try/catch to not swallow errors this.updateReadMarker(); } } async updateReadReceiptOnUserActivity() { this._readReceiptActivityTimer = new _Timer.default(READ_RECEIPT_INTERVAL_MS); while (this._readReceiptActivityTimer) { //unset on unmount _UserActivity.default.sharedInstance().timeWhileActiveNow(this._readReceiptActivityTimer); try { await this._readReceiptActivityTimer.finished(); } catch (e) { continue; /* aborted */ } // outside of try/catch to not swallow errors this.sendReadReceipt(); } } // advance the read marker past any events we sent ourselves. _advanceReadMarkerPastMyEvents() { if (!this.props.manageReadMarkers) return; // we call `_timelineWindow.getEvents()` rather than using // `this.state.liveEvents`, because React batches the update to the // latter, so it may not have been updated yet. const events = this._timelineWindow.getEvents(); // first find where the current RM is let i; for (i = 0; i < events.length; i++) { if (events[i].getId() == this.state.readMarkerEventId) { break; } } if (i >= events.length) { return; } // now think about advancing it const myUserId = _MatrixClientPeg.MatrixClientPeg.get().credentials.userId; for (i++; i < events.length; i++) { const ev = events[i]; if (!ev.sender || ev.sender.userId != myUserId) { break; } } // i is now the first unread message which we didn't send ourselves. i--; const ev = events[i]; this._setReadMarker(ev.getId(), ev.getTs()); } /* jump down to the bottom of this room, where new events are arriving */ _initTimeline(props) { const initialEvent = props.eventId; const pixelOffset = props.eventPixelOffset; // if a pixelOffset is given, it is relative to the bottom of the // container. If not, put the event in the middle of the container. let offsetBase = 1; if (pixelOffset == null) { offsetBase = 0.5; } return this._loadTimeline(initialEvent, pixelOffset, offsetBase); } /** * (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 offsetBase). If omitted, defaults to 0. * * @param {number?} offsetBase the reference point for the pixelOffset. 0 * means the top of the container, 1 means the bottom, and fractional * values mean somewhere in the middle. If omitted, it defaults to 0. * * returns a promise which will resolve when the load completes. */ _loadTimeline(eventId, pixelOffset, offsetBase) { this._timelineWindow = new _timelineWindow.TimelineWindow(_MatrixClientPeg.MatrixClientPeg.get(), this.props.timelineSet, { windowLimit: this.props.timelineCap }); const onLoaded = () => { // clear the timeline min-height when // (re)loading the timeline if (this._messagePanel.current) { this._messagePanel.current.onTimelineReset(); } this._reloadEvents(); // If we switched away from the room while there were pending // outgoing events, the read-marker will be before those events. // We need to skip over any which have subsequently been sent. this._advanceReadMarkerPastMyEvents(); this.setState({ canBackPaginate: this._timelineWindow.canPaginate(_eventTimeline.EventTimeline.BACKWARDS), canForwardPaginate: this._timelineWindow.canPaginate(_eventTimeline.EventTimeline.FORWARDS), timelineLoading: false }, () => { // initialise the scroll state of the message panel if (!this._messagePanel.current) { // this shouldn't happen - we know we're mounted because // we're in a setState callback, and we know // timelineLoading is now false, so render() should have // mounted the message panel. console.log("can't initialise scroll state because " + "messagePanel didn't load"); return; } if (eventId) { this._messagePanel.current.scrollToEvent(eventId, pixelOffset, offsetBase); } else { this._messagePanel.current.scrollToBottom(); } if (this.props.sendReadReceiptOnLoad) { this.sendReadReceipt(); } }); }; const onError = error => { this.setState({ timelineLoading: false }); console.error(`Error loading timeline panel at ${eventId}: ${error}`); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); let onFinished; // if we were given an event ID, then when the user closes the // dialog, let's jump to the end of the timeline. If we weren't, // something has gone badly wrong and rather than causing a loop of // undismissable dialogs, let's just give up. if (eventId) { onFinished = () => { // go via the dispatcher so that the URL is updated _dispatcher.default.dispatch({ action: 'view_room', room_id: this.props.timelineSet.room.roomId }); }; } let message; if (error.errcode == 'M_FORBIDDEN') { message = (0, _languageHandler._t)("Tried to load a specific point in this room's timeline, but you " + "do not have permission to view the message in question."); } else { message = (0, _languageHandler._t)("Tried to load a specific point in this room's timeline, but was " + "unable to find it."); } _Modal.default.createTrackedDialog('Failed to load timeline position', '', ErrorDialog, { title: (0, _languageHandler._t)("Failed to load timeline position"), description: message, onFinished: onFinished }); }; // if we already have the event in question, TimelineWindow.load // returns a resolved promise. // // In this situation, we don't really want to defer the update of the // state to the next event loop, because it makes room-switching feel // quite slow. So we detect that situation and shortcut straight to // calling _reloadEvents and updating the state. const timeline = this.props.timelineSet.getTimelineForEvent(eventId); if (timeline) { // This is a hot-path optimization by skipping a promise tick // by repeating a no-op sync branch in TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline this._timelineWindow.load(eventId, INITIAL_SIZE); // in this branch this method will happen in sync time onLoaded(); } else { const prom = this._timelineWindow.load(eventId, INITIAL_SIZE); this.setState({ events: [], liveEvents: [], canBackPaginate: false, canForwardPaginate: false, timelineLoading: true }); prom.then(onLoaded, onError); } } // handle the completion of a timeline load or localEchoUpdate, by // reloading the events from the timelinewindow and pending event list into // the state. _reloadEvents() { // we might have switched rooms since the load started - just bin // the results if so. if (this.unmounted) return; this.setState(this._getEvents()); } // get the list of events from the timeline window and the pending event list _getEvents() { const events = this._timelineWindow.getEvents(); // `arrayFastClone` performs a shallow copy of the array // we want the last event to be decrypted first but displayed last // `reverse` is destructive and unfortunately mutates the "events" array (0, _arrays.arrayFastClone)(events).reverse().forEach(event => { const client = _MatrixClientPeg.MatrixClientPeg.get(); client.decryptEventIfNeeded(event); }); const firstVisibleEventIndex = this._checkForPreJoinUISI(events); // Hold onto the live events separately. The read receipt and read marker // should use this list, so that they don't advance into pending events. const liveEvents = [...events]; // if we're at the end of the live timeline, append the pending events if (!this._timelineWindow.canPaginate(_eventTimeline.EventTimeline.FORWARDS)) { events.push(...this.props.timelineSet.getPendingEvents()); } return { events, liveEvents, firstVisibleEventIndex }; } /** * Check for undecryptable messages that were sent while the user was not in * the room. * * @param {Array<MatrixEvent>} events The timeline events to check * * @return {Number} The index within `events` of the event after the most recent * undecryptable event that was sent while the user was not in the room. If no * such events were found, then it returns 0. */ _checkForPreJoinUISI(events) { const room = this.props.timelineSet.room; if (events.length === 0 || !room || !_MatrixClientPeg.MatrixClientPeg.get().isRoomEncrypted(room.roomId)) { return 0; } const userId = _MatrixClientPeg.MatrixClientPeg.get().credentials.userId; // get the user's membership at the last event by getting the timeline // that the event belongs to, and traversing the timeline looking for // that event, while keeping track of the user's membership let i; let userMembership = "leave"; for (i = events.length - 1; i >= 0; i--) { const timeline = room.getTimelineForEvent(events[i].getId()); if (!timeline) { // Somehow, it seems to be possible for live events to not have // a timeline, even though that should not happen. :( // https://github.com/vector-im/element-web/issues/12120 console.warn(`Event ${events[i].getId()} in room ${room.roomId} is live, ` + `but it does not have a timeline`); continue; } const userMembershipEvent = timeline.getState(_eventTimeline.EventTimeline.FORWARDS).getMember(userId); userMembership = userMembershipEvent ? userMembershipEvent.membership : "leave"; const timelineEvents = timeline.getEvents(); for (let j = timelineEvents.length - 1; j >= 0; j--) { const event = timelineEvents[j]; if (event.getId() === events[i].getId()) { break; } else if (event.getStateKey() === userId && event.getType() === "m.room.member") { const prevContent = event.getPrevContent(); userMembership = prevContent.membership || "leave"; } } break; } // now go through the rest of the events and find the first undecryptable // one that was sent when the user wasn't in the room for (; i >= 0; i--) { const event = events[i]; if (event.getStateKey() === userId && event.getType() === "m.room.member") { const prevContent = event.getPrevContent(); userMembership = prevContent.membership || "leave"; } else if (userMembership === "leave" && (event.isDecryptionFailure() || event.isBeingDecrypted())) { // reached an undecryptable message when the user wasn't in // the room -- don't try to load any more // Note: for now, we assume that events that are being decrypted are // not decryptable return i + 1; } } return 0; } _indexForEventId(evId) { for (let i = 0; i < this.state.events.length; ++i) { if (evId == this.state.events[i].getId()) { return i; } } return null; } _getLastDisplayedEventIndex(opts) { opts = opts || {}; const ignoreOwn = opts.ignoreOwn || false; const allowPartial = opts.allowPartial || false; const messagePanel = this._messagePanel.current; if (!messagePanel) return null; const messagePanelNode = _reactDom.default.findDOMNode(messagePanel); if (!messagePanelNode) return null; // sometimes this happens for fresh rooms/post-sync const wrapperRect = messagePanelNode.getBoundingClientRect(); const myUserId = _MatrixClientPeg.MatrixClientPeg.get().credentials.userId; const isNodeInView = node => { if (node) { const boundingRect = node.getBoundingClientRect(); if (allowPartial && boundingRect.top < wrapperRect.bottom || !allowPartial && boundingRect.bottom < wrapperRect.bottom) { return true; } } return false; }; // We keep track of how many of the adjacent events didn't have a tile // but should have the read receipt moved past them, so // we can include those once we find the last displayed (visible) event. // The counter is not started for events we don't want // to send a read receipt for (our own events, local echos). let adjacentInvisibleEventCount = 0; // Use `liveEvents` here because we don't want the read marker or read // receipt to advance into pending events. for (let i = this.state.liveEvents.length - 1; i >= 0; --i) { const ev = this.state.liveEvents[i]; const node = messagePanel.getNodeForEventId(ev.getId()); const isInView = isNodeInView(node); // when we've reached the first visible event, and the previous // events were all invisible (with the first one not being ignored), // return the index of the first invisible event. if (isInView && adjacentInvisibleEventCount !== 0) { return i + adjacentInvisibleEventCount; } if (node && !isInView) { // has node but not in view, so reset adjacent invisible events adjacentInvisibleEventCount = 0; } const shouldIgnore = !!ev.status || // local echo ignoreOwn && ev.sender && ev.sender.userId == myUserId; // own message const isWithoutTile = !(0, _EventTile.haveTileForEvent)(ev) || (0, _shouldHideEvent.default)(ev); if (isWithoutTile || !node) { // don't start counting if the event should be ignored, // but continue counting if we were already so the offset // to the previous invisble event that didn't need to be ignored // doesn't get messed up if (!shouldIgnore || shouldIgnore && adjacentInvisibleEventCount !== 0) { ++adjacentInvisibleEventCount; } continue; } if (shouldIgnore) { continue; } if (isInView) { return i; } } return null; } /** * Get the id of the event corresponding to our user's latest read-receipt. * * @param {Boolean} ignoreSynthesized If true, return only receipts that * have been sent by the server, not * implicit ones generated by the JS * SDK. * @return {String} the event ID */ _getCurrentReadReceipt(ignoreSynthesized) { const client = _MatrixClientPeg.MatrixClientPeg.get(); // the client can be null on logout if (client == null) { return null; } const myUserId = client.credentials.userId; return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized); } _setReadMarker(eventId, eventTs, inhibitSetState) { const roomId = this.props.timelineSet.room.roomId; // don't update the state (and cause a re-render) if there is // no change to the RM. if (eventId === this.state.readMarkerEventId) { return; } // 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; if (inhibitSetState) { 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({ readMarkerEventId: eventId }, this.props.onReadMarkerUpdated); } _shouldPaginate() { // don't try to paginate while events in the timeline are // still being decrypted. We don't render events while they're // being decrypted, so they don't take up space in the timeline. // This means we can pull quite a lot of events into the timeline // and end up trying to render a lot of events. return !this.state.events.some(e => { return e.isBeingDecrypted(); }); } render() { const MessagePanel = sdk.getComponent("structures.MessagePanel"); const Loader = sdk.getComponent("elements.Spi