Add option to enable read receipt and marker when user interact with UI (#31353)

* feat(room view): add `enableReadReceiptsAndMarkersOnActivity` props

For the multiroom module, we display several room views at the same
time. In order to avoid all the rooms to send read receipts and markers
automatically when we are interacting with the UI, we add
`enableReadReceiptsAndMarkersOnActivity`props.

When at false, the timeline doesn't listen to user activity to send
these receipts. Only when the room is focused, marker and read receipts
are updated.

* test(room view): add test for `enableReadReceiptsAndMarkersOnActivity`

* build(ew-api): update `@element-hq/element-web-module-api` to `v1.9.0`
This commit is contained in:
Florian Duros 2025-12-05 12:52:41 +01:00 committed by GitHub
parent 5607291f1e
commit 242f2deb64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 190 additions and 32 deletions

View File

@ -81,7 +81,7 @@
},
"dependencies": {
"@babel/runtime": "^7.12.5",
"@element-hq/element-web-module-api": "1.8.0",
"@element-hq/element-web-module-api": "1.9.0",
"@element-hq/web-shared-components": "link:packages/shared-components",
"@fontsource/fira-code": "^5",
"@fontsource/inter": "^5",

View File

@ -190,6 +190,13 @@ interface IRoomProps extends RoomViewProps {
* If true, hide the widgets
*/
hideWidgets?: boolean;
/**
* If true, enable sending read receipts and markers on user activity in the room view. When the user interacts with the room view, read receipts and markers are sent.
* If false, the read receipts and markers are only send when the room view is focused. The user has to focus the room view in order to clear any unreads and to move the unread marker to the bottom of the view.
* @default true
*/
enableReadReceiptsAndMarkersOnActivity?: boolean;
}
export { MainSplitContentType };
@ -418,6 +425,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
public static contextType = SDKContext;
declare public context: React.ContextType<typeof SDKContext>;
public static readonly defaultProps = {
enableReadReceiptsAndMarkersOnActivity: true,
};
public constructor(props: IRoomProps, context: React.ContextType<typeof SDKContext>) {
super(props, context);
@ -2182,6 +2193,19 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
};
/**
* Handles the focus event on the RoomView component.
*
* Sends read receipts and updates the read marker if the
* disableReadReceiptsAndMarkersOnActivity prop is set.
*/
private onFocus = (): void => {
if (this.props.enableReadReceiptsAndMarkersOnActivity) return;
this.messagePanel?.sendReadReceipts();
this.messagePanel?.updateReadMarker();
};
public render(): ReactNode {
if (!this.context.client) return null;
const { isRoomEncrypted } = this.state;
@ -2539,7 +2563,9 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
timelineSet={this.state.room.getUnfilteredTimelineSet()}
showReadReceipts={this.state.showReadReceipts}
manageReadReceipts={!this.state.isPeeking}
sendReadReceiptOnLoad={!this.state.wasContextSwitch}
sendReadReceiptOnLoad={
!this.state.wasContextSwitch && this.props.enableReadReceiptsAndMarkersOnActivity
}
manageReadMarkers={!this.state.isPeeking}
hidden={hideMessagePanel}
highlightedEventId={highlightedEventId}
@ -2556,6 +2582,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
showReactions={true}
layout={this.state.layout}
editState={this.state.editState}
enableReadReceiptsAndMarkersOnActivity={this.props.enableReadReceiptsAndMarkersOnActivity}
/>
);
}
@ -2622,7 +2649,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
<Measured sensor={this.roomViewBody} onMeasurement={this.onMeasurement} />
{auxPanel}
{pinnedMessageBanner}
<main className={timelineClasses}>
<main className={timelineClasses} data-testid="timeline">
<FileDropTarget
parent={this.roomView.current}
onFileDrop={this.onFileDrop}
@ -2683,7 +2710,13 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
return (
<ScopedRoomContextProvider {...this.state} roomViewStore={this.roomViewStore}>
<div className={mainClasses} ref={this.roomView} onKeyDown={this.onReactKeyDown}>
<div
className={mainClasses}
ref={this.roomView}
onKeyDown={this.onReactKeyDown}
onFocus={this.onFocus}
tabIndex={-1}
>
{showChatEffects && this.roomView.current && (
<EffectsOverlay roomWidth={this.roomView.current.offsetWidth} />
)}

View File

@ -139,6 +139,12 @@ interface IProps {
hideThreadedMessages?: boolean;
disableGrouping?: boolean;
/**
* Enable updating the read receipts and markers on user activity.
* @default true
*/
enableReadReceiptsAndMarkersOnActivity?: boolean;
}
interface IState {
@ -228,6 +234,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
sendReadReceiptOnLoad: true,
hideThreadedMessages: true,
disableGrouping: false,
enableReadReceiptsAndMarkersOnActivity: true,
};
private lastRRSentEventId: string | null | undefined = undefined;
@ -302,10 +309,10 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.props.timelineSet.room?.on(ThreadEvent.Update, this.onThreadUpdate);
if (this.props.manageReadReceipts) {
if (this.props.manageReadReceipts && this.props.enableReadReceiptsAndMarkersOnActivity) {
this.updateReadReceiptOnUserActivity();
}
if (this.props.manageReadMarkers) {
if (this.props.manageReadMarkers && this.props.enableReadReceiptsAndMarkersOnActivity) {
this.updateReadMarkerOnUserActivity();
}
this.initTimeline(this.props);
@ -1028,7 +1035,10 @@ class TimelinePanel extends React.Component<IProps, IState> {
);
}
private sendReadReceipts = async (): Promise<void> => {
/**
* Sends read receipts and fully read markers as appropriate.
*/
public sendReadReceipts = async (): Promise<void> => {
if (SettingsStore.getValue("lowBandwidth")) return;
if (!this.messagePanel.current) return;
if (!this.props.manageReadReceipts) return;
@ -1134,9 +1144,12 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
}
// 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 = async (): Promise<void> => {
/**
* Move the marker to the bottom of the screen.
* 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.
*/
public updateReadMarker = async (): Promise<void> => {
if (!this.props.manageReadMarkers) return;
if (this.getReadMarkerPosition() === 1) {
// the read marker is at an event below the viewport,

View File

@ -335,6 +335,54 @@ describe("RoomView", () => {
expect(asFragment()).toMatchSnapshot();
});
describe("enableReadReceiptsAndMarkersOnActivity", () => {
it.each([
{
enabled: false,
testName: "should send read receipts and update read marker on focus when disabled",
checkCall: (sendReadReceiptsSpy: jest.Mock, updateReadMarkerSpy: jest.Mock) => {
expect(sendReadReceiptsSpy).toHaveBeenCalled();
expect(updateReadMarkerSpy).toHaveBeenCalled();
},
},
{
enabled: true,
testName: "should not send read receipts and update read marker on focus when enabled",
checkCall: (sendReadReceiptsSpy: jest.Mock, updateReadMarkerSpy: jest.Mock) => {
expect(sendReadReceiptsSpy).not.toHaveBeenCalled();
expect(updateReadMarkerSpy).not.toHaveBeenCalled();
},
},
])("$testName", async ({ enabled, checkCall }) => {
// Join the room
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
const ref = createRef<RoomView>();
await mountRoomView(ref, {
enableReadReceiptsAndMarkersOnActivity: enabled,
});
// Wait for the timeline to be rendered
await waitFor(() => expect(screen.getByTestId("timeline")).not.toBeNull());
// Get the RoomView instance and mock the messagePanel methods
const instance = ref.current!;
const sendReadReceiptsSpy = jest.fn();
const updateReadMarkerSpy = jest.fn();
// @ts-ignore - accessing private property for testing
instance.messagePanel = {
sendReadReceipts: sendReadReceiptsSpy,
updateReadMarker: updateReadMarkerSpy,
};
// Find the main RoomView div and trigger focus
const timeline = screen.getByTestId("timeline");
fireEvent.focus(timeline);
// Verify that sendReadReceipts and updateReadMarker were called or not based on the enabled state
checkCall(sendReadReceiptsSpy, updateReadMarkerSpy);
});
});
describe("invites", () => {
beforeEach(() => {
const member = new RoomMember(room.roomId, cli.getSafeUserId());

View File

@ -51,6 +51,7 @@ import { Action } from "../../../../src/dispatcher/actions";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import MatrixClientBackedController from "../../../../src/settings/controllers/MatrixClientBackedController";
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
import type Timer from "../../../../src/utils/Timer";
// ScrollPanel calls this, but jsdom doesn't mock it for us
HTMLDivElement.prototype.scrollBy = () => {};
@ -369,6 +370,56 @@ describe("TimelinePanel", () => {
});
});
describe("enableReadReceiptsAndMarkersOnActivity", () => {
it.each([
{
enabled: false,
testName: "should not set up activity timers when disabled",
checkCall: (readReceiptTimer: Timer | null, readMarkerTimer: Timer | null) => {
expect(readReceiptTimer).toBeNull();
expect(readMarkerTimer).toBeNull();
},
},
{
enabled: true,
testName: "should set up activity timers when enabled",
checkCall: (readReceiptTimer: Timer | null, readMarkerTimer: Timer | null) => {
expect(readReceiptTimer).toBeTruthy();
expect(readMarkerTimer).toBeTruthy();
},
},
])("$testName", async ({ enabled, checkCall }) => {
const room = mkRoom(client, "roomId");
const events = mockEvents(room);
const [, timelineSet] = mkTimeline(room, events);
let timelinePanel: TimelinePanel | null = null;
render(
<TimelinePanel
timelineSet={timelineSet}
manageReadMarkers={true}
manageReadReceipts={true}
enableReadReceiptsAndMarkersOnActivity={enabled}
ref={(ref) => {
timelinePanel = ref;
}}
/>,
clientAndSDKContextRenderOptions(client, sdkContext),
);
await waitFor(() => expect(timelinePanel).toBeTruthy());
// Check if the activity timers were set up
// @ts-ignore - accessing private property for testing
const readReceiptTimer = timelinePanel!.readReceiptActivityTimer;
// @ts-ignore - accessing private property for testing
const readMarkerTimer = timelinePanel!.readMarkerActivityTimer;
checkCall(readReceiptTimer, readMarkerTimer);
});
});
it("should scroll event into view when props.eventId changes", () => {
const client = MatrixClientPeg.safeGet();
const room = mkRoom(client, "roomId");

View File

@ -363,7 +363,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
>
<svg
aria-label="Messages in this room are not end-to-end encrypted"
aria-labelledby="_r_jb_"
aria-labelledby="_r_o5_"
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
@ -939,6 +939,7 @@ exports[`RoomView should hide the composer when hideComposer=true 1`] = `
<DocumentFragment>
<div
class="mx_RoomView"
tabindex="-1"
>
<canvas
aria-hidden="true"
@ -1127,6 +1128,7 @@ exports[`RoomView should hide the composer when hideComposer=true 1`] = `
</div>
<main
class="mx_RoomView_timeline mx_RoomView_timeline_rr_enabled"
data-testid="timeline"
>
<div
class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel"
@ -1166,6 +1168,7 @@ exports[`RoomView should hide the header when hideHeader=true 1`] = `
<DocumentFragment>
<div
class="mx_RoomView"
tabindex="-1"
>
<canvas
aria-hidden="true"
@ -1189,6 +1192,7 @@ exports[`RoomView should hide the header when hideHeader=true 1`] = `
</div>
<main
class="mx_RoomView_timeline mx_RoomView_timeline_rr_enabled"
data-testid="timeline"
>
<div
class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel"
@ -1435,6 +1439,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
<DocumentFragment>
<div
class="mx_RoomView"
tabindex="-1"
>
<canvas
aria-hidden="true"
@ -1623,6 +1628,7 @@ exports[`RoomView should hide the pinned message banner when hidePinnedMessageBa
</div>
<main
class="mx_RoomView_timeline mx_RoomView_timeline_rr_enabled"
data-testid="timeline"
>
<div
class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel"
@ -1869,6 +1875,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
<DocumentFragment>
<div
class="mx_RoomView"
tabindex="-1"
>
<canvas
aria-hidden="true"
@ -2057,6 +2064,7 @@ exports[`RoomView should hide the right panel when hideRightPanel=true 1`] = `
</div>
<main
class="mx_RoomView_timeline mx_RoomView_timeline_rr_enabled"
data-testid="timeline"
>
<div
class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel"
@ -2303,6 +2311,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<DocumentFragment>
<div
class="mx_RoomView"
tabindex="-1"
>
<canvas
aria-hidden="true"
@ -2371,7 +2380,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_ab_"
aria-labelledby="_r_f5_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -2398,7 +2407,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_ag_"
aria-labelledby="_r_fa_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -2413,7 +2422,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</button>
<button
aria-label="Threads"
aria-labelledby="_r_al_"
aria-labelledby="_r_ff_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -2440,7 +2449,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</button>
<button
aria-label="Room info"
aria-labelledby="_r_aq_"
aria-labelledby="_r_fk_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -2470,7 +2479,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
>
<div
aria-label="0 members"
aria-labelledby="_r_av_"
aria-labelledby="_r_fp_"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
@ -2491,6 +2500,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</div>
<main
class="mx_RoomView_timeline mx_RoomView_timeline_rr_enabled"
data-testid="timeline"
/>
<div
aria-label="Room status bar"
@ -2515,6 +2525,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<DocumentFragment>
<div
class="mx_RoomView"
tabindex="-1"
>
<canvas
aria-hidden="true"
@ -2583,7 +2594,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_ab_"
aria-labelledby="_r_f5_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -2610,7 +2621,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-labelledby="_r_ag_"
aria-labelledby="_r_fa_"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@ -2625,7 +2636,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</button>
<button
aria-label="Threads"
aria-labelledby="_r_al_"
aria-labelledby="_r_ff_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -2652,7 +2663,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</button>
<button
aria-label="Room info"
aria-labelledby="_r_aq_"
aria-labelledby="_r_fk_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -2682,7 +2693,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
>
<div
aria-label="0 members"
aria-labelledby="_r_av_"
aria-labelledby="_r_fp_"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
@ -2703,6 +2714,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
</div>
<main
class="mx_RoomView_timeline mx_RoomView_timeline_rr_enabled"
data-testid="timeline"
>
<div
class="mx_AutoHideScrollbar mx_ScrollPanel mx_RoomView_messagePanel"
@ -2750,7 +2762,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
tabindex="0"
>
<div
aria-labelledby="_r_be_"
aria-labelledby="_r_g8_"
class="mx_E2EIcon mx_E2EIcon_verified mx_MessageComposer_e2eIcon"
data-testid="e2e-icon"
>
@ -2977,6 +2989,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
<DocumentFragment>
<div
class="mx_RoomView mx_RoomView_immersive"
tabindex="-1"
>
<canvas
aria-hidden="true"
@ -3033,7 +3046,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</button>
<button
aria-label="Chat"
aria-labelledby="_r_fd_"
aria-labelledby="_r_k7_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -3060,7 +3073,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</button>
<button
aria-label="Threads"
aria-labelledby="_r_fi_"
aria-labelledby="_r_kc_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -3087,7 +3100,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</button>
<button
aria-label="Room info"
aria-labelledby="_r_fn_"
aria-labelledby="_r_kh_"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
@ -3117,7 +3130,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
>
<div
aria-label="0 members"
aria-labelledby="_r_fs_"
aria-labelledby="_r_km_"
class="mx_AccessibleButton mx_FacePile"
role="button"
tabindex="0"
@ -3191,7 +3204,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</p>
</div>
<button
aria-labelledby="_r_g5_"
aria-labelledby="_r_kv_"
class="_icon-button_1pz9o_8"
data-kind="secondary"
data-testid="base-card-close-button"
@ -3251,7 +3264,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
>
<svg
aria-label="Messages in this room are not end-to-end encrypted"
aria-labelledby="_r_ge_"
aria-labelledby="_r_l8_"
class="mx_E2EIcon mx_MessageComposer_e2eIcon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"

View File

@ -1559,10 +1559,10 @@
resolved "https://registry.yarnpkg.com/@element-hq/element-call-embedded/-/element-call-embedded-0.16.1.tgz#28bdbde426051cc2a3228a36e7196e0a254569d3"
integrity sha512-g3v/QFuNy8YVRGrKC5SxjIYvgBh6biOHgejhJT2Jk/yjOOUEuP0y2PBaADm+suPD9BB/Vk1jPxFk2uEIpEzhpA==
"@element-hq/element-web-module-api@1.8.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.8.0.tgz#95aa4ec22609cf0f4a7f24274473af0645a16f2a"
integrity sha512-lMiDA9ubP3mZZupIMT8T3wS0riX30rYZj3pFpdP4cfZhkWZa3FJFStokAy5OnaHyENC7Px1cqkBGqilOWewY/A==
"@element-hq/element-web-module-api@1.9.0", "@element-hq/element-web-module-api@^1.8.0":
version "1.9.0"
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.9.0.tgz#2e4fcc8809418c8670d4f0576bc4a9a235bc6c50"
integrity sha512-Ao/V9w+wysZK4bh61LlKlznF10n2ZbD6KcUI46/zUMttXbmJn3ahvbzhEpwYcD+Cjy3ag5ycxLIIGkKV/fncXg==
"@element-hq/element-web-playwright-common@^2.0.0":
version "2.0.0"