diff --git a/package.json b/package.json index 390d707a51..6cd7eb3d36 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 169bd3d560..60fb26d3fd 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -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 { public static contextType = SDKContext; declare public context: React.ContextType; + public static readonly defaultProps = { + enableReadReceiptsAndMarkersOnActivity: true, + }; + public constructor(props: IRoomProps, context: React.ContextType) { super(props, context); @@ -2182,6 +2193,19 @@ export class RoomView extends React.Component { } }; + /** + * 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 { 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 { showReactions={true} layout={this.state.layout} editState={this.state.editState} + enableReadReceiptsAndMarkersOnActivity={this.props.enableReadReceiptsAndMarkersOnActivity} /> ); } @@ -2622,7 +2649,7 @@ export class RoomView extends React.Component { {auxPanel} {pinnedMessageBanner} -
+
{ return ( -
+
{showChatEffects && this.roomView.current && ( )} diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index c112d341b3..665ca6f677 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -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 { sendReadReceiptOnLoad: true, hideThreadedMessages: true, disableGrouping: false, + enableReadReceiptsAndMarkersOnActivity: true, }; private lastRRSentEventId: string | null | undefined = undefined; @@ -302,10 +309,10 @@ class TimelinePanel extends React.Component { 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 { ); } - private sendReadReceipts = async (): Promise => { + /** + * Sends read receipts and fully read markers as appropriate. + */ + public sendReadReceipts = async (): Promise => { if (SettingsStore.getValue("lowBandwidth")) return; if (!this.messagePanel.current) return; if (!this.props.manageReadReceipts) return; @@ -1134,9 +1144,12 @@ class TimelinePanel extends React.Component { } } - // 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 => { + /** + * 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 => { if (!this.props.manageReadMarkers) return; if (this.getReadMarkerPosition() === 1) { // the read marker is at an event below the viewport, diff --git a/test/unit-tests/components/structures/RoomView-test.tsx b/test/unit-tests/components/structures/RoomView-test.tsx index 46e67499fe..58a26ab73e 100644 --- a/test/unit-tests/components/structures/RoomView-test.tsx +++ b/test/unit-tests/components/structures/RoomView-test.tsx @@ -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(); + 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()); diff --git a/test/unit-tests/components/structures/TimelinePanel-test.tsx b/test/unit-tests/components/structures/TimelinePanel-test.tsx index 3602dd8322..0d3f80b433 100644 --- a/test/unit-tests/components/structures/TimelinePanel-test.tsx +++ b/test/unit-tests/components/structures/TimelinePanel-test.tsx @@ -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 = 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"); diff --git a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap index 4353bbecfa..cd276bbed9 100644 --- a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -363,7 +363,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] = >