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