Ensure consistency when rendering the sent event indicator (#11314)

* Ensure consistency when considering where to render the sent event indicator

* Add test

* Fix redacted edge case

* Comments
This commit is contained in:
Michael Telatynski 2023-07-25 12:50:20 +01:00 committed by GitHub
parent 5fbdbccdc6
commit b5cbd9eeca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 146 additions and 107 deletions

View file

@ -34,7 +34,12 @@ import SettingsStore from "../../settings/SettingsStore";
import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext";
import { Layout } from "../../settings/enums/Layout"; import { Layout } from "../../settings/enums/Layout";
import { _t } from "../../languageHandler"; import { _t } from "../../languageHandler";
import EventTile, { GetRelationsForEvent, IReadReceiptProps, UnwrappedEventTile } from "../views/rooms/EventTile"; import EventTile, {
GetRelationsForEvent,
IReadReceiptProps,
isEligibleForSpecialReceipt,
UnwrappedEventTile,
} from "../views/rooms/EventTile";
import { hasText } from "../../TextForEvent"; import { hasText } from "../../TextForEvent";
import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer"; import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer";
import DMRoomMap from "../../utils/DMRoomMap"; import DMRoomMap from "../../utils/DMRoomMap";
@ -583,9 +588,9 @@ export default class MessagePanel extends React.Component<IProps, IState> {
* the tile. * the tile.
*/ */
private getNextEventInfo( private getNextEventInfo(
events: EventAndShouldShow[], events: WrappedEvent[],
i: number, i: number,
): { nextEventAndShouldShow: EventAndShouldShow | null; nextTile: MatrixEvent | null } { ): { nextEventAndShouldShow: WrappedEvent | null; nextTile: MatrixEvent | null } {
// WARNING: this method is on a hot path. // WARNING: this method is on a hot path.
const nextEventAndShouldShow = i < events.length - 1 ? events[i + 1] : null; const nextEventAndShouldShow = i < events.length - 1 ? events[i + 1] : null;
@ -608,6 +613,12 @@ export default class MessagePanel extends React.Component<IProps, IState> {
} }
} }
private isSentState(ev: MatrixEvent): boolean {
const status = ev.getAssociatedStatus();
// A falsey state applies to events which have come down sync, including remote echoes
return !status || status === EventStatus.SENT;
}
private getEventTiles(): ReactNode[] { private getEventTiles(): ReactNode[] {
// first figure out which is the last event in the list which we're // first figure out which is the last event in the list which we're
// actually going to show; this allows us to behave slightly // actually going to show; this allows us to behave slightly
@ -616,11 +627,15 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// we also need to figure out which is the last event we show which isn't // we also need to figure out which is the last event we show which isn't
// a local echo, to manage the read-marker. // a local echo, to manage the read-marker.
let lastShownEvent: MatrixEvent | undefined; let lastShownEvent: MatrixEvent | undefined;
const events: EventAndShouldShow[] = this.props.events.map((event) => { const events: WrappedEvent[] = this.props.events.map((event) => {
return { event, shouldShow: this.shouldShowEvent(event) }; return { event, shouldShow: this.shouldShowEvent(event) };
}); });
const userId = MatrixClientPeg.safeGet().getSafeUserId();
let foundLastSuccessfulWeSent = false;
let lastShownNonLocalEchoIndex = -1; let lastShownNonLocalEchoIndex = -1;
// Find the indices of the last successful event we sent and the last non-local-echo events shown
for (let i = events.length - 1; i >= 0; i--) { for (let i = events.length - 1; i >= 0; i--) {
const { event, shouldShow } = events[i]; const { event, shouldShow } = events[i];
if (!shouldShow) { if (!shouldShow) {
@ -631,14 +646,19 @@ export default class MessagePanel extends React.Component<IProps, IState> {
lastShownEvent = event; lastShownEvent = event;
} }
if (event.status) { if (!foundLastSuccessfulWeSent && this.isSentState(event) && isEligibleForSpecialReceipt(event, userId)) {
// this is a local echo events[i].lastSuccessfulWeSent = true;
continue; foundLastSuccessfulWeSent = true;
} }
if (lastShownNonLocalEchoIndex < 0 && !event.status) {
lastShownNonLocalEchoIndex = i; lastShownNonLocalEchoIndex = i;
}
if (lastShownNonLocalEchoIndex >= 0 && foundLastSuccessfulWeSent) {
break; break;
} }
}
const ret: ReactNode[] = []; const ret: ReactNode[] = [];
let prevEvent: MatrixEvent | null = null; // the last event we showed let prevEvent: MatrixEvent | null = null; // the last event we showed
@ -654,15 +674,15 @@ export default class MessagePanel extends React.Component<IProps, IState> {
let grouper: BaseGrouper | null = null; let grouper: BaseGrouper | null = null;
for (let i = 0; i < events.length; i++) { for (let i = 0; i < events.length; i++) {
const eventAndShouldShow = events[i]; const wrappedEvent = events[i];
const { event, shouldShow } = eventAndShouldShow; const { event, shouldShow } = wrappedEvent;
const eventId = event.getId()!; const eventId = event.getId()!;
const last = event === lastShownEvent; const last = event === lastShownEvent;
const { nextEventAndShouldShow, nextTile } = this.getNextEventInfo(events, i); const { nextEventAndShouldShow, nextTile } = this.getNextEventInfo(events, i);
if (grouper) { if (grouper) {
if (grouper.shouldGroup(eventAndShouldShow)) { if (grouper.shouldGroup(wrappedEvent)) {
grouper.add(eventAndShouldShow); grouper.add(wrappedEvent);
continue; continue;
} else { } else {
// not part of group, so get the group tiles, close the // not part of group, so get the group tiles, close the
@ -674,10 +694,10 @@ export default class MessagePanel extends React.Component<IProps, IState> {
} }
for (const Grouper of groupers) { for (const Grouper of groupers) {
if (Grouper.canStartGroup(this, eventAndShouldShow) && !this.props.disableGrouping) { if (Grouper.canStartGroup(this, wrappedEvent) && !this.props.disableGrouping) {
grouper = new Grouper( grouper = new Grouper(
this, this,
eventAndShouldShow, wrappedEvent,
prevEvent, prevEvent,
lastShownEvent, lastShownEvent,
nextEventAndShouldShow, nextEventAndShouldShow,
@ -692,7 +712,16 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// make sure we unpack the array returned by getTilesForEvent, // make sure we unpack the array returned by getTilesForEvent,
// otherwise React will auto-generate keys, and we will end up // otherwise React will auto-generate keys, and we will end up
// replacing all the DOM elements every time we paginate. // replacing all the DOM elements every time we paginate.
ret.push(...this.getTilesForEvent(prevEvent, event, last, false, nextEventAndShouldShow, nextTile)); ret.push(
...this.getTilesForEvent(
prevEvent,
wrappedEvent,
last,
false,
nextEventAndShouldShow,
nextTile,
),
);
prevEvent = event; prevEvent = event;
} }
@ -710,12 +739,13 @@ export default class MessagePanel extends React.Component<IProps, IState> {
public getTilesForEvent( public getTilesForEvent(
prevEvent: MatrixEvent | null, prevEvent: MatrixEvent | null,
mxEv: MatrixEvent, wrappedEvent: WrappedEvent,
last = false, last = false,
isGrouped = false, isGrouped = false,
nextEvent: EventAndShouldShow | null = null, nextEvent: WrappedEvent | null = null,
nextEventWithTile: MatrixEvent | null = null, nextEventWithTile: MatrixEvent | null = null,
): ReactNode[] { ): ReactNode[] {
const mxEv = wrappedEvent.event;
const ret: ReactNode[] = []; const ret: ReactNode[] = [];
const isEditing = this.props.editState?.getEvent().getId() === mxEv.getId(); const isEditing = this.props.editState?.getEvent().getId() === mxEv.getId();
@ -760,30 +790,6 @@ export default class MessagePanel extends React.Component<IProps, IState> {
const readReceipts = this.readReceiptsByEvent.get(eventId); const readReceipts = this.readReceiptsByEvent.get(eventId);
let isLastSuccessful = false;
const isSentState = (s: EventStatus | null): boolean => !s || s === EventStatus.SENT;
const isSent = isSentState(mxEv.getAssociatedStatus());
const hasNextEvent = nextEvent?.shouldShow;
if (!hasNextEvent && isSent) {
isLastSuccessful = true;
} else if (hasNextEvent && isSent && !isSentState(nextEvent.event.getAssociatedStatus())) {
isLastSuccessful = true;
}
// This is a bit nuanced, but if our next event is hidden but a future event is not
// hidden then we're not the last successful.
if (
nextEventWithTile &&
nextEventWithTile !== nextEvent?.event &&
isSentState(nextEventWithTile.getAssociatedStatus())
) {
isLastSuccessful = false;
}
// We only want to consider "last successful" if the event is sent by us, otherwise of course
// it's successful: we received it.
isLastSuccessful = isLastSuccessful && mxEv.getSender() === MatrixClientPeg.safeGet().getUserId();
const callEventGrouper = this.props.callEventGroupers.get(mxEv.getContent().call_id); const callEventGrouper = this.props.callEventGroupers.get(mxEv.getContent().call_id);
// use txnId as key if available so that we don't remount during sending // use txnId as key if available so that we don't remount during sending
ret.push( ret.push(
@ -807,7 +813,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
last={last} last={last}
lastInSection={lastInSection} lastInSection={lastInSection}
lastSuccessful={isLastSuccessful} lastSuccessful={wrappedEvent.lastSuccessfulWeSent}
isSelectedEvent={highlight} isSelectedEvent={highlight}
getRelationsForEvent={this.props.getRelationsForEvent} getRelationsForEvent={this.props.getRelationsForEvent}
showReactions={this.props.showReactions} showReactions={this.props.showReactions}
@ -875,7 +881,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// Get an object that maps from event ID to a list of read receipts that // Get an object that maps from event ID to a list of read receipts that
// should be shown next to that event. If a hidden event has read receipts, // should be shown next to that event. If a hidden event has read receipts,
// they are folded into the receipts of the last shown event. // they are folded into the receipts of the last shown event.
private getReadReceiptsByShownEvent(events: EventAndShouldShow[]): Map<string, IReadReceiptProps[]> { private getReadReceiptsByShownEvent(events: WrappedEvent[]): Map<string, IReadReceiptProps[]> {
const receiptsByEvent: Map<string, IReadReceiptProps[]> = new Map(); const receiptsByEvent: Map<string, IReadReceiptProps[]> = new Map();
const receiptsByUserId: Map<string, IReadReceiptForUser> = new Map(); const receiptsByUserId: Map<string, IReadReceiptForUser> = new Map();
@ -1061,28 +1067,31 @@ export default class MessagePanel extends React.Component<IProps, IState> {
} }
/** /**
* Holds on to an event, caching the information about whether it should be * Holds on to an event, caching the information about it in the context of the current messages list.
* shown. Avoids calling shouldShowEvent more times than we need to. * Avoids calling shouldShowEvent more times than we need to.
* Simplifies threading of event context like whether it's the last successful event we sent which cannot be determined
* by a consumer from the event alone, so has to be done by the event list processing code earlier.
*/ */
interface EventAndShouldShow { interface WrappedEvent {
event: MatrixEvent; event: MatrixEvent;
shouldShow: boolean; shouldShow?: boolean;
lastSuccessfulWeSent?: boolean;
} }
abstract class BaseGrouper { abstract class BaseGrouper {
public static canStartGroup = (_panel: MessagePanel, _ev: EventAndShouldShow): boolean => true; public static canStartGroup = (_panel: MessagePanel, _ev: WrappedEvent): boolean => true;
public events: MatrixEvent[] = []; public events: WrappedEvent[] = [];
// events that we include in the group but then eject out and place above the group. // events that we include in the group but then eject out and place above the group.
public ejectedEvents: MatrixEvent[] = []; public ejectedEvents: WrappedEvent[] = [];
public readMarker: ReactNode; public readMarker: ReactNode;
public constructor( public constructor(
public readonly panel: MessagePanel, public readonly panel: MessagePanel,
public readonly firstEventAndShouldShow: EventAndShouldShow, public readonly firstEventAndShouldShow: WrappedEvent,
public readonly prevEvent: MatrixEvent | null, public readonly prevEvent: MatrixEvent | null,
public readonly lastShownEvent: MatrixEvent | undefined, public readonly lastShownEvent: MatrixEvent | undefined,
public readonly nextEvent: EventAndShouldShow | null, public readonly nextEvent: WrappedEvent | null,
public readonly nextEventTile?: MatrixEvent | null, public readonly nextEventTile?: MatrixEvent | null,
) { ) {
this.readMarker = panel.readMarkerForEvent( this.readMarker = panel.readMarkerForEvent(
@ -1091,8 +1100,8 @@ abstract class BaseGrouper {
); );
} }
public abstract shouldGroup(ev: EventAndShouldShow): boolean; public abstract shouldGroup(ev: WrappedEvent): boolean;
public abstract add(ev: EventAndShouldShow): void; public abstract add(ev: WrappedEvent): void;
public abstract getTiles(): ReactNode[]; public abstract getTiles(): ReactNode[];
public abstract getNewPrevEvent(): MatrixEvent; public abstract getNewPrevEvent(): MatrixEvent;
} }
@ -1113,11 +1122,11 @@ abstract class BaseGrouper {
// Grouping only events sent by the same user that sent the `m.room.create` and only until // Grouping only events sent by the same user that sent the `m.room.create` and only until
// the first non-state event, beacon_info event or membership event which is not regarding the sender of the `m.room.create` event // the first non-state event, beacon_info event or membership event which is not regarding the sender of the `m.room.create` event
class CreationGrouper extends BaseGrouper { class CreationGrouper extends BaseGrouper {
public static canStartGroup = function (_panel: MessagePanel, { event }: EventAndShouldShow): boolean { public static canStartGroup = function (_panel: MessagePanel, { event }: WrappedEvent): boolean {
return event.getType() === EventType.RoomCreate; return event.getType() === EventType.RoomCreate;
}; };
public shouldGroup({ event, shouldShow }: EventAndShouldShow): boolean { public shouldGroup({ event, shouldShow }: WrappedEvent): boolean {
const panel = this.panel; const panel = this.panel;
const createEvent = this.firstEventAndShouldShow.event; const createEvent = this.firstEventAndShouldShow.event;
if (!shouldShow) { if (!shouldShow) {
@ -1152,16 +1161,17 @@ class CreationGrouper extends BaseGrouper {
return false; return false;
} }
public add({ event: ev, shouldShow }: EventAndShouldShow): void { public add(wrappedEvent: WrappedEvent): void {
const { event: ev, shouldShow } = wrappedEvent;
const panel = this.panel; const panel = this.panel;
this.readMarker = this.readMarker || panel.readMarkerForEvent(ev.getId()!, ev === this.lastShownEvent); this.readMarker = this.readMarker || panel.readMarkerForEvent(ev.getId()!, ev === this.lastShownEvent);
if (!shouldShow) { if (!shouldShow) {
return; return;
} }
if (ev.getType() === EventType.RoomEncryption) { if (ev.getType() === EventType.RoomEncryption) {
this.ejectedEvents.push(ev); this.ejectedEvents.push(wrappedEvent);
} else { } else {
this.events.push(ev); this.events.push(wrappedEvent);
} }
} }
@ -1189,7 +1199,7 @@ class CreationGrouper extends BaseGrouper {
// If this m.room.create event should be shown (room upgrade) then show it before the summary // If this m.room.create event should be shown (room upgrade) then show it before the summary
if (createEvent.shouldShow) { if (createEvent.shouldShow) {
// pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered // pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered
ret.push(...panel.getTilesForEvent(createEvent.event, createEvent.event)); ret.push(...panel.getTilesForEvent(createEvent.event, createEvent));
} }
for (const ejected of this.ejectedEvents) { for (const ejected of this.ejectedEvents) {
@ -1204,11 +1214,11 @@ class CreationGrouper extends BaseGrouper {
// of GenericEventListSummary, render each member event as if the previous // of GenericEventListSummary, render each member event as if the previous
// one was itself. This way, the timestamp of the previous event === the // one was itself. This way, the timestamp of the previous event === the
// timestamp of the current event, and no DateSeparator is inserted. // timestamp of the current event, and no DateSeparator is inserted.
return panel.getTilesForEvent(e, e, e === lastShownEvent, isGrouped); return panel.getTilesForEvent(e.event, e, e.event === lastShownEvent, isGrouped);
}) })
.reduce((a, b) => a.concat(b), []); .reduce((a, b) => a.concat(b), []);
// Get sender profile from the latest event in the summary as the m.room.create doesn't contain one // Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
const ev = this.events[this.events.length - 1]; const ev = this.events[this.events.length - 1].event;
let summaryText: string; let summaryText: string;
const roomId = ev.getRoomId(); const roomId = ev.getRoomId();
@ -1224,7 +1234,7 @@ class CreationGrouper extends BaseGrouper {
ret.push( ret.push(
<GenericEventListSummary <GenericEventListSummary
key="roomcreationsummary" key="roomcreationsummary"
events={this.events} events={this.events.map((e) => e.event)}
onToggle={panel.onHeightChanged} // Update scroll state onToggle={panel.onHeightChanged} // Update scroll state
summaryMembers={ev.sender ? [ev.sender] : undefined} summaryMembers={ev.sender ? [ev.sender] : undefined}
summaryText={summaryText} summaryText={summaryText}
@ -1248,10 +1258,7 @@ class CreationGrouper extends BaseGrouper {
// Wrap consecutive grouped events in a ListSummary // Wrap consecutive grouped events in a ListSummary
class MainGrouper extends BaseGrouper { class MainGrouper extends BaseGrouper {
public static canStartGroup = function ( public static canStartGroup = function (panel: MessagePanel, { event: ev, shouldShow }: WrappedEvent): boolean {
panel: MessagePanel,
{ event: ev, shouldShow }: EventAndShouldShow,
): boolean {
if (!shouldShow) return false; if (!shouldShow) return false;
if (ev.isState() && groupedStateEvents.includes(ev.getType() as EventType)) { if (ev.isState() && groupedStateEvents.includes(ev.getType() as EventType)) {
@ -1271,22 +1278,22 @@ class MainGrouper extends BaseGrouper {
public constructor( public constructor(
public readonly panel: MessagePanel, public readonly panel: MessagePanel,
public readonly firstEventAndShouldShow: EventAndShouldShow, public readonly firstEventAndShouldShow: WrappedEvent,
public readonly prevEvent: MatrixEvent | null, public readonly prevEvent: MatrixEvent | null,
public readonly lastShownEvent: MatrixEvent | undefined, public readonly lastShownEvent: MatrixEvent | undefined,
nextEvent: EventAndShouldShow | null, nextEvent: WrappedEvent | null,
nextEventTile: MatrixEvent | null, nextEventTile: MatrixEvent | null,
) { ) {
super(panel, firstEventAndShouldShow, prevEvent, lastShownEvent, nextEvent, nextEventTile); super(panel, firstEventAndShouldShow, prevEvent, lastShownEvent, nextEvent, nextEventTile);
this.events = [firstEventAndShouldShow.event]; this.events = [firstEventAndShouldShow];
} }
public shouldGroup({ event: ev, shouldShow }: EventAndShouldShow): boolean { public shouldGroup({ event: ev, shouldShow }: WrappedEvent): boolean {
if (!shouldShow) { if (!shouldShow) {
// absorb hidden events so that they do not break up streams of messages & redaction events being grouped // absorb hidden events so that they do not break up streams of messages & redaction events being grouped
return true; return true;
} }
if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) { if (this.panel.wantsDateSeparator(this.events[0].event, ev.getDate())) {
return false; return false;
} }
if (ev.isState() && groupedStateEvents.includes(ev.getType() as EventType)) { if (ev.isState() && groupedStateEvents.includes(ev.getType() as EventType)) {
@ -1301,7 +1308,8 @@ class MainGrouper extends BaseGrouper {
return false; return false;
} }
public add({ event: ev, shouldShow }: EventAndShouldShow): void { public add(wrappedEvent: WrappedEvent): void {
const { event: ev, shouldShow } = wrappedEvent;
if (ev.getType() === EventType.RoomMember) { if (ev.getType() === EventType.RoomMember) {
// We can ignore any events that don't actually have a message to display // We can ignore any events that don't actually have a message to display
if (!hasText(ev, MatrixClientPeg.safeGet(), this.panel.showHiddenEvents)) return; if (!hasText(ev, MatrixClientPeg.safeGet(), this.panel.showHiddenEvents)) return;
@ -1311,11 +1319,11 @@ class MainGrouper extends BaseGrouper {
// absorb hidden events to not split the summary // absorb hidden events to not split the summary
return; return;
} }
this.events.push(ev); this.events.push(wrappedEvent);
} }
private generateKey(): string { private generateKey(): string {
return "eventlistsummary-" + this.events[0].getId(); return "eventlistsummary-" + this.events[0].event.getId();
} }
public getTiles(): ReactNode[] { public getTiles(): ReactNode[] {
@ -1329,11 +1337,11 @@ class MainGrouper extends BaseGrouper {
const lastShownEvent = this.lastShownEvent; const lastShownEvent = this.lastShownEvent;
const ret: ReactNode[] = []; const ret: ReactNode[] = [];
if (panel.wantsDateSeparator(this.prevEvent, this.events[0].getDate())) { if (panel.wantsDateSeparator(this.prevEvent, this.events[0].event.getDate())) {
const ts = this.events[0].getTs(); const ts = this.events[0].event.getTs();
ret.push( ret.push(
<li key={ts + "~"}> <li key={ts + "~"}>
<DateSeparator roomId={this.events[0].getRoomId()!} ts={ts} /> <DateSeparator roomId={this.events[0].event.getRoomId()!} ts={ts} />
</li>, </li>,
); );
} }
@ -1341,29 +1349,29 @@ class MainGrouper extends BaseGrouper {
// Ensure that the key of the EventListSummary does not change with new events in either direction. // Ensure that the key of the EventListSummary does not change with new events in either direction.
// This will prevent it from being re-created unnecessarily, and instead will allow new props to be provided. // This will prevent it from being re-created unnecessarily, and instead will allow new props to be provided.
// In turn, the shouldComponentUpdate method on ELS can be used to prevent unnecessary renderings. // In turn, the shouldComponentUpdate method on ELS can be used to prevent unnecessary renderings.
const keyEvent = this.events.find((e) => this.panel.grouperKeyMap.get(e)); const keyEvent = this.events.find((e) => this.panel.grouperKeyMap.get(e.event));
const key = const key =
keyEvent && this.panel.grouperKeyMap.has(keyEvent) keyEvent && this.panel.grouperKeyMap.has(keyEvent.event)
? this.panel.grouperKeyMap.get(keyEvent)! ? this.panel.grouperKeyMap.get(keyEvent.event)!
: this.generateKey(); : this.generateKey();
if (!keyEvent) { if (!keyEvent) {
// Populate the weak map with the key. // Populate the weak map with the key.
// Note that we only set the key on the specific event it refers to, since this group might get // Note that we only set the key on the specific event it refers to, since this group might get
// split up in the future by other intervening events. If we were to set the key on all events // split up in the future by other intervening events. If we were to set the key on all events
// currently in the group, we would risk later giving the same key to multiple groups. // currently in the group, we would risk later giving the same key to multiple groups.
this.panel.grouperKeyMap.set(this.events[0], key); this.panel.grouperKeyMap.set(this.events[0].event, key);
} }
let highlightInSummary = false; let highlightInSummary = false;
let eventTiles: ReactNode[] | null = this.events let eventTiles: ReactNode[] | null = this.events
.map((e, i) => { .map((e, i) => {
if (e.getId() === panel.props.highlightedEventId) { if (e.event.getId() === panel.props.highlightedEventId) {
highlightInSummary = true; highlightInSummary = true;
} }
return panel.getTilesForEvent( return panel.getTilesForEvent(
i === 0 ? this.prevEvent : this.events[i - 1], i === 0 ? this.prevEvent : this.events[i - 1].event,
e, e,
e === lastShownEvent, e.event === lastShownEvent,
isGrouped, isGrouped,
this.nextEvent, this.nextEvent,
this.nextEventTile, this.nextEventTile,
@ -1385,7 +1393,7 @@ class MainGrouper extends BaseGrouper {
<EventListSummary <EventListSummary
key={key} key={key}
data-testid={key} data-testid={key}
events={this.events} events={this.events.map((e) => e.event)}
onToggle={panel.onHeightChanged} // Update scroll state onToggle={panel.onHeightChanged} // Update scroll state
startExpanded={highlightInSummary} startExpanded={highlightInSummary}
layout={this.panel.props.layout} layout={this.panel.props.layout}
@ -1402,7 +1410,7 @@ class MainGrouper extends BaseGrouper {
} }
public getNewPrevEvent(): MatrixEvent { public getNewPrevEvent(): MatrixEvent {
return this.events[this.events.length - 1]; return this.events[this.events.length - 1].event;
} }
} }
@ -1410,10 +1418,10 @@ class MainGrouper extends BaseGrouper {
const groupers = [CreationGrouper, MainGrouper]; const groupers = [CreationGrouper, MainGrouper];
/** /**
* Look through the supplied list of EventAndShouldShow, and return the first * Look through the supplied list of WrappedEvent, and return the first
* event that is >start items through the list, and is shown. * event that is >start items through the list, and is shown.
*/ */
function findFirstShownAfter(start: number, events: EventAndShouldShow[]): MatrixEvent | null { function findFirstShownAfter(start: number, events: WrappedEvent[]): MatrixEvent | null {
// Note: this could be done with something like: // Note: this could be done with something like:
// events.slice(i + 1).find((e) => e.shouldShow)?.event ?? null; // events.slice(i + 1).find((e) => e.shouldShow)?.event ?? null;
// but it is ~10% slower, and this is on the critical path. // but it is ~10% slower, and this is on the critical path.

View file

@ -246,6 +246,24 @@ interface IState {
threadNotification?: NotificationCountType; threadNotification?: NotificationCountType;
} }
/**
* When true, the tile qualifies for some sort of special read receipt.
* This could be a 'sending' or 'sent' receipt, for example.
* @returns {boolean}
*/
export function isEligibleForSpecialReceipt(event: MatrixEvent, myUserId: string): boolean {
// Check to see if the event was sent by us. If it wasn't, it won't qualify for special read receipts.
if (event.getSender() !== myUserId) return false;
// Determine if the type is relevant to the user.
// This notably excludes state events and pretty much anything that can't be sent by the composer as a message.
// For those we rely on local echo giving the impression of things changing, and expect them to be quick.
if (!isMessageEvent(event) && event.getType() !== EventType.RoomMessageEncrypted) return false;
// Default case
return true;
}
// MUST be rendered within a RoomContext with a set timelineRenderingType // MUST be rendered within a RoomContext with a set timelineRenderingType
export class UnwrappedEventTile extends React.Component<EventTileProps, IState> { export class UnwrappedEventTile extends React.Component<EventTileProps, IState> {
private suppressReadReceiptAnimation: boolean; private suppressReadReceiptAnimation: boolean;
@ -313,23 +331,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
// Quickly check to see if the event was sent by us. If it wasn't, it won't qualify for // Quickly check to see if the event was sent by us. If it wasn't, it won't qualify for
// special read receipts. // special read receipts.
const myUserId = MatrixClientPeg.safeGet().getUserId(); const myUserId = MatrixClientPeg.safeGet().getSafeUserId();
if (this.props.mxEvent.getSender() !== myUserId) return false; return isEligibleForSpecialReceipt(this.props.mxEvent, myUserId);
// Finally, determine if the type is relevant to the user. This notably excludes state
// events and pretty much anything that can't be sent by the composer as a message. For
// those we rely on local echo giving the impression of things changing, and expect them
// to be quick.
const simpleSendableEvents = [
EventType.Sticker,
EventType.RoomMessage,
EventType.RoomMessageEncrypted,
EventType.PollStart,
];
if (!simpleSendableEvents.includes(this.props.mxEvent.getType() as EventType)) return false;
// Default case
return true;
} }
private get shouldShowSentReceipt(): boolean { private get shouldShowSentReceipt(): boolean {

View file

@ -735,6 +735,34 @@ describe("MessagePanel", function () {
expect(cpt).toMatchSnapshot(); expect(cpt).toMatchSnapshot();
}); });
it("should set lastSuccessful=true on non-last event if last event is not eligible for special receipt", () => {
client.getRoom.mockImplementation((id) => (id === room.roomId ? room : null));
const events = [
TestUtilsMatrix.mkMessage({
event: true,
room: room.roomId,
user: client.getSafeUserId(),
ts: 1000,
}),
TestUtilsMatrix.mkEvent({
event: true,
room: room.roomId,
user: client.getSafeUserId(),
ts: 1000,
type: "m.room.topic",
skey: "",
content: { topic: "TOPIC" },
}),
];
const { container } = render(getComponent({ events, showReadReceipts: true }));
// just check we have the right number of tiles for now
const tiles = container.getElementsByClassName("mx_EventTile");
expect(tiles.length).toEqual(2);
expect(tiles[0].querySelector(".mx_EventTile_receiptSent")).toBeTruthy();
expect(tiles[1].querySelector(".mx_EventTile_receiptSent")).toBeFalsy();
});
}); });
describe("shouldFormContinuation", () => { describe("shouldFormContinuation", () => {