From 325cca670cda4d2ba45ccccef61e147917e39616 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Fri, 20 Jan 2023 12:30:52 +0000 Subject: [PATCH 01/40] Upgrade matrix-js-sdk to 23.1.1 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index f9e9a87255..fb604404cc 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "maplibre-gl": "^2.0.0", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "0.0.1", - "matrix-js-sdk": "23.1.0", + "matrix-js-sdk": "23.1.1", "matrix-widget-api": "^1.1.1", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", diff --git a/yarn.lock b/yarn.lock index 58d4d7f3f9..fd0801eb0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6400,10 +6400,10 @@ matrix-events-sdk@0.0.1: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd" integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== -matrix-js-sdk@23.1.0: - version "23.1.0" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-23.1.0.tgz#c8dc89f82c5dce1ec25eedf05613cd9665cc1bbd" - integrity sha512-/9eFWJBQceQX+2dHYZ5/9p0kjJs1PVDbIEWvR6FPjdnuNK3R4qi2NwpM6MoAHt8m7mX5qUG7x0NUWlalaOwrGQ== +matrix-js-sdk@23.1.1: + version "23.1.1" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-23.1.1.tgz#0678edbf4d32241d7e3ef841597302559ac92608" + integrity sha512-lPxthMS2Cjd1jaQtYDRhfa2OwcIYS95UtrIwOCSFAI1Irh+PySgNenLjDLFdnmkwGmTf5LNrs5HKO1ZdcPY6pw== dependencies: "@babel/runtime" "^7.12.5" "@matrix-org/matrix-sdk-crypto-js" "^0.1.0-alpha.2" From dd34fe74e0e40169b2dfd25b5e8137da32824ed1 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Fri, 20 Jan 2023 12:34:01 +0000 Subject: [PATCH 02/40] Prepare changelog for v3.64.2 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6a0f92e41..495f538aef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +Changes in [3.64.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.64.2) (2023-01-20) +===================================================================================================== + +## 🐛 Bug Fixes + * Fix second occurence of a crash in older browsers + Changes in [3.64.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.64.1) (2023-01-18) ===================================================================================================== From e3692ce2e45b4dedb2bfeb7355ea312928310748 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Fri, 20 Jan 2023 12:34:04 +0000 Subject: [PATCH 03/40] v3.64.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fb604404cc..09682dc292 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.64.1", + "version": "3.64.2", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { From 45fc0c05ca9ce3807830b38b19f506445dcb10bd Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 24 Jan 2023 11:27:41 +0000 Subject: [PATCH 04/40] Upgrade matrix-js-sdk to 23.2.0-rc.1 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 17d2c761ff..4a89679c55 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "maplibre-gl": "^2.0.0", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "0.0.1", - "matrix-js-sdk": "23.1.1", + "matrix-js-sdk": "23.2.0-rc.1", "matrix-widget-api": "^1.1.1", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", diff --git a/yarn.lock b/yarn.lock index e65382d1bc..718a5f8a4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6491,10 +6491,10 @@ matrix-events-sdk@0.0.1: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd" integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA== -matrix-js-sdk@23.1.1: - version "23.1.1" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-23.1.1.tgz#0678edbf4d32241d7e3ef841597302559ac92608" - integrity sha512-lPxthMS2Cjd1jaQtYDRhfa2OwcIYS95UtrIwOCSFAI1Irh+PySgNenLjDLFdnmkwGmTf5LNrs5HKO1ZdcPY6pw== +matrix-js-sdk@23.2.0-rc.1: + version "23.2.0-rc.1" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-23.2.0-rc.1.tgz#9c908e9856e9d51b376cc57839f630ff0cf87291" + integrity sha512-PWj5DzQJzbAMsHMbOITvkKl/vn5+AS2k19uDhUNObpZJFpbOD+JV3tVEiTZlUxYhNzvtYDFG9Aj3T0Wokx5lqw== dependencies: "@babel/runtime" "^7.12.5" "@matrix-org/matrix-sdk-crypto-js" "^0.1.0-alpha.2" From d6161de463e8c0dbb22eeccaa0f766e8c2a405da Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 24 Jan 2023 11:29:45 +0000 Subject: [PATCH 05/40] Prepare changelog for v3.65.0-rc.1 --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 495f538aef..ee73064d69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,42 @@ +Changes in [3.65.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.65.0-rc.1) (2023-01-24) +=============================================================================================================== + +## ✨ Features + * Quotes for rte ([\#9932](https://github.com/matrix-org/matrix-react-sdk/pull/9932)). Contributed by @alunturner. + * Show the room name in the room header during calls ([\#9942](https://github.com/matrix-org/matrix-react-sdk/pull/9942)). Fixes vector-im/element-web#24268. + * Add code blocks to rich text editor ([\#9921](https://github.com/matrix-org/matrix-react-sdk/pull/9921)). Contributed by @alunturner. + * Add new style for inline code ([\#9936](https://github.com/matrix-org/matrix-react-sdk/pull/9936)). Contributed by @florianduros. + * Add disabled button state to rich text editor ([\#9930](https://github.com/matrix-org/matrix-react-sdk/pull/9930)). Contributed by @alunturner. + * Change the rageshake "app" for auto-rageshakes ([\#9909](https://github.com/matrix-org/matrix-react-sdk/pull/9909)). + * Device manager - tweak settings display ([\#9905](https://github.com/matrix-org/matrix-react-sdk/pull/9905)). Contributed by @kerryarchibald. + * Add list functionality to rich text editor ([\#9871](https://github.com/matrix-org/matrix-react-sdk/pull/9871)). Contributed by @alunturner. + +## 🐛 Bug Fixes + * Fix RTE focus behaviour in threads ([\#9969](https://github.com/matrix-org/matrix-react-sdk/pull/9969)). Fixes vector-im/element-web#23755. Contributed by @florianduros. + * #22204 Issue: Centered File info in lightbox ([\#9971](https://github.com/matrix-org/matrix-react-sdk/pull/9971)). Fixes vector-im/element-web#22204. Contributed by @Spartan09. + * Fix seekbar position for zero length audio ([\#9949](https://github.com/matrix-org/matrix-react-sdk/pull/9949)). Fixes vector-im/element-web#24248. + * Allow thread panel to be closed after being opened from notification ([\#9937](https://github.com/matrix-org/matrix-react-sdk/pull/9937)). Fixes vector-im/element-web#23764 vector-im/element-web#23852 and vector-im/element-web#24213. Contributed by @justjanne. + * Only highlight focused menu item if focus is supposed to be visible ([\#9945](https://github.com/matrix-org/matrix-react-sdk/pull/9945)). Fixes vector-im/element-web#23582. + * Prevent call durations from breaking onto multiple lines ([\#9944](https://github.com/matrix-org/matrix-react-sdk/pull/9944)). + * Tweak call lobby buttons to more closely match designs ([\#9943](https://github.com/matrix-org/matrix-react-sdk/pull/9943)). + * Do not show a broadcast as live immediately after the recording has stopped ([\#9947](https://github.com/matrix-org/matrix-react-sdk/pull/9947)). Fixes vector-im/element-web#24233. + * Clear the RTE before sending a message ([\#9948](https://github.com/matrix-org/matrix-react-sdk/pull/9948)). Contributed by @florianduros. + * Fix {enter} press in RTE ([\#9927](https://github.com/matrix-org/matrix-react-sdk/pull/9927)). Contributed by @florianduros. + * Fix the problem that the password reset email has to be confirmed twice ([\#9926](https://github.com/matrix-org/matrix-react-sdk/pull/9926)). Fixes vector-im/element-web#24226. + * replace .at() with array.length-1 ([\#9933](https://github.com/matrix-org/matrix-react-sdk/pull/9933)). Fixes matrix-org/element-web-rageshakes#19281. + * Fix broken threads list timestamp layout ([\#9922](https://github.com/matrix-org/matrix-react-sdk/pull/9922)). Fixes vector-im/element-web#24243 and vector-im/element-web#24191. Contributed by @justjanne. + * Disable multiple messages when {enter} is pressed multiple times ([\#9929](https://github.com/matrix-org/matrix-react-sdk/pull/9929)). Fixes vector-im/element-web#24249. Contributed by @florianduros. + * Fix logout devices when resetting the password ([\#9925](https://github.com/matrix-org/matrix-react-sdk/pull/9925)). Fixes vector-im/element-web#24228. + * Fix: Poll replies overflow when not enough space ([\#9924](https://github.com/matrix-org/matrix-react-sdk/pull/9924)). Fixes vector-im/element-web#24227. Contributed by @kerryarchibald. + * State event updates are not forwarded to the widget from invitation room ([\#9802](https://github.com/matrix-org/matrix-react-sdk/pull/9802)). Contributed by @maheichyk. + * Fix error when viewing source of redacted events ([\#9914](https://github.com/matrix-org/matrix-react-sdk/pull/9914)). Fixes vector-im/element-web#24165. Contributed by @clarkf. + * Replace outdated css attribute ([\#9912](https://github.com/matrix-org/matrix-react-sdk/pull/9912)). Fixes vector-im/element-web#24218. Contributed by @justjanne. + * Clear isLogin theme override when user is no longer viewing login screens ([\#9911](https://github.com/matrix-org/matrix-react-sdk/pull/9911)). Fixes vector-im/element-web#23893. + * Fix reply action in message context menu notif & file panels ([\#9895](https://github.com/matrix-org/matrix-react-sdk/pull/9895)). Fixes vector-im/element-web#23970. + * Fix issue where thread dropdown would not show up correctly ([\#9872](https://github.com/matrix-org/matrix-react-sdk/pull/9872)). Fixes vector-im/element-web#24040. Contributed by @justjanne. + * Fix unexpected composer growing ([\#9889](https://github.com/matrix-org/matrix-react-sdk/pull/9889)). Contributed by @florianduros. + * Fix misaligned timestamps for thread roots which are emotes ([\#9875](https://github.com/matrix-org/matrix-react-sdk/pull/9875)). Fixes vector-im/element-web#23897. Contributed by @justjanne. + Changes in [3.64.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.64.2) (2023-01-20) ===================================================================================================== From 403cc940f2c8be89a84865b40b2f15edf7488824 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 24 Jan 2023 11:29:47 +0000 Subject: [PATCH 06/40] v3.65.0-rc.1 --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 4a89679c55..36f3ba49f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.64.2", + "version": "3.65.0-rc.1", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -23,7 +23,7 @@ "package.json", ".stylelintrc.js" ], - "main": "./src/index.ts", + "main": "./lib/index.ts", "matrix_src_main": "./src/index.ts", "matrix_lib_main": "./lib/index.ts", "matrix_lib_typings": "./lib/index.d.ts", @@ -259,5 +259,6 @@ "outputDirectory": "coverage", "outputName": "jest-sonar-report.xml", "relativePaths": true - } + }, + "typings": "./lib/index.d.ts" } From a8aa4de4b4c14a6a995071aab3f2f871b3ff2388 Mon Sep 17 00:00:00 2001 From: Clark Fischer <439978+clarkf@users.noreply.github.com> Date: Mon, 30 Jan 2023 09:50:08 +0000 Subject: [PATCH 07/40] Member avatars without canvas (#9990) * Strict typechecking fixes for Base/Member/Avatar Update the core avatar files to pass `--strict --noImplicitAny` typechecks. Signed-off-by: Clark Fischer * Add tests for Base/Member/Avatar More thoroughly test the core avatar files. Not necessarily the most thorough, but an improvement. Signed-off-by: Clark Fischer * Extract TextAvatar from BaseAvatar Extracted the fallback/textual avatar into its own component. Signed-off-by: Clark Fischer * Use standard HTML for non-image avatars Firefox users with `resistFingerprinting` enabled were seeing random noise for rooms and users without avatars. There's no real reason to use data URLs to present flat colors. This converts non-image avatars to inline blocks with background colors. See https://github.com/vector-im/element-web/issues/23936 Signed-off-by: Clark Fischer * Have pills use solid backgrounds rather than colored images Similar to room and member avatars, pills now use colored pseudo-elements rather than background images. Signed-off-by: Clark Fischer --------- Signed-off-by: Clark Fischer Co-authored-by: Andy Balaam --- .../views/rooms/_BasicMessageComposer.pcss | 2 +- src/Avatar.ts | 64 ++++-- src/components/views/avatars/BaseAvatar.tsx | 128 ++++++----- src/components/views/avatars/MemberAvatar.tsx | 22 +- src/components/views/avatars/RoomAvatar.tsx | 3 +- src/dispatcher/payloads/ViewUserPayload.ts | 7 + src/editor/parts.ts | 40 ++-- test/Avatar-test.ts | 119 +++++++++-- .../__snapshots__/RoomView-test.tsx.snap | 98 +++------ .../__snapshots__/UserMenu-test.tsx.snap | 14 +- .../views/avatars/BaseAvatar-test.tsx | 201 ++++++++++++++++++ .../views/avatars/MemberAvatar-test.tsx | 96 +++++++-- .../views/avatars/RoomAvatar-test.tsx | 10 +- .../__snapshots__/BaseAvatar-test.tsx.snap | 72 +++++++ .../__snapshots__/MemberAvatar-test.tsx.snap | 14 ++ .../__snapshots__/RoomAvatar-test.tsx.snap | 42 ++-- .../__snapshots__/BeaconMarker-test.tsx.snap | 16 +- .../views/rooms/RoomHeader-test.tsx | 10 +- .../RoomPreviewBar-test.tsx.snap | 28 +-- .../__snapshots__/RoomTile-test.tsx.snap | 14 +- test/editor/__snapshots__/parts-test.ts.snap | 45 ++++ test/editor/parts-test.ts | 72 ++++++- 22 files changed, 806 insertions(+), 311 deletions(-) create mode 100644 test/components/views/avatars/BaseAvatar-test.tsx create mode 100644 test/components/views/avatars/__snapshots__/BaseAvatar-test.tsx.snap create mode 100644 test/components/views/avatars/__snapshots__/MemberAvatar-test.tsx.snap create mode 100644 test/editor/__snapshots__/parts-test.ts.snap diff --git a/res/css/views/rooms/_BasicMessageComposer.pcss b/res/css/views/rooms/_BasicMessageComposer.pcss index 7b88a05815..32e7c5288f 100644 --- a/res/css/views/rooms/_BasicMessageComposer.pcss +++ b/res/css/views/rooms/_BasicMessageComposer.pcss @@ -78,7 +78,7 @@ limitations under the License. min-width: $font-16px; /* ensure the avatar is not compressed */ height: $font-16px; margin-inline-end: 0.24rem; - background: var(--avatar-background), $background; + background: var(--avatar-background); color: $avatar-initial-color; background-repeat: no-repeat; background-size: $font-16px; diff --git a/src/Avatar.ts b/src/Avatar.ts index 8a3f10a22c..3e6b18dbc7 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015, 2016, 2023 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. @@ -24,16 +24,19 @@ import DMRoomMap from "./utils/DMRoomMap"; import { mediaFromMxc } from "./customisations/Media"; import { isLocalRoom } from "./utils/localRoom/isLocalRoom"; +const DEFAULT_COLORS: Readonly = ["#0DBD8B", "#368bd6", "#ac3ba8"]; + // Not to be used for BaseAvatar urls as that has similar default avatar fallback already export function avatarUrlForMember( - member: RoomMember, + member: RoomMember | null | undefined, width: number, height: number, resizeMethod: ResizeMethod, ): string { - let url: string; - if (member?.getMxcAvatarUrl()) { - url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); + let url: string | undefined; + const mxcUrl = member?.getMxcAvatarUrl(); + if (mxcUrl) { + url = mediaFromMxc(mxcUrl).getThumbnailOfSourceHttp(width, height, resizeMethod); } if (!url) { // member can be null here currently since on invites, the JS SDK @@ -44,6 +47,17 @@ export function avatarUrlForMember( return url; } +export function getMemberAvatar( + member: RoomMember | null | undefined, + width: number, + height: number, + resizeMethod: ResizeMethod, +): string | undefined { + const mxcUrl = member?.getMxcAvatarUrl(); + if (!mxcUrl) return undefined; + return mediaFromMxc(mxcUrl).getThumbnailOfSourceHttp(width, height, resizeMethod); +} + export function avatarUrlForUser( user: Pick, width: number, @@ -86,18 +100,10 @@ function urlForColor(color: string): string { // hard to install a listener here, even if there were a clear event to listen to const colorToDataURLCache = new Map(); -export function defaultAvatarUrlForString(s: string): string { +export function defaultAvatarUrlForString(s: string | undefined): string { if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake - const defaultColors = ["#0DBD8B", "#368bd6", "#ac3ba8"]; - let total = 0; - for (let i = 0; i < s.length; ++i) { - total += s.charCodeAt(i); - } - const colorIndex = total % defaultColors.length; - // overwritten color value in custom themes - const cssVariable = `--avatar-background-colors_${colorIndex}`; - const cssValue = document.body.style.getPropertyValue(cssVariable); - const color = cssValue || defaultColors[colorIndex]; + + const color = getColorForString(s); let dataUrl = colorToDataURLCache.get(color); if (!dataUrl) { // validate color as this can come from account_data @@ -112,13 +118,23 @@ export function defaultAvatarUrlForString(s: string): string { return dataUrl; } +export function getColorForString(input: string): string { + const charSum = [...input].reduce((s, c) => s + c.charCodeAt(0), 0); + const index = charSum % DEFAULT_COLORS.length; + + // overwritten color value in custom themes + const cssVariable = `--avatar-background-colors_${index}`; + const cssValue = document.body.style.getPropertyValue(cssVariable); + return cssValue || DEFAULT_COLORS[index]!; +} + /** * returns the first (non-sigil) character of 'name', * converted to uppercase * @param {string} name * @return {string} the first letter */ -export function getInitialLetter(name: string): string { +export function getInitialLetter(name: string): string | undefined { if (!name) { // XXX: We should find out what causes the name to sometimes be falsy. console.trace("`name` argument to `getInitialLetter` not supplied"); @@ -134,19 +150,20 @@ export function getInitialLetter(name: string): string { } // rely on the grapheme cluster splitter in lodash so that we don't break apart compound emojis - return split(name, "", 1)[0].toUpperCase(); + return split(name, "", 1)[0]!.toUpperCase(); } export function avatarUrlForRoom( - room: Room, + room: Room | undefined, width: number, height: number, resizeMethod?: ResizeMethod, ): string | null { if (!room) return null; // null-guard - if (room.getMxcAvatarUrl()) { - return mediaFromMxc(room.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); + const mxcUrl = room.getMxcAvatarUrl(); + if (mxcUrl) { + return mediaFromMxc(mxcUrl).getThumbnailOfSourceHttp(width, height, resizeMethod); } // space rooms cannot be DMs so skip the rest @@ -159,8 +176,9 @@ export function avatarUrlForRoom( // If there are only two members in the DM use the avatar of the other member const otherMember = room.getAvatarFallbackMember(); - if (otherMember?.getMxcAvatarUrl()) { - return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); + const otherMemberMxc = otherMember?.getMxcAvatarUrl(); + if (otherMemberMxc) { + return mediaFromMxc(otherMemberMxc).getThumbnailOfSourceHttp(width, height, resizeMethod); } return null; } diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 025cb9d271..d1dbe7743d 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -1,8 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015, 2016, 2018, 2019, 2020, 2023 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. @@ -21,34 +19,41 @@ import React, { useCallback, useContext, useEffect, useState } from "react"; import classNames from "classnames"; import { ResizeMethod } from "matrix-js-sdk/src/@types/partials"; import { ClientEvent } from "matrix-js-sdk/src/client"; +import { SyncState } from "matrix-js-sdk/src/sync"; import * as AvatarLogic from "../../../Avatar"; -import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from "../elements/AccessibleButton"; import RoomContext from "../../../contexts/RoomContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import { toPx } from "../../../utils/units"; import { _t } from "../../../languageHandler"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; interface IProps { - name: string; // The name (first initial used as default) - idName?: string; // ID for generating hash colours - title?: string; // onHover title text - url?: string; // highest priority of them all, shortcut to set in urls[0] - urls?: string[]; // [highest_priority, ... , lowest_priority] + /** The name (first initial used as default) */ + name: string; + /** ID for generating hash colours */ + idName?: string; + /** onHover title text */ + title?: string; + /** highest priority of them all, shortcut to set in urls[0] */ + url?: string; + /** [highest_priority, ... , lowest_priority] */ + urls?: string[]; width?: number; height?: number; - // XXX: resizeMethod not actually used. + /** @deprecated not actually used */ resizeMethod?: ResizeMethod; - defaultToInitialLetter?: boolean; // true to add default url - onClick?: React.MouseEventHandler; + /** true to add default url */ + defaultToInitialLetter?: boolean; + onClick?: React.ComponentPropsWithoutRef["onClick"]; inputRef?: React.RefObject; className?: string; tabIndex?: number; } -const calculateUrls = (url: string, urls: string[], lowBandwidth: boolean): string[] => { +const calculateUrls = (url: string | undefined, urls: string[] | undefined, lowBandwidth: boolean): string[] => { // work out the full set of urls to try to load. This is formed like so: // imageUrls: [ props.url, ...props.urls ] @@ -66,11 +71,26 @@ const calculateUrls = (url: string, urls: string[], lowBandwidth: boolean): stri return Array.from(new Set(_urls)); }; -const useImageUrl = ({ url, urls }): [string, () => void] => { +/** + * Hook for cycling through a changing set of images. + * + * The set of images is updated whenever `url` or `urls` change, the user's + * `lowBandwidth` preference changes, or the client reconnects. + * + * Returns `[imageUrl, onError]`. When `onError` is called, the next image in + * the set will be displayed. + */ +const useImageUrl = ({ + url, + urls, +}: { + url: string | undefined; + urls: string[] | undefined; +}): [string | undefined, () => void] => { // Since this is a hot code path and the settings store can be slow, we // use the cached lowBandwidth value from the room context if it exists const roomContext = useContext(RoomContext); - const lowBandwidth = roomContext ? roomContext.lowBandwidth : SettingsStore.getValue("lowBandwidth"); + const lowBandwidth = roomContext.lowBandwidth; const [imageUrls, setUrls] = useState(calculateUrls(url, urls, lowBandwidth)); const [urlsIndex, setIndex] = useState(0); @@ -85,10 +105,10 @@ const useImageUrl = ({ url, urls }): [string, () => void] => { }, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps const cli = useContext(MatrixClientContext); - const onClientSync = useCallback((syncState, prevState) => { + const onClientSync = useCallback((syncState: SyncState, prevState: SyncState | null) => { // Consider the client reconnected if there is no error with syncing. // This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP. - const reconnected = syncState !== "ERROR" && prevState !== syncState; + const reconnected = syncState !== SyncState.Error && prevState !== syncState; if (reconnected) { setIndex(0); } @@ -108,46 +128,18 @@ const BaseAvatar: React.FC = (props) => { urls, width = 40, height = 40, - resizeMethod = "crop", // eslint-disable-line @typescript-eslint/no-unused-vars defaultToInitialLetter = true, onClick, inputRef, className, + resizeMethod: _unused, // to keep it from being in `otherProps` ...otherProps } = props; const [imageUrl, onError] = useImageUrl({ url, urls }); if (!imageUrl && defaultToInitialLetter && name) { - const initialLetter = AvatarLogic.getInitialLetter(name); - const textNode = ( - - ); - const imgNode = ( - - ); + const avatar = ; if (onClick) { return ( @@ -159,9 +151,12 @@ const BaseAvatar: React.FC = (props) => { className={classNames("mx_BaseAvatar", className)} onClick={onClick} inputRef={inputRef} + style={{ + width: toPx(width), + height: toPx(height), + }} > - {textNode} - {imgNode} + {avatar} ); } else { @@ -170,10 +165,13 @@ const BaseAvatar: React.FC = (props) => { className={classNames("mx_BaseAvatar", className)} ref={inputRef} {...otherProps} + style={{ + width: toPx(width), + height: toPx(height), + }} role="presentation" > - {textNode} - {imgNode} + {avatar} ); } @@ -220,3 +218,31 @@ const BaseAvatar: React.FC = (props) => { export default BaseAvatar; export type BaseAvatarType = React.FC; + +const TextAvatar: React.FC<{ + name: string; + idName?: string; + width: number; + height: number; + title?: string; +}> = ({ name, idName, width, height, title }) => { + const initialLetter = AvatarLogic.getInitialLetter(name); + + return ( + + ); +}; diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index 4813871455..f493c58f8c 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 - 2022 The Matrix.org Foundation C.I.C. +Copyright 2015, 2016, 2019 - 2023 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. @@ -26,6 +25,7 @@ import { mediaFromMxc } from "../../../customisations/Media"; import { CardContext } from "../right_panel/context"; import UserIdentifierCustomisations from "../../../customisations/UserIdentifier"; import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile"; +import { ViewUserPayload } from "../../../dispatcher/payloads/ViewUserPayload"; interface IProps extends Omit, "name" | "idName" | "url"> { member: RoomMember | null; @@ -33,14 +33,13 @@ interface IProps extends Omit, "name" | width: number; height: number; resizeMethod?: ResizeMethod; - // The onClick to give the avatar - onClick?: React.MouseEventHandler; - // Whether the onClick of the avatar should be overridden to dispatch `Action.ViewUser` + /** Whether the onClick of the avatar should be overridden to dispatch `Action.ViewUser` */ viewUserOnClick?: boolean; pushUserOnClick?: boolean; title?: string; - style?: any; - forceHistorical?: boolean; // true to deny `useOnlyCurrentProfiles` usage. Default false. + style?: React.CSSProperties; + /** true to deny `useOnlyCurrentProfiles` usage. Default false. */ + forceHistorical?: boolean; hideTitle?: boolean; } @@ -77,8 +76,8 @@ export default function MemberAvatar({ if (!title) { title = - UserIdentifierCustomisations.getDisplayUserIdentifier(member?.userId ?? "", { - roomId: member?.roomId ?? "", + UserIdentifierCustomisations.getDisplayUserIdentifier!(member.userId, { + roomId: member.roomId, }) ?? fallbackUserId; } } @@ -88,7 +87,6 @@ export default function MemberAvatar({ {...props} width={width} height={height} - resizeMethod={resizeMethod} name={name ?? ""} title={hideTitle ? undefined : title} idName={member?.userId ?? fallbackUserId} @@ -96,9 +94,9 @@ export default function MemberAvatar({ onClick={ viewUserOnClick ? () => { - dis.dispatch({ + dis.dispatch({ action: Action.ViewUser, - member: propsMember, + member: propsMember || undefined, push: card.isCard, }); } diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index 50389c7749..4abfdbbf67 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -109,7 +109,8 @@ export default class RoomAvatar extends React.Component { } private onRoomAvatarClick = (): void => { - const avatarUrl = Avatar.avatarUrlForRoom(this.props.room, null, null, null); + const avatarMxc = this.props.room?.getMxcAvatarUrl(); + const avatarUrl = avatarMxc ? mediaFromMxc(avatarMxc).srcHttp : null; const params = { src: avatarUrl, name: this.props.room.name, diff --git a/src/dispatcher/payloads/ViewUserPayload.ts b/src/dispatcher/payloads/ViewUserPayload.ts index 20df21beb4..a09804babe 100644 --- a/src/dispatcher/payloads/ViewUserPayload.ts +++ b/src/dispatcher/payloads/ViewUserPayload.ts @@ -28,4 +28,11 @@ export interface ViewUserPayload extends ActionPayload { * should be shown (hide whichever relevant components). */ member?: RoomMember | User; + + /** + * Should this event be pushed as a card into the right panel? + * + * @see RightPanelStore#pushCard + */ + push?: boolean; } diff --git a/src/editor/parts.ts b/src/editor/parts.ts index 306d86dbc9..0157cd738a 100644 --- a/src/editor/parts.ts +++ b/src/editor/parts.ts @@ -1,6 +1,5 @@ /* -Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2023 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. @@ -295,9 +294,9 @@ export abstract class PillPart extends BasePart implements IPillPart { } // helper method for subclasses - protected setAvatarVars(node: HTMLElement, avatarUrl: string, initialLetter: string): void { - const avatarBackground = `url('${avatarUrl}')`; - const avatarLetter = `'${initialLetter}'`; + protected setAvatarVars(node: HTMLElement, avatarBackground: string, initialLetter: string | undefined): void { + // const avatarBackground = `url('${avatarUrl}')`; + const avatarLetter = `'${initialLetter || ""}'`; // check if the value is changing, // otherwise the avatars flicker on every keystroke while updating. if (node.style.getPropertyValue("--avatar-background") !== avatarBackground) { @@ -413,13 +412,15 @@ class RoomPillPart extends PillPart { } protected setAvatar(node: HTMLElement): void { - let initialLetter = ""; - let avatarUrl = Avatar.avatarUrlForRoom(this.room, 16, 16, "crop"); - if (!avatarUrl) { - initialLetter = Avatar.getInitialLetter(this.room?.name || this.resourceId); - avatarUrl = Avatar.defaultAvatarUrlForString(this.room?.roomId ?? this.resourceId); + const avatarUrl = Avatar.avatarUrlForRoom(this.room, 16, 16, "crop"); + if (avatarUrl) { + this.setAvatarVars(node, `url('${avatarUrl}')`, ""); + return; } - this.setAvatarVars(node, avatarUrl, initialLetter); + + const initialLetter = Avatar.getInitialLetter(this.room?.name || this.resourceId); + const color = Avatar.getColorForString(this.room?.roomId ?? this.resourceId); + this.setAvatarVars(node, color, initialLetter); } public get type(): IPillPart["type"] { @@ -465,14 +466,17 @@ class UserPillPart extends PillPart { if (!this.member) { return; } - const name = this.member.name || this.member.userId; - const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this.member.userId); - const avatarUrl = Avatar.avatarUrlForMember(this.member, 16, 16, "crop"); - let initialLetter = ""; - if (avatarUrl === defaultAvatarUrl) { - initialLetter = Avatar.getInitialLetter(name); + + const avatar = Avatar.getMemberAvatar(this.member, 16, 16, "crop"); + if (avatar) { + this.setAvatarVars(node, `url('${avatar}')`, ""); + return; } - this.setAvatarVars(node, avatarUrl, initialLetter); + + const name = this.member.name || this.member.userId; + const initialLetter = Avatar.getInitialLetter(name); + const color = Avatar.getColorForString(this.member.userId); + this.setAvatarVars(node, color, initialLetter); } protected onClick = (): void => { diff --git a/test/Avatar-test.ts b/test/Avatar-test.ts index 0ff064ed57..8b4ee03b7f 100644 --- a/test/Avatar-test.ts +++ b/test/Avatar-test.ts @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022 - 2023 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,33 +15,106 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { Room, RoomMember, RoomType } from "matrix-js-sdk/src/matrix"; +import { Room, RoomMember, RoomType, User } from "matrix-js-sdk/src/matrix"; -import { avatarUrlForRoom } from "../src/Avatar"; -import { Media, mediaFromMxc } from "../src/customisations/Media"; +import { + avatarUrlForMember, + avatarUrlForRoom, + avatarUrlForUser, + defaultAvatarUrlForString, + getColorForString, + getInitialLetter, +} from "../src/Avatar"; +import { mediaFromMxc } from "../src/customisations/Media"; import DMRoomMap from "../src/utils/DMRoomMap"; - -jest.mock("../src/customisations/Media", () => ({ - mediaFromMxc: jest.fn(), -})); +import { filterConsole, stubClient } from "./test-utils"; const roomId = "!room:example.com"; const avatarUrl1 = "https://example.com/avatar1"; const avatarUrl2 = "https://example.com/avatar2"; +describe("avatarUrlForMember", () => { + let member: RoomMember; + + beforeEach(() => { + stubClient(); + member = new RoomMember(roomId, "@user:example.com"); + }); + + it("returns the member's url", () => { + const mxc = "mxc://example.com/a/b/c/d/avatar.gif"; + jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue(mxc); + + expect(avatarUrlForMember(member, 32, 32, "crop")).toBe( + mediaFromMxc(mxc).getThumbnailOfSourceHttp(32, 32, "crop"), + ); + }); + + it("returns a default if the member has no avatar", () => { + jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue(undefined); + + expect(avatarUrlForMember(member, 32, 32, "crop")).toMatch(/^data:/); + }); +}); + +describe("avatarUrlForUser", () => { + let user: User; + + beforeEach(() => { + stubClient(); + user = new User("@user:example.com"); + }); + + it("should return the user's avatar", () => { + const mxc = "mxc://example.com/a/b/c/d/avatar.gif"; + user.avatarUrl = mxc; + + expect(avatarUrlForUser(user, 64, 64, "scale")).toBe( + mediaFromMxc(mxc).getThumbnailOfSourceHttp(64, 64, "scale"), + ); + }); + + it("should not provide a fallback", () => { + expect(avatarUrlForUser(user, 64, 64, "scale")).toBeNull(); + }); +}); + +describe("defaultAvatarUrlForString", () => { + it.each(["a", "abc", "abcde", "@".repeat(150)])("should return a value for %s", (s) => { + expect(defaultAvatarUrlForString(s)).not.toBe(""); + }); +}); + +describe("getColorForString", () => { + it.each(["a", "abc", "abcde", "@".repeat(150)])("should return a value for %s", (s) => { + expect(getColorForString(s)).toMatch(/^#\w+$/); + }); + + it("should return different values for different strings", () => { + expect(getColorForString("a")).not.toBe(getColorForString("b")); + }); +}); + +describe("getInitialLetter", () => { + filterConsole("argument to `getInitialLetter` not supplied"); + + it.each(["a", "abc", "abcde", "@".repeat(150)])("should return a value for %s", (s) => { + expect(getInitialLetter(s)).not.toBe(""); + }); + + it("should return undefined for empty strings", () => { + expect(getInitialLetter("")).toBeUndefined(); + }); +}); + describe("avatarUrlForRoom", () => { - let getThumbnailOfSourceHttp: jest.Mock; let room: Room; let roomMember: RoomMember; let dmRoomMap: DMRoomMap; beforeEach(() => { - getThumbnailOfSourceHttp = jest.fn(); - mocked(mediaFromMxc).mockImplementation((): Media => { - return { - getThumbnailOfSourceHttp, - } as unknown as Media; - }); + stubClient(); + room = { roomId, getMxcAvatarUrl: jest.fn(), @@ -59,14 +132,14 @@ describe("avatarUrlForRoom", () => { }); it("should return null for a null room", () => { - expect(avatarUrlForRoom(null, 128, 128)).toBeNull(); + expect(avatarUrlForRoom(undefined, 128, 128)).toBeNull(); }); it("should return the HTTP source if the room provides a MXC url", () => { mocked(room.getMxcAvatarUrl).mockReturnValue(avatarUrl1); - getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2); - expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2); - expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop"); + expect(avatarUrlForRoom(room, 128, 256, "crop")).toBe( + mediaFromMxc(avatarUrl1).getThumbnailOfSourceHttp(128, 256, "crop"), + ); }); it("should return null for a space room", () => { @@ -83,7 +156,7 @@ describe("avatarUrlForRoom", () => { it("should return null if there is no other member in the room", () => { mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com"); - mocked(room.getAvatarFallbackMember).mockReturnValue(null); + mocked(room.getAvatarFallbackMember).mockReturnValue(undefined); expect(avatarUrlForRoom(room, 128, 128)).toBeNull(); }); @@ -97,8 +170,8 @@ describe("avatarUrlForRoom", () => { mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com"); mocked(room.getAvatarFallbackMember).mockReturnValue(roomMember); mocked(roomMember.getMxcAvatarUrl).mockReturnValue(avatarUrl2); - getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2); - expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2); - expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop"); + expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual( + mediaFromMxc(avatarUrl2).getThumbnailOfSourceHttp(128, 256, "crop"), + ); }); }); diff --git a/test/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/components/structures/__snapshots__/RoomView-test.tsx.snap index 47318525d5..c81e180c42 100644 --- a/test/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -20,22 +20,16 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1 - @@ -119,22 +113,16 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`] - @@ -215,23 +203,17 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`] aria-live="off" class="mx_AccessibleButton mx_BaseAvatar" role="button" + style="width: 52px; height: 52px;" tabindex="0" > -

