UNPKG

matrix-react-sdk

Version:
1,097 lines (1,038 loc) 266 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.default = void 0; var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty")); var _react = _interopRequireWildcard(require("react")); var _reactDom = _interopRequireDefault(require("react-dom")); var _matrix = require("matrix-js-sdk/src/matrix"); var _lodash = require("lodash"); var _logger = require("matrix-js-sdk/src/logger"); var _SettingsStore = _interopRequireDefault(require("../../settings/SettingsStore")); var _languageHandler = require("../../languageHandler"); var _MatrixClientPeg = require("../../MatrixClientPeg"); var _RoomContext = _interopRequireWildcard(require("../../contexts/RoomContext")); var _UserActivity = _interopRequireDefault(require("../../UserActivity")); var _Modal = _interopRequireDefault(require("../../Modal")); var _dispatcher = _interopRequireDefault(require("../../dispatcher/dispatcher")); var _actions = require("../../dispatcher/actions"); var _Timer = _interopRequireDefault(require("../../utils/Timer")); var _shouldHideEvent = _interopRequireDefault(require("../../shouldHideEvent")); var _MessagePanel = _interopRequireDefault(require("./MessagePanel")); var _Spinner = _interopRequireDefault(require("../views/elements/Spinner")); var _ErrorDialog = _interopRequireDefault(require("../views/dialogs/ErrorDialog")); var _LegacyCallEventGrouper = require("./LegacyCallEventGrouper"); var _KeyBindingsManager = require("../../KeyBindingsManager"); var _KeyboardShortcuts = require("../../accessibility/KeyboardShortcuts"); var _EventTileFactory = require("../../events/EventTileFactory"); function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); } function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; } /* Copyright 2024 New Vector Ltd. Copyright 2016-2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ // These pagination sizes are higher than they may possibly need be // once https://github.com/matrix-org/matrix-spec-proposals/pull/3874 lands const PAGINATE_SIZE = 50; const INITIAL_SIZE = 30; const READ_RECEIPT_INTERVAL_MS = 500; const READ_MARKER_DEBOUNCE_MS = 100; // How far off-screen a decryption failure can be for it to still count as "visible" const VISIBLE_DECRYPTION_FAILURE_MARGIN = 100; const debuglog = (...args) => { if (_SettingsStore.default.getValue("debug_timeline_panel")) { _logger.logger.log.call(console, "TimelinePanel debuglog:", ...args); } }; const overlaysBefore = (overlayEvent, mainEvent) => overlayEvent.localTimestamp < mainEvent.localTimestamp; const overlaysAfter = (overlayEvent, mainEvent) => overlayEvent.localTimestamp >= mainEvent.localTimestamp; /* * Component which shows the event timeline in a room view. * * Also responsible for handling and sending read receipts. */ class TimelinePanel extends _react.default.Component { constructor(props, context) { super(props, context); (0, _defineProperty2.default)(this, "lastRRSentEventId", undefined); (0, _defineProperty2.default)(this, "lastRMSentEventId", undefined); (0, _defineProperty2.default)(this, "messagePanel", /*#__PURE__*/(0, _react.createRef)()); (0, _defineProperty2.default)(this, "dispatcherRef", void 0); (0, _defineProperty2.default)(this, "timelineWindow", void 0); (0, _defineProperty2.default)(this, "overlayTimelineWindow", void 0); (0, _defineProperty2.default)(this, "unmounted", false); (0, _defineProperty2.default)(this, "readReceiptActivityTimer", null); (0, _defineProperty2.default)(this, "readMarkerActivityTimer", null); // A map of <callId, LegacyCallEventGrouper> (0, _defineProperty2.default)(this, "callEventGroupers", new Map()); (0, _defineProperty2.default)(this, "initialReadMarkerId", null); /** * Logs out debug info to describe the state of the TimelinePanel and the * events in the room according to the matrix-js-sdk. This is useful when * debugging problems like messages out of order, or messages that should * not be showing up in a thread, etc. * * It's too expensive and cumbersome to do all of these calculations for * every message change so instead we only log it out when asked. */ (0, _defineProperty2.default)(this, "onDumpDebugLogs", () => { const room = this.props.timelineSet?.room; // Get a list of the event IDs used in this TimelinePanel. // This includes state and hidden events which we don't render const eventIdList = this.state?.events?.map(ev => ev.getId()); // Get the list of actually rendered events seen in the DOM. // This is useful to know for sure what's being shown on screen. // And we can suss out any corrupted React `key` problems. let renderedEventIds; try { const messagePanel = this.messagePanel.current; if (messagePanel) { const messagePanelNode = _reactDom.default.findDOMNode(messagePanel); if (messagePanelNode) { const actuallyRenderedEvents = messagePanelNode.querySelectorAll("[data-event-id]"); renderedEventIds = [...actuallyRenderedEvents].map(renderedEvent => { return renderedEvent.getAttribute("data-event-id"); }); } } } catch (err) { _logger.logger.error(`onDumpDebugLogs: Failed to get the actual event ID's in the DOM`, err); } // Get the list of events and threads for the room as seen by the // matrix-js-sdk. let serializedEventIdsFromTimelineSets; let serializedEventIdsFromThreadsTimelineSets; const serializedThreadsMap = {}; if (room) { const timelineSets = room.getTimelineSets(); const threadsTimelineSets = room.threadsTimelineSets; try { // Serialize all of the timelineSets and timelines in each set to their event IDs serializedEventIdsFromTimelineSets = serializeEventIdsFromTimelineSets(timelineSets); serializedEventIdsFromThreadsTimelineSets = serializeEventIdsFromTimelineSets(threadsTimelineSets); } catch (err) { _logger.logger.error(`onDumpDebugLogs: Failed to serialize event IDs from timelinesets`, err); } try { // Serialize all threads in the room from theadId -> event IDs in the thread room.getThreads().forEach(thread => { serializedThreadsMap[thread.id] = { events: thread.events.map(ev => ev.getId()), numTimelines: thread.timelineSet.getTimelines().length, liveTimeline: thread.timelineSet.getLiveTimeline().getEvents().length, prevTimeline: thread.timelineSet.getLiveTimeline().getNeighbouringTimeline(_matrix.Direction.Backward)?.getEvents().length, nextTimeline: thread.timelineSet.getLiveTimeline().getNeighbouringTimeline(_matrix.Direction.Forward)?.getEvents().length }; }); } catch (err) { _logger.logger.error(`onDumpDebugLogs: Failed to serialize event IDs from the threads`, err); } } let timelineWindowEventIds; try { timelineWindowEventIds = this.timelineWindow?.getEvents().map(ev => ev.getId()); } catch (err) { _logger.logger.error(`onDumpDebugLogs: Failed to get event IDs from the timelineWindow`, err); } let pendingEventIds; try { pendingEventIds = this.props.timelineSet.getPendingEvents().map(ev => ev.getId()); } catch (err) { _logger.logger.error(`onDumpDebugLogs: Failed to get pending event IDs`, err); } _logger.logger.debug(`TimelinePanel(${this.context.timelineRenderingType}): Debugging info for ${room?.roomId}\n` + `\tevents(${eventIdList.length})=${JSON.stringify(eventIdList)}\n` + `\trenderedEventIds(${renderedEventIds?.length ?? 0})=` + `${JSON.stringify(renderedEventIds)}\n` + `\tserializedEventIdsFromTimelineSets=${JSON.stringify(serializedEventIdsFromTimelineSets)}\n` + `\tserializedEventIdsFromThreadsTimelineSets=` + `${JSON.stringify(serializedEventIdsFromThreadsTimelineSets)}\n` + `\tserializedThreadsMap=${JSON.stringify(serializedThreadsMap)}\n` + `\ttimelineWindowEventIds(${timelineWindowEventIds?.length})=${JSON.stringify(timelineWindowEventIds)}\n` + `\tpendingEventIds(${pendingEventIds?.length})=${JSON.stringify(pendingEventIds)}`); }); (0, _defineProperty2.default)(this, "onMessageListUnfillRequest", (backwards, scrollToken) => { // If backwards, unpaginate from the back (i.e. the start of the timeline) const dir = backwards ? _matrix.EventTimeline.BACKWARDS : _matrix.EventTimeline.FORWARDS; debuglog("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; // The event in question could belong to either the main timeline or // overlay timeline; let's check both const mainEvents = this.timelineWindow.getEvents(); const overlayEvents = this.overlayTimelineWindow?.getEvents() ?? []; let marker = mainEvents.findIndex(ev => ev.getId() === eventId); let overlayMarker; if (marker === -1) { // The event must be from the overlay timeline instead overlayMarker = overlayEvents.findIndex(ev => ev.getId() === eventId); marker = backwards ? (0, _lodash.findLastIndex)(mainEvents, ev => overlaysAfter(overlayEvents[overlayMarker], ev)) : mainEvents.findIndex(ev => overlaysBefore(overlayEvents[overlayMarker], ev)); } else { overlayMarker = backwards ? (0, _lodash.findLastIndex)(overlayEvents, ev => overlaysBefore(ev, mainEvents[marker])) : overlayEvents.findIndex(ev => overlaysAfter(ev, mainEvents[marker])); } // The number of events to unpaginate from the main timeline let count; if (marker === -1) { count = 0; } else { count = backwards ? marker + 1 : mainEvents.length - marker; } // The number of events to unpaginate from the overlay timeline let overlayCount; if (overlayMarker === -1) { overlayCount = 0; } else { overlayCount = backwards ? overlayMarker + 1 : overlayEvents.length - overlayMarker; } if (count > 0) { debuglog("Unpaginating", count, "in direction", dir); this.timelineWindow.unpaginate(count, backwards); } if (overlayCount > 0) { debuglog("Unpaginating", count, "from overlay timeline in direction", dir); this.overlayTimelineWindow.unpaginate(overlayCount, backwards); } const { events, liveEvents } = this.getEvents(); this.buildLegacyCallEventGroupers(events); this.setState({ events, liveEvents }); // We can now paginate in the unpaginated direction if (backwards) { this.setState({ canBackPaginate: true }); } else { this.setState({ canForwardPaginate: true }); } }); (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); } }); // set off a pagination request. (0, _defineProperty2.default)(this, "onMessageListFillRequest", backwards => { if (!this.shouldPaginate()) return Promise.resolve(false); const dir = backwards ? _matrix.EventTimeline.BACKWARDS : _matrix.EventTimeline.FORWARDS; const canPaginateKey = backwards ? "canBackPaginate" : "canForwardPaginate"; const paginatingKey = backwards ? "backPaginating" : "forwardPaginating"; if (!this.state[canPaginateKey]) { debuglog("have given up", dir, "paginating this timeline"); return Promise.resolve(false); } if (!this.timelineWindow?.canPaginate(dir)) { debuglog("can't", dir, "paginate any further"); this.setState({ [canPaginateKey]: false }); return Promise.resolve(false); } debuglog("Initiating paginate; backwards:" + backwards); this.setState({ [paginatingKey]: true }); return this.onPaginationRequest(this.timelineWindow, dir, PAGINATE_SIZE).then(async r => { if (this.unmounted) { return false; } if (this.overlayTimelineWindow) { await this.extendOverlayWindowToCoverMainWindow(); } debuglog("paginate complete backwards:" + backwards + "; success:" + r); const { events, liveEvents } = this.getEvents(); this.buildLegacyCallEventGroupers(events); const newState = { [paginatingKey]: false, [canPaginateKey]: r, events, liveEvents }; // moving the window in this direction may mean that we can now // paginate in the other where we previously could not. const otherDirection = backwards ? _matrix.EventTimeline.FORWARDS : _matrix.EventTimeline.BACKWARDS; const canPaginateOtherWayKey = backwards ? "canForwardPaginate" : "canBackPaginate"; if (!this.state[canPaginateOtherWayKey] && this.timelineWindow?.canPaginate(otherDirection)) { debuglog("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 resolve(r); }); }); }); }); (0, _defineProperty2.default)(this, "onMessageListScroll", e => { this.props.onScroll?.(e); if (this.props.manageReadMarkers) { this.doManageReadMarkers(); } }); /* * Debounced function to manage read markers because we don't need to * do this on every tiny scroll update. It also sets state which causes * a component update, which can in turn reset the scroll position, so * it's important we allow the browser to scroll a bit before running this * (hence trailing edge only and debounce rather than throttle because * we really only need to update this once the user has finished scrolling, * not periodically while they scroll). */ (0, _defineProperty2.default)(this, "doManageReadMarkers", (0, _lodash.debounce)(() => { const rmPosition = this.getReadMarkerPosition(); if (rmPosition === null) return; // 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); }, READ_MARKER_DEBOUNCE_MS, { leading: false, trailing: true })); (0, _defineProperty2.default)(this, "onAction", payload => { switch (payload.action) { case "ignore_state_changed": this.forceUpdate(); break; case _actions.Action.DumpDebugLogs: this.onDumpDebugLogs(); break; } }); (0, _defineProperty2.default)(this, "onRoomTimeline", (ev, room, toStartOfTimeline, removed, data) => { // ignore events for other timeline sets if (data.timeline.getTimelineSet() !== this.props.timelineSet && data.timeline.getTimelineSet() !== this.props.overlayTimelineSet) { return; } if (!_matrix.Thread.hasServerSideSupport && this.context.timelineRenderingType === _RoomContext.TimelineRenderingType.Thread) { if (toStartOfTimeline && !this.state.canBackPaginate) { this.setState({ canBackPaginate: true }); } if (!toStartOfTimeline && !this.state.canForwardPaginate) { this.setState({ canForwardPaginate: true }); } } // 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?.getScrollState()) 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 // a 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(_matrix.EventTimeline.FORWARDS, 1, false).then(() => { if (this.overlayTimelineWindow) { return this.overlayTimelineWindow.paginate(_matrix.EventTimeline.FORWARDS, 1, false); } }).then(() => { if (this.unmounted) { return; } const { events, liveEvents } = this.getEvents(); this.buildLegacyCallEventGroupers(events); const lastLiveEvent = liveEvents[liveEvents.length - 1]; const updatedState = { events, liveEvents }; let callRMUpdated = false; 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.safeGet().credentials.userId; callRMUpdated = false; if (ev.getSender() !== 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() ?? null, 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 && timelineSet !== this.props.overlayTimelineSet) return; if (this.canResetTimeline()) { this.loadTimeline(); } }); (0, _defineProperty2.default)(this, "canResetTimeline", () => this.messagePanel?.current?.isAtBottom() === true); (0, _defineProperty2.default)(this, "onRoomRedaction", (ev, room) => { if (this.unmounted) return; // ignore events for other rooms if (!this.hasTimelineSetFor(room.roomId)) 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, "onThreadUpdate", thread => { if (this.unmounted) { return; } // ignore events for other rooms if (!this.hasTimelineSetFor(thread.roomId)) return; // we could skip an update if the event isn't in our timeline, // but that's probably an early optimisation. const tile = this.messagePanel.current?.getTileForEventId(thread.id); if (tile) { tile.forceUpdate(); } }); // Called whenever the visibility of an event changes, as per // MSC3531. We typically need to re-render the tile. (0, _defineProperty2.default)(this, "onEventVisibilityChange", ev => { if (this.unmounted) { return; } // ignore events for other rooms if (!this.hasTimelineSetFor(ev.getRoomId())) return; // we could skip an update if the event isn't in our timeline, // but that's probably an early optimisation. const tile = this.messagePanel.current?.getTileForEventId(ev.getId()); if (tile) { tile.forceUpdate(); } }); (0, _defineProperty2.default)(this, "onVisibilityPowerLevelChange", (ev, member) => { if (this.unmounted) return; // ignore events for other rooms if (!this.hasTimelineSetFor(member.roomId)) return; // ignore events for other users if (member.userId != _MatrixClientPeg.MatrixClientPeg.safeGet().credentials?.userId) return; // We could skip an update if the power level change didn't cross the // threshold for `VISIBILITY_CHANGE_TYPE`. for (const event of this.state.events) { const tile = this.messagePanel.current?.getTileForEventId(event.getId()); if (!tile) { // The event is not visible, nothing to re-render. continue; } tile.forceUpdate(); } this.forceUpdate(); }); (0, _defineProperty2.default)(this, "onEventReplaced", replacedEvent => { if (this.unmounted) return; // ignore events for other rooms if (!this.hasTimelineSetFor(replacedEvent.getRoomId())) 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 (!this.hasTimelineSetFor(room.roomId)) 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() !== _matrix.EventType.FullyRead) 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; if (!this.hasTimelineSetFor(ev.getRoomId())) return; if (!this.state.events.includes(ev)) 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. this.buildLegacyCallEventGroupers(this.state.events); this.forceUpdate(); }); (0, _defineProperty2.default)(this, "onSync", (clientSyncState, prevState, data) => { if (this.unmounted) return; this.setState({ clientSyncState }); }); (0, _defineProperty2.default)(this, "sendReadReceipts", async () => { 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 client = _MatrixClientPeg.MatrixClientPeg.get(); // if no client or client is guest don't send RR or RM if (!client || client.isGuest()) return; // "current" here means the receipts that have already been sent const currentReadReceiptEventId = this.getCurrentReadReceipt(true); const currentReadReceiptEventIndex = this.indexForEventId(currentReadReceiptEventId); // "last" here means the last displayed event const lastReadEventIndex = this.getLastDisplayedEventIndex({ ignoreOwn: true }); const lastReadEvent = this.state.events[lastReadEventIndex ?? this.state.events.length - 1] ?? null; const shouldSendReadReceipt = this.shouldSendReadReceipt(currentReadReceiptEventId, currentReadReceiptEventIndex, lastReadEvent, lastReadEventIndex); const fullyReadMarkerEventId = this.state.readMarkerEventId; const shouldSendFullyReadMarker = this.shouldSendFullyReadMarker(fullyReadMarkerEventId); const roomId = this.props.timelineSet.room?.roomId; debuglog(`Sending Read Markers for ${roomId}: `, { shouldSendReadReceipt, shouldSendFullyReadMarker, currentReadReceiptEventId, currentReadReceiptEventIndex, lastReadEventId: lastReadEvent?.getId(), lastReadEventIndex, readMarkerEventId: this.state.readMarkerEventId }); const proms = []; if (shouldSendReadReceipt) { proms.push(this.sendReadReceipt(client, lastReadEvent)); } if (shouldSendFullyReadMarker) { const readMarkerEvent = this.props.timelineSet.findEventById(fullyReadMarkerEventId); if (readMarkerEvent) { // Empty room Id should not happen here. // Either way fall back to empty string and let further functions handle it. proms.push(this.sendFullyReadMarker(client, roomId ?? "", fullyReadMarkerEventId)); } } await Promise.all(proms); }); // if the read marker is on the screen, we can now assume we've caught up to the end // of the screen, so move the marker down to the bottom of the screen. (0, _defineProperty2.default)(this, "updateReadMarker", async () => { 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 await this.sendReadReceipts(); }); /* jump down to the bottom of this room, where new events are arriving */ (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(_matrix.EventTimeline.FORWARDS)) { this.loadTimeline(); } else { this.messagePanel.current?.scrollToBottom(); } }); (0, _defineProperty2.default)(this, "scrollToEventIfNeeded", eventId => { this.messagePanel.current?.scrollToEventIfNeeded(eventId); }); /* scroll to show the read-up-to marker. We put it 1/3 of the way down * the container. */ (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); }); /** * update the read-up-to marker to match the read receipt */ (0, _defineProperty2.default)(this, "forgetReadMarker", async () => { if (!this.props.manageReadMarkers) return; // Find the read receipt - we will set the read marker to this const rmId = this.getCurrentReadReceipt(); // Look up the timestamp if we can find it 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(); } } // Update the read marker to the values we found this.setReadMarker(rmId, rmTs); // Send the receipts to the server immediately (don't wait for activity) await this.sendReadReceipts(); }); /* return true if the content is fully scrolled down and we are * at the end of the live timeline. */ (0, _defineProperty2.default)(this, "isAtEndOfLiveTimeline", () => { return this.messagePanel.current?.isAtBottom() && this.timelineWindow && !this.timelineWindow.canPaginate(_matrix.EventTimeline.FORWARDS); }); /* get the current scroll state. See ScrollPanel.getScrollState for * details. * * returns null if we are not mounted. */ (0, _defineProperty2.default)(this, "getScrollState", () => { if (!this.messagePanel.current) { return null; } return this.messagePanel.current.getScrollState(); }); // returns one of: // // null: there is no read marker // -1: read marker is above the window // 0: read marker is visible // +1: read marker is below the window (0, _defineProperty2.default)(this, "getReadMarkerPosition", () => { if (!this.props.manageReadMarkers) return null; if (!this.messagePanel.current) return null; if (!this.props.timelineSet.room) 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 === null || pos < 0); // 3., 4. return ret; }); /* * called by the parent component when PageUp/Down/etc is pressed. * * We pass it down to the scroll panel. */ (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. const action = (0, _KeyBindingsManager.getKeyBindingsManager)().getRoomAction(ev); if (action === _KeyboardShortcuts.KeyBindingAction.JumpToLatestMessage) { this.jumpToLiveTimeline(); } else { this.messagePanel.current.handleScrollKey(ev); } }); (0, _defineProperty2.default)(this, "getRelationsForEvent", (eventId, relationType, eventType) => this.props.timelineSet.relations?.getChildEventsForEvent(eventId, relationType, eventType)); debuglog("mounting"); // XXX: we could track RM per TimelineSet rather than per Room. // but for now we just do it per room for simplicity. if (this.props.manageReadMarkers) { const readmarker = this.props.timelineSet.room?.getAccountData("m.fully_read"); if (readmarker) { this.initialReadMarkerId = readmarker.getContent().event_id; } else { this.initialReadMarkerId = this.getCurrentReadReceipt(); } } this.state = { events: [], liveEvents: [], timelineLoading: true, canBackPaginate: false, canForwardPaginate: false, readMarkerVisible: true, readMarkerEventId: this.initialReadMarkerId, backPaginating: false, forwardPaginating: false, clientSyncState: _MatrixClientPeg.MatrixClientPeg.safeGet().getSyncState(), isTwelveHour: _SettingsStore.default.getValue("showTwelveHourTimestamps"), alwaysShowTimestamps: _SettingsStore.default.getValue("alwaysShowTimestamps"), readMarkerInViewThresholdMs: _SettingsStore.default.getValue("readMarkerInViewThresholdMs"), readMarkerOutOfViewThresholdMs: _SettingsStore.default.getValue("readMarkerOutOfViewThresholdMs") }; this.dispatcherRef = _dispatcher.default.register(this.onAction); const cli = _MatrixClientPeg.MatrixClientPeg.safeGet(); cli.on(_matrix.RoomEvent.Timeline, this.onRoomTimeline); cli.on(_matrix.RoomEvent.TimelineReset, this.onRoomTimelineReset); cli.on(_matrix.RoomEvent.Redaction, this.onRoomRedaction); if (_SettingsStore.default.getValue("feature_msc3531_hide_messages_pending_moderation")) { // Make sure that events are re-rendered when their visibility-pending-moderation changes. cli.on(_matrix.MatrixEventEvent.VisibilityChange, this.onEventVisibilityChange); cli.on(_matrix.RoomMemberEvent.PowerLevel, this.onVisibilityPowerLevelChange); } // same event handler as Room.redaction as for both we just do forceUpdate cli.on(_matrix.RoomEvent.RedactionCancelled, this.onRoomRedaction); cli.on(_matrix.RoomEvent.Receipt, this.onRoomReceipt); cli.on(_matrix.RoomEvent.LocalEchoUpdated, this.onLocalEchoUpdated); cli.on(_matrix.RoomEvent.AccountData, this.onAccountData); cli.on(_matrix.MatrixEventEvent.Decrypted, this.onEventDecrypted); cli.on(_matrix.MatrixEventEvent.Replaced, this.onEventReplaced); cli.on(_matrix.ClientEvent.Sync, this.onSync); this.props.timelineSet.room?.on(_matrix.ThreadEvent.Update, this.onThreadUpdate); } componentDidMount() { if (this.props.manageReadReceipts) { this.updateReadReceiptOnUserActivity(); } if (this.props.manageReadMarkers) { this.updateReadMarkerOnUserActivity(); } this.initTimeline(this.props); } componentDidUpdate(prevProps) { if (prevProps.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. _logger.logger.warn("Replacing timelineSet on a TimelinePanel - confusion may ensue"); } this.props.timelineSet.room?.off(_matrix.ThreadEvent.Update, this.onThreadUpdate); this.props.timelineSet.room?.on(_matrix.ThreadEvent.Update, this.onThreadUpdate); const differentEventId = prevProps.eventId != this.props.eventId; const differentHighlightedEventId = prevProps.highlightedEventId != this.props.highlightedEventId; const differentAvoidJump = prevProps.eventScrollIntoView && !this.props.eventScrollIntoView; const differentOverlayTimeline = prevProps.overlayTimelineSet !== this.props.overlayTimelineSet; if (differentEventId || differentHighlightedEventId || differentAvoidJump) { _logger.logger.log(`TimelinePanel switching to eventId ${this.props.eventId} (was ${prevProps.eventId}), ` + `scrollIntoView: ${this.props.eventScrollIntoView} (was ${prevProps.eventScrollIntoView})`); this.initTimeline(this.props); } else if (differentOverlayTimeline) { _logger.logger.log(`TimelinePanel updating overlay timeline.`); this.initTimeline(this.props); } } 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(_matrix.RoomEvent.Timeline, this.onRoomTimeline); client.removeListener(_matrix.RoomEvent.TimelineReset, this.onRoomTimelineReset); client.removeListener(_matrix.RoomEvent.Redaction, this.onRoomRedaction); client.removeListener(_matrix.RoomEvent.RedactionCancelled, this.onRoomRedaction); client.removeListener(_matrix.RoomEvent.Receipt, this.onRoomReceipt); client.removeListener(_matrix.RoomEvent.LocalEchoUpdated, this.onLocalEchoUpdated); client.removeListener(_matrix.RoomEvent.AccountData, this.onAccountData); client.removeListener(_matrix.RoomMemberEvent.PowerLevel, this.onVisibilityPowerLevelChange); client.removeListener(_matrix.MatrixEventEvent.Decrypted, this.onEventDecrypted); client.removeListener(_matrix.MatrixEventEvent.Replaced, this.onEventReplaced); client.removeListener(_matrix.MatrixEventEvent.VisibilityChange, this.onEventVisibilityChange); client.removeListener(_matrix.ClientEvent.Sync, this.onSync); this.props.timelineSet.room?.removeListener(_matrix.ThreadEvent.Update, this.onThreadUpdate); } } hasTimelineSetFor(roomId) { return roomId !== undefined && roomId === this.props.timelineSet.room?.roomId || roomId === this.props.overlayTimelineSet?.room?.roomId; } readMarkerTimeout(readMarkerPosition) { return readMarkerPosition === 0 ? this.context?.readMarkerInViewThresholdMs ?? this.state.readMarkerInViewThresholdMs : this.context?.readMarkerOutOfViewThresholdMs ?? 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 await 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 await this.sendReadReceipts(); } } /** * Whether to send public or private receipts. */ async determineReceiptType(client) { const roomId = this.props.timelineSet.room?.roomId ?? null; const shouldSendPublicReadReceipts = _SettingsStore.default.getValue("sendReadReceipts", roomId); if (shouldSendPublicReadReceipts) { return _matrix.ReceiptType.Read; } if (!(await client.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) && !(await client.isVersionSupported("v1.4"))) { _logger.logger.warn("Falling back to public instead of private receipts because the homeserver does not support them"); // The server does not support private read receipt. Fall back to public ones. return _matrix.ReceiptType.Read; } return _matrix.ReceiptType.ReadPrivate; } /** * Whether a fully_read marker should be send. */ shouldSendFullyReadMarker(fullyReadMarkerEventId) { if (!this.state.readMarkerEventId) { // Nothing that can be send. return false; } if (this.lastRMSentEventId && this.lastRMSentEventId === this.state.readMarkerEventId) { // Prevent sending the same receipt twice. return false; } if (this.state.readMarkerEventId && this.state.readMarkerEventId === this.initialReadMarkerId) { // The initial read marker is the one stored in the room account data. // It makes no sense to send a read marker for it, // because if it is in the room account data, a read marker must have been sent before. return false; } if (this.props.timelineSet.thread) { // Read marker for threads are not supported per spec. return false; } return true; } /** * Whether a read receipt should be send. */ shouldSendReadReceipt(currentReadReceiptEventId, currentReadReceiptEventIndex, lastReadEvent, lastReadEventIndex) { if (!lastReadEvent) return false; // 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 (currentReadReceiptEventId && currentReadReceiptEventIndex === null && this.timelineWindow?.canPaginate(_matrix.EventTimeline.FORWARDS)) { return false; } // Only send a RR if the last read event is ahead in the timeline relative to the current RR event. // Only send a RR if the last RR set != the one we would send return (lastReadEventIndex === null || currentReadReceiptEventIndex === null || lastReadEventIndex > currentReadReceiptEventIndex) && (!this.lastRRSentEventId || this.lastRRSentEventId !== lastReadEvent?.getId()); } /** * Sends a read receipt for event. * Resets the last sent event Id in case of an error, so that it will be retried next time. */ async sendReadReceipt(client, event) { this.lastRRSentEventId = event.getId(); const receiptType = await this.determineReceiptType(client); try { await client.sendReadReceipt(event, receiptType); } catch (err) { // it failed, so allow retries next time the user is active this.lastRRSentEventId = undefined; _logger.logger.error("Error sending receipt", { room: this.props.timelineSet.room?.roomId, error: err }); } } /** * Sends a fully_read marker for readMarkerEvent. * Resets the last sent event Id in case of an error, so that it will be retried next time. */ async sendFullyReadMarker(client, roomId, fullyReadMarkerEventId) { this.lastRMSentEventId = this.state.readMarkerEventId; try { await client.setRoomReadMarkers(roomId, fullyReadMarkerEventId); } catch (error) { // it failed, so allow retries next time the user is active this.lastRMSentEventId = undefined; _logger.logger.error("Error sending fully_read", { roomId, error }); } } // advance the read marker past any events we sent ourselves. advanceReadMarkerPastMyEvents() { if (!this.props.manageReadMarkers || !this.timelineWindow) 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.safeGet().credentials.userId; for (i++; i < events.length; i++) { const ev = events[i]; if (ev.getSender() !== 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()); } 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, props.eventScrollIntoView); } scrollIntoView(eventId, pixelOffset, offsetBase) { const doScroll = () => { if (!this.messagePanel.current) return; if (eventId) { debuglog("TimelinePanel scrolling to eventId " + eventId + " at position " + offsetBase * 100 + "% + " + pixelOffset); this.messagePanel.current.scrollToEvent(eventId, pixelOffset, offsetBase); } else { debuglog("TimelinePanel scrolling to bottom"); this.messagePanel.current.scrollToBottom(); } }; debuglog("TimelineP