Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Send correct receipts when viewing a room
Browse files Browse the repository at this point in the history
  • Loading branch information
weeman1337 committed May 11, 2023
1 parent f4f2cb1 commit e3e129f
Show file tree
Hide file tree
Showing 3 changed files with 335 additions and 153 deletions.
263 changes: 156 additions & 107 deletions src/components/structures/TimelinePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ limitations under the License.

import React, { createRef, ReactNode } from "react";
import ReactDOM from "react-dom";
import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
import { EventTimelineSet, IRoomTimelineData } from "matrix-js-sdk/src/models/event-timeline-set";
import { Direction, EventTimeline } from "matrix-js-sdk/src/models/event-timeline";
Expand All @@ -26,7 +26,7 @@ import { SyncState } from "matrix-js-sdk/src/sync";
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/models/room-member";
import { debounce, throttle } from "lodash";
import { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
import { Thread, ThreadEvent } from "matrix-js-sdk/src/models/thread";
import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts";
import { MatrixError } from "matrix-js-sdk/src/http-api";
Expand Down Expand Up @@ -256,6 +256,7 @@ class TimelinePanel extends React.Component<IProps, IState> {

// A map of <callId, LegacyCallEventGrouper>
private callEventGroupers = new Map<string, LegacyCallEventGrouper>();
private initialReadMarkerId: string | null = null;

public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
Expand All @@ -265,13 +266,12 @@ class TimelinePanel extends React.Component<IProps, IState> {

// XXX: we could track RM per TimelineSet rather than per Room.
// but for now we just do it per room for simplicity.
let initialReadMarker: string | null = null;
if (this.props.manageReadMarkers) {
const readmarker = this.props.timelineSet.room?.getAccountData("m.fully_read");
if (readmarker) {
initialReadMarker = readmarker.getContent().event_id;
this.initialReadMarkerId = readmarker.getContent().event_id;
} else {
initialReadMarker = this.getCurrentReadReceipt();
this.initialReadMarkerId = this.getCurrentReadReceipt();
}
}

Expand All @@ -283,7 +283,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
canBackPaginate: false,
canForwardPaginate: false,
readMarkerVisible: true,
readMarkerEventId: initialReadMarker,
readMarkerEventId: this.initialReadMarkerId,
backPaginating: false,
forwardPaginating: false,
clientSyncState: MatrixClientPeg.get().getSyncState(),
Expand Down Expand Up @@ -971,26 +971,64 @@ class TimelinePanel extends React.Component<IProps, IState> {
continue; /* aborted */
}
// outside of try/catch to not swallow errors
this.sendReadReceipt();
this.sendReadReceipts();
}
}

private sendReadReceipt = (): void => {
if (SettingsStore.getValue("lowBandwidth")) return;
/**
* Whether to send public or private receipts.
*/
private async determineReceiptType(client: MatrixClient): Promise<ReceiptType> {
const roomId = this.props.timelineSet.room.roomId;
const shouldSendPublicReadReceipts = SettingsStore.getValue("sendReadReceipts", roomId);

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.get();
// if no client or client is guest don't send RR or RM
if (!cli || cli.isGuest()) return;
if (shouldSendPublicReadReceipts) {
return ReceiptType.Read;
}

let shouldSendRR = true;
if (
!(await client.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) ||
!(await client.isVersionSupported("v1.4"))
) {
// The server does not support private read receipt. Fall back to public ones.
return ReceiptType.Read;
}

const currentRREventId = this.getCurrentReadReceipt(true);
const currentRREventIndex = this.indexForEventId(currentRREventId);
return ReceiptType.ReadPrivate;
}

/**
* Whether a fully_read marker should be send.
*/
private shouldSendRM(): boolean {
if (this.lastRRSentEventId && 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 a historical one.
// Prevent sending it again.
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.
*/
private shouldSendRR(
currentRREventId: string | null,
currentRREventIndex: number | null,
lastReadEvent: MatrixEvent | null,
lastReadEventIndex: number | null,
): boolean {
// We want to avoid sending out read receipts when we are looking at
// events in the past which are before the latest RR.
//
Expand All @@ -1003,107 +1041,117 @@ class TimelinePanel extends React.Component<IProps, IState> {
// 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.FORWARDS)
) {
shouldSendRR = false;
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 || currentRREventIndex === null || lastReadEventIndex > currentRREventIndex) &&
(!this.lastRRSentEventId || this.lastRRSentEventId !== lastReadEvent?.getId())
);
}

private sendReadReceipts = async (): Promise<void> => {
if (SettingsStore.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.get();
// if no client or client is guest don't send RR or RM
if (!client || client.isGuest()) return;

const currentRREventId = this.getCurrentReadReceipt(true);
const currentRREventIndex = this.indexForEventId(currentRREventId);
const lastReadEventIndex = this.getLastDisplayedEventIndex({
ignoreOwn: true,
});
if (lastReadEventIndex === null) {
shouldSendRR = false;
}
let lastReadEvent: MatrixEvent | null = this.state.events[lastReadEventIndex ?? 0];
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;
const lastReadEvent: MatrixEvent | null = this.state.events[lastReadEventIndex ?? 0];

const roomId = this.props.timelineSet.room.roomId;
const sendRRs = SettingsStore.getValue("sendReadReceipts", roomId);
const shouldSendRR = this.shouldSendRR(
currentRREventId,
currentRREventIndex,
lastReadEvent,
lastReadEventIndex,
);
const shouldSendRM = this.shouldSendRM();

debuglog(`Sending Read Markers for ${this.props.timelineSet.room.roomId}: `, {
shouldSendRR,
shouldSendRM,
currentRREventId,
currentRREventIndex,
lastReadEventId: lastReadEvent?.getId(),
lastReadEventIndex,
readMarkerEventId: this.state.readMarkerEventId,
});

debuglog(
`Sending Read Markers for ${roomId}: `,
`rm=${this.state.readMarkerEventId} `,
`rr=${sendRRs ? lastReadEvent?.getId() : null} `,
`prr=${lastReadEvent?.getId()}`,
);
const proms = [];

if (this.props.timelineSet.thread && sendRRs && lastReadEvent) {
// There's no support for fully read markers on threads
// as defined by MSC3771
cli.sendReadReceipt(lastReadEvent, sendRRs ? ReceiptType.Read : ReceiptType.ReadPrivate);
} else {
cli.setRoomReadMarkers(
roomId,
this.state.readMarkerEventId ?? "",
sendRRs ? lastReadEvent ?? undefined : undefined, // Public read receipt (could be null)
lastReadEvent ?? undefined, // Private read receipt (could be null)
).catch(async (e): Promise<void> => {
// /read_markers API is not implemented on this HS, fallback to just RR
if (e.errcode === "M_UNRECOGNIZED" && lastReadEvent) {
if (
!sendRRs &&
!(await cli.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) &&
!(await cli.isVersionSupported("v1.4"))
)
return;
try {
await cli.sendReadReceipt(
lastReadEvent,
sendRRs ? ReceiptType.Read : ReceiptType.ReadPrivate,
);
return;
} catch (error) {
logger.error(e);
this.lastRRSentEventId = undefined;
}
} else {
logger.error(e);
}
// it failed, so allow retries next time the user is active
this.lastRRSentEventId = undefined;
this.lastRMSentEventId = undefined;
});
if (shouldSendRR && lastReadEvent) {
proms.push(this.sendReadReceipt(client, lastReadEvent));
}

// 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) {
this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Total, 0);
this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Highlight, 0);
dis.dispatch({
action: "on_room_read",
roomId: this.props.timelineSet.room.roomId,
});
}
if (shouldSendRM) {
const readMarkerEvent = this.props.timelineSet.findEventById(this.state.readMarkerEventId);

if (readMarkerEvent) {
proms.push(await this.sendReadMarker(client, readMarkerEvent));
}
}

await Promise.all(proms);
};

/**
* 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.
*/
private async sendReadReceipt(client: MatrixClient, event: MatrixEvent): Promise<void> {
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.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.
*/
private async sendReadMarker(client: MatrixClient, readMarkerEvent: MatrixEvent): Promise<void> {
this.lastRMSentEventId = this.state.readMarkerEventId;

try {
await client.sendReadReceipt(readMarkerEvent, ReceiptType.FullyRead, true);
} catch (err) {
// it failed, so allow retries next time the user is active
this.lastRMSentEventId = undefined;

logger.error("Error sending fully_read", {
room: this.props.timelineSet.room.roomId,
error: err,
});
}
}

// 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.
private updateReadMarker = (): void => {
Expand Down Expand Up @@ -1135,7 +1183,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
}

// Send the updated read marker (along with read receipt) to the server
this.sendReadReceipt();
this.sendReadReceipts();
};

// advance the read marker past any events we sent ourselves.
Expand Down Expand Up @@ -1220,7 +1268,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.loadTimeline(this.state.readMarkerEventId, 0, 1 / 3);
};

/* update the read-up-to marker to match the read receipt
/**
* update the read-up-to marker to match the read receipt
*/
public forgetReadMarker = (): void => {
if (!this.props.manageReadMarkers) return;
Expand All @@ -1244,7 +1293,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.setReadMarker(rmId, rmTs);

// Send the receipts to the server immediately (don't wait for activity)
this.sendReadReceipt();
this.sendReadReceipts();
};

/* return true if the content is fully scrolled down and we are
Expand Down Expand Up @@ -1437,7 +1486,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
}

if (this.props.sendReadReceiptOnLoad) {
this.sendReadReceipt();
this.sendReadReceipts();
}
},
);
Expand Down
Loading

0 comments on commit e3e129f

Please sign in to comment.