@user:example.com @@ -314,22 +296,16 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] = - @@ -410,23 +386,17 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] = aria-live="off" class="mx_AccessibleButton mx_BaseAvatar" role="button" + style="width: 52px; height: 52px;" tabindex="0" > -

@user:example.com @@ -581,22 +551,16 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t - @@ -672,23 +636,17 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t aria-live="off" class="mx_AccessibleButton mx_BaseAvatar" role="button" + style="width: 52px; height: 52px;" tabindex="0" > -

@user:example.com diff --git a/test/components/structures/__snapshots__/UserMenu-test.tsx.snap b/test/components/structures/__snapshots__/UserMenu-test.tsx.snap index 769711434a..0546900abb 100644 --- a/test/components/structures/__snapshots__/UserMenu-test.tsx.snap +++ b/test/components/structures/__snapshots__/UserMenu-test.tsx.snap @@ -20,22 +20,16 @@ exports[` when rendered should render as expected 1`] = ` - diff --git a/test/components/views/avatars/BaseAvatar-test.tsx b/test/components/views/avatars/BaseAvatar-test.tsx new file mode 100644 index 0000000000..294a64c436 --- /dev/null +++ b/test/components/views/avatars/BaseAvatar-test.tsx @@ -0,0 +1,201 @@ +/* +Copyright 2023 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 { fireEvent, render } from "@testing-library/react"; +import { ClientEvent, PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import React from "react"; +import { act } from "react-dom/test-utils"; +import { SyncState } from "matrix-js-sdk/src/sync"; + +import type { MatrixClient } from "matrix-js-sdk/src/client"; +import RoomContext from "../../../../src/contexts/RoomContext"; +import { getRoomContext } from "../../../test-utils/room"; +import { stubClient } from "../../../test-utils/test-utils"; +import BaseAvatar from "../../../../src/components/views/avatars/BaseAvatar"; +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; + +type Props = React.ComponentPropsWithoutRef; + +describe("", () => { + let client: MatrixClient; + let room: Room; + let member: RoomMember; + + function getComponent(props: Partial) { + return ( + + + + + + ); + } + + function failLoadingImg(container: HTMLElement): void { + const img = container.querySelector("img")!; + expect(img).not.toBeNull(); + act(() => { + fireEvent.error(img); + }); + } + + function emitReconnect(): void { + act(() => { + client.emit(ClientEvent.Sync, SyncState.Prepared, SyncState.Reconnecting); + }); + } + + beforeEach(() => { + client = stubClient(); + + room = new Room("!room:example.com", client, client.getUserId() ?? "", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + member = new RoomMember(room.roomId, "@bob:example.org"); + jest.spyOn(room, "getMember").mockReturnValue(member); + }); + + it("renders with minimal properties", () => { + const { container } = render(getComponent({})); + + expect(container.querySelector(".mx_BaseAvatar")).not.toBeNull(); + }); + + it("matches snapshot (avatar)", () => { + const { container } = render( + getComponent({ + name: "CoolUser22", + title: "Hover title", + url: "https://example.com/images/avatar.gif", + className: "mx_SomethingArbitrary", + }), + ); + + expect(container).toMatchSnapshot(); + }); + + it("matches snapshot (avatar + click)", () => { + const { container } = render( + getComponent({ + name: "CoolUser22", + title: "Hover title", + url: "https://example.com/images/avatar.gif", + className: "mx_SomethingArbitrary", + onClick: () => {}, + }), + ); + + expect(container).toMatchSnapshot(); + }); + + it("matches snapshot (no avatar)", () => { + const { container } = render( + getComponent({ + name: "xX_Element_User_Xx", + title: ":kiss:", + defaultToInitialLetter: true, + className: "big-and-bold", + }), + ); + + expect(container).toMatchSnapshot(); + }); + + it("matches snapshot (no avatar + click)", () => { + const { container } = render( + getComponent({ + name: "xX_Element_User_Xx", + title: ":kiss:", + defaultToInitialLetter: true, + className: "big-and-bold", + onClick: () => {}, + }), + ); + + expect(container).toMatchSnapshot(); + }); + + it("uses fallback images", () => { + const images = [...Array(10)].map((_, i) => `https://example.com/images/${i}.webp`); + + const { container } = render( + getComponent({ + url: images[0], + urls: images.slice(1), + }), + ); + + for (const image of images) { + expect(container.querySelector("img")!.src).toBe(image); + failLoadingImg(container); + } + }); + + it("re-renders on reconnect", () => { + const primary = "https://example.com/image.jpeg"; + const fallback = "https://example.com/fallback.png"; + const { container } = render( + getComponent({ + url: primary, + urls: [fallback], + }), + ); + + failLoadingImg(container); + expect(container.querySelector("img")!.src).toBe(fallback); + + emitReconnect(); + expect(container.querySelector("img")!.src).toBe(primary); + }); + + it("renders with an image", () => { + const url = "https://example.com/images/small/avatar.gif?size=realBig"; + const { container } = render(getComponent({ url })); + + const img = container.querySelector("img"); + expect(img!.src).toBe(url); + }); + + it("renders the initial letter", () => { + const { container } = render(getComponent({ name: "Yellow", defaultToInitialLetter: true })); + + const avatar = container.querySelector(".mx_BaseAvatar_initial")!; + expect(avatar.innerHTML).toBe("Y"); + }); + + it.each([{}, { name: "CoolUser22" }, { name: "XxElement_FanxX", defaultToInitialLetter: true }])( + "includes a click handler", + (props: Partial) => { + const onClick = jest.fn(); + + const { container } = render( + getComponent({ + ...props, + onClick, + }), + ); + + act(() => { + fireEvent.click(container.querySelector(".mx_BaseAvatar")!); + }); + + expect(onClick).toHaveBeenCalled(); + }, + ); +}); diff --git a/test/components/views/avatars/MemberAvatar-test.tsx b/test/components/views/avatars/MemberAvatar-test.tsx index 4895b70f21..3dc793bd92 100644 --- a/test/components/views/avatars/MemberAvatar-test.tsx +++ b/test/components/views/avatars/MemberAvatar-test.tsx @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022 - 2023 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. @@ -14,19 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { getByTestId, render, waitFor } from "@testing-library/react"; -import { mocked } from "jest-mock"; +import { fireEvent, getByTestId, render } from "@testing-library/react"; import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import React from "react"; +import { act } from "react-dom/test-utils"; import MemberAvatar from "../../../../src/components/views/avatars/MemberAvatar"; import RoomContext from "../../../../src/contexts/RoomContext"; -import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import { mediaFromMxc } from "../../../../src/customisations/Media"; +import { ViewUserPayload } from "../../../../src/dispatcher/payloads/ViewUserPayload"; +import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; +import { SettingLevel } from "../../../../src/settings/SettingLevel"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { getRoomContext } from "../../../test-utils/room"; import { stubClient } from "../../../test-utils/test-utils"; +import { Action } from "../../../../src/dispatcher/actions"; + +type Props = React.ComponentPropsWithoutRef; describe("MemberAvatar", () => { const ROOM_ID = "roomId"; @@ -35,7 +41,7 @@ describe("MemberAvatar", () => { let room: Room; let member: RoomMember; - function getComponent(props) { + function getComponent(props: Partial) { return ( @@ -44,10 +50,7 @@ describe("MemberAvatar", () => { } beforeEach(() => { - jest.clearAllMocks(); - - stubClient(); - mockClient = mocked(MatrixClientPeg.get()); + mockClient = stubClient(); room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", { pendingEventOrdering: PendingEventOrdering.Detached, @@ -55,22 +58,77 @@ describe("MemberAvatar", () => { member = new RoomMember(ROOM_ID, "@bob:example.org"); jest.spyOn(room, "getMember").mockReturnValue(member); - jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue("http://placekitten.com/400/400"); }); - it("shows an avatar for useOnlyCurrentProfiles", async () => { - jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => { - return settingName === "useOnlyCurrentProfiles"; - }); + it("supports 'null' members", () => { + const { container } = render(getComponent({ member: null })); + + expect(container.querySelector("img")).not.toBeNull(); + }); + + it("matches the snapshot", () => { + jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue("http://placekitten.com/400/400"); + const { container } = render( + getComponent({ + member, + fallbackUserId: "Fallback User ID", + title: "Hover title", + style: { + color: "pink", + }, + }), + ); + + expect(container).toMatchSnapshot(); + }); + + it("shows an avatar for useOnlyCurrentProfiles", () => { + jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue("http://placekitten.com/400/400"); + + SettingsStore.setValue("useOnlyCurrentProfiles", null, SettingLevel.DEVICE, true); const { container } = render(getComponent({})); - let avatar: HTMLElement; - await waitFor(() => { - avatar = getByTestId(container, "avatar-img"); - expect(avatar).toBeInTheDocument(); + const avatar = getByTestId(container, "avatar-img"); + expect(avatar).toBeInTheDocument(); + expect(avatar.getAttribute("src")).not.toBe(""); + }); + + it("uses the member's configured avatar", () => { + const mxcUrl = "mxc://example.com/avatars/user.tiff"; + jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue(mxcUrl); + + const { container } = render(getComponent({ member })); + + const img = container.querySelector("img"); + expect(img).not.toBeNull(); + expect(img!.src).toBe(mediaFromMxc(mxcUrl).srcHttp); + }); + + it("uses a fallback when the member has no avatar", () => { + jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue(undefined); + + const { container } = render(getComponent({ member })); + + const img = container.querySelector(".mx_BaseAvatar_image"); + expect(img).not.toBeNull(); + }); + + it("dispatches on click", () => { + const { container } = render(getComponent({ member, viewUserOnClick: true })); + + const spy = jest.spyOn(defaultDispatcher, "dispatch"); + + act(() => { + fireEvent.click(container.querySelector(".mx_BaseAvatar")!); }); - expect(avatar!.getAttribute("src")).not.toBe(""); + expect(spy).toHaveBeenCalled(); + const [payload] = spy.mock.lastCall!; + expect(payload).toStrictEqual({ + action: Action.ViewUser, + member, + push: false, + }); }); }); diff --git a/test/components/views/avatars/RoomAvatar-test.tsx b/test/components/views/avatars/RoomAvatar-test.tsx index e23cd96f02..7be7dd65e9 100644 --- a/test/components/views/avatars/RoomAvatar-test.tsx +++ b/test/components/views/avatars/RoomAvatar-test.tsx @@ -39,7 +39,7 @@ describe("RoomAvatar", () => { const dmRoomMap = new DMRoomMap(client); jest.spyOn(dmRoomMap, "getUserIdForRoomId"); jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); - jest.spyOn(AvatarModule, "defaultAvatarUrlForString"); + jest.spyOn(AvatarModule, "getColorForString"); }); afterAll(() => { @@ -48,14 +48,14 @@ describe("RoomAvatar", () => { afterEach(() => { mocked(DMRoomMap.shared().getUserIdForRoomId).mockReset(); - mocked(AvatarModule.defaultAvatarUrlForString).mockClear(); + mocked(AvatarModule.getColorForString).mockClear(); }); it("should render as expected for a Room", () => { const room = new Room("!room:example.com", client, client.getSafeUserId()); room.name = "test room"; expect(render().container).toMatchSnapshot(); - expect(AvatarModule.defaultAvatarUrlForString).toHaveBeenCalledWith(room.roomId); + expect(AvatarModule.getColorForString).toHaveBeenCalledWith(room.roomId); }); it("should render as expected for a DM room", () => { @@ -64,7 +64,7 @@ describe("RoomAvatar", () => { room.name = "DM room"; mocked(DMRoomMap.shared().getUserIdForRoomId).mockReturnValue(userId); expect(render().container).toMatchSnapshot(); - expect(AvatarModule.defaultAvatarUrlForString).toHaveBeenCalledWith(userId); + expect(AvatarModule.getColorForString).toHaveBeenCalledWith(userId); }); it("should render as expected for a LocalRoom", () => { @@ -73,6 +73,6 @@ describe("RoomAvatar", () => { localRoom.name = "local test room"; localRoom.targets.push(new DirectoryMember({ user_id: userId })); expect(render().container).toMatchSnapshot(); - expect(AvatarModule.defaultAvatarUrlForString).toHaveBeenCalledWith(userId); + expect(AvatarModule.getColorForString).toHaveBeenCalledWith(userId); }); }); diff --git a/test/components/views/avatars/__snapshots__/BaseAvatar-test.tsx.snap b/test/components/views/avatars/__snapshots__/BaseAvatar-test.tsx.snap new file mode 100644 index 0000000000..da62540b90 --- /dev/null +++ b/test/components/views/avatars/__snapshots__/BaseAvatar-test.tsx.snap @@ -0,0 +1,72 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` matches snapshot (avatar + click) 1`] = ` +
+ Avatar +
+`; + +exports[` matches snapshot (avatar) 1`] = ` +
+ +
+`; + +exports[` matches snapshot (no avatar + click) 1`] = ` +
+ + + +
+`; + +exports[` matches snapshot (no avatar) 1`] = ` +
+ + + +
+`; diff --git a/test/components/views/avatars/__snapshots__/MemberAvatar-test.tsx.snap b/test/components/views/avatars/__snapshots__/MemberAvatar-test.tsx.snap new file mode 100644 index 0000000000..feda79035c --- /dev/null +++ b/test/components/views/avatars/__snapshots__/MemberAvatar-test.tsx.snap @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MemberAvatar matches the snapshot 1`] = ` +
+ +
+`; diff --git a/test/components/views/avatars/__snapshots__/RoomAvatar-test.tsx.snap b/test/components/views/avatars/__snapshots__/RoomAvatar-test.tsx.snap index 6bffa157b6..699113689e 100644 --- a/test/components/views/avatars/__snapshots__/RoomAvatar-test.tsx.snap +++ b/test/components/views/avatars/__snapshots__/RoomAvatar-test.tsx.snap @@ -5,22 +5,16 @@ exports[`RoomAvatar should render as expected for a DM room 1`] = ` - `; @@ -30,22 +24,16 @@ exports[`RoomAvatar should render as expected for a LocalRoom 1`] = ` - `; @@ -55,22 +43,16 @@ exports[`RoomAvatar should render as expected for a Room 1`] = ` - `; diff --git a/test/components/views/beacon/__snapshots__/BeaconMarker-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconMarker-test.tsx.snap index b42ccb83ee..b965f50b2f 100644 --- a/test/components/views/beacon/__snapshots__/BeaconMarker-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/BeaconMarker-test.tsx.snap @@ -13,23 +13,17 @@ exports[` renders marker when beacon has location 1`] = ` - diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index 5857e28295..c27d4c0c20 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -72,7 +72,7 @@ describe("RoomHeader (Enzyme)", () => { // And there is no image avatar (because it's not set on this room) const image = findImg(rendered, ".mx_BaseAvatar_image"); - expect(image.prop("src")).toEqual("data:image/png;base64,00"); + expect(image).toBeTruthy(); }); it("shows the room avatar in a room with 2 people", () => { @@ -86,7 +86,7 @@ describe("RoomHeader (Enzyme)", () => { // And there is no image avatar (because it's not set on this room) const image = findImg(rendered, ".mx_BaseAvatar_image"); - expect(image.prop("src")).toEqual("data:image/png;base64,00"); + expect(image).toBeTruthy(); }); it("shows the room avatar in a room with >2 people", () => { @@ -100,7 +100,7 @@ describe("RoomHeader (Enzyme)", () => { // And there is no image avatar (because it's not set on this room) const image = findImg(rendered, ".mx_BaseAvatar_image"); - expect(image.prop("src")).toEqual("data:image/png;base64,00"); + expect(image).toBeTruthy(); }); it("shows the room avatar in a DM with only ourselves", () => { @@ -114,7 +114,7 @@ describe("RoomHeader (Enzyme)", () => { // And there is no image avatar (because it's not set on this room) const image = findImg(rendered, ".mx_BaseAvatar_image"); - expect(image.prop("src")).toEqual("data:image/png;base64,00"); + expect(image).toBeTruthy(); }); it("shows the user avatar in a DM with 2 people", () => { @@ -148,7 +148,7 @@ describe("RoomHeader (Enzyme)", () => { // And there is no image avatar (because it's not set on this room) const image = findImg(rendered, ".mx_BaseAvatar_image"); - expect(image.prop("src")).toEqual("data:image/png;base64,00"); + expect(image).toBeTruthy(); }); it("renders call buttons normally", () => { diff --git a/test/components/views/rooms/__snapshots__/RoomPreviewBar-test.tsx.snap b/test/components/views/rooms/__snapshots__/RoomPreviewBar-test.tsx.snap index f35467e1ef..a78a452e89 100644 --- a/test/components/views/rooms/__snapshots__/RoomPreviewBar-test.tsx.snap +++ b/test/components/views/rooms/__snapshots__/RoomPreviewBar-test.tsx.snap @@ -161,22 +161,16 @@ exports[` with an invite without an invited email for a dm roo -

