Remove old pre-join UTD logic (#12464)

* remove old pre-join UTD logic

and add a playwright test for new pre-join UTD

* remove variable that isn't used any more

* add missing synapse template

* remove unused variable (again) and run prettier

* add test that we can jump to an event before our latest join membership event

* modify default template instead of creating a new template
This commit is contained in:
Hubert Chathi 2024-05-10 16:20:40 -04:00 committed by GitHub
parent 948435ceb9
commit 4ed6da4eba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 232 additions and 126 deletions

View file

@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022-2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,6 +15,7 @@ limitations under the License.
*/
import type { Page } from "@playwright/test";
import type { EmittedEvents, Preset } from "matrix-js-sdk/src/matrix";
import { expect, test } from "../../element-web-test";
import {
copyAndContinue,
@ -31,6 +32,7 @@ import {
import { Bot } from "../../pages/bot";
import { ElementAppPage } from "../../pages/ElementAppPage";
import { Client } from "../../pages/client";
import { isDendrite } from "../../plugins/homeserver/dendrite";
const openRoomInfo = async (page: Page) => {
await page.getByRole("button", { name: "Room info" }).click();
@ -599,5 +601,217 @@ test.describe("Cryptography", function () {
await expect(tilesAfterVerify[1]).toContainText("test2 test2");
await expect(tilesAfterVerify[1].locator(".mx_EventTile_e2eIcon_normal")).toBeVisible();
});
test.describe("non-joined historical messages", () => {
test.skip(isDendrite, "does not yet support membership on events");
test("should display undecryptable non-joined historical messages with a different message", async ({
homeserver,
page,
app,
credentials: aliceCredentials,
user: alice,
cryptoBackend,
bot: bob,
}) => {
test.skip(cryptoBackend === "legacy", "Not implemented for legacy crypto");
// Bob creates an encrypted room and sends a message to it. He then invites Alice
const roomId = await bob.evaluate(
async (client, { alice }) => {
const encryptionStatePromise = new Promise<void>((resolve) => {
client.on("RoomState.events" as EmittedEvents, (event, _state, _lastStateEvent) => {
if (event.getType() === "m.room.encryption") {
resolve();
}
});
});
const { room_id: roomId } = await client.createRoom({
initial_state: [
{
type: "m.room.encryption",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
},
],
name: "Test room",
preset: "private_chat" as Preset,
});
// wait for m.room.encryption event, so that when we send a
// message, it will be encrypted
await encryptionStatePromise;
await client.sendTextMessage(roomId, "This should be undecryptable");
await client.invite(roomId, alice.userId);
return roomId;
},
{ alice },
);
// Alice accepts the invite
await expect(
page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"),
).toHaveCount(1);
await page.getByRole("treeitem", { name: "Test room" }).click();
await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click();
// Bob sends an encrypted event and an undecryptable event
await bob.evaluate(
async (client, { roomId }) => {
await client.sendTextMessage(roomId, "This should be decryptable");
await client.sendEvent(
roomId,
"m.room.encrypted" as any,
{
algorithm: "m.megolm.v1.aes-sha2",
ciphertext: "this+message+will+be+undecryptable",
device_id: client.getDeviceId()!,
sender_key: (await client.getCrypto()!.getOwnDeviceKeys()).ed25519,
session_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
} as any,
);
},
{ roomId },
);
// We wait for the event tiles that we expect from the messages that
// Bob sent, in sequence.
await expect(
page.locator(`.mx_EventTile`).getByText("You don't have access to this message"),
).toBeVisible();
await expect(page.locator(`.mx_EventTile`).getByText("This should be decryptable")).toBeVisible();
await expect(page.locator(`.mx_EventTile`).getByText("Unable to decrypt message")).toBeVisible();
// And then we ensure that they are where we expect them to be
// Alice should see these event tiles:
// - first message sent by Bob (undecryptable)
// - Bob invited Alice
// - Alice joined the room
// - second message sent by Bob (decryptable)
// - third message sent by Bob (undecryptable)
const tiles = await page.locator(".mx_EventTile").all();
expect(tiles.length).toBeGreaterThanOrEqual(5);
// The first message from Bob was sent before Alice was in the room, so should
// be different from the standard UTD message
await expect(tiles[tiles.length - 5]).toContainText("You don't have access to this message");
await expect(tiles[tiles.length - 5].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
// The second message from Bob should be decryptable
await expect(tiles[tiles.length - 2]).toContainText("This should be decryptable");
// this tile won't have an e2e icon since we got the key from the sender
// The third message from Bob is undecryptable, but was sent while Alice was
// in the room and is expected to be decryptable, so this should have the
// standard UTD message
await expect(tiles[tiles.length - 1]).toContainText("Unable to decrypt message");
await expect(tiles[tiles.length - 1].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible();
});
test("should be able to jump to a message sent before our last join event", async ({
homeserver,
page,
app,
credentials: aliceCredentials,
user: alice,
cryptoBackend,
bot: bob,
}) => {
// The old pre-join UTD hiding code would hide events sent
// before our latest join event, even if the event that we're
// jumping to was decryptable. We test that this no longer happens.
test.skip(cryptoBackend === "legacy", "Not implemented for legacy crypto");
// Bob:
// - creates an encrypted room,
// - invites Alice,
// - sends a message to it,
// - kicks Alice,
// - sends a bunch more events
// - invites Alice again
// In this way, there will be an event that Alice can decrypt,
// followed by a bunch of undecryptable events which Alice shouldn't
// expect to be able to decrypt. The old code would have hidden all
// the events, even the decryptable event (which it wouldn't have
// even tried to fetch, if it was far enough back).
const { roomId, eventId } = await bob.evaluate(
async (client, { alice }) => {
const { room_id: roomId } = await client.createRoom({
initial_state: [
{
type: "m.room.encryption",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
},
],
name: "Test room",
preset: "private_chat" as Preset,
});
// invite Alice
const inviteAlicePromise = new Promise<void>((resolve) => {
client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => {
if (member.userId === alice.userId && member.membership === "invite") {
resolve();
}
});
});
await client.invite(roomId, alice.userId);
// wait for the invite to come back so that we encrypt to Alice
await inviteAlicePromise;
// send a message that Alice should be able to decrypt
const { event_id: eventId } = await client.sendTextMessage(
roomId,
"This should be decryptable",
);
// kick Alice
const kickAlicePromise = new Promise<void>((resolve) => {
client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => {
if (member.userId === alice.userId && member.membership === "leave") {
resolve();
}
});
});
await client.kick(roomId, alice.userId);
await kickAlicePromise;
// send a bunch of messages that Alice won't be able to decrypt
for (let i = 0; i < 20; i++) {
await client.sendTextMessage(roomId, `${i}`);
}
// invite Alice again
await client.invite(roomId, alice.userId);
return { roomId, eventId };
},
{ alice },
);
// Alice accepts the invite
await expect(
page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"),
).toHaveCount(1);
await page.getByRole("treeitem", { name: "Test room" }).click();
await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click();
// wait until we're joined and see the timeline
await expect(page.locator(`.mx_EventTile`).getByText("Alice joined the room")).toBeVisible();
// we should be able to jump to the decryptable message that Bob sent
await page.goto(`#/room/${roomId}/${eventId}`);
await expect(page.locator(`.mx_EventTile`).getByText("This should be decryptable")).toBeVisible();
});
});
});
});

