diff --git a/CHANGELOG.md b/CHANGELOG.md index 495f538aef..5dbf6ebc47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,42 @@ +Changes in [3.65.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.65.0) (2023-01-31) +===================================================================================================== + +## ✨ 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) ===================================================================================================== 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/cypress/e2e/spaces/spaces.spec.ts b/cypress/e2e/spaces/spaces.spec.ts index f07746c0f5..2deb58574b 100644 --- a/cypress/e2e/spaces/spaces.spec.ts +++ b/cypress/e2e/spaces/spaces.spec.ts @@ -153,10 +153,7 @@ describe("Spaces", () => { openSpaceCreateMenu().within(() => { cy.get(".mx_SpaceCreateMenuType_private").click(); - cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]').selectFile( - "cypress/fixtures/riot.png", - { force: true }, - ); + // We don't set an avatar here to get a Percy snapshot of the default avatar style for spaces cy.get('input[label="Address"]').should("not.exist"); cy.get('textarea[label="Description"]').type("This is a personal space to mourn Riot.im..."); cy.get('input[label="Name"]').type("This is my Riot{enter}"); @@ -169,6 +166,7 @@ describe("Spaces", () => { cy.contains(".mx_RoomList .mx_RoomTile", "Sample Room").should("exist"); cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Sample Room").should("exist"); + cy.get(".mx_LeftPanel_outerWrapper").percySnapshotElement("Left panel with default avatar space"); }); it("should allow user to invite another to a space", () => { diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index 17b763a089..f4a861ab7c 100644 --- a/cypress/e2e/timeline/timeline.spec.ts +++ b/cypress/e2e/timeline/timeline.spec.ts @@ -384,5 +384,24 @@ describe("Timeline", () => { 1, ); }); + + it("should not be possible to send flag with regional emojis", () => { + cy.visit("/#/room/" + roomId); + + // Send a message + cy.getComposer().type(":regional_indicator_a"); + cy.contains(".mx_Autocomplete_Completion_title", ":regional_indicator_a:").click(); + cy.getComposer().type(":regional_indicator_r"); + cy.contains(".mx_Autocomplete_Completion_title", ":regional_indicator_r:").click(); + cy.getComposer().type(" :regional_indicator_z"); + cy.contains(".mx_Autocomplete_Completion_title", ":regional_indicator_z:").click(); + cy.getComposer().type(":regional_indicator_a"); + cy.contains(".mx_Autocomplete_Completion_title", ":regional_indicator_a:").click(); + cy.getComposer().type("{enter}"); + + cy.get(".mx_RoomView_body .mx_EventTile .mx_EventTile_line .mx_MTextBody .mx_EventTile_bigEmoji") + .children() + .should("have.length", 4); + }); }); }); diff --git a/package.json b/package.json index c5da640ee0..8b8c3209f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.64.2", + "version": "3.65.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -57,7 +57,7 @@ "dependencies": { "@babel/runtime": "^7.12.5", "@matrix-org/analytics-events": "^0.4.0", - "@matrix-org/matrix-wysiwyg": "^0.20.0", + "@matrix-org/matrix-wysiwyg": "^0.23.0", "@matrix-org/react-sdk-module-api": "^0.0.3", "@sentry/browser": "^7.0.0", "@sentry/tracing": "^7.0.0", diff --git a/res/css/structures/_SpacePanel.pcss b/res/css/structures/_SpacePanel.pcss index 70c65b3f74..859714bfb9 100644 --- a/res/css/structures/_SpacePanel.pcss +++ b/res/css/structures/_SpacePanel.pcss @@ -277,14 +277,11 @@ $activeBorderColor: $primary-content; .mx_BaseAvatar:not(.mx_UserMenu_userAvatar_BaseAvatar) .mx_BaseAvatar_initial { color: $secondary-content; border-radius: 8px; - background-color: $panel-actions; - font-size: $font-15px !important; /* override inline style */ font-weight: $font-semi-bold; line-height: $font-18px; - - & + .mx_BaseAvatar_image { - visibility: hidden; - } + /* override inline styles which are part of the default avatar style as these uses a monochrome style */ + background-color: $panel-actions !important; + font-size: $font-15px !important; } .mx_SpaceTreeLevel { diff --git a/res/css/structures/_ViewSource.pcss b/res/css/structures/_ViewSource.pcss index c063eeb49c..52d3afecc4 100644 --- a/res/css/structures/_ViewSource.pcss +++ b/res/css/structures/_ViewSource.pcss @@ -27,6 +27,7 @@ limitations under the License. border-bottom: 1px solid $quinary-content; padding-bottom: $spacing-12; margin-bottom: $spacing-12; + font-family: monospace; .mx_CopyableText { word-break: break-all; diff --git a/res/css/views/avatars/_BaseAvatar.pcss b/res/css/views/avatars/_BaseAvatar.pcss index a6a4b0b74b..43e273b6ff 100644 --- a/res/css/views/avatars/_BaseAvatar.pcss +++ b/res/css/views/avatars/_BaseAvatar.pcss @@ -16,16 +16,7 @@ limitations under the License. .mx_BaseAvatar { position: relative; - /* In at least Firefox, the case of relative positioned inline elements */ - /* (such as mx_BaseAvatar) with absolute positioned children (such as */ - /* mx_BaseAvatar_initial) is a dark corner full of spider webs. It will give */ - /* different results during full reflow of the page vs. incremental reflow */ - /* of small portions. While that's surely a browser bug, we can avoid it by */ - /* using `inline-block` instead of the default `inline`. */ - /* https://github.com/vector-im/element-web/issues/5594 */ - /* https://bugzilla.mozilla.org/show_bug.cgi?id=1535053 */ - /* https://bugzilla.mozilla.org/show_bug.cgi?id=255139 */ - display: inline-block; + display: block; user-select: none; &.mx_RoomAvatar_isSpaceRoom { diff --git a/res/css/views/emojipicker/_EmojiPicker.pcss b/res/css/views/emojipicker/_EmojiPicker.pcss index 4be6a4fa69..c9169dbe7d 100644 --- a/res/css/views/emojipicker/_EmojiPicker.pcss +++ b/res/css/views/emojipicker/_EmojiPicker.pcss @@ -224,6 +224,10 @@ limitations under the License. .mx_EmojiPicker_preview_text { display: flex; + flex: 1; + overflow: hidden; + padding-top: 1rem; + padding-bottom: 1rem; flex-direction: column; } @@ -233,6 +237,7 @@ limitations under the License. .mx_EmojiPicker_shortcode { color: $light-fg-color; + overflow-wrap: break-word; font-size: $font-14px; &::before, 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/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index 5d55e8bf34..72ee340d4f 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -639,8 +639,8 @@ $left-gutter: 64px; list-style-type: disc; } - /* Remove top and bottom margin for better consecutive list display */ - > :is(ol, ul) { + /* Remove top and bottom margin for better display in rich text editor output */ + :is(p, ol, ul) { margin-top: 0; margin-bottom: 0; } diff --git a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss index cc805e1ac1..77e07ab48b 100644 --- a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss +++ b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss @@ -37,6 +37,20 @@ limitations under the License. user-select: all; } + // we always have a
tag at the end of the html, we need it to be present at first then hide it as soon as + // we have any other elements + br:not(:only-child) { + display: none; + } + + p { + margin-top: 0; + margin-bottom: 0; + // this may seem redundant, but we need to handle zero content formatting tags, which occur when we split a + // formatting tag into paragraphs + min-height: $font-22px; + } + ul, ol { margin-top: 0; @@ -56,12 +70,6 @@ limitations under the License. margin-inline-end: 0; } - // model output always includes a linebreak but we do not want the user - // to see it when writing input in lists - :is(ol, ul, pre, blockquote) + br:last-of-type { - display: none; - } - > pre { font-size: $font-15px; line-height: $font-24px; diff --git a/res/img/element-icons/room/composer/bulleted_list.svg b/res/img/element-icons/room/composer/bulleted_list.svg index 828bb8ab03..df07604567 100644 --- a/res/img/element-icons/room/composer/bulleted_list.svg +++ b/res/img/element-icons/room/composer/bulleted_list.svg @@ -1,3 +1,10 @@ - - + + + + + + + + + diff --git a/res/img/element-icons/room/composer/code_block.svg b/res/img/element-icons/room/composer/code_block.svg index dd0be2aefc..e2949ec8c7 100644 --- a/res/img/element-icons/room/composer/code_block.svg +++ b/res/img/element-icons/room/composer/code_block.svg @@ -1,3 +1,3 @@ - - + + diff --git a/res/img/element-icons/room/composer/indent_decrease.svg b/res/img/element-icons/room/composer/indent_decrease.svg new file mode 100644 index 0000000000..660c3e55ca --- /dev/null +++ b/res/img/element-icons/room/composer/indent_decrease.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/res/img/element-icons/room/composer/indent_increase.svg b/res/img/element-icons/room/composer/indent_increase.svg new file mode 100644 index 0000000000..f40162e05d --- /dev/null +++ b/res/img/element-icons/room/composer/indent_increase.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/res/img/element-icons/room/composer/numbered_list.svg b/res/img/element-icons/room/composer/numbered_list.svg index 46a5438f3f..5748c97766 100644 --- a/res/img/element-icons/room/composer/numbered_list.svg +++ b/res/img/element-icons/room/composer/numbered_list.svg @@ -1,3 +1,10 @@ - - + + + + + + + + + diff --git a/res/img/element-icons/room/composer/quote.svg b/res/img/element-icons/room/composer/quote.svg index 82cc2d2875..e83480a6ee 100644 --- a/res/img/element-icons/room/composer/quote.svg +++ b/res/img/element-icons/room/composer/quote.svg @@ -1,6 +1,6 @@ - - - - - + + + + + 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/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/HtmlUtils.tsx b/src/HtmlUtils.tsx index 3e67e42256..e7d19f0834 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -49,11 +49,8 @@ const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; // (with plenty of false positives, but that's OK) const SYMBOL_PATTERN = /([\u2100-\u2bff])/; -// Regex pattern for Zero-Width joiner unicode characters -const ZWJ_REGEX = /[\u200D\u2003]/g; - -// Regex pattern for whitespace characters -const WHITESPACE_REGEX = /\s/g; +// Regex pattern for non-emoji characters that can appear in an "all-emoji" message (Zero-Width Joiner, Zero-Width Space, other whitespace) +const EMOJI_SEPARATOR_REGEX = /[\u200D\u200B\s]/g; const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, "i"); @@ -591,14 +588,11 @@ export function bodyToHtml(content: IContent, highlights: Optional, op if (!opts.disableBigEmoji && bodyHasEmoji) { let contentBodyTrimmed = contentBody !== undefined ? contentBody.trim() : ""; - // Ignore spaces in body text. Emojis with spaces in between should - // still be counted as purely emoji messages. - contentBodyTrimmed = contentBodyTrimmed.replace(WHITESPACE_REGEX, ""); - - // Remove zero width joiner characters from emoji messages. This ensures - // that emojis that are made up of multiple unicode characters are still - // presented as large. - contentBodyTrimmed = contentBodyTrimmed.replace(ZWJ_REGEX, ""); + // Remove zero width joiner, zero width spaces and other spaces in body + // text. This ensures that emojis with spaces in between or that are made + // up of multiple unicode characters are still counted as purely emoji + // messages. + contentBodyTrimmed = contentBodyTrimmed.replace(EMOJI_SEPARATOR_REGEX, ""); const match = BIGEMOJI_REGEX.exec(contentBodyTrimmed); emojiBody = 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/RoomNotifs.ts b/src/RoomNotifs.ts index d63baa3e0f..0962e45cb9 100644 --- a/src/RoomNotifs.ts +++ b/src/RoomNotifs.ts @@ -1,6 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 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. @@ -16,18 +15,18 @@ limitations under the License. */ import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; -import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; -import { - ConditionKind, - IPushRule, - PushRuleActionName, - PushRuleKind, - TweakName, -} from "matrix-js-sdk/src/@types/PushRules"; +import { NotificationCountType } from "matrix-js-sdk/src/models/room"; +import { ConditionKind, PushRuleActionName, PushRuleKind, TweakName } from "matrix-js-sdk/src/@types/PushRules"; import { EventType } from "matrix-js-sdk/src/@types/event"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import type { IPushRule } from "matrix-js-sdk/src/@types/PushRules"; +import type { Room } from "matrix-js-sdk/src/models/room"; +import type { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "./MatrixClientPeg"; +import { NotificationColor } from "./stores/notifications/NotificationColor"; +import { getUnsentMessages } from "./components/structures/RoomStatusBar"; +import { doesRoomHaveUnreadMessages, doesRoomOrThreadHaveUnreadMessages } from "./Unread"; +import { EffectiveMembership, getEffectiveMembership } from "./utils/membership"; export enum RoomNotifState { AllMessagesLoud = "all_messages_loud", @@ -36,7 +35,7 @@ export enum RoomNotifState { Mute = "mute", } -export function getRoomNotifsState(client: MatrixClient, roomId: string): RoomNotifState { +export function getRoomNotifsState(client: MatrixClient, roomId: string): RoomNotifState | null { if (client.isGuest()) return RoomNotifState.AllMessages; // look through the override rules for a rule affecting this room: @@ -177,7 +176,7 @@ function setRoomNotifsStateUnmuted(roomId: string, newState: RoomNotifState): Pr return Promise.all(promises); } -function findOverrideMuteRule(roomId: string): IPushRule { +function findOverrideMuteRule(roomId: string): IPushRule | null { const cli = MatrixClientPeg.get(); if (!cli?.pushRules?.global?.override) { return null; @@ -201,3 +200,48 @@ function isRuleForRoom(roomId: string, rule: IPushRule): boolean { function isMuteRule(rule: IPushRule): boolean { return rule.actions.length === 1 && rule.actions[0] === PushRuleActionName.DontNotify; } + +export function determineUnreadState( + room?: Room, + threadId?: string, +): { color: NotificationColor; symbol: string | null; count: number } { + if (!room) { + return { symbol: null, count: 0, color: NotificationColor.None }; + } + + if (getUnsentMessages(room, threadId).length > 0) { + return { symbol: "!", count: 1, color: NotificationColor.Unsent }; + } + + if (getEffectiveMembership(room.getMyMembership()) === EffectiveMembership.Invite) { + return { symbol: "!", count: 1, color: NotificationColor.Red }; + } + + if (getRoomNotifsState(room.client, room.roomId) === RoomNotifState.Mute) { + return { symbol: null, count: 0, color: NotificationColor.None }; + } + + const redNotifs = getUnreadNotificationCount(room, NotificationCountType.Highlight, threadId); + const greyNotifs = getUnreadNotificationCount(room, NotificationCountType.Total, threadId); + + const trueCount = greyNotifs || redNotifs; + if (redNotifs > 0) { + return { symbol: null, count: trueCount, color: NotificationColor.Red }; + } + + if (greyNotifs > 0) { + return { symbol: null, count: trueCount, color: NotificationColor.Grey }; + } + + // We don't have any notified messages, but we might have unread messages. Let's + // find out. + let hasUnread = false; + if (threadId) hasUnread = doesRoomOrThreadHaveUnreadMessages(room.getThread(threadId)!); + else hasUnread = doesRoomHaveUnreadMessages(room); + + return { + symbol: null, + count: trueCount, + color: hasUnread ? NotificationColor.Bold : NotificationColor.None, + }; +} 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 { 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/structures/PictureInPictureDragger.tsx b/src/components/structures/PictureInPictureDragger.tsx index 19205229c8..40c1caee6f 100644 --- a/src/components/structures/PictureInPictureDragger.tsx +++ b/src/components/structures/PictureInPictureDragger.tsx @@ -70,6 +70,8 @@ export default class PictureInPictureDragger extends React.Component { () => this.animationCallback(), () => requestAnimationFrame(() => this.scheduledUpdate.trigger()), ); + private startingPositionX = 0; + private startingPositionY = 0; private _moving = false; public get moving(): boolean { @@ -192,11 +194,22 @@ export default class PictureInPictureDragger extends React.Component { event.stopPropagation(); this.mouseHeld = true; + this.startingPositionX = event.clientX; + this.startingPositionY = event.clientY; }; private onMoving = (event: MouseEvent): void => { if (!this.mouseHeld) return; + if ( + Math.abs(this.startingPositionX - event.clientX) < 5 && + Math.abs(this.startingPositionY - event.clientY) < 5 + ) { + // User needs to move the widget by at least five pixels. + // Improves click detection when using a touchpad or with nervous hands. + return; + } + event.preventDefault(); event.stopPropagation(); diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index 57bb7cdaf0..ef97bd71c8 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -1,5 +1,5 @@ /* -Copyright 2021 - 2022 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. @@ -32,16 +32,9 @@ import { Layout } from "../../settings/enums/Layout"; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import Measured from "../views/elements/Measured"; import PosthogTrackers from "../../PosthogTrackers"; -import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; -import { BetaPill } from "../views/beta/BetaCard"; -import Modal from "../../Modal"; -import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog"; -import { Action } from "../../dispatcher/actions"; -import { UserTab } from "../views/dialogs/UserTab"; -import dis from "../../dispatcher/dispatcher"; +import { ButtonEvent } from "../views/elements/AccessibleButton"; import Spinner from "../views/elements/Spinner"; import Heading from "../views/typography/Heading"; -import { shouldShowFeedback } from "../../utils/Feedback"; interface IProps { roomId: string; @@ -231,14 +224,6 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => } }, [timelineSet, timelinePanel]); - const openFeedback = shouldShowFeedback() - ? () => { - Modal.createDialog(BetaFeedbackDialog, { - featureId: "feature_threadenabled", - }); - } - : null; - return ( = ({ roomId, onClose, permalinkCreator }) => empty={!hasThreads} /> } - footer={ - <> - { - dis.dispatch({ - action: Action.ViewUserSettings, - initialTabId: UserTab.Labs, - }); - }} - /> - {openFeedback && - _t( - "Give feedback", - {}, - { - a: (sub) => ( - - {sub} - - ), - }, - )} - - } className="mx_ThreadPanel" onClose={onClose} withoutScrollContainer={true} diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 025cb9d271..7fb0ba458f 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. @@ -17,38 +15,46 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useCallback, useContext, useEffect, useState } from "react"; +import React, { CSSProperties, 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; + style?: CSSProperties; } -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 +72,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 +106,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 +129,25 @@ 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, + style: parentStyle, + resizeMethod: _unused, // to keep it from being in `otherProps` ...otherProps } = props; + const style = { + ...parentStyle, + width: toPx(width), + height: toPx(height), + }; + 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 +159,9 @@ const BaseAvatar: React.FC = (props) => { className={classNames("mx_BaseAvatar", className)} onClick={onClick} inputRef={inputRef} + style={style} > - {textNode} - {imgNode} + {avatar} ); } else { @@ -170,10 +170,10 @@ const BaseAvatar: React.FC = (props) => { className={classNames("mx_BaseAvatar", className)} ref={inputRef} {...otherProps} + style={style} role="presentation" > - {textNode} - {imgNode} + {avatar} ); } @@ -187,10 +187,7 @@ const BaseAvatar: React.FC = (props) => { src={imageUrl} onClick={onClick} onError={onError} - style={{ - width: toPx(width), - height: toPx(height), - }} + style={style} title={title} alt={_t("Avatar")} inputRef={inputRef} @@ -204,10 +201,7 @@ const BaseAvatar: React.FC = (props) => { className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)} src={imageUrl} onError={onError} - style={{ - width: toPx(width), - height: toPx(height), - }} + style={style} title={title} alt="" ref={inputRef} @@ -220,3 +214,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/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx index 862bf27c8a..f16dd94f6b 100644 --- a/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -1,6 +1,6 @@ /* Copyright 2022 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2018-2021 The Matrix.org Foundation C.I.C. +Copyright 2018-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. @@ -32,6 +32,7 @@ import SettingsFlag from "../elements/SettingsFlag"; import { SettingLevel } from "../../../settings/SettingLevel"; import ServerInfo from "./devtools/ServerInfo"; import { Features } from "../../../settings/Settings"; +import CopyableText from "../elements/CopyableText"; enum Category { Room, @@ -119,11 +120,15 @@ const DevtoolsDialog: React.FC = ({ roomId, onFinished }) => { {(cli) => ( <>
{label}
-
{_t("Room ID: %(roomId)s", { roomId })}
+ roomId} border={false}> + {_t("Room ID: %(roomId)s", { roomId })} +
- - {body} - + {cli.getRoom(roomId) && ( + + {body} + + )} )} 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/messages/RoomCreate.tsx b/src/components/views/messages/RoomCreate.tsx index 8bff5dfdcc..ccad5ea11e 100644 --- a/src/components/views/messages/RoomCreate.tsx +++ b/src/components/views/messages/RoomCreate.tsx @@ -1,6 +1,6 @@ /* Copyright 2018 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. @@ -15,7 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { useCallback, useContext } from "react"; +import { logger } from "matrix-js-sdk/src/logger"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import dis from "../../../dispatcher/dispatcher"; @@ -25,6 +26,8 @@ import { _t } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import EventTileBubble from "./EventTileBubble"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import RoomContext from "../../../contexts/RoomContext"; +import { useRoomState } from "../../../hooks/useRoomState"; interface IProps { /** The m.room.create MatrixEvent that this tile represents */ @@ -36,44 +39,70 @@ 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 }) => { + // Note: we ask the room for its predecessor here, instead of directly using + // the information inside mxEvent. This allows us the flexibility later to + // use a different predecessor (e.g. through MSC3946) and still display it + // in the timeline location of the create event. + const roomContext = useContext(RoomContext); + const predecessor = useRoomState( + roomContext.room, + useCallback((state) => state.findPredecessor(), []), + ); - const predecessor = this.props.mxEvent.getContent()["predecessor"]; + const onLinkClicked = useCallback( + (e: React.MouseEvent): void => { + e.preventDefault(); - dis.dispatch({ - action: Action.ViewRoom, - event_id: predecessor["event_id"], - highlighted: true, - room_id: predecessor["room_id"], - metricsTrigger: "Predecessor", - metricsViaKeyboard: e.type !== "click", - }); - }; + dis.dispatch({ + action: Action.ViewRoom, + event_id: predecessor.eventId, + highlighted: true, + room_id: predecessor.roomId, + metricsTrigger: "Predecessor", + metricsViaKeyboard: e.type !== "click", + }); + }, + [predecessor?.eventId, predecessor?.roomId], + ); - 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 ( - + if (!roomContext.room || roomContext.room.roomId !== mxEvent.getRoomId()) { + logger.warn( + "RoomCreate unexpectedly used outside of the context of the room containing this m.room.create event.", ); + return <>; } -} + + if (!predecessor) { + logger.warn("RoomCreate unexpectedly used in a room with no predecessor."); + return
    ; + } + + const prevRoom = MatrixClientPeg.get().getRoom(predecessor.roomId); + if (!prevRoom) { + logger.warn(`Failed to find predecessor room with id ${predecessor.roomId}`); + return <>; + } + const permalinkCreator = new RoomPermalinkCreator(prevRoom, predecessor.roomId); + permalinkCreator.load(); + let predecessorPermalink: string; + if (predecessor.eventId) { + predecessorPermalink = permalinkCreator.forEvent(predecessor.eventId); + } else { + predecessorPermalink = permalinkCreator.forRoom(); + } + const link = ( + + {_t("Click here to see older messages.")} + + ); + + return ( + + ); +}; diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 2bddfcaccf..1679c08f12 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -265,7 +265,7 @@ export default class TextualBody extends React.Component { // We don't use highlightElement here because we can't force language detection // off. It should use the one we've found in the CSS class but we'd rather pass // it in explicitly to make sure. - code.innerHTML = highlight.highlight(advertisedLang, code.textContent).value; + code.innerHTML = highlight.highlight(code.textContent, { language: advertisedLang }).value; } else if ( SettingsStore.getValue("enableSyntaxHighlightLanguageDetection") && code.parentElement instanceof HTMLPreElement diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx index 93e549b4ab..67509801d3 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.tsx +++ b/src/components/views/right_panel/RoomHeaderButtons.tsx @@ -22,7 +22,6 @@ import React from "react"; import classNames from "classnames"; import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { ThreadEvent } from "matrix-js-sdk/src/models/thread"; -import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; import { _t } from "../../../languageHandler"; import HeaderButton from "./HeaderButton"; @@ -39,12 +38,9 @@ import { UPDATE_STATUS_INDICATOR, } from "../../../stores/notifications/RoomNotificationStateStore"; import { NotificationColor } from "../../../stores/notifications/NotificationColor"; -import { ThreadsRoomNotificationState } from "../../../stores/notifications/ThreadsRoomNotificationState"; import { SummarizedNotificationState } from "../../../stores/notifications/SummarizedNotificationState"; -import { NotificationStateEvents } from "../../../stores/notifications/NotificationState"; import PosthogTrackers from "../../../PosthogTrackers"; import { ButtonEvent } from "../elements/AccessibleButton"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { doesRoomOrThreadHaveUnreadMessages } from "../../../Unread"; const ROOM_INFO_PHASES = [ @@ -133,74 +129,48 @@ interface IProps { export default class RoomHeaderButtons extends HeaderButtons { private static readonly THREAD_PHASES = [RightPanelPhases.ThreadPanel, RightPanelPhases.ThreadView]; - private threadNotificationState: ThreadsRoomNotificationState | null; private globalNotificationState: SummarizedNotificationState; - private get supportsThreadNotifications(): boolean { - const client = MatrixClientPeg.get(); - return client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported; - } - public constructor(props: IProps) { super(props, HeaderKind.Room); - - this.threadNotificationState = - !this.supportsThreadNotifications && this.props.room - ? RoomNotificationStateStore.instance.getThreadsRoomState(this.props.room) - : null; this.globalNotificationState = RoomNotificationStateStore.instance.globalState; } public componentDidMount(): void { super.componentDidMount(); - if (!this.supportsThreadNotifications) { - this.threadNotificationState?.on(NotificationStateEvents.Update, this.onNotificationUpdate); - } else { - // Notification badge may change if the notification counts from the - // server change, if a new thread is created or updated, or if a - // receipt is sent in the thread. - this.props.room?.on(RoomEvent.UnreadNotifications, this.onNotificationUpdate); - this.props.room?.on(RoomEvent.Receipt, this.onNotificationUpdate); - this.props.room?.on(RoomEvent.Timeline, this.onNotificationUpdate); - this.props.room?.on(RoomEvent.Redaction, this.onNotificationUpdate); - this.props.room?.on(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate); - this.props.room?.on(RoomEvent.MyMembership, this.onNotificationUpdate); - this.props.room?.on(ThreadEvent.New, this.onNotificationUpdate); - this.props.room?.on(ThreadEvent.Update, this.onNotificationUpdate); - } + // Notification badge may change if the notification counts from the + // server change, if a new thread is created or updated, or if a + // receipt is sent in the thread. + this.props.room?.on(RoomEvent.UnreadNotifications, this.onNotificationUpdate); + this.props.room?.on(RoomEvent.Receipt, this.onNotificationUpdate); + this.props.room?.on(RoomEvent.Timeline, this.onNotificationUpdate); + this.props.room?.on(RoomEvent.Redaction, this.onNotificationUpdate); + this.props.room?.on(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate); + this.props.room?.on(RoomEvent.MyMembership, this.onNotificationUpdate); + this.props.room?.on(ThreadEvent.New, this.onNotificationUpdate); + this.props.room?.on(ThreadEvent.Update, this.onNotificationUpdate); this.onNotificationUpdate(); RoomNotificationStateStore.instance.on(UPDATE_STATUS_INDICATOR, this.onUpdateStatus); } public componentWillUnmount(): void { super.componentWillUnmount(); - if (!this.supportsThreadNotifications) { - this.threadNotificationState?.off(NotificationStateEvents.Update, this.onNotificationUpdate); - } else { - this.props.room?.off(RoomEvent.UnreadNotifications, this.onNotificationUpdate); - this.props.room?.off(RoomEvent.Receipt, this.onNotificationUpdate); - this.props.room?.off(RoomEvent.Timeline, this.onNotificationUpdate); - this.props.room?.off(RoomEvent.Redaction, this.onNotificationUpdate); - this.props.room?.off(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate); - this.props.room?.off(RoomEvent.MyMembership, this.onNotificationUpdate); - this.props.room?.off(ThreadEvent.New, this.onNotificationUpdate); - this.props.room?.off(ThreadEvent.Update, this.onNotificationUpdate); - } + this.props.room?.off(RoomEvent.UnreadNotifications, this.onNotificationUpdate); + this.props.room?.off(RoomEvent.Receipt, this.onNotificationUpdate); + this.props.room?.off(RoomEvent.Timeline, this.onNotificationUpdate); + this.props.room?.off(RoomEvent.Redaction, this.onNotificationUpdate); + this.props.room?.off(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate); + this.props.room?.off(RoomEvent.MyMembership, this.onNotificationUpdate); + this.props.room?.off(ThreadEvent.New, this.onNotificationUpdate); + this.props.room?.off(ThreadEvent.Update, this.onNotificationUpdate); RoomNotificationStateStore.instance.off(UPDATE_STATUS_INDICATOR, this.onUpdateStatus); } private onNotificationUpdate = (): void => { - let threadNotificationColor: NotificationColor; - if (!this.supportsThreadNotifications) { - threadNotificationColor = this.threadNotificationState?.color ?? NotificationColor.None; - } else { - threadNotificationColor = this.notificationColor; - } - // console.log // XXX: why don't we read from this.state.threadNotificationColor in the render methods? this.setState({ - threadNotificationColor, + threadNotificationColor: this.notificationColor, }); }; diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 0a742c61cc..72df34290d 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -353,25 +353,42 @@ export const UserOptionsSection: React.FC<{ }); }; + const unignore = useCallback(() => { + const ignoredUsers = cli.getIgnoredUsers(); + const index = ignoredUsers.indexOf(member.userId); + if (index !== -1) ignoredUsers.splice(index, 1); + cli.setIgnoredUsers(ignoredUsers); + }, [cli, member]); + + const ignore = useCallback(async () => { + const { finished } = Modal.createDialog(QuestionDialog, { + title: _t("Ignore %(user)s", { user: member.name }), + description: ( +
    + {_t( + "All messages and invites from this user will be hidden. " + + "Are you sure you want to ignore them?", + )} +
    + ), + button: _t("Ignore"), + }); + const [confirmed] = await finished; + + if (confirmed) { + const ignoredUsers = cli.getIgnoredUsers(); + ignoredUsers.push(member.userId); + cli.setIgnoredUsers(ignoredUsers); + } + }, [cli, member]); + // Only allow the user to ignore the user if its not ourselves // same goes for jumping to read receipt if (!isMe) { - const onIgnoreToggle = (): void => { - const ignoredUsers = cli.getIgnoredUsers(); - if (isIgnored) { - const index = ignoredUsers.indexOf(member.userId); - if (index !== -1) ignoredUsers.splice(index, 1); - } else { - ignoredUsers.push(member.userId); - } - - cli.setIgnoredUsers(ignoredUsers); - }; - ignoreButton = ( {isIgnored ? _t("Unignore") : _t("Ignore")} diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 5620ab9356..d6c2052d08 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -27,7 +27,6 @@ import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning"; -import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; import ReplyChain from "../elements/ReplyChain"; import { _t } from "../../../languageHandler"; @@ -62,10 +61,6 @@ import SettingsStore from "../../../settings/SettingsStore"; import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; -import { ThreadNotificationState } from "../../../stores/notifications/ThreadNotificationState"; -import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; -import { NotificationStateEvents } from "../../../stores/notifications/NotificationState"; -import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { ButtonEvent } from "../elements/AccessibleButton"; import { copyPlaintext, getSelectedText } from "../../../utils/strings"; import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker"; @@ -254,7 +249,6 @@ export class UnwrappedEventTile extends React.Component private isListeningForReceipts: boolean; private tile = React.createRef(); private replyChain = React.createRef(); - private threadState: ThreadNotificationState; public readonly ref = createRef(); @@ -389,10 +383,6 @@ export class UnwrappedEventTile extends React.Component if (SettingsStore.getValue("feature_threadenabled")) { this.props.mxEvent.on(ThreadEvent.Update, this.updateThread); - - if (this.thread && !this.supportsThreadNotifications) { - this.setupNotificationListener(this.thread); - } } client.decryptEventIfNeeded(this.props.mxEvent); @@ -403,47 +393,7 @@ export class UnwrappedEventTile extends React.Component this.verifyEvent(); } - private get supportsThreadNotifications(): boolean { - const client = MatrixClientPeg.get(); - return client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported; - } - - private setupNotificationListener(thread: Thread): void { - if (!this.supportsThreadNotifications) { - const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(thread.room); - this.threadState = notifications.getThreadRoomState(thread); - this.threadState.on(NotificationStateEvents.Update, this.onThreadStateUpdate); - this.onThreadStateUpdate(); - } - } - - private onThreadStateUpdate = (): void => { - if (!this.supportsThreadNotifications) { - let threadNotification = null; - switch (this.threadState?.color) { - case NotificationColor.Grey: - threadNotification = NotificationCountType.Total; - break; - case NotificationColor.Red: - threadNotification = NotificationCountType.Highlight; - break; - } - - this.setState({ - threadNotification, - }); - } - }; - private updateThread = (thread: Thread): void => { - if (thread !== this.state.thread && !this.supportsThreadNotifications) { - if (this.threadState) { - this.threadState.off(NotificationStateEvents.Update, this.onThreadStateUpdate); - } - - this.setupNotificationListener(thread); - } - this.setState({ thread }); }; @@ -473,7 +423,6 @@ export class UnwrappedEventTile extends React.Component if (SettingsStore.getValue("feature_threadenabled")) { this.props.mxEvent.off(ThreadEvent.Update, this.updateThread); } - this.threadState?.off(NotificationStateEvents.Update, this.onThreadStateUpdate); } public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { @@ -1280,9 +1229,6 @@ export class UnwrappedEventTile extends React.Component "data-shape": this.context.timelineRenderingType, "data-self": isOwnEvent, "data-has-reply": !!replyChain, - "data-notification": !this.supportsThreadNotifications - ? this.state.threadNotification - : undefined, "onMouseEnter": () => this.setState({ hover: true }), "onMouseLeave": () => this.setState({ hover: false }), "onClick": (ev: MouseEvent) => { @@ -1348,7 +1294,7 @@ export class UnwrappedEventTile extends React.Component )} {msgOption} - + , ); } diff --git a/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx b/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx index 33612ad731..f09c151690 100644 --- a/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx @@ -21,7 +21,7 @@ import { useUnreadNotifications } from "../../../../hooks/useUnreadNotifications import { StatelessNotificationBadge } from "./StatelessNotificationBadge"; interface Props { - room: Room; + room?: Room; threadId?: string; } 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/components/views/rooms/wysiwyg_composer/ComposerContext.ts b/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts index 582c883dfe..1de070216c 100644 --- a/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts +++ b/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts @@ -17,15 +17,18 @@ limitations under the License. import { createContext, useContext } from "react"; import { SubSelection } from "./types"; +import EditorStateTransfer from "../../../../utils/EditorStateTransfer"; -export function getDefaultContextValue(): { selection: SubSelection } { +export function getDefaultContextValue(defaultValue?: Partial): { selection: SubSelection } { return { selection: { anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0, isForward: true }, + ...defaultValue, }; } export interface ComposerContextState { selection: SubSelection; + editorStateTransfer?: EditorStateTransfer; } export const ComposerContext = createContext(getDefaultContextValue()); diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx index 502afc9622..c0915469e2 100644 --- a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -52,7 +52,7 @@ export default function EditWysiwygComposer({ className, ...props }: EditWysiwygComposerProps): JSX.Element { - const defaultContextValue = useRef(getDefaultContextValue()); + const defaultContextValue = useRef(getDefaultContextValue({ editorStateTransfer })); const initialContent = useInitialContent(editorStateTransfer); const isReady = !editorStateTransfer || initialContent !== undefined; diff --git a/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx index 80f2563d1d..7bc4b33d41 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx @@ -28,6 +28,8 @@ import { Icon as LinkIcon } from "../../../../../../res/img/element-icons/room/c import { Icon as BulletedListIcon } from "../../../../../../res/img/element-icons/room/composer/bulleted_list.svg"; import { Icon as NumberedListIcon } from "../../../../../../res/img/element-icons/room/composer/numbered_list.svg"; import { Icon as CodeBlockIcon } from "../../../../../../res/img/element-icons/room/composer/code_block.svg"; +import { Icon as IndentIcon } from "../../../../../../res/img/element-icons/room/composer/indent_increase.svg"; +import { Icon as UnIndentIcon } from "../../../../../../res/img/element-icons/room/composer/indent_decrease.svg"; import AccessibleTooltipButton from "../../../elements/AccessibleTooltipButton"; import { Alignment } from "../../../elements/Tooltip"; import { KeyboardShortcut } from "../../../settings/KeyboardShortcut"; @@ -127,6 +129,18 @@ export function FormattingButtons({ composer, actionStates }: FormattingButtonsP onClick={() => composer.orderedList()} icon={} /> +
    @@ -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..ef29b03bc8 --- /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/dialogs/DevtoolsDialog-test.tsx b/test/components/views/dialogs/DevtoolsDialog-test.tsx new file mode 100644 index 0000000000..bc451255fa --- /dev/null +++ b/test/components/views/dialogs/DevtoolsDialog-test.tsx @@ -0,0 +1,71 @@ +/* +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 { getByLabelText, render } from "@testing-library/react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import userEvent from "@testing-library/user-event"; + +import { stubClient } from "../../../test-utils"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; +import DevtoolsDialog from "../../../../src/components/views/dialogs/DevtoolsDialog"; + +describe("DevtoolsDialog", () => { + let cli: MatrixClient; + let room: Room; + + function getComponent(roomId: string, onFinished = () => true) { + return render( + + + , + ); + } + + beforeEach(() => { + stubClient(); + cli = MatrixClientPeg.get(); + room = new Room("!id", cli, "@alice:matrix.org"); + + jest.spyOn(cli, "getRoom").mockReturnValue(room); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("renders the devtools dialog", () => { + const { asFragment } = getComponent(room.roomId); + expect(asFragment()).toMatchSnapshot(); + }); + + it("copies the roomid", async () => { + const user = userEvent.setup(); + jest.spyOn(navigator.clipboard, "writeText"); + + const { container } = getComponent(room.roomId); + + const copyBtn = getByLabelText(container, "Copy"); + await user.click(copyBtn); + const copiedBtn = getByLabelText(container, "Copied!"); + + expect(copiedBtn).toBeInTheDocument(); + expect(navigator.clipboard.writeText).toHaveBeenCalled(); + expect(navigator.clipboard.readText()).resolves.toBe(room.roomId); + }); +}); 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__/DevtoolsDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap new file mode 100644 index 0000000000..62014e2d2b --- /dev/null +++ b/test/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap @@ -0,0 +1,229 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DevtoolsDialog renders the devtools dialog 1`] = ` + +
    +