@@ -236,22 +230,16 @@ exports[` with an invite without an invited email for a non-dm -

diff --git a/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap b/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap index bcbb7932c6..557d97c243 100644 --- a/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap +++ b/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap @@ -15,22 +15,16 @@ exports[`RoomTile should render the room 1`] = ` -

+ !room:example.com + +`; + +exports[`RoomPillPart matches snapshot (no avatar) 1`] = ` + + !room:example.com + +`; + +exports[`UserPillPart matches snapshot (avatar) 1`] = ` + + DisplayName + +`; + +exports[`UserPillPart matches snapshot (no avatar) 1`] = ` + + DisplayName + +`; diff --git a/test/editor/parts-test.ts b/test/editor/parts-test.ts index 534221ece3..31c620c94a 100644 --- a/test/editor/parts-test.ts +++ b/test/editor/parts-test.ts @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022 - 2023 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. @@ -14,7 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EmojiPart, PlainPart } from "../../src/editor/parts"; +import { MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix"; + +import { EmojiPart, PartCreator, PlainPart } from "../../src/editor/parts"; +import DMRoomMap from "../../src/utils/DMRoomMap"; +import { stubClient } from "../test-utils"; import { createPartCreator } from "./mock"; describe("editor/parts", () => { @@ -40,3 +44,67 @@ describe("editor/parts", () => { expect(() => part.toDOMNode()).not.toThrow(); }); }); + +describe("UserPillPart", () => { + const roomId = "!room:example.com"; + let client: MatrixClient; + let room: Room; + let creator: PartCreator; + + beforeEach(() => { + client = stubClient(); + room = new Room(roomId, client, "@me:example.com"); + creator = new PartCreator(room, client); + }); + + it("matches snapshot (no avatar)", () => { + jest.spyOn(room, "getMember").mockReturnValue(new RoomMember(room.roomId, "@user:example.com")); + const pill = creator.userPill("DisplayName", "@user:example.com"); + const el = pill.toDOMNode(); + + expect(el).toMatchSnapshot(); + }); + + it("matches snapshot (avatar)", () => { + const member = new RoomMember(room.roomId, "@user:example.com"); + jest.spyOn(room, "getMember").mockReturnValue(member); + jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue("mxc://www.example.com/avatar.png"); + + const pill = creator.userPill("DisplayName", "@user:example.com"); + const el = pill.toDOMNode(); + + expect(el).toMatchSnapshot(); + }); +}); + +describe("RoomPillPart", () => { + const roomId = "!room:example.com"; + let client: jest.Mocked; + let room: Room; + let creator: PartCreator; + + beforeEach(() => { + client = stubClient() as jest.Mocked; + DMRoomMap.makeShared(); + + room = new Room(roomId, client, "@me:example.com"); + client.getRoom.mockReturnValue(room); + creator = new PartCreator(room, client); + }); + + it("matches snapshot (no avatar)", () => { + jest.spyOn(room, "getMxcAvatarUrl").mockReturnValue(null); + const pill = creator.roomPill("super-secret clubhouse"); + const el = pill.toDOMNode(); + + expect(el).toMatchSnapshot(); + }); + + it("matches snapshot (avatar)", () => { + jest.spyOn(room, "getMxcAvatarUrl").mockReturnValue("mxc://www.example.com/avatars/room1.jpeg"); + const pill = creator.roomPill("cool chat club"); + const el = pill.toDOMNode(); + + expect(el).toMatchSnapshot(); + }); +}); From c7b01af49e648482707792d4292a6e7f211feb2c Mon Sep 17 00:00:00 2001 From: AHMAD KADRI <52747422+ahmadkadri@users.noreply.github.com> Date: Mon, 30 Jan 2023 10:54:05 +0100 Subject: [PATCH 08/40] Should open new 1:1 chat room after leaving the old one (#9880) * should open new 1:1 chat room after leaving the old one Signed-off-by: Ahmad Kadri * change the copyright * update the test Signed-off-by: AHMAD KADRI <52747422+ahmadkadri@users.noreply.github.com> --------- Signed-off-by: Ahmad Kadri Signed-off-by: AHMAD KADRI <52747422+ahmadkadri@users.noreply.github.com> Co-authored-by: Oliver Sand Co-authored-by: Dominik Henneke --- .../one-to-one-chat/one-to-one-chat.spec.ts | 61 +++++++++++++++++++ src/components/structures/MatrixChat.tsx | 8 +-- 2 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 cypress/e2e/one-to-one-chat/one-to-one-chat.spec.ts diff --git a/cypress/e2e/one-to-one-chat/one-to-one-chat.spec.ts b/cypress/e2e/one-to-one-chat/one-to-one-chat.spec.ts new file mode 100644 index 0000000000..897b916105 --- /dev/null +++ b/cypress/e2e/one-to-one-chat/one-to-one-chat.spec.ts @@ -0,0 +1,61 @@ +/* +Copyright 2023 Ahmad Kadri +Copyright 2023 Nordeck IT + Consulting GmbH. + +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 { HomeserverInstance } from "../../plugins/utils/homeserver"; +import { Credentials } from "../../support/homeserver"; + +describe("1:1 chat room", () => { + let homeserver: HomeserverInstance; + let user2: Credentials; + + const username = "user1234"; + const password = "p4s5W0rD"; + + beforeEach(() => { + cy.startHomeserver("default").then((data) => { + homeserver = data; + + cy.initTestUser(homeserver, "Jeff"); + cy.registerUser(homeserver, username, password).then((credential) => { + user2 = credential; + cy.visit(`/#/user/${user2.userId}?action=chat`); + }); + }); + }); + + afterEach(() => { + cy.stopHomeserver(homeserver); + }); + + it("should open new 1:1 chat room after leaving the old one", () => { + // leave 1:1 chat room + cy.contains(".mx_RoomHeader_nametext", username).click(); + cy.contains('[role="menuitem"]', "Leave").click(); + cy.get('[data-testid="dialog-primary-button"]').click(); + + // wait till the room was left + cy.get('[role="group"][aria-label="Historical"]').within(() => { + cy.contains(".mx_RoomTile", username); + }); + + // open new 1:1 chat room + cy.visit(`/#/user/${user2.userId}?action=chat`); + cy.contains(".mx_RoomHeader_nametext", username); + }); +}); diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 429adb6f50..e517aaaf83 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -138,6 +138,7 @@ import { cleanUpBroadcasts, VoiceBroadcastResumer } from "../../voice-broadcast" import GenericToast from "../views/toasts/GenericToast"; import { Linkify } from "../views/elements/Linkify"; import RovingSpotlightDialog, { Filter } from "../views/dialogs/spotlight/SpotlightDialog"; +import { findDMForUser } from "../../utils/dm/findDMForUser"; // legacy export export { default as Views } from "../../Views"; @@ -1101,13 +1102,12 @@ export default class MatrixChat extends React.PureComponent { // TODO: Immutable DMs replaces this const client = MatrixClientPeg.get(); - const dmRoomMap = new DMRoomMap(client); - const dmRooms = dmRoomMap.getDMRoomsForUserId(userId); + const dmRoom = findDMForUser(client, userId); - if (dmRooms.length > 0) { + if (dmRoom) { dis.dispatch({ action: Action.ViewRoom, - room_id: dmRooms[0], + room_id: dmRoom.roomId, metricsTrigger: "MessageUser", }); } else { From a21929dba00491e026926d2cdea51a4457b18313 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Mon, 30 Jan 2023 10:02:32 +0000 Subject: [PATCH 09/40] Convert RoomCreate to a functional component (#9999) --- src/components/views/messages/RoomCreate.tsx | 78 +++++++++---------- src/events/EventTileFactory.tsx | 4 +- .../views/messages/RoomCreate-test.tsx | 2 +- 3 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/components/views/messages/RoomCreate.tsx b/src/components/views/messages/RoomCreate.tsx index 8bff5dfdcc..25cfb90a48 100644 --- a/src/components/views/messages/RoomCreate.tsx +++ b/src/components/views/messages/RoomCreate.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { useCallback } from "react"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import dis from "../../../dispatcher/dispatcher"; @@ -36,44 +36,44 @@ interface IProps { * A message tile showing that this room was created as an upgrade of a previous * room. */ -export default class RoomCreate extends React.Component { - private onLinkClicked = (e: React.MouseEvent): void => { - e.preventDefault(); +export const RoomCreate: React.FC = ({ mxEvent, timestamp }) => { + const onLinkClicked = useCallback( + (e: React.MouseEvent): void => { + e.preventDefault(); - const predecessor = this.props.mxEvent.getContent()["predecessor"]; + const predecessor = mxEvent.getContent()["predecessor"]; - dis.dispatch({ - action: Action.ViewRoom, - event_id: predecessor["event_id"], - highlighted: true, - room_id: predecessor["room_id"], - metricsTrigger: "Predecessor", - metricsViaKeyboard: e.type !== "click", - }); - }; - - public render(): JSX.Element { - const predecessor = this.props.mxEvent.getContent()["predecessor"]; - if (predecessor === undefined) { - return
; // We should never have been instantiated in this case - } - const prevRoom = MatrixClientPeg.get().getRoom(predecessor["room_id"]); - const permalinkCreator = new RoomPermalinkCreator(prevRoom, predecessor["room_id"]); - permalinkCreator.load(); - const predecessorPermalink = permalinkCreator.forEvent(predecessor["event_id"]); - const link = ( - - {_t("Click here to see older messages.")} - - ); - - return ( - - ); + dis.dispatch({ + action: Action.ViewRoom, + event_id: predecessor["event_id"], + highlighted: true, + room_id: predecessor["room_id"], + metricsTrigger: "Predecessor", + metricsViaKeyboard: e.type !== "click", + }); + }, + [mxEvent], + ); + const predecessor = mxEvent.getContent()["predecessor"]; + if (predecessor === undefined) { + return
; // We should never have been instantiated in this case } -} + const prevRoom = MatrixClientPeg.get().getRoom(predecessor["room_id"]); + const permalinkCreator = new RoomPermalinkCreator(prevRoom, predecessor["room_id"]); + permalinkCreator.load(); + const predecessorPermalink = permalinkCreator.forEvent(predecessor["event_id"]); + const link = ( + + {_t("Click here to see older messages.")} + + ); + + return ( + + ); +}; diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx index 1d30416f0f..d6b5e18ad5 100644 --- a/src/events/EventTileFactory.tsx +++ b/src/events/EventTileFactory.tsx @@ -33,7 +33,7 @@ import LegacyCallEvent from "../components/views/messages/LegacyCallEvent"; import { CallEvent } from "../components/views/messages/CallEvent"; import TextualEvent from "../components/views/messages/TextualEvent"; import EncryptionEvent from "../components/views/messages/EncryptionEvent"; -import RoomCreate from "../components/views/messages/RoomCreate"; +import { RoomCreate } from "../components/views/messages/RoomCreate"; import RoomAvatarEvent from "../components/views/messages/RoomAvatarEvent"; import { WIDGET_LAYOUT_EVENT_TYPE } from "../stores/widgets/WidgetLayoutStore"; import { ALL_RULE_TYPES } from "../mjolnir/BanList"; @@ -101,7 +101,7 @@ const EVENT_TILE_TYPES = new Map([ const STATE_EVENT_TILE_TYPES = new Map([ [EventType.RoomEncryption, (ref, props) => ], [EventType.RoomCanonicalAlias, TextualEventFactory], - [EventType.RoomCreate, (ref, props) => ], + [EventType.RoomCreate, (_ref, props) => ], [EventType.RoomMember, TextualEventFactory], [EventType.RoomName, TextualEventFactory], [EventType.RoomAvatar, (ref, props) => ], diff --git a/test/components/views/messages/RoomCreate-test.tsx b/test/components/views/messages/RoomCreate-test.tsx index 31763f9ae8..09f17e2ae4 100644 --- a/test/components/views/messages/RoomCreate-test.tsx +++ b/test/components/views/messages/RoomCreate-test.tsx @@ -22,7 +22,7 @@ import { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix"; import dis from "../../../../src/dispatcher/dispatcher"; import SettingsStore from "../../../../src/settings/SettingsStore"; -import RoomCreate from "../../../../src/components/views/messages/RoomCreate"; +import { RoomCreate } from "../../../../src/components/views/messages/RoomCreate"; import { stubClient } from "../../../test-utils/test-utils"; import { Action } from "../../../../src/dispatcher/actions"; From 3e2bf5640e17ff1c096a746089c87f8cf93e174a Mon Sep 17 00:00:00 2001 From: Germain Date: Mon, 30 Jan 2023 12:20:11 +0000 Subject: [PATCH 10/40] Update to supportsThreads (#9907) --- src/MatrixClientPeg.ts | 2 +- src/utils/EventUtils.ts | 6 +----- test/Notifier-test.ts | 2 +- test/Unread-test.ts | 2 +- test/components/structures/RoomSearchView-test.tsx | 2 +- test/components/structures/ThreadPanel-test.tsx | 2 +- test/components/structures/ThreadView-test.tsx | 2 +- test/components/structures/TimelinePanel-test.tsx | 4 ++-- .../components/views/right_panel/RoomHeaderButtons-test.tsx | 2 +- test/components/views/rooms/EventTile-test.tsx | 2 +- .../NotificationBadge/UnreadNotificationBadge-test.tsx | 2 +- test/components/views/settings/Notifications-test.tsx | 2 +- test/stores/RoomViewStore-test.ts | 2 +- test/test-utils/test-utils.ts | 2 +- test/utils/EventUtils-test.ts | 2 +- 15 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index f674892bf7..19a9eb5fde 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -218,7 +218,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { opts.pendingEventOrdering = PendingEventOrdering.Detached; opts.lazyLoadMembers = true; opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours - opts.experimentalThreadSupport = SettingsStore.getValue("feature_threadenabled"); + opts.threadSupport = SettingsStore.getValue("feature_threadenabled"); if (SettingsStore.getValue("feature_sliding_sync")) { const proxyUrl = SettingsStore.getValue("feature_sliding_sync_proxy_url"); diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index e80ffb83e2..5e650018a0 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -229,11 +229,7 @@ export async function fetchInitialEvent( initialEvent = null; } - if ( - client.supportsExperimentalThreads() && - initialEvent?.isRelation(THREAD_RELATION_TYPE.name) && - !initialEvent.getThread() - ) { + if (client.supportsThreads() && initialEvent?.isRelation(THREAD_RELATION_TYPE.name) && !initialEvent.getThread()) { const threadId = initialEvent.threadRootId; const room = client.getRoom(roomId); const mapper = client.getEventMapper(); diff --git a/test/Notifier-test.ts b/test/Notifier-test.ts index 20b2c2e361..e2b867ee7f 100644 --- a/test/Notifier-test.ts +++ b/test/Notifier-test.ts @@ -109,7 +109,7 @@ describe("Notifier", () => { decryptEventIfNeeded: jest.fn(), getRoom: jest.fn(), getPushActionsForEvent: jest.fn(), - supportsExperimentalThreads: jest.fn().mockReturnValue(false), + supportsThreads: jest.fn().mockReturnValue(false), }); mockClient.pushRules = { diff --git a/test/Unread-test.ts b/test/Unread-test.ts index 8ff759b142..4cfb7f265a 100644 --- a/test/Unread-test.ts +++ b/test/Unread-test.ts @@ -124,7 +124,7 @@ describe("Unread", () => { const myId = client.getUserId()!; beforeAll(() => { - client.supportsExperimentalThreads = () => true; + client.supportsThreads = () => true; }); beforeEach(() => { diff --git a/test/components/structures/RoomSearchView-test.tsx b/test/components/structures/RoomSearchView-test.tsx index 26786956b5..35e297b7a1 100644 --- a/test/components/structures/RoomSearchView-test.tsx +++ b/test/components/structures/RoomSearchView-test.tsx @@ -48,7 +48,7 @@ describe("", () => { beforeEach(async () => { stubClient(); client = MatrixClientPeg.get(); - client.supportsExperimentalThreads = jest.fn().mockReturnValue(true); + client.supportsThreads = jest.fn().mockReturnValue(true); room = new Room("!room:server", client, client.getUserId()); mocked(client.getRoom).mockReturnValue(room); permalinkCreator = new RoomPermalinkCreator(room, room.roomId); diff --git a/test/components/structures/ThreadPanel-test.tsx b/test/components/structures/ThreadPanel-test.tsx index b868549da4..20c6cf0397 100644 --- a/test/components/structures/ThreadPanel-test.tsx +++ b/test/components/structures/ThreadPanel-test.tsx @@ -161,7 +161,7 @@ describe("ThreadPanel", () => { Thread.setServerSideSupport(FeatureSupport.Stable); Thread.setServerSideListSupport(FeatureSupport.Stable); Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable); - jest.spyOn(mockClient, "supportsExperimentalThreads").mockReturnValue(true); + jest.spyOn(mockClient, "supportsThreads").mockReturnValue(true); room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", { pendingEventOrdering: PendingEventOrdering.Detached, diff --git a/test/components/structures/ThreadView-test.tsx b/test/components/structures/ThreadView-test.tsx index c578121e46..ef6b195a2d 100644 --- a/test/components/structures/ThreadView-test.tsx +++ b/test/components/structures/ThreadView-test.tsx @@ -117,7 +117,7 @@ describe("ThreadView", () => { stubClient(); mockPlatformPeg(); mockClient = mocked(MatrixClientPeg.get()); - jest.spyOn(mockClient, "supportsExperimentalThreads").mockReturnValue(true); + jest.spyOn(mockClient, "supportsThreads").mockReturnValue(true); room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", { pendingEventOrdering: PendingEventOrdering.Detached, diff --git a/test/components/structures/TimelinePanel-test.tsx b/test/components/structures/TimelinePanel-test.tsx index e66a015a5c..eaeb19d8ec 100644 --- a/test/components/structures/TimelinePanel-test.tsx +++ b/test/components/structures/TimelinePanel-test.tsx @@ -362,7 +362,7 @@ describe("TimelinePanel", () => { client = MatrixClientPeg.get(); Thread.hasServerSideSupport = FeatureSupport.Stable; - client.supportsExperimentalThreads = () => true; + client.supportsThreads = () => true; const getValueCopy = SettingsStore.getValue; SettingsStore.getValue = jest.fn().mockImplementation((name: string) => { if (name === "feature_threadenabled") return true; @@ -524,7 +524,7 @@ describe("TimelinePanel", () => { const client = MatrixClientPeg.get(); client.isRoomEncrypted = () => true; - client.supportsExperimentalThreads = () => true; + client.supportsThreads = () => true; client.decryptEventIfNeeded = () => Promise.resolve(); const authorId = client.getUserId()!; const room = new Room("roomId", client, authorId, { diff --git a/test/components/views/right_panel/RoomHeaderButtons-test.tsx b/test/components/views/right_panel/RoomHeaderButtons-test.tsx index 06192ccc23..8df11a7602 100644 --- a/test/components/views/right_panel/RoomHeaderButtons-test.tsx +++ b/test/components/views/right_panel/RoomHeaderButtons-test.tsx @@ -38,7 +38,7 @@ describe("RoomHeaderButtons-test.tsx", function () { stubClient(); client = MatrixClientPeg.get(); - client.supportsExperimentalThreads = () => true; + client.supportsThreads = () => true; room = new Room(ROOM_ID, client, client.getUserId() ?? "", { pendingEventOrdering: PendingEventOrdering.Detached, }); diff --git a/test/components/views/rooms/EventTile-test.tsx b/test/components/views/rooms/EventTile-test.tsx index c3c0b55ab5..e3f28d851a 100644 --- a/test/components/views/rooms/EventTile-test.tsx +++ b/test/components/views/rooms/EventTile-test.tsx @@ -92,7 +92,7 @@ describe("EventTile", () => { describe("EventTile thread summary", () => { beforeEach(() => { - jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true); + jest.spyOn(client, "supportsThreads").mockReturnValue(true); }); it("removes the thread summary when thread is deleted", async () => { diff --git a/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx b/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx index cfa4416576..9c60d26e26 100644 --- a/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx +++ b/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx @@ -48,7 +48,7 @@ describe("UnreadNotificationBadge", () => { } beforeAll(() => { - client.supportsExperimentalThreads = () => true; + client.supportsThreads = () => true; }); beforeEach(() => { diff --git a/test/components/views/settings/Notifications-test.tsx b/test/components/views/settings/Notifications-test.tsx index b33f72838a..aedb96fb13 100644 --- a/test/components/views/settings/Notifications-test.tsx +++ b/test/components/views/settings/Notifications-test.tsx @@ -225,7 +225,7 @@ describe("", () => { }), setAccountData: jest.fn(), sendReadReceipt: jest.fn(), - supportsExperimentalThreads: jest.fn().mockReturnValue(true), + supportsThreads: jest.fn().mockReturnValue(true), }); mockClient.getPushRules.mockResolvedValue(pushRules); diff --git a/test/stores/RoomViewStore-test.ts b/test/stores/RoomViewStore-test.ts index 7c2b1ec81b..370fb95bcd 100644 --- a/test/stores/RoomViewStore-test.ts +++ b/test/stores/RoomViewStore-test.ts @@ -93,7 +93,7 @@ describe("RoomViewStore", function () { getSafeUserId: jest.fn().mockReturnValue(userId), getDeviceId: jest.fn().mockReturnValue("ABC123"), sendStateEvent: jest.fn().mockResolvedValue({}), - supportsExperimentalThreads: jest.fn(), + supportsThreads: jest.fn(), }); const room = new Room(roomId, mockClient, userId); const room2 = new Room(roomId2, mockClient, userId); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index ef7bc1dcef..5938f89cfe 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -166,7 +166,7 @@ export function createTestClient(): MatrixClient { decryptEventIfNeeded: () => Promise.resolve(), isUserIgnored: jest.fn().mockReturnValue(false), getCapabilities: jest.fn().mockResolvedValue({}), - supportsExperimentalThreads: () => false, + supportsThreads: () => false, getRoomUpgradeHistory: jest.fn().mockReturnValue([]), getOpenIdToken: jest.fn().mockResolvedValue(undefined), registerWithIdentityServer: jest.fn().mockResolvedValue({}), diff --git a/test/utils/EventUtils-test.ts b/test/utils/EventUtils-test.ts index decf42931a..9c4012a4c4 100644 --- a/test/utils/EventUtils-test.ts +++ b/test/utils/EventUtils-test.ts @@ -436,7 +436,7 @@ describe("EventUtils", () => { pendingEventOrdering: PendingEventOrdering.Detached, }); - jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true); + jest.spyOn(client, "supportsThreads").mockReturnValue(true); jest.spyOn(client, "getRoom").mockReturnValue(room); jest.spyOn(client, "fetchRoomEvent").mockImplementation(async (roomId, eventId) => { return events[eventId] ?? Promise.reject(); From 4c1e4f5127bd7fc8a3ab852d630242a704ffbda2 Mon Sep 17 00:00:00 2001 From: Clark Fischer <439978+clarkf@users.noreply.github.com> Date: Mon, 30 Jan 2023 14:31:32 +0000 Subject: [PATCH 11/40] Fix "[object Promise]" appearing in HTML exports (#9975) Fixes https://github.com/vector-im/element-web/issues/24272 --- src/DateUtils.ts | 2 +- src/components/structures/MessagePanel.tsx | 4 +- .../dialogs/MessageEditHistoryDialog.tsx | 2 +- .../views/rooms/SearchResultTile.tsx | 7 +- src/utils/exportUtils/HtmlExport.tsx | 43 ++- src/utils/exportUtils/exportCSS.ts | 4 +- .../dialogs/MessageEditHistoryDialog-test.tsx | 83 +++++ .../MessageEditHistoryDialog-test.tsx.snap | 322 ++++++++++++++++++ .../views/rooms/SearchResultTile-test.tsx | 110 +++--- test/test-utils/test-utils.ts | 4 + test/utils/exportUtils/HTMLExport-test.ts | 286 +++++++++++++++- .../__snapshots__/HTMLExport-test.ts.snap | 86 +++++ test/utils/exportUtils/exportCSS-test.ts | 26 ++ 13 files changed, 895 insertions(+), 84 deletions(-) create mode 100644 test/components/views/dialogs/MessageEditHistoryDialog-test.tsx create mode 100644 test/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap create mode 100644 test/utils/exportUtils/exportCSS-test.ts diff --git a/src/DateUtils.ts b/src/DateUtils.ts index 5973a7c5f2..c1aa69aacd 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -175,7 +175,7 @@ function withinCurrentYear(prevDate: Date, nextDate: Date): boolean { return prevDate.getFullYear() === nextDate.getFullYear(); } -export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean { +export function wantsDateSeparator(prevEventDate: Date | undefined, nextEventDate: Date | undefined): boolean { if (!nextEventDate || !prevEventDate) { return false; } diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 98e8f79ec7..2dd432cb92 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -72,7 +72,7 @@ const groupedStateEvents = [ // check if there is a previous event and it has the same sender as this event // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL export function shouldFormContinuation( - prevEvent: MatrixEvent, + prevEvent: MatrixEvent | null, mxEvent: MatrixEvent, showHiddenEvents: boolean, threadsEnabled: boolean, @@ -821,7 +821,7 @@ export default class MessagePanel extends React.Component { // here. return !this.props.canBackPaginate; } - return wantsDateSeparator(prevEvent.getDate(), nextEventDate); + return wantsDateSeparator(prevEvent.getDate() || undefined, nextEventDate); } // Get a list of read receipts that should be shown next to this event diff --git a/src/components/views/dialogs/MessageEditHistoryDialog.tsx b/src/components/views/dialogs/MessageEditHistoryDialog.tsx index 943e7f58d2..8775b4eb5c 100644 --- a/src/components/views/dialogs/MessageEditHistoryDialog.tsx +++ b/src/components/views/dialogs/MessageEditHistoryDialog.tsx @@ -130,7 +130,7 @@ export default class MessageEditHistoryDialog extends React.PureComponent { - if (!lastEvent || wantsDateSeparator(lastEvent.getDate(), e.getDate())) { + if (!lastEvent || wantsDateSeparator(lastEvent.getDate() || undefined, e.getDate() || undefined)) { nodes.push(
  • diff --git a/src/components/views/rooms/SearchResultTile.tsx b/src/components/views/rooms/SearchResultTile.tsx index 067cbaee38..3ec68b989f 100644 --- a/src/components/views/rooms/SearchResultTile.tsx +++ b/src/components/views/rooms/SearchResultTile.tsx @@ -84,7 +84,7 @@ export default class SearchResultTile extends React.Component { // is this a continuation of the previous message? const continuation = prevEv && - !wantsDateSeparator(prevEv.getDate(), mxEv.getDate()) && + !wantsDateSeparator(prevEv.getDate() || undefined, mxEv.getDate() || undefined) && shouldFormContinuation( prevEv, mxEv, @@ -96,7 +96,10 @@ export default class SearchResultTile extends React.Component { let lastInSection = true; const nextEv = timeline[j + 1]; if (nextEv) { - const willWantDateSeparator = wantsDateSeparator(mxEv.getDate(), nextEv.getDate()); + const willWantDateSeparator = wantsDateSeparator( + mxEv.getDate() || undefined, + nextEv.getDate() || undefined, + ); lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEv.getSender() || diff --git a/src/utils/exportUtils/HtmlExport.tsx b/src/utils/exportUtils/HtmlExport.tsx index 6b4240375c..e915d18025 100644 --- a/src/utils/exportUtils/HtmlExport.tsx +++ b/src/utils/exportUtils/HtmlExport.tsx @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021, 2023 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. @@ -66,7 +66,7 @@ export default class HTMLExporter extends Exporter { } protected async getRoomAvatar(): Promise { - let blob: Blob; + let blob: Blob | undefined = undefined; const avatarUrl = Avatar.avatarUrlForRoom(this.room, 32, 32, "crop"); const avatarPath = "room.png"; if (avatarUrl) { @@ -85,7 +85,7 @@ export default class HTMLExporter extends Exporter { height={32} name={this.room.name} title={this.room.name} - url={blob ? avatarPath : null} + url={blob ? avatarPath : ""} resizeMethod="crop" /> ); @@ -96,9 +96,9 @@ export default class HTMLExporter extends Exporter { const roomAvatar = await this.getRoomAvatar(); const exportDate = formatFullDateNoDayNoTime(new Date()); const creator = this.room.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender(); - const creatorName = this.room?.getMember(creator)?.rawDisplayName || creator; - const exporter = this.client.getUserId(); - const exporterName = this.room?.getMember(exporter)?.rawDisplayName; + const creatorName = (creator ? this.room.getMember(creator)?.rawDisplayName : creator) || creator; + const exporter = this.client.getUserId()!; + const exporterName = this.room.getMember(exporter)?.rawDisplayName; const topic = this.room.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || ""; const createdText = _t("%(creatorName)s created this room.", { creatorName, @@ -217,20 +217,19 @@ export default class HTMLExporter extends Exporter { `; } - protected getAvatarURL(event: MatrixEvent): string { + protected getAvatarURL(event: MatrixEvent): string | undefined { const member = event.sender; - return ( - member.getMxcAvatarUrl() && mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(30, 30, "crop") - ); + const avatarUrl = member?.getMxcAvatarUrl(); + return avatarUrl ? mediaFromMxc(avatarUrl).getThumbnailOfSourceHttp(30, 30, "crop") : undefined; } protected async saveAvatarIfNeeded(event: MatrixEvent): Promise { - const member = event.sender; + const member = event.sender!; if (!this.avatars.has(member.userId)) { try { const avatarUrl = this.getAvatarURL(event); this.avatars.set(member.userId, true); - const image = await fetch(avatarUrl); + const image = await fetch(avatarUrl!); const blob = await image.blob(); this.addFile(`users/${member.userId.replace(/:/g, "-")}.png`, blob); } catch (err) { @@ -239,19 +238,19 @@ export default class HTMLExporter extends Exporter { } } - protected async getDateSeparator(event: MatrixEvent): Promise { + protected getDateSeparator(event: MatrixEvent): string { const ts = event.getTs(); const dateSeparator = (
  • - +
  • ); return renderToStaticMarkup(dateSeparator); } - protected async needsDateSeparator(event: MatrixEvent, prevEvent: MatrixEvent): Promise { - if (prevEvent == null) return true; - return wantsDateSeparator(prevEvent.getDate(), event.getDate()); + protected needsDateSeparator(event: MatrixEvent, prevEvent: MatrixEvent | null): boolean { + if (!prevEvent) return true; + return wantsDateSeparator(prevEvent.getDate() || undefined, event.getDate() || undefined); } public getEventTile(mxEv: MatrixEvent, continuation: boolean): JSX.Element { @@ -264,9 +263,7 @@ export default class HTMLExporter extends Exporter { isRedacted={mxEv.isRedacted()} replacingEventId={mxEv.replacingEventId()} forExport={true} - readReceipts={null} alwaysShowTimestamps={true} - readReceiptMap={null} showUrlPreview={false} checkUnmounting={() => false} isTwelveHour={false} @@ -275,7 +272,6 @@ export default class HTMLExporter extends Exporter { permalinkCreator={this.permalinkCreator} lastSuccessful={false} isSelectedEvent={false} - getRelationsForEvent={null} showReactions={false} layout={Layout.Group} showReadReceipts={false} @@ -286,7 +282,8 @@ export default class HTMLExporter extends Exporter { } protected async getEventTileMarkup(mxEv: MatrixEvent, continuation: boolean, filePath?: string): Promise { - const hasAvatar = !!this.getAvatarURL(mxEv); + const avatarUrl = this.getAvatarURL(mxEv); + const hasAvatar = !!avatarUrl; if (hasAvatar) await this.saveAvatarIfNeeded(mxEv); const EventTile = this.getEventTile(mxEv, continuation); let eventTileMarkup: string; @@ -312,8 +309,8 @@ export default class HTMLExporter extends Exporter { eventTileMarkup = eventTileMarkup.replace(/.*?<\/span>/, ""); if (hasAvatar) { eventTileMarkup = eventTileMarkup.replace( - encodeURI(this.getAvatarURL(mxEv)).replace(/&/g, "&"), - `users/${mxEv.sender.userId.replace(/:/g, "-")}.png`, + encodeURI(avatarUrl).replace(/&/g, "&"), + `users/${mxEv.sender!.userId.replace(/:/g, "-")}.png`, ); } return eventTileMarkup; diff --git a/src/utils/exportUtils/exportCSS.ts b/src/utils/exportUtils/exportCSS.ts index f92e339b02..2a6a098a14 100644 --- a/src/utils/exportUtils/exportCSS.ts +++ b/src/utils/exportUtils/exportCSS.ts @@ -58,8 +58,8 @@ const getExportCSS = async (usedClasses: Set): Promise => { // If the light theme isn't loaded we will have to fetch & parse it manually if (!stylesheets.some(isLightTheme)) { - const href = document.querySelector('link[rel="stylesheet"][href$="theme-light.css"]').href; - stylesheets.push(await getRulesFromCssFile(href)); + const href = document.querySelector('link[rel="stylesheet"][href$="theme-light.css"]')?.href; + if (href) stylesheets.push(await getRulesFromCssFile(href)); } let css = ""; diff --git a/test/components/views/dialogs/MessageEditHistoryDialog-test.tsx b/test/components/views/dialogs/MessageEditHistoryDialog-test.tsx new file mode 100644 index 0000000000..cadb92e488 --- /dev/null +++ b/test/components/views/dialogs/MessageEditHistoryDialog-test.tsx @@ -0,0 +1,83 @@ +/* +Copyright 2023 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 React from "react"; +import { render, RenderResult } from "@testing-library/react"; +import { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import type { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { flushPromises, mkMessage, stubClient } from "../../../test-utils"; +import MessageEditHistoryDialog from "../../../../src/components/views/dialogs/MessageEditHistoryDialog"; + +describe("", () => { + const roomId = "!aroom:example.com"; + let client: jest.Mocked; + let event: MatrixEvent; + + beforeEach(() => { + client = stubClient() as jest.Mocked; + event = mkMessage({ + event: true, + user: "@user:example.com", + room: "!room:example.com", + msg: "My Great Message", + }); + }); + + async function renderComponent(): Promise { + const result = render(); + await flushPromises(); + return result; + } + + function mockEdits(...edits: { msg: string; ts: number | undefined }[]) { + client.relations.mockImplementation(() => + Promise.resolve({ + events: edits.map( + (e) => + new MatrixEvent({ + type: EventType.RoomMessage, + room_id: roomId, + origin_server_ts: e.ts, + content: { + body: e.msg, + }, + }), + ), + }), + ); + } + + it("should match the snapshot", async () => { + mockEdits({ msg: "My Great Massage", ts: 1234 }); + + const { container } = await renderComponent(); + + expect(container).toMatchSnapshot(); + }); + + it("should support events with ", async () => { + mockEdits( + { msg: "My Great Massage", ts: undefined }, + { msg: "My Great Massage?", ts: undefined }, + { msg: "My Great Missage", ts: undefined }, + ); + + const { container } = await renderComponent(); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap new file mode 100644 index 0000000000..0eb2683003 --- /dev/null +++ b/test/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap @@ -0,0 +1,322 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should match the snapshot 1`] = ` +
    +
    +