From 7eaed1a3f870eac582c66f0fc2949dc5663bad79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Fri, 5 Aug 2022 17:33:57 +0200 Subject: [PATCH] Add option to stop sending read receipts (delabs MSC2285: private read receipts) (#8629) Co-authored-by: Travis Ralston --- src/components/structures/MessagePanel.tsx | 8 +- src/components/structures/TimelinePanel.tsx | 41 ++++---- .../views/elements/SettingsFlag.tsx | 9 ++ .../tabs/user/LabsUserSettingsTab.tsx | 16 ---- .../tabs/user/PreferencesUserSettingsTab.tsx | 54 +++++++++-- .../tabs/user/SecurityUserSettingsTab.tsx | 2 +- src/i18n/strings/en_EN.json | 5 +- src/settings/Settings.tsx | 8 +- src/utils/read-receipts.ts | 13 +-- .../structures/TimelinePanel-test.tsx | 96 +++++++++++++++++-- test/test-utils/test-utils.ts | 4 + 11 files changed, 188 insertions(+), 68 deletions(-) diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index cd579a6648..71f698c9f8 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -23,8 +23,8 @@ import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { Relations } from "matrix-js-sdk/src/models/relations"; import { logger } from 'matrix-js-sdk/src/logger'; import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; -import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; import { M_BEACON_INFO } from 'matrix-js-sdk/src/@types/beacon'; +import { isSupportedReceiptType } from "matrix-js-sdk/src/utils"; import shouldHideEvent from '../../shouldHideEvent'; import { wantsDateSeparator } from '../../DateUtils'; @@ -828,7 +828,11 @@ export default class MessagePanel extends React.Component { } const receipts: IReadReceiptProps[] = []; room.getReceiptsForEvent(event).forEach((r) => { - if (!r.userId || ![ReceiptType.Read, ReceiptType.ReadPrivate].includes(r.type) || r.userId === myUserId) { + if ( + !r.userId || + !isSupportedReceiptType(r.type) || + r.userId === myUserId + ) { return; // ignore non-read receipts and receipts from self. } if (MatrixClientPeg.get().isUserIgnored(r.userId)) { diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 3c787748ba..27367caf1c 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -30,6 +30,7 @@ import { ClientEvent } from "matrix-js-sdk/src/client"; import { Thread } 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'; +import { getPrivateReadReceiptField } from "matrix-js-sdk/src/utils"; import SettingsStore from "../../settings/SettingsStore"; import { Layout } from "../../settings/enums/Layout"; @@ -965,29 +966,35 @@ class TimelinePanel extends React.Component { this.lastRMSentEventId = this.state.readMarkerEventId; const roomId = this.props.timelineSet.room.roomId; - const hiddenRR = SettingsStore.getValue("feature_hidden_read_receipts", roomId); + const sendRRs = SettingsStore.getValue("sendReadReceipts", roomId); + + debuglog( + `Sending Read Markers for ${this.props.timelineSet.room.roomId}: `, + `rm=${this.state.readMarkerEventId} `, + `rr=${sendRRs ? lastReadEvent?.getId() : null} `, + `prr=${lastReadEvent?.getId()}`, - debuglog('Sending Read Markers for ', - this.props.timelineSet.room.roomId, - 'rm', this.state.readMarkerEventId, - lastReadEvent ? 'rr ' + lastReadEvent.getId() : '', - ' hidden:' + hiddenRR, ); MatrixClientPeg.get().setRoomReadMarkers( roomId, this.state.readMarkerEventId, - hiddenRR ? null : lastReadEvent, // Could be null, in which case no RR is sent - lastReadEvent, // Could be null, in which case no private RR is sent - ).catch((e) => { + sendRRs ? lastReadEvent : null, // Public read receipt (could be null) + lastReadEvent, // Private read receipt (could be null) + ).catch(async (e) => { // /read_markers API is not implemented on this HS, fallback to just RR if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) { - return MatrixClientPeg.get().sendReadReceipt( - lastReadEvent, - hiddenRR ? ReceiptType.ReadPrivate : ReceiptType.Read, - ).catch((e) => { + const privateField = await getPrivateReadReceiptField(MatrixClientPeg.get()); + if (!sendRRs && !privateField) return; + + try { + return await MatrixClientPeg.get().sendReadReceipt( + lastReadEvent, + sendRRs ? ReceiptType.Read : privateField, + ); + } catch (error) { logger.error(e); this.lastRRSentEventId = undefined; - }); + } } else { logger.error(e); } @@ -1575,8 +1582,10 @@ class TimelinePanel extends React.Component { const isNodeInView = (node) => { if (node) { const boundingRect = node.getBoundingClientRect(); - if ((allowPartial && boundingRect.top < wrapperRect.bottom) || - (!allowPartial && boundingRect.bottom < wrapperRect.bottom)) { + if ( + (allowPartial && boundingRect.top <= wrapperRect.bottom) || + (!allowPartial && boundingRect.bottom <= wrapperRect.bottom) + ) { return true; } } diff --git a/src/components/views/elements/SettingsFlag.tsx b/src/components/views/elements/SettingsFlag.tsx index f433613a77..e369b29c18 100644 --- a/src/components/views/elements/SettingsFlag.tsx +++ b/src/components/views/elements/SettingsFlag.tsx @@ -33,6 +33,7 @@ interface IProps { // XXX: once design replaces all toggles make this the default useCheckbox?: boolean; disabled?: boolean; + disabledDescription?: string; hideIfCannotSet?: boolean; onChange?(checked: boolean): void; } @@ -84,6 +85,13 @@ export default class SettingsFlag extends React.Component { : SettingsStore.getDisplayName(this.props.name, this.props.level); const description = SettingsStore.getDescription(this.props.name); + let disabledDescription: JSX.Element; + if (this.props.disabled && this.props.disabledDescription) { + disabledDescription =
+ { this.props.disabledDescription } +
; + } + if (this.props.useCheckbox) { return { { description &&
{ description }
} + { disabledDescription } } interface IState { - showHiddenReadReceipts: boolean; showJumpToDate: boolean; showExploringPublicSpaces: boolean; } @@ -58,10 +57,6 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> { const cli = MatrixClientPeg.get(); - cli.doesServerSupportUnstableFeature("org.matrix.msc2285").then((showHiddenReadReceipts) => { - this.setState({ showHiddenReadReceipts }); - }); - cli.doesServerSupportUnstableFeature("org.matrix.msc3030").then((showJumpToDate) => { this.setState({ showJumpToDate }); }); @@ -71,7 +66,6 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> { }); this.state = { - showHiddenReadReceipts: false, showJumpToDate: false, showExploringPublicSpaces: false, }; @@ -121,16 +115,6 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> { />, ); - if (this.state.showHiddenReadReceipts) { - groups.getOrCreate(LabGroup.Messaging, []).push( - , - ); - } - if (this.state.showJumpToDate) { groups.getOrCreate(LabGroup.Messaging, []).push( { - static ROOM_LIST_SETTINGS = [ + private static ROOM_LIST_SETTINGS = [ 'breadcrumbs', ]; - static SPACES_SETTINGS = [ + private static SPACES_SETTINGS = [ "Spaces.allRoomsInHome", ]; - static KEYBINDINGS_SETTINGS = [ + private static KEYBINDINGS_SETTINGS = [ 'ctrlFForSearch', ]; - static COMPOSER_SETTINGS = [ + private static PRESENCE_SETTINGS = [ + "sendTypingNotifications", + // sendReadReceipts - handled specially due to server needing support + ]; + + private static COMPOSER_SETTINGS = [ 'MessageComposerInput.autoReplaceEmoji', 'MessageComposerInput.useMarkdown', 'MessageComposerInput.suggestEmoji', - 'sendTypingNotifications', 'MessageComposerInput.ctrlEnterToSend', 'MessageComposerInput.surroundWith', 'MessageComposerInput.showStickersButton', 'MessageComposerInput.insertTrailingColon', ]; - static TIME_SETTINGS = [ + private static TIME_SETTINGS = [ 'showTwelveHourTimestamps', 'alwaysShowTimestamps', ]; - static CODE_BLOCKS_SETTINGS = [ + + private static CODE_BLOCKS_SETTINGS = [ 'enableSyntaxHighlightLanguageDetection', 'expandCodeByDefault', 'showCodeLineNumbers', ]; - static IMAGES_AND_VIDEOS_SETTINGS = [ + + private static IMAGES_AND_VIDEOS_SETTINGS = [ 'urlPreviewsEnabled', 'autoplayGifs', 'autoplayVideo', 'showImages', ]; - static TIMELINE_SETTINGS = [ + + private static TIMELINE_SETTINGS = [ 'showTypingNotifications', 'showRedactions', 'showReadReceipts', @@ -93,7 +102,8 @@ export default class PreferencesUserSettingsTab extends React.Component { + this.setState({ + disablingReadReceiptsSupported: ( + await MatrixClientPeg.get().doesServerSupportUnstableFeature("org.matrix.msc2285.stable") || + await MatrixClientPeg.get().doesServerSupportUnstableFeature("org.matrix.msc2285") + ), + }); + } + private onAutocompleteDelayChange = (e: React.ChangeEvent) => { this.setState({ autocompleteDelay: e.target.value }); SettingsStore.setValue("autocompleteDelay", null, SettingLevel.DEVICE, e.target.value); @@ -185,6 +205,20 @@ export default class PreferencesUserSettingsTab extends React.Component +
+ { _t("Presence") } + + { _t("Share your activity and status with others.") } + + + { this.renderGroup(PreferencesUserSettingsTab.PRESENCE_SETTINGS) } +
+
{ _t("Composer") } { this.renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS) } diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx index 5a950d6535..177c3b4f5e 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx @@ -304,7 +304,7 @@ export default class SecurityUserSettingsTab extends React.Component

{ _t("Share anonymous data to help us identify issues. Nothing personal. " + - "No third parties.") } + "No third parties.") }

click here.": "To view all keyboard shortcuts, click here.", "Displaying time": "Displaying time", + "Presence": "Presence", + "Share your activity and status with others.": "Share your activity and status with others.", + "Your server doesn't support disabling sending read receipts.": "Your server doesn't support disabling sending read receipts.", "Composer": "Composer", "Code blocks": "Code blocks", "Images, GIFs and videos": "Images, GIFs and videos", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 44d82da5cd..8414b25394 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -401,10 +401,10 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: null, }, - "feature_hidden_read_receipts": { - supportedLevels: LEVELS_FEATURE, - displayName: _td("Don't send read receipts"), - default: false, + "sendReadReceipts": { + supportedLevels: LEVELS_ACCOUNT_SETTINGS, + displayName: _td("Send read receipts"), + default: true, }, "feature_message_right_click_context_menu": { isFeature: true, diff --git a/src/utils/read-receipts.ts b/src/utils/read-receipts.ts index e416072576..35eda2e338 100644 --- a/src/utils/read-receipts.ts +++ b/src/utils/read-receipts.ts @@ -16,7 +16,7 @@ limitations under the License. import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixClient } from "matrix-js-sdk/src/client"; -import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; +import { isSupportedReceiptType } from "matrix-js-sdk/src/utils"; /** * Determines if a read receipt update event includes the client's own user. @@ -27,13 +27,10 @@ import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; export function readReceiptChangeIsFor(event: MatrixEvent, client: MatrixClient): boolean { const myUserId = client.getUserId(); for (const eventId of Object.keys(event.getContent())) { - const readReceiptUsers = Object.keys(event.getContent()[eventId][ReceiptType.Read] || {}); - if (readReceiptUsers.includes(myUserId)) { - return true; - } - const readPrivateReceiptUsers = Object.keys(event.getContent()[eventId][ReceiptType.ReadPrivate] || {}); - if (readPrivateReceiptUsers.includes(myUserId)) { - return true; + for (const [receiptType, receipt] of Object.entries(event.getContent()[eventId])) { + if (!isSupportedReceiptType(receiptType)) continue; + + if (Object.keys((receipt || {})).includes(myUserId)) return true; } } } diff --git a/test/components/structures/TimelinePanel-test.tsx b/test/components/structures/TimelinePanel-test.tsx index 3331bc5934..5d133fb705 100644 --- a/test/components/structures/TimelinePanel-test.tsx +++ b/test/components/structures/TimelinePanel-test.tsx @@ -17,28 +17,74 @@ limitations under the License. import React from 'react'; // eslint-disable-next-line deprecate/import import { mount, ReactWrapper } from "enzyme"; +import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline"; import { MessageEvent } from 'matrix-events-sdk'; -import { EventTimelineSet, MatrixEvent, PendingEventOrdering, Room } from 'matrix-js-sdk/src/matrix'; +import { + EventTimelineSet, + EventType, + MatrixEvent, + PendingEventOrdering, + Room, +} from 'matrix-js-sdk/src/matrix'; +import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; +import { render, RenderResult } from "@testing-library/react"; -import { stubClient } from "../../test-utils"; +import { mkRoom, stubClient } from "../../test-utils"; import TimelinePanel from '../../../src/components/structures/TimelinePanel'; import { MatrixClientPeg } from '../../../src/MatrixClientPeg'; +import SettingsStore from "../../../src/settings/SettingsStore"; -function newReceipt(eventId: string, userId: string, readTs: number, fullyReadTs: number): MatrixEvent { +const newReceipt = (eventId: string, userId: string, readTs: number, fullyReadTs: number): MatrixEvent => { const receiptContent = { [eventId]: { - "m.read": { [userId]: { ts: readTs } }, - "org.matrix.msc2285.read.private": { [userId]: { ts: readTs } }, - "m.fully_read": { [userId]: { ts: fullyReadTs } }, + [ReceiptType.Read]: { [userId]: { ts: readTs } }, + [ReceiptType.ReadPrivate]: { [userId]: { ts: readTs } }, + [ReceiptType.FullyRead]: { [userId]: { ts: fullyReadTs } }, }, }; return new MatrixEvent({ content: receiptContent, type: "m.receipt" }); -} +}; + +const renderPanel = (room: Room, events: MatrixEvent[]): RenderResult => { + const timelineSet = { room: room as Room } as EventTimelineSet; + const timeline = new EventTimeline(timelineSet); + events.forEach((event) => timeline.addEvent(event, true)); + timelineSet.getLiveTimeline = () => timeline; + timelineSet.getTimelineForEvent = () => timeline; + timelineSet.getPendingEvents = () => events; + timelineSet.room.getEventReadUpTo = () => events[1].getId(); + + return render( + , + ); +}; + +const mockEvents = (room: Room, count = 2): MatrixEvent[] => { + const events = []; + for (let index = 0; index < count; index++) { + events.push(new MatrixEvent({ + room_id: room.roomId, + event_id: `event_${index}`, + type: EventType.RoomMessage, + user_id: "userId", + content: MessageEvent.from(`Event${index}`).serialize().content, + })); + } + + return events; +}; describe('TimelinePanel', () => { - describe('Read Receipts and Markers', () => { - it('Forgets the read marker when asked to', () => { - stubClient(); + beforeEach(() => { + stubClient(); + }); + + describe('read receipts and markers', () => { + it('should forget the read marker when asked to', () => { const cli = MatrixClientPeg.get(); const readMarkersSent = []; @@ -95,5 +141,35 @@ describe('TimelinePanel', () => { // We sent off a read marker for the new event expect(readMarkersSent).toEqual(["ev1"]); }); + + it("sends public read receipt when enabled", () => { + const client = MatrixClientPeg.get(); + const room = mkRoom(client, "roomId"); + const events = mockEvents(room); + + const getValueCopy = SettingsStore.getValue; + SettingsStore.getValue = jest.fn().mockImplementation((name: string) => { + if (name === "sendReadReceipts") return true; + return getValueCopy(name); + }); + + renderPanel(room, events); + expect(client.setRoomReadMarkers).toHaveBeenCalledWith(room.roomId, null, events[0], events[0]); + }); + + it("does not send public read receipt when enabled", () => { + const client = MatrixClientPeg.get(); + const room = mkRoom(client, "roomId"); + const events = mockEvents(room); + + const getValueCopy = SettingsStore.getValue; + SettingsStore.getValue = jest.fn().mockImplementation((name: string) => { + if (name === "sendReadReceipts") return false; + return getValueCopy(name); + }); + + renderPanel(room, events); + expect(client.setRoomReadMarkers).toHaveBeenCalledWith(room.roomId, null, null, events[0]); + }); }); }); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index ef2b163d42..39f8e8a7ec 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -127,6 +127,7 @@ export function createTestClient(): MatrixClient { setAccountData: jest.fn(), setRoomAccountData: jest.fn(), setRoomTopic: jest.fn(), + setRoomReadMarkers: jest.fn().mockResolvedValue({}), sendTyping: jest.fn().mockResolvedValue({}), sendMessage: jest.fn().mockResolvedValue({}), sendStateEvent: jest.fn().mockResolvedValue(undefined), @@ -361,6 +362,8 @@ export function mkStubRoom(roomId: string = null, name: string, client: MatrixCl getMembersWithMembership: jest.fn().mockReturnValue([]), getJoinedMembers: jest.fn().mockReturnValue([]), getJoinedMemberCount: jest.fn().mockReturnValue(1), + getInvitedAndJoinedMemberCount: jest.fn().mockReturnValue(1), + setUnreadNotificationCount: jest.fn(), getMembers: jest.fn().mockReturnValue([]), getPendingEvents: () => [], getLiveTimeline: jest.fn().mockReturnValue(stubTimeline), @@ -407,6 +410,7 @@ export function mkStubRoom(roomId: string = null, name: string, client: MatrixCl myUserId: client?.getUserId(), canInvite: jest.fn(), getThreads: jest.fn().mockReturnValue([]), + eventShouldLiveIn: jest.fn().mockReturnValue({}), } as unknown as Room; }