View file

@ -96,3 +96,9 @@ oidc_providers:
background_updates:
min_batch_size: 100000
sleep_duration_ms: 100000
experimental_features:
# Needed for e2e/crypto/crypto.spec.ts > Cryptography > decryption failure
# messages > non-joined historical messages.
# Can be removed after Synapse enables it by default
msc4115_membership_on_events: true

View file

@ -39,8 +39,7 @@ import {
ThreadEvent,
ReceiptType,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership, Membership } from "matrix-js-sdk/src/types";
import { debounce, findLastIndex, throttle } from "lodash";
import { debounce, findLastIndex } from "lodash";
import { logger } from "matrix-js-sdk/src/logger";
import SettingsStore from "../../settings/SettingsStore";
@ -176,9 +175,6 @@ interface IState {
// track whether our room timeline is loading
timelineLoading: boolean;
// the index of the first event that is to be shown
firstVisibleEventIndex: number;
// canBackPaginate == false may mean:
//
// * we haven't (successfully) loaded the timeline yet, or:
@ -296,7 +292,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
events: [],
liveEvents: [],
timelineLoading: true,
firstVisibleEventIndex: 0,
canBackPaginate: false,
canForwardPaginate: false,
readMarkerVisible: true,
@ -568,12 +563,11 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.overlayTimelineWindow!.unpaginate(overlayCount, backwards);
}
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
const { events, liveEvents } = this.getEvents();
this.buildLegacyCallEventGroupers(events);
this.setState({
events,
liveEvents,
firstVisibleEventIndex,
});
// We can now paginate in the unpaginated direction
@ -617,11 +611,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
return Promise.resolve(false);
}
if (backwards && this.state.firstVisibleEventIndex !== 0) {
debuglog("won't", dir, "paginate past first visible event");
return Promise.resolve(false);
}
debuglog("Initiating paginate; backwards:" + backwards);
this.setState({ [paginatingKey]: true } as Pick<IState, PaginatingKey>);
@ -636,15 +625,14 @@ class TimelinePanel extends React.Component<IProps, IState> {
debuglog("paginate complete backwards:" + backwards + "; success:" + r);
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
const { events, liveEvents } = this.getEvents();
this.buildLegacyCallEventGroupers(events);
const newState = {
[paginatingKey]: false,
[canPaginateKey]: r,
events,
liveEvents,
firstVisibleEventIndex,
} as Pick<IState, PaginatingKey | CanPaginateKey | "events" | "liveEvents" | "firstVisibleEventIndex">;
} as Pick<IState, PaginatingKey | CanPaginateKey | "events" | "liveEvents">;
// moving the window in this direction may mean that we can now
// paginate in the other where we previously could not.
@ -662,11 +650,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
// itself into the right place
return new Promise((resolve) => {
this.setState(newState, () => {
// we can continue paginating in the given direction if:
// - timelineWindow.paginate says we can
// - we're paginating forwards, or we won't be trying to
// paginate backwards past the first visible event
resolve(r && (!backwards || firstVisibleEventIndex === 0));
// we can continue paginating in the given direction if
// timelineWindow.paginate says we can
resolve(r);
});
});
});
@ -782,14 +768,13 @@ class TimelinePanel extends React.Component<IProps, IState> {
return;
}
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
const { events, liveEvents } = this.getEvents();
this.buildLegacyCallEventGroupers(events);
const lastLiveEvent = liveEvents[liveEvents.length - 1];
const updatedState: Partial<IState> = {
events,
liveEvents,
firstVisibleEventIndex,
};
let callRMUpdated = false;
@ -967,8 +952,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (!this.state.events.includes(ev)) return;
this.recheckFirstVisibleEventIndex();
// Need to update as we don't display event tiles for events that
// haven't yet been decrypted. The event will have just been updated
// in place so we just need to re-render.
@ -984,17 +967,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.setState({ clientSyncState });
};
private recheckFirstVisibleEventIndex = throttle(
(): void => {
const firstVisibleEventIndex = this.checkForPreJoinUISI(this.state.events);
if (firstVisibleEventIndex !== this.state.firstVisibleEventIndex) {
this.setState({ firstVisibleEventIndex });
}
},
500,
{ leading: true, trailing: true },
);
private readMarkerTimeout(readMarkerPosition: number | null): number {
return readMarkerPosition === 0
? this.context?.readMarkerInViewThresholdMs ?? this.state.readMarkerInViewThresholdMs
@ -1721,7 +1693,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
}
// get the list of events from the timeline windows and the pending event list
private getEvents(): Pick<IState, "events" | "liveEvents" | "firstVisibleEventIndex"> {
private getEvents(): Pick<IState, "events" | "liveEvents"> {
const mainEvents = this.timelineWindow!.getEvents();
let overlayEvents = this.overlayTimelineWindow?.getEvents() ?? [];
if (this.props.overlayTimelineSetFilter !== undefined) {
@ -1759,8 +1731,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
client.decryptEventIfNeeded(events[i]);
}
const firstVisibleEventIndex = this.checkForPreJoinUISI(events);
// Hold onto the live events separately. The read receipt and read marker
// should use this list, so that they don't advance into pending events.
const liveEvents = [...events];
@ -1788,87 +1758,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
return {
events,
liveEvents,
firstVisibleEventIndex,
};
}
/**
* Check for undecryptable messages that were sent while the user was not in
* the room.
*
* @param {Array<MatrixEvent>} events The timeline events to check
*
* @return {Number} The index within `events` of the event after the most recent
* undecryptable event that was sent while the user was not in the room. If no
* such events were found, then it returns 0.
*/
private checkForPreJoinUISI(events: MatrixEvent[]): number {
const cli = MatrixClientPeg.safeGet();
const room = this.props.timelineSet.room;
const isThreadTimeline = [TimelineRenderingType.Thread, TimelineRenderingType.ThreadsList].includes(
this.context.timelineRenderingType,
);
if (events.length === 0 || !room || !cli.isRoomEncrypted(room.roomId) || isThreadTimeline) {
logger.debug("checkForPreJoinUISI: showing all messages, skipping check");
return 0;
}
const userId = cli.getSafeUserId();
// get the user's membership at the last event by getting the timeline
// that the event belongs to, and traversing the timeline looking for
// that event, while keeping track of the user's membership
let i = events.length - 1;
let userMembership: Membership = KnownMembership.Leave;
for (; i >= 0; i--) {
const timeline = this.props.timelineSet.getTimelineForEvent(events[i].getId()!);
if (!timeline) {
// Somehow, it seems to be possible for live events to not have
// a timeline, even though that should not happen. :(
// https://github.com/vector-im/element-web/issues/12120
logger.warn(
`Event ${events[i].getId()} in room ${room.roomId} is live, ` + `but it does not have a timeline`,
);
continue;
}
userMembership =
timeline.getState(EventTimeline.FORWARDS)?.getMember(userId)?.membership ?? KnownMembership.Leave;
const timelineEvents = timeline.getEvents();
for (let j = timelineEvents.length - 1; j >= 0; j--) {
const event = timelineEvents[j];
if (event.getId() === events[i].getId()) {
break;
} else if (event.getStateKey() === userId && event.getType() === EventType.RoomMember) {
userMembership = event.getPrevContent().membership || KnownMembership.Leave;
}
}
break;
}
// now go through the rest of the events and find the first undecryptable
// one that was sent when the user wasn't in the room
for (; i >= 0; i--) {
const event = events[i];
if (event.getStateKey() === userId && event.getType() === EventType.RoomMember) {
userMembership = event.getPrevContent().membership || KnownMembership.Leave;
} else if (
userMembership === KnownMembership.Leave &&
(event.isDecryptionFailure() || event.isBeingDecrypted())
) {
// reached an undecryptable message when the user wasn't in the room -- don't try to load any more
// Note: for now, we assume that events that are being decrypted are
// not decryptable - we will be called once more when it is decrypted.
logger.debug("checkForPreJoinUISI: reached a pre-join UISI at index ", i);
return i + 1;
}
}
logger.debug("checkForPreJoinUISI: did not find pre-join UISI");
return 0;
}
private indexForEventId(evId: string | null): number | null {
if (evId === null) {
return null;
@ -2119,9 +2011,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
// the HS and fetch the latest events, so we are effectively forward paginating.
const forwardPaginating =
this.state.forwardPaginating || ["PREPARED", "CATCHUP"].includes(this.state.clientSyncState!);
const events = this.state.firstVisibleEventIndex
? this.state.events.slice(this.state.firstVisibleEventIndex)
: this.state.events;
const events = this.state.events;
return (
<MessagePanel
ref={this.messagePanel}
@ -2134,7 +2024,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
highlightedEventId={this.props.highlightedEventId}
readMarkerEventId={this.state.readMarkerEventId}
readMarkerVisible={this.state.readMarkerVisible}
canBackPaginate={this.state.canBackPaginate && this.state.firstVisibleEventIndex === 0}
canBackPaginate={this.state.canBackPaginate}
showUrlPreview={this.props.showUrlPreview}
showReadReceipts={this.props.showReadReceipts}
ourUserId={MatrixClientPeg.safeGet().getSafeUserId()}

View file

@ -25,7 +25,6 @@ const HistoryTile: React.FC = () => {
const { room } = useContext(RoomContext);
const oldState = room?.getLiveTimeline().getState(EventTimeline.BACKWARDS);
const encryptionState = oldState?.getStateEvents("m.room.encryption")[0];
const historyState = oldState?.getStateEvents("m.room.history_visibility")[0]?.getContent().history_visibility;
let subtitle: string | undefined;
@ -33,8 +32,6 @@ const HistoryTile: React.FC = () => {
subtitle = _t("timeline|no_permission_messages_before_invite");
} else if (historyState == "joined") {
subtitle = _t("timeline|no_permission_messages_before_join");
} else if (encryptionState) {
subtitle = _t("timeline|encrypted_historical_messages_unavailable");
}
return (

View file

@ -3241,7 +3241,6 @@
"tooltip_sub": "Click to view edits",
"tooltip_title": "Edited at %(date)s"
},
"encrypted_historical_messages_unavailable": "Encrypted messages before this point are unavailable.",
"error_no_renderer": "This event could not be displayed",
"error_rendering_message": "Can't load this message",
"historical_messages_unavailable": "You can't see earlier messages",