matrix-react-sdk
Version:
SDK for matrix.org using React
1,097 lines (1,038 loc) • 266 kB
JavaScript
"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