From 88afd552d866cf0d82eda52652a152b84f673189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Mon, 4 Jul 2022 21:37:48 +0200 Subject: [PATCH] Delabs `Show current avatar and name for users in message history` (#8764) Co-authored-by: Travis Ralston --- cypress/global.d.ts | 2 + .../integration/14-timeline/timeline.spec.ts | 145 ++++++++++++++++++ cypress/support/client.ts | 92 ++++++++++- cypress/support/settings.ts | 55 +++++++ src/components/views/avatars/MemberAvatar.tsx | 4 +- .../views/messages/SenderProfile.tsx | 2 +- .../tabs/user/PreferencesUserSettingsTab.tsx | 1 + src/settings/Settings.tsx | 6 +- 8 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 cypress/integration/14-timeline/timeline.spec.ts 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