diff --git a/cypress/global.d.ts b/cypress/global.d.ts
index 2cbfff94c2..18f4314d1c 100644
--- a/cypress/global.d.ts
+++ b/cypress/global.d.ts
@@ -31,11 +31,13 @@ import type {
} from "matrix-js-sdk/src/matrix";
import type { MatrixDispatcher } from "../src/dispatcher/dispatcher";
import type PerformanceMonitor from "../src/performance";
+import type SettingsStore from "../src/settings/SettingsStore";
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface ApplicationWindow {
+ mxSettingsStore: typeof SettingsStore;
mxMatrixClientPeg: {
matrixClient?: MatrixClient;
};
diff --git a/cypress/integration/14-timeline/timeline.spec.ts b/cypress/integration/14-timeline/timeline.spec.ts
new file mode 100644
index 0000000000..22861c8fd7
--- /dev/null
+++ b/cypress/integration/14-timeline/timeline.spec.ts
@@ -0,0 +1,145 @@
+/*
+Copyright 2022 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.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+///
+
+import { MessageEvent } from "matrix-events-sdk";
+
+import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests";
+import type { EventType } from "matrix-js-sdk/src/@types/event";
+import type { MatrixClient } from "matrix-js-sdk/src/client";
+import { SynapseInstance } from "../../plugins/synapsedocker";
+import { SettingLevel } from "../../../src/settings/SettingLevel";
+import Chainable = Cypress.Chainable;
+
+// The avatar size used in the timeline
+const AVATAR_SIZE = 30;
+// The resize method used in the timeline
+const AVATAR_RESIZE_METHOD = "crop";
+
+const ROOM_NAME = "Test room";
+const OLD_AVATAR = "avatar_image1";
+const NEW_AVATAR = "avatar_image2";
+const OLD_NAME = "Alan";
+const NEW_NAME = "Alan (away)";
+
+const getEventTilesWithBodies = (): Chainable => {
+ return cy.get(".mx_EventTile").filter((_i, e) => e.getElementsByClassName("mx_EventTile_body").length > 0);
+};
+
+const expectDisplayName = (e: JQuery, displayName: string): void => {
+ expect(e.find(".mx_DisambiguatedProfile_displayName").text()).to.equal(displayName);
+};
+
+const expectAvatar = (e: JQuery, avatarUrl: string): void => {
+ cy.getClient().then((cli: MatrixClient) => {
+ expect(e.find(".mx_BaseAvatar_image").attr("src")).to.equal(
+ // eslint-disable-next-line no-restricted-properties
+ cli.mxcUrlToHttp(avatarUrl, AVATAR_SIZE, AVATAR_SIZE, AVATAR_RESIZE_METHOD),
+ );
+ });
+};
+
+const sendEvent = (roomId: string): Chainable => {
+ return cy.sendEvent(
+ roomId,
+ null,
+ "m.room.message" as EventType,
+ MessageEvent.from("Message").serialize().content,
+ );
+};
+
+describe("Timeline", () => {
+ let synapse: SynapseInstance;
+
+ let roomId: string;
+
+ let oldAvatarUrl: string;
+ let newAvatarUrl: string;
+
+ describe("useOnlyCurrentProfiles", () => {
+ beforeEach(() => {
+ cy.startSynapse("default").then(data => {
+ synapse = data;
+ cy.initTestUser(synapse, OLD_NAME).then(() =>
+ cy.window({ log: false }).then(() => {
+ cy.createRoom({ name: ROOM_NAME }).then(_room1Id => {
+ roomId = _room1Id;
+ });
+ }),
+ ).then(() => {
+ cy.uploadContent(OLD_AVATAR).then((url) => {
+ oldAvatarUrl = url;
+ cy.setAvatarUrl(url);
+ });
+ }).then(() => {
+ cy.uploadContent(NEW_AVATAR).then((url) => {
+ newAvatarUrl = url;
+ });
+ });
+ });
+ });
+
+ afterEach(() => {
+ cy.stopSynapse(synapse);
+ });
+
+ it("should show historical profiles if disabled", () => {
+ cy.setSettingValue("useOnlyCurrentProfiles", null, SettingLevel.ACCOUNT, false);
+ sendEvent(roomId);
+ cy.setDisplayName("Alan (away)");
+ cy.setAvatarUrl(newAvatarUrl);
+ // XXX: If we send the second event too quickly, there won't be
+ // enough time for the client to register the profile change
+ cy.wait(500);
+ sendEvent(roomId);
+ cy.viewRoomByName(ROOM_NAME);
+
+ const events = getEventTilesWithBodies();
+
+ events.should("have.length", 2);
+ events.each((e, i) => {
+ if (i === 0) {
+ expectDisplayName(e, OLD_NAME);
+ expectAvatar(e, oldAvatarUrl);
+ } else if (i === 1) {
+ expectDisplayName(e, NEW_NAME);
+ expectAvatar(e, newAvatarUrl);
+ }
+ });
+ });
+
+ it("should not show historical profiles if enabled", () => {
+ cy.setSettingValue("useOnlyCurrentProfiles", null, SettingLevel.ACCOUNT, true);
+ sendEvent(roomId);
+ cy.setDisplayName(NEW_NAME);
+ cy.setAvatarUrl(newAvatarUrl);
+ // XXX: If we send the second event too quickly, there won't be
+ // enough time for the client to register the profile change
+ cy.wait(500);
+ sendEvent(roomId);
+ cy.viewRoomByName(ROOM_NAME);
+
+ const events = getEventTilesWithBodies();
+
+ events.should("have.length", 2);
+ events.each((e) => {
+ expectDisplayName(e, NEW_NAME);
+ expectAvatar(e, newAvatarUrl);
+ });
+ });
+ });
+});
diff --git a/cypress/support/client.ts b/cypress/support/client.ts
index c4577760a8..8f9b14e851 100644
--- a/cypress/support/client.ts
+++ b/cypress/support/client.ts
@@ -16,9 +16,12 @@ limitations under the License.
///
-import type { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
+import type { FileType, UploadContentResponseType } from "matrix-js-sdk/src/http-api";
+import type { IAbortablePromise } from "matrix-js-sdk/src/@types/partials";
+import type { ICreateRoomOpts, ISendEventResponse, IUploadOpts } from "matrix-js-sdk/src/@types/requests";
import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { Room } from "matrix-js-sdk/src/models/room";
+import type { IContent } from "matrix-js-sdk/src/models/event";
import Chainable = Cypress.Chainable;
declare global {
@@ -53,6 +56,64 @@ declare global {
* @param data The data to store.
*/
setAccountData(type: string, data: object): Chainable<{}>;
+ /**
+ * @param {string} roomId
+ * @param {string} threadId
+ * @param {string} eventType
+ * @param {Object} content
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+ sendEvent(
+ roomId: string,
+ threadId: string | null,
+ eventType: string,
+ content: IContent
+ ): Chainable;
+ /**
+ * @param {string} name
+ * @param {module:client.callback} callback Optional.
+ * @return {Promise} Resolves: {} an empty object.
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+ setDisplayName(name: string): Chainable<{}>;
+ /**
+ * @param {string} url
+ * @param {module:client.callback} callback Optional.
+ * @return {Promise} Resolves: {} an empty object.
+ * @return {module:http-api.MatrixError} Rejects: with an error response.
+ */
+ setAvatarUrl(url: string): Chainable<{}>;
+ /**
+ * Upload a file to the media repository on the homeserver.
+ *
+ * @param {object} file The object to upload. On a browser, something that
+ * can be sent to XMLHttpRequest.send (typically a File). Under node.js,
+ * a a Buffer, String or ReadStream.
+ */
+ uploadContent(
+ file: FileType,
+ opts?: O,
+ ): IAbortablePromise>;
+ /**
+ * Turn an MXC URL into an HTTP one. This method is experimental and
+ * may change.
+ * @param {string} mxcUrl The MXC URL
+ * @param {Number} width The desired width of the thumbnail.
+ * @param {Number} height The desired height of the thumbnail.
+ * @param {string} resizeMethod The thumbnail resize method to use, either
+ * "crop" or "scale".
+ * @param {Boolean} allowDirectLinks If true, return any non-mxc URLs
+ * directly. Fetching such URLs will leak information about the user to
+ * anyone they share a room with. If false, will return null for such URLs.
+ * @return {?string} the avatar URL or null.
+ */
+ mxcUrlToHttp(
+ mxcUrl: string,
+ width?: number,
+ height?: number,
+ resizeMethod?: string,
+ allowDirectLinks?: boolean,
+ ): string | null;
/**
* Gets the list of DMs with a given user
* @param userId The ID of the user
@@ -120,6 +181,35 @@ Cypress.Commands.add("setAccountData", (type: string, data: object): Chainable<{
});
});
+Cypress.Commands.add("sendEvent", (
+ roomId: string,
+ threadId: string | null,
+ eventType: string,
+ content: IContent,
+): Chainable => {
+ return cy.getClient().then(async (cli: MatrixClient) => {
+ return cli.sendEvent(roomId, threadId, eventType, content);
+ });
+});
+
+Cypress.Commands.add("setDisplayName", (name: string): Chainable<{}> => {
+ return cy.getClient().then(async (cli: MatrixClient) => {
+ return cli.setDisplayName(name);
+ });
+});
+
+Cypress.Commands.add("uploadContent", (file: FileType): Chainable<{}> => {
+ return cy.getClient().then(async (cli: MatrixClient) => {
+ return cli.uploadContent(file);
+ });
+});
+
+Cypress.Commands.add("setAvatarUrl", (url: string): Chainable<{}> => {
+ return cy.getClient().then(async (cli: MatrixClient) => {
+ return cli.setAvatarUrl(url);
+ });
+});
+
Cypress.Commands.add("bootstrapCrossSigning", () => {
cy.window({ log: false }).then(win => {
win.mxMatrixClientPeg.matrixClient.bootstrapCrossSigning({
diff --git a/cypress/support/settings.ts b/cypress/support/settings.ts
index aed0631354..a44f3f06d2 100644
--- a/cypress/support/settings.ts
+++ b/cypress/support/settings.ts
@@ -17,11 +17,17 @@ limitations under the License.
///
import Chainable = Cypress.Chainable;
+import type { SettingLevel } from "../../src/settings/SettingLevel";
+import type SettingsStore from "../../src/settings/SettingsStore";
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
+ /**
+ * Returns the SettingsStore
+ */
+ getSettingsStore(): Chainable; // XXX: Importing SettingsStore causes a bunch of type lint errors
/**
* Open the top left user menu, returning a handle to the resulting context menu.
*/
@@ -63,10 +69,59 @@ declare global {
* @param name the name of the beta to leave.
*/
leaveBeta(name: string): Chainable>;
+
+ /**
+ * Sets the value for a setting. The room ID is optional if the
+ * setting is not being set for a particular room, otherwise it
+ * should be supplied. The value may be null to indicate that the
+ * level should no longer have an override.
+ * @param {string} settingName The name of the setting to change.
+ * @param {String} roomId The room ID to change the value in, may be
+ * null.
+ * @param {SettingLevel} level The level to change the value at.
+ * @param {*} value The new value of the setting, may be null.
+ * @return {Promise} Resolves when the setting has been changed.
+ */
+ setSettingValue(name: string, roomId: string, level: SettingLevel, value: any): Chainable;
+
+ /**
+ * Gets the value of a setting. The room ID is optional if the
+ * setting is not to be applied to any particular room, otherwise it
+ * should be supplied.
+ * @param {string} settingName The name of the setting to read the
+ * value of.
+ * @param {String} roomId The room ID to read the setting value in,
+ * may be null.
+ * @param {boolean} excludeDefault True to disable using the default
+ * value.
+ * @return {*} The value, or null if not found
+ */
+ getSettingValue(name: string, roomId?: string): Chainable;
}
}
}
+Cypress.Commands.add("getSettingsStore", (): Chainable => {
+ return cy.window({ log: false }).then(win => win.mxSettingsStore);
+});
+
+Cypress.Commands.add("setSettingValue", (
+ name: string,
+ roomId: string,
+ level: SettingLevel,
+ value: any,
+): Chainable => {
+ return cy.getSettingsStore().then(async (store: typeof SettingsStore) => {
+ return store.setValue(name, roomId, level, value);
+ });
+});
+
+Cypress.Commands.add("getSettingValue", (name: string, roomId?: string): Chainable => {
+ return cy.getSettingsStore().then((store: typeof SettingsStore) => {
+ return store.getValue(name, roomId);
+ });
+});
+
Cypress.Commands.add("openUserMenu", (): Chainable> => {
cy.get('[aria-label="User menu"]').click();
return cy.get(".mx_ContextualMenu");
diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx
index 76dc9e6962..4866439473 100644
--- a/src/components/views/avatars/MemberAvatar.tsx
+++ b/src/components/views/avatars/MemberAvatar.tsx
@@ -42,7 +42,7 @@ interface IProps extends Omit, "name" |
pushUserOnClick?: boolean;
title?: string;
style?: any;
- forceHistorical?: boolean; // true to deny `feature_use_only_current_profiles` usage. Default false.
+ forceHistorical?: boolean; // true to deny `useOnlyCurrentProfiles` usage. Default false.
hideTitle?: boolean;
}
@@ -72,7 +72,7 @@ export default class MemberAvatar extends React.PureComponent {
private static getState(props: IProps): IState {
let member = props.member;
- if (member && !props.forceHistorical && SettingsStore.getValue("feature_use_only_current_profiles")) {
+ if (member && !props.forceHistorical && SettingsStore.getValue("useOnlyCurrentProfiles")) {
const room = MatrixClientPeg.get().getRoom(member.roomId);
if (room) {
member = room.getMember(member.userId);
diff --git a/src/components/views/messages/SenderProfile.tsx b/src/components/views/messages/SenderProfile.tsx
index da7d1206d1..db44cfeb04 100644
--- a/src/components/views/messages/SenderProfile.tsx
+++ b/src/components/views/messages/SenderProfile.tsx
@@ -38,7 +38,7 @@ export default class SenderProfile extends React.PureComponent {
const msgtype = mxEvent.getContent().msgtype;
let member = mxEvent.sender;
- if (SettingsStore.getValue("feature_use_only_current_profiles")) {
+ if (SettingsStore.getValue("useOnlyCurrentProfiles")) {
const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId());
if (room) {
member = room.getMember(mxEvent.getSender());
diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx
index e72be3404b..06883703bd 100644
--- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx
+++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx
@@ -89,6 +89,7 @@ export default class PreferencesUserSettingsTab extends React.Component