Add option to stop sending read receipts (delabs MSC2285: private read receipts) (#8629)

Co-authored-by: Travis Ralston <travisr@matrix.org>
pull/28217/head
Šimon Brandner 2022-08-05 17:33:57 +02:00 committed by GitHub
parent b61cc4850b
commit 7eaed1a3f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 188 additions and 68 deletions

View File

@ -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<IProps, IState> {
}
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)) {

View File

@ -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<IProps, IState> {
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<IProps, IState> {
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;
}
}

View File

@ -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<IProps, IState> {
: 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 = <div className="mx_SettingsFlag_microcopy">
{ this.props.disabledDescription }
</div>;
}
if (this.props.useCheckbox) {
return <StyledCheckbox
checked={this.state.value}
@ -100,6 +108,7 @@ export default class SettingsFlag extends React.Component<IProps, IState> {
{ description && <div className="mx_SettingsFlag_microcopy">
{ description }
</div> }
{ disabledDescription }
</label>
<ToggleSwitch
checked={this.state.value}

View File

@ -47,7 +47,6 @@ export class LabsSettingToggle extends React.Component<ILabsSettingToggleProps>
}
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(
<SettingsFlag
key="feature_hidden_read_receipts"
name="feature_hidden_read_receipts"
level={SettingLevel.DEVICE}
/>,
);
}
if (this.state.showJumpToDate) {
groups.getOrCreate(LabGroup.Messaging, []).push(
<SettingsFlag

View File

@ -29,6 +29,7 @@ import { UserTab } from "../../../dialogs/UserTab";
import { OpenToTabPayload } from "../../../../../dispatcher/payloads/OpenToTabPayload";
import { Action } from "../../../../../dispatcher/actions";
import SdkConfig from "../../../../../SdkConfig";
import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
import { showUserOnboardingPage } from "../../../user-onboarding/UserOnboardingPage";
interface IProps {
@ -36,51 +37,59 @@ interface IProps {
}
interface IState {
disablingReadReceiptsSupported: boolean;
autocompleteDelay: string;
readMarkerInViewThresholdMs: string;
readMarkerOutOfViewThresholdMs: string;
}
export default class PreferencesUserSettingsTab extends React.Component<IProps, IState> {
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<IProps,
'scrollToBottomOnMessageSent',
'useOnlyCurrentProfiles',
];
static GENERAL_SETTINGS = [
private static GENERAL_SETTINGS = [
'promptBeforeInviteUnknownUsers',
// Start automatically after startup (electron-only)
// Autocomplete delay (niche text box)
@ -103,6 +113,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
super(props);
this.state = {
disablingReadReceiptsSupported: false,
autocompleteDelay:
SettingsStore.getValueAt(SettingLevel.DEVICE, 'autocompleteDelay').toString(10),
readMarkerInViewThresholdMs:
@ -112,6 +123,15 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
};
}
public async componentDidMount(): Promise<void> {
this.setState({
disablingReadReceiptsSupported: (
await MatrixClientPeg.get().doesServerSupportUnstableFeature("org.matrix.msc2285.stable") ||
await MatrixClientPeg.get().doesServerSupportUnstableFeature("org.matrix.msc2285")
),
});
}
private onAutocompleteDelayChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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<IProps,
{ this.renderGroup(PreferencesUserSettingsTab.TIME_SETTINGS) }
</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Presence") }</span>
<span className="mx_SettingsTab_subsectionText">
{ _t("Share your activity and status with others.") }
</span>
<SettingsFlag
disabled={!this.state.disablingReadReceiptsSupported}
disabledDescription={_t("Your server doesn't support disabling sending read receipts.")}
name="sendReadReceipts"
level={SettingLevel.ACCOUNT}
/>
{ this.renderGroup(PreferencesUserSettingsTab.PRESENCE_SETTINGS) }
</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Composer") }</span>
{ this.renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS) }

View File

@ -304,7 +304,7 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
<div className="mx_SettingsTab_subsectionText">
<p>
{ _t("Share anonymous data to help us identify issues. Nothing personal. " +
"No third parties.") }
"No third parties.") }
</p>
<AccessibleButton
kind="link"

View File

@ -898,7 +898,7 @@
"Use new room breadcrumbs": "Use new room breadcrumbs",
"Right panel stays open (defaults to room member list)": "Right panel stays open (defaults to room member list)",
"Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)",
"Don't send read receipts": "Don't send read receipts",
"Send read receipts": "Send read receipts",
"Right-click message context menu": "Right-click message context menu",
"Location sharing - pin drop": "Location sharing - pin drop",
"Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)",
@ -1538,6 +1538,9 @@
"Keyboard shortcuts": "Keyboard shortcuts",
"To view all keyboard shortcuts, <a>click here</a>.": "To view all keyboard shortcuts, <a>click here</a>.",
"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",

View File

@ -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,

View File

@ -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;
}
}
}

View File

@ -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(
<TimelinePanel
timelineSet={timelineSet}
manageReadReceipts
sendReadReceiptOnLoad
/>,
);
};
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]);
});
});
});

View File

@ -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;
}