From c3eb517700f35eb501b3557ff2561f249a2e4ef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Sat, 5 Jun 2021 08:22:43 +0200 Subject: [PATCH 01/31] Use fallback avatar only for DMs with 2 people MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/Avatar.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Avatar.ts b/src/Avatar.ts index a6499c688e..5b033feb8f 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -146,15 +146,11 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi // space rooms cannot be DMs so skip the rest if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null; - let otherMember = null; const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); - if (otherUserId) { - otherMember = room.getMember(otherUserId); - } else { - // if the room is not marked as a 1:1, but only has max 2 members - // then still try to show any avatar (pref. other member) - otherMember = room.getAvatarFallbackMember(); - } + if (!otherUserId) return null; + + // If there are only two members in the DM use the avatar of the other member + const otherMember = room.getAvatarFallbackMember(otherUserId); if (otherMember?.getMxcAvatarUrl()) { return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); } From 4b2a9a6bf77471f261f98084fb3b61e8caedf257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Sat, 5 Jun 2021 08:33:14 +0200 Subject: [PATCH 02/31] Remove mistaken param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/Avatar.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Avatar.ts b/src/Avatar.ts index 5b033feb8f..79f9bb699b 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -146,11 +146,10 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi // space rooms cannot be DMs so skip the rest if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null; - const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); - if (!otherUserId) return null; + if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) return null; // If there are only two members in the DM use the avatar of the other member - const otherMember = room.getAvatarFallbackMember(otherUserId); + const otherMember = room.getAvatarFallbackMember(); if (otherMember?.getMxcAvatarUrl()) { return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); } From 87622fb8eaccf0ce0b59799b050b095c8e354857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Sat, 5 Jun 2021 08:34:32 +0200 Subject: [PATCH 03/31] Add a comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Šimon Brandner --- src/Avatar.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Avatar.ts b/src/Avatar.ts index 79f9bb699b..e6b47f8a7f 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -146,6 +146,7 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi // space rooms cannot be DMs so skip the rest if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null; + // If the room is not a DM don't fallback to a member avatar if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) return null; // If there are only two members in the DM use the avatar of the other member From 171874ae30ca36513c01a5ac74596dba33bc6826 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 2 Jul 2021 16:31:37 +0100 Subject: [PATCH 04/31] Use FocusLock around ContextMenus to simplify focus management --- src/components/structures/ContextMenu.tsx | 27 +++++++------------ .../views/spaces/SpaceCreateMenu.tsx | 23 +++++++--------- 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 407dc6f04c..9bc9c0a8b2 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -19,6 +19,7 @@ limitations under the License. import React, { CSSProperties, RefObject, useRef, useState } from "react"; import ReactDOM from "react-dom"; import classNames from "classnames"; +import FocusLock from "react-focus-lock"; import { Key } from "../../Keyboard"; import { Writeable } from "../../@types/common"; @@ -44,6 +45,7 @@ function getOrCreateContainer(): HTMLDivElement { } const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]); +const ARIA_MENU_ITEM_SELECTOR = '[role^="menuitem"], [role^="menuitemcheckbox"], [role^="menuitemradio"]'; interface IPosition { top?: number; @@ -95,8 +97,6 @@ interface IState { // this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines. @replaceableComponent("structures.ContextMenu") export class ContextMenu extends React.PureComponent { - private initialFocus: HTMLElement; - static defaultProps = { hasBackground: true, managed: true, @@ -107,24 +107,15 @@ export class ContextMenu extends React.PureComponent { this.state = { contextMenuElem: null, }; - - // persist what had focus when we got initialized so we can return it after - this.initialFocus = document.activeElement as HTMLElement; } - componentWillUnmount() { - // return focus to the thing which had it before us - this.initialFocus.focus(); - } - - private collectContextMenuRect = (element) => { + private collectContextMenuRect = (element: HTMLDivElement) => { // We don't need to clean up when unmounting, so ignore if (!element) return; - let first = element.querySelector('[role^="menuitem"]'); - if (!first) { - first = element.querySelector('[tab-index]'); - } + const first = element.querySelector(ARIA_MENU_ITEM_SELECTOR) + || element.querySelector('[tab-index]'); + if (first) { first.focus(); } @@ -381,8 +372,10 @@ export class ContextMenu extends React.PureComponent { ref={this.collectContextMenuRect} role={this.props.managed ? "menu" : undefined} > - { chevron } - { props.children } + + { chevron } + { props.children } + { background } diff --git a/src/components/views/spaces/SpaceCreateMenu.tsx b/src/components/views/spaces/SpaceCreateMenu.tsx index 4bb61d7ccb..11f68698ee 100644 --- a/src/components/views/spaces/SpaceCreateMenu.tsx +++ b/src/components/views/spaces/SpaceCreateMenu.tsx @@ -17,7 +17,8 @@ limitations under the License. import React, { useContext, useRef, useState } from "react"; import classNames from "classnames"; import { EventType, RoomType, RoomCreateTypeField } from "matrix-js-sdk/src/@types/event"; -import FocusLock from "react-focus-lock"; +import { Preset } from "matrix-js-sdk/src/@types/partials"; +import { ICreateRoomStateEvent } from "matrix-js-sdk/src/@types/requests"; import { _t } from "../../../languageHandler"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; @@ -33,8 +34,6 @@ import { UserTab } from "../dialogs/UserSettingsDialog"; import Field from "../elements/Field"; import withValidation from "../elements/Validation"; import { SpaceFeedbackPrompt } from "../../structures/SpaceRoomView"; -import { Preset } from "matrix-js-sdk/src/@types/partials"; -import { ICreateRoomStateEvent } from "matrix-js-sdk/src/@types/requests"; import RoomAliasField from "../elements/RoomAliasField"; const SpaceCreateMenuType = ({ title, description, className, onClick }) => { @@ -250,16 +249,14 @@ const SpaceCreateMenu = ({ onFinished }) => { wrapperClassName="mx_SpaceCreateMenu_wrapper" managed={false} > - - { - onFinished(); - defaultDispatcher.dispatch({ - action: Action.ViewUserSettings, - initialTabId: UserTab.Labs, - }); - }} /> - { body } - + { + onFinished(); + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Labs, + }); + }} /> + { body } ; }; From b257393d74d5fc6c0b731e6696f136e906af6542 Mon Sep 17 00:00:00 2001 From: William Lachance Date: Sun, 19 Sep 2021 07:48:35 -0400 Subject: [PATCH 05/31] Make placeholder more grey when no input (fixes #17243) The placeholder is actually the label in this case. --- res/css/views/auth/_AuthBody.scss | 4 ---- res/css/views/elements/_Field.scss | 1 - 2 files changed, 5 deletions(-) diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index 90dca32e48..3c2736459a 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -58,10 +58,6 @@ limitations under the License. background-color: $authpage-body-bg-color; } - .mx_Field label { - color: $authpage-primary-color; - } - .mx_Field_labelAlwaysTopLeft label, .mx_Field select + label /* Always show a select's label on top to not collide with the value */, .mx_Field input:focus + label, diff --git a/res/css/views/elements/_Field.scss b/res/css/views/elements/_Field.scss index 71d37a015d..37d335b76d 100644 --- a/res/css/views/elements/_Field.scss +++ b/res/css/views/elements/_Field.scss @@ -100,7 +100,6 @@ limitations under the License. color 0.25s ease-out 0.1s, transform 0.25s ease-out 0.1s, background-color 0.25s ease-out 0.1s; - color: $primary-content; background-color: transparent; font-size: $font-14px; transform: translateY(0); From 8ac77c498f76f871648aad0a4c4589a5a61fc1ed Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 30 Sep 2021 13:43:59 +0100 Subject: [PATCH 06/31] Add progress bar to Community to Space migration tool and invite-one-by-one to workaround Synapse ratelimits --- .../dialogs/_AddExistingToSpaceDialog.scss | 2 +- .../_CreateSpaceFromCommunityDialog.scss | 1 + src/RoomInvite.tsx | 13 ++-- .../CreateSpaceFromCommunityDialog.tsx | 71 ++++++++++++++++--- src/i18n/strings/en_EN.json | 2 + src/utils/MultiInviter.ts | 4 +- 6 files changed, 78 insertions(+), 15 deletions(-) diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss index 444b29c9bf..8b19f506f5 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -75,7 +75,7 @@ limitations under the License. @mixin ProgressBarBorderRadius 8px; } - .mx_AddExistingToSpace_progressText { + .mx_AddExistingToSpaceDialog_progressText { margin-top: 8px; font-size: $font-15px; line-height: $font-24px; diff --git a/res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss b/res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss index 6ff328f6ab..f1af24cc5f 100644 --- a/res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss +++ b/res/css/views/dialogs/_CreateSpaceFromCommunityDialog.scss @@ -74,6 +74,7 @@ limitations under the License. font-size: $font-12px; line-height: $font-15px; color: $secondary-content; + margin-top: -13px; // match height of buttons to prevent height changing .mx_ProgressBar { height: 8px; diff --git a/src/RoomInvite.tsx b/src/RoomInvite.tsx index 7d093f4092..5c9d96f509 100644 --- a/src/RoomInvite.tsx +++ b/src/RoomInvite.tsx @@ -42,10 +42,15 @@ export interface IInviteResult { * * @param {string} roomId The ID of the room to invite to * @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids. + * @param {function} progressCallback optional callback, fired after each invite. * @returns {Promise} Promise */ -export function inviteMultipleToRoom(roomId: string, addresses: string[]): Promise { - const inviter = new MultiInviter(roomId); +export function inviteMultipleToRoom( + roomId: string, + addresses: string[], + progressCallback?: () => void, +): Promise { + const inviter = new MultiInviter(roomId, progressCallback); return inviter.invite(addresses).then(states => Promise.resolve({ states, inviter })); } @@ -104,8 +109,8 @@ export function isValid3pidInvite(event: MatrixEvent): boolean { return true; } -export function inviteUsersToRoom(roomId: string, userIds: string[]): Promise { - return inviteMultipleToRoom(roomId, userIds).then((result) => { +export function inviteUsersToRoom(roomId: string, userIds: string[], progressCallback?: () => void): Promise { + return inviteMultipleToRoom(roomId, userIds, progressCallback).then((result) => { const room = MatrixClientPeg.get().getRoom(roomId); showAnyInviteErrors(result.states, room, result.inviter); }).catch((err) => { diff --git a/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx b/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx index e74082427f..c7706c115c 100644 --- a/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx +++ b/src/components/views/dialogs/CreateSpaceFromCommunityDialog.tsx @@ -39,6 +39,8 @@ import dis from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { UserTab } from "./UserSettingsDialog"; import TagOrderActions from "../../../actions/TagOrderActions"; +import { inviteUsersToRoom } from "../../../RoomInvite"; +import ProgressBar from "../elements/ProgressBar"; interface IProps { matrixClient: MatrixClient; @@ -90,10 +92,22 @@ export interface IGroupSummary { } /* eslint-enable camelcase */ +enum Progress { + NotStarted, + ValidatingInputs, + FetchingData, + CreatingSpace, + InvitingUsers, + // anything beyond here is inviting user n - 4 +} + const CreateSpaceFromCommunityDialog: React.FC = ({ matrixClient: cli, groupId, onFinished }) => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); + + const [progress, setProgress] = useState(Progress.NotStarted); + const [numInvites, setNumInvites] = useState(0); + const busy = progress > 0; const [avatar, setAvatar] = useState(null); // undefined means to remove avatar const [name, setName] = useState(""); @@ -122,30 +136,34 @@ const CreateSpaceFromCommunityDialog: React.FC = ({ matrixClient: cli, g if (busy) return; setError(null); - setBusy(true); + setProgress(Progress.ValidatingInputs); // require & validate the space name field if (!(await spaceNameField.current.validate({ allowEmpty: false }))) { - setBusy(false); + setProgress(0); spaceNameField.current.focus(); spaceNameField.current.validate({ allowEmpty: false, focused: true }); return; } // validate the space name alias field but do not require it if (joinRule === JoinRule.Public && !(await spaceAliasField.current.validate({ allowEmpty: true }))) { - setBusy(false); + setProgress(0); spaceAliasField.current.focus(); spaceAliasField.current.validate({ allowEmpty: true, focused: true }); return; } try { + setProgress(Progress.FetchingData); + const [rooms, members, invitedMembers] = await Promise.all([ cli.getGroupRooms(groupId).then(parseRoomsResponse) as Promise, cli.getGroupUsers(groupId).then(parseMembersResponse) as Promise, cli.getGroupInvitedUsers(groupId).then(parseMembersResponse) as Promise, ]); + setNumInvites(members.length + invitedMembers.length); + const viaMap = new Map(); for (const { roomId, canonicalAlias } of rooms) { const room = cli.getRoom(roomId); @@ -167,6 +185,8 @@ const CreateSpaceFromCommunityDialog: React.FC = ({ matrixClient: cli, g } } + setProgress(Progress.CreatingSpace); + const spaceAvatar = avatar !== undefined ? avatar : groupSummary.profile.avatar_url; const roomId = await createSpace(name, joinRule === JoinRule.Public, alias, topic, spaceAvatar, { creation_content: { @@ -179,11 +199,16 @@ const CreateSpaceFromCommunityDialog: React.FC = ({ matrixClient: cli, g via: viaMap.get(roomId) || [], }, })), - invite: [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId()), + // we do not specify the inviters here because Synapse applies a limit and this may cause it to trip }, { andView: false, }); + setProgress(Progress.InvitingUsers); + + const userIds = [...members, ...invitedMembers].map(m => m.userId).filter(m => m !== cli.getUserId()); + await inviteUsersToRoom(roomId, userIds, () => setProgress(p => p + 1)); + // eagerly remove it from the community panel dis.dispatch(TagOrderActions.removeTag(cli, groupId)); @@ -250,7 +275,7 @@ const CreateSpaceFromCommunityDialog: React.FC = ({ matrixClient: cli, g setError(e); } - setBusy(false); + setProgress(Progress.NotStarted); }; let footer; @@ -267,13 +292,41 @@ const CreateSpaceFromCommunityDialog: React.FC = ({ matrixClient: cli, g { _t("Retry") } ; + } else if (busy) { + let description: string; + switch (progress) { + case Progress.ValidatingInputs: + case Progress.FetchingData: + description = _t("Fetching data..."); + break; + case Progress.CreatingSpace: + description = _t("Creating Space..."); + break; + case Progress.InvitingUsers: + default: + description = _t("Adding rooms... (%(progress)s out of %(count)s)", { + count: numInvites, + progress, + }); + break; + } + + footer = + Progress.FetchingData ? progress : 0} + max={numInvites + Progress.InvitingUsers} + /> +
+ { description } +
+
; } else { footer = <> - onFinished()}> + onFinished()}> { _t("Cancel") } - - { busy ? _t("Creating...") : _t("Create Space") } + + { _t("Create Space") } ; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 9f245b8dab..8fc5fd0afd 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2280,6 +2280,8 @@ " has been made and everyone who was a part of the community has been invited to it.": " has been made and everyone who was a part of the community has been invited to it.", "To create a Space from another community, just pick the community in Preferences.": "To create a Space from another community, just pick the community in Preferences.", "Failed to migrate community": "Failed to migrate community", + "Fetching data...": "Fetching data...", + "Creating Space...": "Creating Space...", "Create Space from community": "Create Space from community", "A link to the Space will be put in your community description.": "A link to the Space will be put in your community description.", "All rooms will be added and all community members will be invited.": "All rooms will be added and all community members will be invited.", diff --git a/src/utils/MultiInviter.ts b/src/utils/MultiInviter.ts index 5b79a2ff93..abf72c97ff 100644 --- a/src/utils/MultiInviter.ts +++ b/src/utils/MultiInviter.ts @@ -62,8 +62,9 @@ export default class MultiInviter { /** * @param {string} targetId The ID of the room or group to invite to + * @param {function} progressCallback optional callback, fired after each invite. */ - constructor(targetId: string) { + constructor(targetId: string, private readonly progressCallback?: () => void) { if (targetId[0] === '+') { this.roomId = null; this.groupId = targetId; @@ -181,6 +182,7 @@ export default class MultiInviter { delete this.errors[address]; resolve(); + this.progressCallback?.(); }).catch((err) => { if (this.canceled) { return; From 226131409f3854cc3bfbc7eecef42b3bb07ea270 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Fri, 1 Oct 2021 14:54:26 +0100 Subject: [PATCH 07/31] Unit tests for room avatars in DM and non-DM rooms Signed-off-by: Andy Balaam --- .../views/rooms/RoomHeader-test.tsx | 252 ++++++++++++++++++ test/test-utils.js | 14 +- 2 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 test/components/views/rooms/RoomHeader-test.tsx diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx new file mode 100644 index 0000000000..6696c8bd9c --- /dev/null +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -0,0 +1,252 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import "../../../skinned-sdk"; + +import * as TestUtils from '../../../test-utils'; + +import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; + +import DMRoomMap from '../../../../src/utils/DMRoomMap'; +import RoomHeader from '../../../../src/components/views/rooms/RoomHeader'; + +import { Room, PendingEventOrdering, MatrixEvent, MatrixClient } from 'matrix-js-sdk'; +import { SearchScope } from '../../../../src/components/views/rooms/SearchBar'; +import { E2EStatus } from '../../../../src/utils/ShieldUtils'; +import { PlaceCallType } from '../../../../src/CallHandler'; +import { mkEvent } from '../../../test-utils'; + +describe('RoomHeader', () => { + test('shows the room avatar in a room with only ourselves', () => { + // When we render a non-DM room with 1 person in it + const room = createRoom({ name: "X Room", isDm: false, users: [] }); + const rendered = render(room); + + // Then the room's avatar is the initial of its name + const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); + expect(initial.innerHTML).toEqual("X"); + + // And there is no image avatar (because it's not set on this room) + const image = findImg(rendered, ".mx_BaseAvatar_image"); + expect(image.src).toEqual("data:image/png;base64,00"); + }); + + test('shows the room avatar in a room with 2 people', () => { + // When we render a non-DM room with 2 people in it + const room = createRoom( + { name: "Y Room", isDm: false, users: ["other"] }); + const rendered = render(room); + + // Then the room's avatar is the initial of its name + const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); + expect(initial.innerHTML).toEqual("Y"); + + // And there is no image avatar (because it's not set on this room) + const image = findImg(rendered, ".mx_BaseAvatar_image"); + expect(image.src).toEqual("data:image/png;base64,00"); + }); + + test('shows the room avatar in a room with >2 people', () => { + // When we render a non-DM room with 3 people in it + const room = createRoom( + { name: "Z Room", isDm: false, users: ["other1", "other2"] }); + const rendered = render(room); + + // Then the room's avatar is the initial of its name + const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); + expect(initial.innerHTML).toEqual("Z"); + + // And there is no image avatar (because it's not set on this room) + const image = findImg(rendered, ".mx_BaseAvatar_image"); + expect(image.src).toEqual("data:image/png;base64,00"); + }); + + test('shows the room avatar in a DM with only ourselves', () => { + // When we render a non-DM room with 1 person in it + const room = createRoom({ name: "Z Room", isDm: true, users: [] }); + const rendered = render(room); + + // Then the room's avatar is the initial of its name + const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); + expect(initial.innerHTML).toEqual("Z"); + + // And there is no image avatar (because it's not set on this room) + const image = findImg(rendered, ".mx_BaseAvatar_image"); + expect(image.src).toEqual("data:image/png;base64,00"); + }); + + test('shows the user avatar in a DM with 2 people', () => { + // Note: this is the interesting case - this is the ONLY + // time we should use the user's avatar. + + // When we render a DM room with only 2 people in it + const room = createRoom( + { name: "Y Room", isDm: true, users: ["other"] }); + const rendered = render(room); + + // Then we use the other user's avatar as our room's image avatar + const image = findImg(rendered, ".mx_BaseAvatar_image"); + expect(image.src).toEqual( + "http://this.is.a.url/example.org/other"); + + // And there is no initial avatar + expect( + rendered.querySelectorAll(".mx_BaseAvatar_initial"), + ).toHaveLength(0); + }); + + test('shows the room avatar in a DM with >2 people', () => { + // When we render a DM room with 3 people in it + const room = createRoom({ + name: "Z Room", isDm: true, users: ["other1", "other2"] }); + const rendered = render(room); + + // Then the room's avatar is the initial of its name + const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); + expect(initial.innerHTML).toEqual("Z"); + + // And there is no image avatar (because it's not set on this room) + const image = findImg(rendered, ".mx_BaseAvatar_image"); + expect(image.src).toEqual("data:image/png;base64,00"); + }); +}); + +interface IRoomCreationInfo { + name: string; + isDm: boolean; + users: string[]; +} + +function createRoom(info: IRoomCreationInfo) { + TestUtils.stubClient(); + const client: MatrixClient = MatrixClientPeg.get(); + + const roomId = '!1234567890:domain'; + const userId = client.getUserId(); + if (info.isDm) { + client.getAccountData = (eventType) => { + expect(eventType).toEqual("m.direct"); + return mkDirectEvent(roomId, userId, info.users); + }; + } + + DMRoomMap.makeShared().start(); + + const room = new Room(roomId, client, userId, { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + const otherJoinEvents = []; + for (const otherUserId of info.users) { + otherJoinEvents.push(mkJoinEvent(roomId, otherUserId)); + } + + room.currentState.setStateEvents([ + mkCreationEvent(roomId, userId), + mkNameEvent(roomId, userId, info.name), + mkJoinEvent(roomId, userId), + ...otherJoinEvents, + ]); + room.recalculate(); + + return room; +} + +function render(room: Room): HTMLDivElement { + const parentDiv = document.createElement('div'); + document.body.appendChild(parentDiv); + ReactDOM.render( + ( + {}} + onSearchClick={() => {}} + onForgetClick={() => {}} + onCallPlaced={(_type: PlaceCallType) => {}} + onAppsClick={() => {}} + e2eStatus={E2EStatus.Normal} + appsShown={true} + searchInfo={{ + searchTerm: "", + searchScope: SearchScope.Room, + searchCount: 0, + }} + /> + ), + parentDiv, + ); + return parentDiv; +} + +function mkCreationEvent(roomId: string, userId: string): MatrixEvent { + return mkEvent({ + event: true, + type: "m.room.create", + room: roomId, + user: userId, + content: { + creator: userId, + room_version: "5", + predecessor: { + room_id: "!prevroom", + event_id: "$someevent", + }, + }, + }); +} + +function mkNameEvent( + roomId: string, userId: string, name: string, +): MatrixEvent { + return mkEvent({ + event: true, + type: "m.room.name", + room: roomId, + user: userId, + content: { name }, + }); +} + +function mkJoinEvent(roomId: string, userId: string) { + const ret = mkEvent({ + event: true, + type: "m.room.member", + room: roomId, + user: userId, + content: { + "membership": "join", + "avatar_url": "mxc://example.org/" + userId, + }, + }); + ret.event.state_key = userId; + return ret; +} + +function mkDirectEvent( + roomId: string, userId: string, otherUsers: string[], +): MatrixEvent { + const content = {}; + for (const otherUserId of otherUsers) { + content[otherUserId] = [roomId]; + } + return mkEvent({ + event: true, + type: "m.direct", + room: roomId, + user: userId, + content, + }); +} + +function findSpan(parent: HTMLElement, selector: string): HTMLSpanElement { + const els = parent.querySelectorAll(selector); + expect(els.length).toEqual(1); + return els[0] as HTMLSpanElement; +} + +function findImg(parent: HTMLElement, selector: string): HTMLImageElement { + const els = parent.querySelectorAll(selector); + expect(els.length).toEqual(1); + return els[0] as HTMLImageElement; +} diff --git a/test/test-utils.js b/test/test-utils.js index c06149991f..2091a6e0ed 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -47,6 +47,8 @@ export function createTestClient() { getIdentityServerUrl: jest.fn(), getDomain: jest.fn().mockReturnValue("matrix.rog"), getUserId: jest.fn().mockReturnValue("@userId:matrix.rog"), + getUser: jest.fn().mockReturnValue({ on: jest.fn() }), + credentials: { userId: "@userId:matrix.rog" }, getPushActionsForEvent: jest.fn(), getRoom: jest.fn().mockImplementation(mkStubRoom), @@ -76,7 +78,7 @@ export function createTestClient() { content: {}, }); }, - mxcUrlToHttp: (mxc) => 'http://this.is.a.url/', + mxcUrlToHttp: (mxc) => `http://this.is.a.url/${mxc.substring(6)}`, setAccountData: jest.fn(), setRoomAccountData: jest.fn(), sendTyping: jest.fn().mockResolvedValue({}), @@ -93,12 +95,14 @@ export function createTestClient() { sessionStore: { store: { getItem: jest.fn(), + setItem: jest.fn(), }, }, pushRules: {}, decryptEventIfNeeded: () => Promise.resolve(), isUserIgnored: jest.fn().mockReturnValue(false), getCapabilities: jest.fn().mockResolvedValue({}), + supportsExperimentalThreads: () => false, }; } @@ -130,9 +134,11 @@ export function mkEvent(opts) { }; if (opts.skey) { event.state_key = opts.skey; - } else if (["m.room.name", "m.room.topic", "m.room.create", "m.room.join_rules", - "m.room.power_levels", "m.room.topic", "m.room.history_visibility", "m.room.encryption", - "com.example.state"].indexOf(opts.type) !== -1) { + } else if ([ + "m.room.name", "m.room.topic", "m.room.create", "m.room.join_rules", + "m.room.power_levels", "m.room.topic", "m.room.history_visibility", + "m.room.encryption", "m.room.member", "com.example.state", + ].indexOf(opts.type) !== -1) { event.state_key = ""; } return opts.event ? new MatrixEvent(event) : event; From e8dba59b42ff6cfc55ba6d97d6d1675dc228a964 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Mon, 4 Oct 2021 11:41:09 +0100 Subject: [PATCH 08/31] =?UTF-8?q?Fix=20review=20comments=20from=20=C5=A0im?= =?UTF-8?q?on=20Brandner.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename test->it; Rename users->userIds; Un-break a line. Signed-off-by: Andy Balaam --- .../views/rooms/RoomHeader-test.tsx | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index 6696c8bd9c..859107416e 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -17,9 +17,9 @@ import { PlaceCallType } from '../../../../src/CallHandler'; import { mkEvent } from '../../../test-utils'; describe('RoomHeader', () => { - test('shows the room avatar in a room with only ourselves', () => { + it('shows the room avatar in a room with only ourselves', () => { // When we render a non-DM room with 1 person in it - const room = createRoom({ name: "X Room", isDm: false, users: [] }); + const room = createRoom({ name: "X Room", isDm: false, userIds: [] }); const rendered = render(room); // Then the room's avatar is the initial of its name @@ -31,10 +31,10 @@ describe('RoomHeader', () => { expect(image.src).toEqual("data:image/png;base64,00"); }); - test('shows the room avatar in a room with 2 people', () => { + it('shows the room avatar in a room with 2 people', () => { // When we render a non-DM room with 2 people in it const room = createRoom( - { name: "Y Room", isDm: false, users: ["other"] }); + { name: "Y Room", isDm: false, userIds: ["other"] }); const rendered = render(room); // Then the room's avatar is the initial of its name @@ -46,10 +46,10 @@ describe('RoomHeader', () => { expect(image.src).toEqual("data:image/png;base64,00"); }); - test('shows the room avatar in a room with >2 people', () => { + it('shows the room avatar in a room with >2 people', () => { // When we render a non-DM room with 3 people in it const room = createRoom( - { name: "Z Room", isDm: false, users: ["other1", "other2"] }); + { name: "Z Room", isDm: false, userIds: ["other1", "other2"] }); const rendered = render(room); // Then the room's avatar is the initial of its name @@ -61,9 +61,9 @@ describe('RoomHeader', () => { expect(image.src).toEqual("data:image/png;base64,00"); }); - test('shows the room avatar in a DM with only ourselves', () => { + it('shows the room avatar in a DM with only ourselves', () => { // When we render a non-DM room with 1 person in it - const room = createRoom({ name: "Z Room", isDm: true, users: [] }); + const room = createRoom({ name: "Z Room", isDm: true, userIds: [] }); const rendered = render(room); // Then the room's avatar is the initial of its name @@ -75,13 +75,12 @@ describe('RoomHeader', () => { expect(image.src).toEqual("data:image/png;base64,00"); }); - test('shows the user avatar in a DM with 2 people', () => { + it('shows the user avatar in a DM with 2 people', () => { // Note: this is the interesting case - this is the ONLY // time we should use the user's avatar. // When we render a DM room with only 2 people in it - const room = createRoom( - { name: "Y Room", isDm: true, users: ["other"] }); + const room = createRoom({ name: "Y Room", isDm: true, userIds: ["other"] }); const rendered = render(room); // Then we use the other user's avatar as our room's image avatar @@ -95,10 +94,10 @@ describe('RoomHeader', () => { ).toHaveLength(0); }); - test('shows the room avatar in a DM with >2 people', () => { + it('shows the room avatar in a DM with >2 people', () => { // When we render a DM room with 3 people in it const room = createRoom({ - name: "Z Room", isDm: true, users: ["other1", "other2"] }); + name: "Z Room", isDm: true, userIds: ["other1", "other2"] }); const rendered = render(room); // Then the room's avatar is the initial of its name @@ -114,7 +113,7 @@ describe('RoomHeader', () => { interface IRoomCreationInfo { name: string; isDm: boolean; - users: string[]; + userIds: string[]; } function createRoom(info: IRoomCreationInfo) { @@ -126,7 +125,7 @@ function createRoom(info: IRoomCreationInfo) { if (info.isDm) { client.getAccountData = (eventType) => { expect(eventType).toEqual("m.direct"); - return mkDirectEvent(roomId, userId, info.users); + return mkDirectEvent(roomId, userId, info.userIds); }; } @@ -137,7 +136,7 @@ function createRoom(info: IRoomCreationInfo) { }); const otherJoinEvents = []; - for (const otherUserId of info.users) { + for (const otherUserId of info.userIds) { otherJoinEvents.push(mkJoinEvent(roomId, otherUserId)); } From fa800796c78978d74634c549c074ef70b3395e4f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 6 Oct 2021 09:41:57 +0100 Subject: [PATCH 09/31] Respect tombstones in locally known rooms for Space children --- src/components/structures/SpaceHierarchy.tsx | 43 ++++++++++++++++---- src/stores/SpaceStore.ts | 31 ++++++++++++-- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index ed87b04c8a..8e1a472984 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -15,17 +15,17 @@ limitations under the License. */ import React, { + Dispatch, + KeyboardEvent, + KeyboardEventHandler, ReactNode, + SetStateAction, useCallback, + useContext, useEffect, useMemo, useRef, useState, - KeyboardEvent, - KeyboardEventHandler, - useContext, - SetStateAction, - Dispatch, } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy"; @@ -33,7 +33,8 @@ import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; import { IHierarchyRelation, IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces"; import { MatrixClient } from "matrix-js-sdk/src/matrix"; import classNames from "classnames"; -import { sortBy } from "lodash"; +import { sortBy, uniqBy } from "lodash"; +import { GuestAccess, HistoryVisibility } from "matrix-js-sdk/src/@types/partials"; import dis from "../../dispatcher/dispatcher"; import defaultDispatcher from "../../dispatcher/dispatcher"; @@ -48,7 +49,7 @@ import { mediaFromMxc } from "../../customisations/Media"; import InfoTooltip from "../views/elements/InfoTooltip"; import TextWithTooltip from "../views/elements/TextWithTooltip"; import { useStateToggle } from "../../hooks/useStateToggle"; -import { getChildOrder } from "../../stores/SpaceStore"; +import SpaceStore, { getChildOrder } from "../../stores/SpaceStore"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { linkifyElement } from "../../HtmlUtils"; import { useDispatcher } from "../../hooks/useDispatcher"; @@ -333,6 +334,29 @@ interface IHierarchyLevelProps { onToggleClick?(parentId: string, childId: string): void; } +const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom, upgradedRoomMap: Map): IHierarchyRoom => { + const cliRoom = cli.getRoom(SpaceStore.instance.findMostUpgradedVersion(room.room_id, upgradedRoomMap)); + if (cliRoom) { + return { + ...room, + room_id: cliRoom.roomId, + room_type: cliRoom.getType(), + name: cliRoom.name, + topic: cliRoom.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent().topic, + avatar_url: cliRoom.getMxcAvatarUrl(), + canonical_alias: cliRoom.getCanonicalAlias(), + aliases: cliRoom.getAltAliases(), + world_readable: cliRoom.currentState.getStateEvents(EventType.RoomHistoryVisibility, "")?.getContent() + .history_visibility === HistoryVisibility.WorldReadable, + guest_can_join: cliRoom.currentState.getStateEvents(EventType.RoomGuestAccess, "")?.getContent() + .guest_access === GuestAccess.CanJoin, + num_joined_members: cliRoom.getJoinedMemberCount(), + }; + } + + return room; +}; + export const HierarchyLevel = ({ root, roomSet, @@ -350,10 +374,11 @@ export const HierarchyLevel = ({ return getChildOrder(ev.content.order, ev.origin_server_ts, ev.state_key); }); + const upgradedRoomMap = new Map(); const [subspaces, childRooms] = sortedChildren.reduce((result, ev: IHierarchyRelation) => { const room = hierarchy.roomMap.get(ev.state_key); if (room && roomSet.has(room)) { - result[room.room_type === RoomType.Space ? 0 : 1].push(room); + result[room.room_type === RoomType.Space ? 0 : 1].push(toLocalRoom(cli, room, upgradedRoomMap)); } return result; }, [[] as IHierarchyRoom[], [] as IHierarchyRoom[]]); @@ -361,7 +386,7 @@ export const HierarchyLevel = ({ const newParents = new Set(parents).add(root.room_id); return { - childRooms.map(room => ( + uniqBy(childRooms, "room_id").map(room => ( { const createTs = childRoom?.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs(); return getChildOrder(ev.getContent().order, createTs, roomId); }).map(ev => { - return this.matrixClient.getRoom(ev.getStateKey()); + return this.matrixClient.getRoom(this.findMostUpgradedVersion(ev.getStateKey())); }).filter(room => { return room?.getMyMembership() === "join" || room?.getMyMembership() === "invite"; }) || []; @@ -452,6 +452,28 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.onRoomsUpdate(); }; + // Utility to walk tombstones and find the most updated variant of the given room, + // takes a Map to enable caching of the responses given the recursive nature of the function. + public findMostUpgradedVersion( + roomId: string, + upgradedRoomMap?: Map, + seen= new Set(), + ): string { + if (seen.has(roomId)) return roomId; + if (upgradedRoomMap?.has(roomId)) return upgradedRoomMap.get(roomId); + const room = this.matrixClient.getRoom(roomId); + const tombstone = room?.currentState.getStateEvents(EventType.RoomTombstone, ""); + const replacementRoom = tombstone?.getContent().replacement_room; + if (replacementRoom && this.matrixClient.getRoom(replacementRoom)?.getMyMembership() === "join") { + seen.add(roomId); + const result = this.findMostUpgradedVersion(replacementRoom, upgradedRoomMap); + upgradedRoomMap?.set(roomId, result); + return result; + } + upgradedRoomMap?.set(roomId, roomId); + return roomId; + } + private onRoomsUpdate = throttle(() => { // TODO resolve some updates as deltas const visibleRooms = this.matrixClient.getVisibleRooms(); @@ -479,6 +501,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }); }); + const upgradedRoomMap = new Map(); this.rootSpaces.forEach(s => { // traverse each space tree in DFS to build up the supersets as you go up, // reusing results from like subtrees. @@ -491,7 +514,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } const [childSpaces, childRooms] = partitionSpacesAndRooms(this.getChildren(spaceId)); - const roomIds = new Set(childRooms.map(r => r.roomId)); + const roomIds = new Set(childRooms.map(r => this.findMostUpgradedVersion(r.roomId, upgradedRoomMap))); const space = this.matrixClient?.getRoom(spaceId); // Add relevant DMs @@ -505,11 +528,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const newPath = new Set(parentPath).add(spaceId); childSpaces.forEach(childSpace => { fn(childSpace.roomId, newPath)?.forEach(roomId => { - roomIds.add(roomId); + roomIds.add(this.findMostUpgradedVersion(roomId, upgradedRoomMap)); }); }); hiddenChildren.get(spaceId)?.forEach(roomId => { - roomIds.add(roomId); + roomIds.add(this.findMostUpgradedVersion(roomId, upgradedRoomMap)); }); this.spaceFilteredRooms.set(spaceId, roomIds); return roomIds; From cadc9d29905ebb84213f7230906e5291c6ab6363 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 6 Oct 2021 09:47:56 +0100 Subject: [PATCH 10/31] Improve emoji shortcodes generated from annotations --- src/emoji.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/emoji.ts b/src/emoji.ts index ee84583fc9..73898c12eb 100644 --- a/src/emoji.ts +++ b/src/emoji.ts @@ -74,7 +74,7 @@ export const EMOJI: IEmoji[] = EMOJIBASE.map((emojiData: Omit Date: Wed, 6 Oct 2021 10:27:16 +0100 Subject: [PATCH 11/31] Simplify and improve useRoomHierarchy hook --- src/components/structures/SpaceHierarchy.tsx | 25 ++++++-------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index ed87b04c8a..069454f842 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -410,50 +410,39 @@ export const HierarchyLevel = ({ const INITIAL_PAGE_SIZE = 20; -export const useSpaceSummary = (space: Room): { +export const useRoomHierarchy = (space: Room): { loading: boolean; rooms: IHierarchyRoom[]; hierarchy: RoomHierarchy; loadMore(pageSize?: number): Promise ; } => { const [rooms, setRooms] = useState([]); - const [loading, setLoading] = useState(true); const [hierarchy, setHierarchy] = useState(); const resetHierarchy = useCallback(() => { const hierarchy = new RoomHierarchy(space, INITIAL_PAGE_SIZE); - setHierarchy(hierarchy); - - let discard = false; hierarchy.load().then(() => { - if (discard) return; + if (space !== hierarchy.root) return; // discard stale results setRooms(hierarchy.rooms); - setLoading(false); }); - - return () => { - discard = true; - }; + setHierarchy(hierarchy); }, [space]); useEffect(resetHierarchy, [resetHierarchy]); useDispatcher(defaultDispatcher, (payload => { if (payload.action === Action.UpdateSpaceHierarchy) { - setLoading(true); setRooms([]); // TODO resetHierarchy(); } })); const loadMore = useCallback(async (pageSize?: number) => { - if (loading || !hierarchy.canLoadMore || hierarchy.noSupport) return; - - setLoading(true); + if (hierarchy.loading || !hierarchy.canLoadMore || hierarchy.noSupport) return; await hierarchy.load(pageSize); setRooms(hierarchy.rooms); - setLoading(false); - }, [loading, hierarchy]); + }, [hierarchy]); + const loading = hierarchy?.loading ?? true; return { loading, rooms, hierarchy, loadMore }; }; @@ -587,7 +576,7 @@ const SpaceHierarchy = ({ const [selected, setSelected] = useState(new Map>()); // Map> - const { loading, rooms, hierarchy, loadMore } = useSpaceSummary(space); + const { loading, rooms, hierarchy, loadMore } = useRoomHierarchy(space); const filteredRoomSet = useMemo>(() => { if (!rooms?.length) return new Set(); From d8bc868e79d3f2dff5eae89503ba0fae82f999d3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 6 Oct 2021 10:56:11 +0100 Subject: [PATCH 12/31] Fix spaces keyboard shortcuts not working for last space --- src/stores/SpaceStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/SpaceStore.ts b/src/stores/SpaceStore.ts index bb22aa4dbb..87562d6a07 100644 --- a/src/stores/SpaceStore.ts +++ b/src/stores/SpaceStore.ts @@ -793,7 +793,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // 1 is Home, 2-9 are the spaces after Home if (payload.num === 1) { this.setActiveSpace(null); - } else if (this.spacePanelSpaces.length >= payload.num) { + } else if (payload.num > 0 && this.spacePanelSpaces.length > payload.num - 2) { this.setActiveSpace(this.spacePanelSpaces[payload.num - 2]); } break; From 52d0b0133ce0e68b23af91e585da2f5ccb2d8341 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 6 Oct 2021 14:46:39 +0100 Subject: [PATCH 13/31] Hide kick & ban options in UserInfo when looking at own profile --- src/components/views/right_panel/UserInfo.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 3ac4088182..829710185d 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -535,6 +535,8 @@ interface IBaseProps { const RoomKickButton: React.FC = ({ member, startUpdating, stopUpdating }) => { const cli = useContext(MatrixClientContext); + // don't render this button on our own profile, we don't want to kick ourselves + if (member.userId === cli.getUserId()) return null; // check if user can be kicked/disinvited if (member.membership !== "invite" && member.membership !== "join") return null; @@ -659,6 +661,9 @@ const RedactMessagesButton: React.FC = ({ member }) => { const BanToggleButton: React.FC = ({ member, startUpdating, stopUpdating }) => { const cli = useContext(MatrixClientContext); + // don't render this button on our own profile, we don't want to ban ourselves and can't unban ourselves anyhow + if (member.userId === cli.getUserId()) return null; + const onBanOrUnban = async () => { const { finished } = Modal.createTrackedDialog( 'Confirm User Action Dialog', From 3f6f2bcbb33df749d673a2023fcdbb165aaef595 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Wed, 6 Oct 2021 16:48:17 +0100 Subject: [PATCH 14/31] no-op a setBotPower request from the integration manager when the PL is equal to or greater to the requested PL When configured --- src/ScalarMessaging.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/ScalarMessaging.ts b/src/ScalarMessaging.ts index 888b9ce9ed..3fc362c0eb 100644 --- a/src/ScalarMessaging.ts +++ b/src/ScalarMessaging.ts @@ -452,7 +452,7 @@ function setBotOptions(event: MessageEvent, roomId: string, userId: string) }); } -function setBotPower(event: MessageEvent, roomId: string, userId: string, level: number): void { +function setBotPower(event: MessageEvent, roomId: string, userId: string, level: number, ignoreIfGreater?: boolean): void { if (!(Number.isInteger(level) && level >= 0)) { sendError(event, _t('Power level must be positive integer.')); return; @@ -473,6 +473,18 @@ function setBotPower(event: MessageEvent, roomId: string, userId: string, l }, ); + // If the PL is equal to or greater than the requested PL, ignore. + if (ignoreIfGreater) { + // As per https://matrix.org/docs/spec/client_server/r0.6.0#m-room-power-levels + const currentPl = (powerLevels.content.users && powerLevels.content.users[userId]) || powerLevels.content.users_default || 0; + + if (currentPl >= level) { + sendResponse(event, { + success: true, + }); + } + } + client.setPowerLevel(roomId, userId, level, powerEvent).then(() => { sendResponse(event, { success: true, @@ -678,7 +690,7 @@ const onMessage = function(event: MessageEvent): void { setBotOptions(event, roomId, userId); break; case Action.SetBotPower: - setBotPower(event, roomId, userId, event.data.level); + setBotPower(event, roomId, userId, event.data.level, event.data.ignoreIfGreater); break; default: console.warn("Unhandled postMessage event with action '" + event.data.action +"'"); From b373b98d487a2ca3305113f62026ea6e52dd425b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 6 Oct 2021 16:49:53 +0100 Subject: [PATCH 15/31] Simplify aria menu item roles/selectors --- src/components/structures/ContextMenu.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index e2ec5d232c..dc6cbb59cd 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -44,9 +44,6 @@ function getOrCreateContainer(): HTMLDivElement { return container; } -const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]); -const ARIA_MENU_ITEM_SELECTOR = '[role^="menuitem"], [role^="menuitemcheckbox"], [role^="menuitemradio"]'; - export interface IPosition { top?: number; bottom?: number; @@ -117,7 +114,7 @@ export class ContextMenu extends React.PureComponent { // We don't need to clean up when unmounting, so ignore if (!element) return; - const first = element.querySelector(ARIA_MENU_ITEM_SELECTOR) + const first = element.querySelector('[role^="menuitem"]') || element.querySelector('[tab-index]'); if (first) { @@ -196,7 +193,7 @@ export class ContextMenu extends React.PureComponent { descending = true; } } - } while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role"))); + } while (element && !element.getAttribute("role")?.startsWith("menuitem")); if (element) { (element as HTMLElement).focus(); From 88410a1b295ae1b2e89f449fbc7a9353073df90f Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Wed, 6 Oct 2021 16:51:07 +0100 Subject: [PATCH 16/31] Make function async --- src/ScalarMessaging.ts | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/ScalarMessaging.ts b/src/ScalarMessaging.ts index 3fc362c0eb..d068c1f924 100644 --- a/src/ScalarMessaging.ts +++ b/src/ScalarMessaging.ts @@ -452,7 +452,9 @@ function setBotOptions(event: MessageEvent, roomId: string, userId: string) }); } -function setBotPower(event: MessageEvent, roomId: string, userId: string, level: number, ignoreIfGreater?: boolean): void { +async function setBotPower( + event: MessageEvent, roomId: string, userId: string, level: number, ignoreIfGreater?: boolean, +): Promise { if (!(Number.isInteger(level) && level >= 0)) { sendError(event, _t('Power level must be positive integer.')); return; @@ -465,34 +467,34 @@ function setBotPower(event: MessageEvent, roomId: string, userId: string, l return; } - client.getStateEvent(roomId, "m.room.power_levels", "").then((powerLevels) => { - const powerEvent = new MatrixEvent( - { - type: "m.room.power_levels", - content: powerLevels, - }, - ); + try { + const powerLevels = await client.getStateEvent(roomId, "m.room.power_levels", ""); // If the PL is equal to or greater than the requested PL, ignore. - if (ignoreIfGreater) { + if (ignoreIfGreater === true) { // As per https://matrix.org/docs/spec/client_server/r0.6.0#m-room-power-levels - const currentPl = (powerLevels.content.users && powerLevels.content.users[userId]) || powerLevels.content.users_default || 0; + const currentPl = ( + powerLevels.content.users && powerLevels.content.users[userId] + ) || powerLevels.content.users_default || 0; if (currentPl >= level) { - sendResponse(event, { + return sendResponse(event, { success: true, }); } } - - client.setPowerLevel(roomId, userId, level, powerEvent).then(() => { - sendResponse(event, { - success: true, - }); - }, (err) => { - sendError(event, err.message ? err.message : _t('Failed to send request.'), err); + await client.setPowerLevel(roomId, userId, level, new MatrixEvent( + { + type: "m.room.power_levels", + content: powerLevels, + }, + )); + return sendResponse(event, { + success: true, }); - }); + } catch (err) { + sendError(event, err.message ? err.message : _t('Failed to send request.'), err); + } } function getMembershipState(event: MessageEvent, roomId: string, userId: string): void { From ab98549fff4cce1df885e20e031484a3f81f0369 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 6 Oct 2021 17:08:32 +0100 Subject: [PATCH 17/31] move the logic to the parent so that the section hiding works --- src/components/views/right_panel/UserInfo.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 829710185d..7de38b587f 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -535,8 +535,6 @@ interface IBaseProps { const RoomKickButton: React.FC = ({ member, startUpdating, stopUpdating }) => { const cli = useContext(MatrixClientContext); - // don't render this button on our own profile, we don't want to kick ourselves - if (member.userId === cli.getUserId()) return null; // check if user can be kicked/disinvited if (member.membership !== "invite" && member.membership !== "join") return null; @@ -661,9 +659,6 @@ const RedactMessagesButton: React.FC = ({ member }) => { const BanToggleButton: React.FC = ({ member, startUpdating, stopUpdating }) => { const cli = useContext(MatrixClientContext); - // don't render this button on our own profile, we don't want to ban ourselves and can't unban ourselves anyhow - if (member.userId === cli.getUserId()) return null; - const onBanOrUnban = async () => { const { finished } = Modal.createTrackedDialog( 'Confirm User Action Dialog', @@ -822,7 +817,7 @@ const RoomAdminToolsContainer: React.FC = ({ const isMe = me.userId === member.userId; const canAffectUser = member.powerLevel < me.powerLevel || isMe; - if (canAffectUser && me.powerLevel >= kickPowerLevel) { + if (!isMe && canAffectUser && me.powerLevel >= kickPowerLevel) { kickButton = ; } if (me.powerLevel >= redactPowerLevel && (!SpaceStore.spacesEnabled || !room.isSpaceRoom())) { @@ -830,10 +825,10 @@ const RoomAdminToolsContainer: React.FC = ({ ); } - if (canAffectUser && me.powerLevel >= banPowerLevel) { + if (!isMe && canAffectUser && me.powerLevel >= banPowerLevel) { banButton = ; } - if (canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) { + if (!isMe && canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) { muteButton = ( Date: Thu, 7 Oct 2021 10:57:23 +0100 Subject: [PATCH 18/31] Allow the header container to collapse on itself when room list not minimised --- res/css/views/rooms/_RoomSublist.scss | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/res/css/views/rooms/_RoomSublist.scss b/res/css/views/rooms/_RoomSublist.scss index 6db2185dd5..5f92108e72 100644 --- a/res/css/views/rooms/_RoomSublist.scss +++ b/res/css/views/rooms/_RoomSublist.scss @@ -22,6 +22,12 @@ limitations under the License. display: none; } + &:not(.mx_RoomSublist_minimized) { + .mx_RoomSublist_headerContainer { + height: auto; + } + } + .mx_RoomSublist_headerContainer { // Create a flexbox to make alignment easy display: flex; @@ -41,9 +47,7 @@ limitations under the License. // The combined height must be set in the LeftPanel component for sticky headers // to work correctly. padding-bottom: 8px; - // Allow the container to collapse on itself if its children - // are not in the normal document flow - max-height: 24px; + height: 24px; color: $roomlist-header-color; .mx_RoomSublist_stickable { @@ -176,9 +180,9 @@ limitations under the License. // scroll jumps when they become sticky. However, that leaves a gap when // scrolled to the top above the first sublist (whose header can only ever // stick to top), so we make sure to exclude the first visible sublist. - &:not(.mx_RoomSublist_hidden) ~ .mx_RoomSublist .mx_RoomSublist_headerContainer { - height: 24px; - } + // &:not(.mx_RoomSublist_hidden) ~ .mx_RoomSublist .mx_RoomSublist_headerContainer { + // height: 24px; + // } .mx_RoomSublist_resizeBox { position: relative; From 047f182cd8f42d82424e960ba3d4ea768f484f3e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 7 Oct 2021 11:04:10 +0100 Subject: [PATCH 19/31] focusLock only specific context menus --- src/components/structures/ContextMenu.tsx | 31 ++++++++++++++++--- .../views/directory/NetworkDropdown.tsx | 2 +- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index dc6cbb59cd..90d16a2eff 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -83,6 +83,10 @@ export interface IProps extends IPosition { // it will be mounted to a container at the root of the DOM. mountAsChild?: boolean; + // If specified, contents will be wrapped in a FocusLock, this is only needed if the context menu is being rendered + // within an existing FocusLock e.g inside a modal. + focusLock?: boolean; + // Function to be called on menu close onFinished(); // on resize callback @@ -98,6 +102,8 @@ interface IState { // this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines. @replaceableComponent("structures.ContextMenu") export class ContextMenu extends React.PureComponent { + private readonly initialFocus: HTMLElement; + static defaultProps = { hasBackground: true, managed: true, @@ -105,9 +111,18 @@ export class ContextMenu extends React.PureComponent { constructor(props, context) { super(props, context); + this.state = { contextMenuElem: null, }; + + // persist what had focus when we got initialized so we can return it after + this.initialFocus = document.activeElement as HTMLElement; + } + + componentWillUnmount() { + // return focus to the thing which had it before us after the unmount + this.initialFocus.focus(); } private collectContextMenuRect = (element: HTMLDivElement) => { @@ -371,6 +386,17 @@ export class ContextMenu extends React.PureComponent { ); } + let body = <> + { chevron } + { props.children } + ; + + if (props.focusLock) { + body = + { body } + ; + } + return (
{ ref={this.collectContextMenuRect} role={this.props.managed ? "menu" : undefined} > - - { chevron } - { props.children } - + { body }
{ background } diff --git a/src/components/views/directory/NetworkDropdown.tsx b/src/components/views/directory/NetworkDropdown.tsx index dbad2ca024..9a999625d7 100644 --- a/src/components/views/directory/NetworkDropdown.tsx +++ b/src/components/views/directory/NetworkDropdown.tsx @@ -268,7 +268,7 @@ const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, s }; const buttonRect = handle.current.getBoundingClientRect(); - content = + content =
{ options } From 1c8bcce0ede25ac8c7a07a17aadb88ed3adeb4f1 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 7 Oct 2021 11:13:13 +0100 Subject: [PATCH 20/31] comment --- src/components/structures/ContextMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 90d16a2eff..4250b5925b 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -121,7 +121,7 @@ export class ContextMenu extends React.PureComponent { } componentWillUnmount() { - // return focus to the thing which had it before us after the unmount + // return focus to the thing which had it before us this.initialFocus.focus(); } From e470d7d030562d032f568296e29f5aaec6755950 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 7 Oct 2021 13:19:51 +0100 Subject: [PATCH 21/31] Make use of MatrixClient::getRoomUpgradeHistory --- src/components/structures/SpaceHierarchy.tsx | 10 ++--- src/stores/SpaceStore.ts | 41 ++++++-------------- 2 files changed, 17 insertions(+), 34 deletions(-) diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index 8e1a472984..38d4129294 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -49,7 +49,7 @@ import { mediaFromMxc } from "../../customisations/Media"; import InfoTooltip from "../views/elements/InfoTooltip"; import TextWithTooltip from "../views/elements/TextWithTooltip"; import { useStateToggle } from "../../hooks/useStateToggle"; -import SpaceStore, { getChildOrder } from "../../stores/SpaceStore"; +import { getChildOrder } from "../../stores/SpaceStore"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { linkifyElement } from "../../HtmlUtils"; import { useDispatcher } from "../../hooks/useDispatcher"; @@ -334,8 +334,9 @@ interface IHierarchyLevelProps { onToggleClick?(parentId: string, childId: string): void; } -const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom, upgradedRoomMap: Map): IHierarchyRoom => { - const cliRoom = cli.getRoom(SpaceStore.instance.findMostUpgradedVersion(room.room_id, upgradedRoomMap)); +const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom): IHierarchyRoom => { + const history = cli.getRoomUpgradeHistory(room.room_id, true); + const cliRoom = history[history.length - 1]; if (cliRoom) { return { ...room, @@ -374,11 +375,10 @@ export const HierarchyLevel = ({ return getChildOrder(ev.content.order, ev.origin_server_ts, ev.state_key); }); - const upgradedRoomMap = new Map(); const [subspaces, childRooms] = sortedChildren.reduce((result, ev: IHierarchyRelation) => { const room = hierarchy.roomMap.get(ev.state_key); if (room && roomSet.has(room)) { - result[room.room_type === RoomType.Space ? 0 : 1].push(toLocalRoom(cli, room, upgradedRoomMap)); + result[room.room_type === RoomType.Space ? 0 : 1].push(toLocalRoom(cli, room)); } return result; }, [[] as IHierarchyRoom[], [] as IHierarchyRoom[]]); diff --git a/src/stores/SpaceStore.ts b/src/stores/SpaceStore.ts index e8bdce738b..3b28cda57f 100644 --- a/src/stores/SpaceStore.ts +++ b/src/stores/SpaceStore.ts @@ -283,7 +283,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const createTs = childRoom?.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs(); return getChildOrder(ev.getContent().order, createTs, roomId); }).map(ev => { - return this.matrixClient.getRoom(this.findMostUpgradedVersion(ev.getStateKey())); + const history = this.matrixClient.getRoomUpgradeHistory(ev.getStateKey(), true); + return history[history.length - 1]; }).filter(room => { return room?.getMyMembership() === "join" || room?.getMyMembership() === "invite"; }) || []; @@ -452,28 +453,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.onRoomsUpdate(); }; - // Utility to walk tombstones and find the most updated variant of the given room, - // takes a Map to enable caching of the responses given the recursive nature of the function. - public findMostUpgradedVersion( - roomId: string, - upgradedRoomMap?: Map, - seen= new Set(), - ): string { - if (seen.has(roomId)) return roomId; - if (upgradedRoomMap?.has(roomId)) return upgradedRoomMap.get(roomId); - const room = this.matrixClient.getRoom(roomId); - const tombstone = room?.currentState.getStateEvents(EventType.RoomTombstone, ""); - const replacementRoom = tombstone?.getContent().replacement_room; - if (replacementRoom && this.matrixClient.getRoom(replacementRoom)?.getMyMembership() === "join") { - seen.add(roomId); - const result = this.findMostUpgradedVersion(replacementRoom, upgradedRoomMap); - upgradedRoomMap?.set(roomId, result); - return result; - } - upgradedRoomMap?.set(roomId, roomId); - return roomId; - } - private onRoomsUpdate = throttle(() => { // TODO resolve some updates as deltas const visibleRooms = this.matrixClient.getVisibleRooms(); @@ -501,7 +480,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }); }); - const upgradedRoomMap = new Map(); this.rootSpaces.forEach(s => { // traverse each space tree in DFS to build up the supersets as you go up, // reusing results from like subtrees. @@ -514,7 +492,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } const [childSpaces, childRooms] = partitionSpacesAndRooms(this.getChildren(spaceId)); - const roomIds = new Set(childRooms.map(r => this.findMostUpgradedVersion(r.roomId, upgradedRoomMap))); + const roomIds = new Set(childRooms.map(r => r.roomId)); const space = this.matrixClient?.getRoom(spaceId); // Add relevant DMs @@ -528,14 +506,19 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const newPath = new Set(parentPath).add(spaceId); childSpaces.forEach(childSpace => { fn(childSpace.roomId, newPath)?.forEach(roomId => { - roomIds.add(this.findMostUpgradedVersion(roomId, upgradedRoomMap)); + roomIds.add(roomId); }); }); hiddenChildren.get(spaceId)?.forEach(roomId => { - roomIds.add(this.findMostUpgradedVersion(roomId, upgradedRoomMap)); + roomIds.add(roomId); }); - this.spaceFilteredRooms.set(spaceId, roomIds); - return roomIds; + + // Expand room IDs to all known versions of the given rooms + const expandedRoomIds = new Set(Array.from(roomIds).flatMap(roomId => { + return this.matrixClient.getRoomUpgradeHistory(roomId, true).map(r => r.roomId); + })); + this.spaceFilteredRooms.set(spaceId, expandedRoomIds); + return expandedRoomIds; }; fn(s.roomId, new Set()); From 1b334e47aae6959d2c6d5949701f0f5bd8431013 Mon Sep 17 00:00:00 2001 From: Paulo Pinto Date: Thu, 7 Oct 2021 15:42:23 +0100 Subject: [PATCH 22/31] Fix issue that caused the value of certain settings to be inverted (#6896) When using the SettingsStore.watchSetting() method for settings which have an invertedSettingName, the newValueAt argument passed to the callback function, would erroneously contain the inverted value. This was making it so that such settings appeared to be disabled when they should in fact be enabled, or vice-versa. This was however only the case for code which took in account the newValueAt argument. Code using the newValue argument was not affected. The settings which have an invertedSettingName, and were thus potentially impacted are: - MessageComposerInput.dontSuggestEmoji - hideRedactions - hideJoinLeaves - hideAvatarChanges - hideDisplaynameChanges - hideReadReceipts - Pill.shouldHidePillAvatar - TextualBody.disableBigEmoji - dontSendTypingNotifications - TagPanel.disableTagPanel - webRtcForceTURN Signed-off-by: Paulo Pinto --- src/settings/SettingsStore.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index af858d2379..d2f5568988 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -162,9 +162,10 @@ export default class SettingsStore { const watcherId = `${new Date().getTime()}_${SettingsStore.watcherCount++}_${settingName}_${roomId}`; - const localizedCallback = (changedInRoomId, atLevel, newValAtLevel) => { + const localizedCallback = (changedInRoomId: string | null, atLevel: SettingLevel, newValAtLevel: any) => { const newValue = SettingsStore.getValue(originalSettingName); - callbackFn(originalSettingName, changedInRoomId, atLevel, newValAtLevel, newValue); + const newValueAtLevel = SettingsStore.getValueAt(atLevel, originalSettingName) ?? newValAtLevel; + callbackFn(originalSettingName, changedInRoomId, atLevel, newValueAtLevel, newValue); }; SettingsStore.watchers.set(watcherId, localizedCallback); From 57b919b10af21663aa56793281d13cad5544c701 Mon Sep 17 00:00:00 2001 From: Germain Date: Thu, 7 Oct 2021 16:51:39 +0100 Subject: [PATCH 23/31] Delete fixed headerContainer height Co-authored-by: Travis Ralston --- res/css/views/rooms/_RoomSublist.scss | 3 --- 1 file changed, 3 deletions(-) diff --git a/res/css/views/rooms/_RoomSublist.scss b/res/css/views/rooms/_RoomSublist.scss index 5f92108e72..494c8174d7 100644 --- a/res/css/views/rooms/_RoomSublist.scss +++ b/res/css/views/rooms/_RoomSublist.scss @@ -180,9 +180,6 @@ limitations under the License. // scroll jumps when they become sticky. However, that leaves a gap when // scrolled to the top above the first sublist (whose header can only ever // stick to top), so we make sure to exclude the first visible sublist. - // &:not(.mx_RoomSublist_hidden) ~ .mx_RoomSublist .mx_RoomSublist_headerContainer { - // height: 24px; - // } .mx_RoomSublist_resizeBox { position: relative; From ea55b0d45f713751066831c451a40ef57819ed9e Mon Sep 17 00:00:00 2001 From: Paulo Pinto Date: Thu, 7 Oct 2021 17:01:39 +0100 Subject: [PATCH 24/31] Add room name to component state Signed-off-by: Paulo Pinto --- .../views/dialogs/RoomSettingsDialog.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/components/views/dialogs/RoomSettingsDialog.tsx b/src/components/views/dialogs/RoomSettingsDialog.tsx index a73f0a595b..b1c21d5044 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.tsx +++ b/src/components/views/dialogs/RoomSettingsDialog.tsx @@ -44,12 +44,22 @@ interface IProps { initialTabId?: string; } +interface IState { + roomName: string; +} + @replaceableComponent("views.dialogs.RoomSettingsDialog") -export default class RoomSettingsDialog extends React.Component { +export default class RoomSettingsDialog extends React.Component { private dispatcherRef: string; + constructor(props: IProps) { + super(props); + this.state = { roomName: '' }; + } + public componentDidMount() { this.dispatcherRef = dis.register(this.onAction); + this.setRoomName(); } public componentWillUnmount() { @@ -66,6 +76,12 @@ export default class RoomSettingsDialog extends React.Component { } }; + private setRoomName = (): void => { + this.setState({ + roomName: MatrixClientPeg.get().getRoom(this.props.roomId).name, + }); + }; + private getTabs(): Tab[] { const tabs: Tab[] = []; @@ -122,7 +138,7 @@ export default class RoomSettingsDialog extends React.Component { } render() { - const roomName = MatrixClientPeg.get().getRoom(this.props.roomId).name; + const roomName = this.state.roomName; return ( Date: Thu, 7 Oct 2021 17:04:30 +0100 Subject: [PATCH 25/31] Keep room name up-to-date in settings dialog Signed-off-by: Paulo Pinto --- src/components/views/dialogs/RoomSettingsDialog.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/views/dialogs/RoomSettingsDialog.tsx b/src/components/views/dialogs/RoomSettingsDialog.tsx index b1c21d5044..a0f1c10884 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.tsx +++ b/src/components/views/dialogs/RoomSettingsDialog.tsx @@ -59,6 +59,7 @@ export default class RoomSettingsDialog extends React.Component public componentDidMount() { this.dispatcherRef = dis.register(this.onAction); + MatrixClientPeg.get().on("Room.name", this.setRoomName); this.setRoomName(); } @@ -66,6 +67,8 @@ export default class RoomSettingsDialog extends React.Component if (this.dispatcherRef) { dis.unregister(this.dispatcherRef); } + + MatrixClientPeg.get().removeListener("Room.name", this.setRoomName); } private onAction = (payload): void => { From 6aa325ed543a2af9efeea032774a8ff3df0d2d7b Mon Sep 17 00:00:00 2001 From: Paulo Pinto Date: Thu, 7 Oct 2021 17:13:57 +0100 Subject: [PATCH 26/31] Rename method to onRoomName Signed-off-by: Paulo Pinto --- src/components/views/dialogs/RoomSettingsDialog.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/views/dialogs/RoomSettingsDialog.tsx b/src/components/views/dialogs/RoomSettingsDialog.tsx index a0f1c10884..b0c6fc4050 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.tsx +++ b/src/components/views/dialogs/RoomSettingsDialog.tsx @@ -59,8 +59,8 @@ export default class RoomSettingsDialog extends React.Component public componentDidMount() { this.dispatcherRef = dis.register(this.onAction); - MatrixClientPeg.get().on("Room.name", this.setRoomName); - this.setRoomName(); + MatrixClientPeg.get().on("Room.name", this.onRoomName); + this.onRoomName(); } public componentWillUnmount() { @@ -68,7 +68,7 @@ export default class RoomSettingsDialog extends React.Component dis.unregister(this.dispatcherRef); } - MatrixClientPeg.get().removeListener("Room.name", this.setRoomName); + MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); } private onAction = (payload): void => { @@ -79,7 +79,7 @@ export default class RoomSettingsDialog extends React.Component } }; - private setRoomName = (): void => { + private onRoomName = (): void => { this.setState({ roomName: MatrixClientPeg.get().getRoom(this.props.roomId).name, }); From 822f73edf7b5a5c8203e297cce95dd279e7a650e Mon Sep 17 00:00:00 2001 From: Germain Souquet Date: Thu, 7 Oct 2021 17:28:37 +0100 Subject: [PATCH 27/31] Remove stale comments in _RoomSublist.scss --- res/css/views/rooms/_RoomSublist.scss | 5 ----- 1 file changed, 5 deletions(-) diff --git a/res/css/views/rooms/_RoomSublist.scss b/res/css/views/rooms/_RoomSublist.scss index 494c8174d7..95b9f1822d 100644 --- a/res/css/views/rooms/_RoomSublist.scss +++ b/res/css/views/rooms/_RoomSublist.scss @@ -176,11 +176,6 @@ limitations under the License. } } - // In the general case, we reserve space for each sublist header to prevent - // scroll jumps when they become sticky. However, that leaves a gap when - // scrolled to the top above the first sublist (whose header can only ever - // stick to top), so we make sure to exclude the first visible sublist. - .mx_RoomSublist_resizeBox { position: relative; From c56d6ba539d00c737544134cea3cb22f765530ac Mon Sep 17 00:00:00 2001 From: Logan Arnett Date: Thu, 7 Oct 2021 13:22:43 -0400 Subject: [PATCH 28/31] updating from boolean to object in order to track if more than one thing is edited --- .../room_settings/RoomProfileSettings.tsx | 62 ++++++++++++++----- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/src/components/views/room_settings/RoomProfileSettings.tsx b/src/components/views/room_settings/RoomProfileSettings.tsx index 6533028e8c..71c0094026 100644 --- a/src/components/views/room_settings/RoomProfileSettings.tsx +++ b/src/components/views/room_settings/RoomProfileSettings.tsx @@ -35,7 +35,7 @@ interface IState { avatarFile: File; originalTopic: string; topic: string; - enableProfileSave: boolean; + profileFieldsTouched: Record; canSetName: boolean; canSetTopic: boolean; canSetAvatar: boolean; @@ -71,7 +71,7 @@ export default class RoomProfileSettings extends React.Component avatarFile: null, originalTopic: topic, topic: topic, - enableProfileSave: false, + profileFieldsTouched: {}, canSetName: room.currentState.maySendStateEvent('m.room.name', client.getUserId()), canSetTopic: room.currentState.maySendStateEvent('m.room.topic', client.getUserId()), canSetAvatar: room.currentState.maySendStateEvent('m.room.avatar', client.getUserId()), @@ -88,17 +88,24 @@ export default class RoomProfileSettings extends React.Component this.setState({ avatarUrl: null, avatarFile: null, - enableProfileSave: true, + profileFieldsTouched: { + ...this.state.profileFieldsTouched, + avatar: true + }, }); }; + private isSaveEnabled = () => { + return Boolean(Object.values(this.state.profileFieldsTouched).length) + } + private cancelProfileChanges = async (e: React.MouseEvent): Promise => { e.stopPropagation(); e.preventDefault(); - if (!this.state.enableProfileSave) return; + if (!this.isSaveEnabled()) return; this.setState({ - enableProfileSave: false, + profileFieldsTouched: {}, displayName: this.state.originalDisplayName, topic: this.state.originalTopic, avatarUrl: this.state.originalAvatarUrl, @@ -110,8 +117,8 @@ export default class RoomProfileSettings extends React.Component e.stopPropagation(); e.preventDefault(); - if (!this.state.enableProfileSave) return; - this.setState({ enableProfileSave: false }); + if (!this.isSaveEnabled()) return; + this.setState({ profileFieldsTouched: {} }); const client = MatrixClientPeg.get(); @@ -156,18 +163,37 @@ export default class RoomProfileSettings extends React.Component private onDisplayNameChanged = (e: React.ChangeEvent): void => { this.setState({ displayName: e.target.value }); if (this.state.originalDisplayName === e.target.value) { - this.setState({ enableProfileSave: false }); + this.setState({ + profileFieldsTouched: { + ...this.state.profileFieldsTouched, + name: false + }, }); } else { - this.setState({ enableProfileSave: true }); + this.setState({ + profileFieldsTouched: { + ...this.state.profileFieldsTouched, + name: true + }, + }); } }; private onTopicChanged = (e: React.ChangeEvent): void => { this.setState({ topic: e.target.value }); if (this.state.originalTopic === e.target.value) { - this.setState({ enableProfileSave: false }); + this.setState({ + profileFieldsTouched: { + ...this.state.profileFieldsTouched, + topic: false + }, + }); } else { - this.setState({ enableProfileSave: true }); + this.setState({ + profileFieldsTouched: { + ...this.state.profileFieldsTouched, + topic: true + }, + }); } }; @@ -176,7 +202,10 @@ export default class RoomProfileSettings extends React.Component this.setState({ avatarUrl: this.state.originalAvatarUrl, avatarFile: null, - enableProfileSave: false, + profileFieldsTouched: { + ...this.state.profileFieldsTouched, + avatar: false + }, }); return; } @@ -187,7 +216,10 @@ export default class RoomProfileSettings extends React.Component this.setState({ avatarUrl: String(ev.target.result), avatarFile: file, - enableProfileSave: true, + profileFieldsTouched: { + ...this.state.profileFieldsTouched, + avatar: true + }, }); }; reader.readAsDataURL(file); @@ -205,14 +237,14 @@ export default class RoomProfileSettings extends React.Component { _t("Cancel") } { _t("Save") } From 0b64dfa59ab60ae57767fbda7997fdc2eee108fd Mon Sep 17 00:00:00 2001 From: Vitor Eller Date: Thu, 7 Oct 2021 15:23:54 -0300 Subject: [PATCH 29/31] Alter phone verification button disable label --- src/components/views/settings/account/PhoneNumbers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/settings/account/PhoneNumbers.tsx b/src/components/views/settings/account/PhoneNumbers.tsx index e5cca72867..9105dfc312 100644 --- a/src/components/views/settings/account/PhoneNumbers.tsx +++ b/src/components/views/settings/account/PhoneNumbers.tsx @@ -268,7 +268,7 @@ export default class PhoneNumbers extends React.Component { { _t("Continue") } From 4759b4fb5d7184fa5c34ccf09908b8e24d2c7687 Mon Sep 17 00:00:00 2001 From: Logan Arnett Date: Thu, 7 Oct 2021 14:58:35 -0400 Subject: [PATCH 30/31] updating for lint issues --- .../room_settings/RoomProfileSettings.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/components/views/room_settings/RoomProfileSettings.tsx b/src/components/views/room_settings/RoomProfileSettings.tsx index 71c0094026..8cc7a620f8 100644 --- a/src/components/views/room_settings/RoomProfileSettings.tsx +++ b/src/components/views/room_settings/RoomProfileSettings.tsx @@ -90,14 +90,14 @@ export default class RoomProfileSettings extends React.Component avatarFile: null, profileFieldsTouched: { ...this.state.profileFieldsTouched, - avatar: true + avatar: true, }, }); }; private isSaveEnabled = () => { - return Boolean(Object.values(this.state.profileFieldsTouched).length) - } + return Boolean(Object.values(this.state.profileFieldsTouched).length); + }; private cancelProfileChanges = async (e: React.MouseEvent): Promise => { e.stopPropagation(); @@ -166,13 +166,14 @@ export default class RoomProfileSettings extends React.Component this.setState({ profileFieldsTouched: { ...this.state.profileFieldsTouched, - name: false - }, }); + name: false, + }, + }); } else { this.setState({ profileFieldsTouched: { ...this.state.profileFieldsTouched, - name: true + name: true, }, }); } @@ -184,14 +185,14 @@ export default class RoomProfileSettings extends React.Component this.setState({ profileFieldsTouched: { ...this.state.profileFieldsTouched, - topic: false + topic: false, }, }); } else { this.setState({ profileFieldsTouched: { ...this.state.profileFieldsTouched, - topic: true + topic: true, }, }); } @@ -204,7 +205,7 @@ export default class RoomProfileSettings extends React.Component avatarFile: null, profileFieldsTouched: { ...this.state.profileFieldsTouched, - avatar: false + avatar: false, }, }); return; @@ -218,7 +219,7 @@ export default class RoomProfileSettings extends React.Component avatarFile: file, profileFieldsTouched: { ...this.state.profileFieldsTouched, - avatar: true + avatar: true, }, }); }; From 82ad85a9744b0da3f1f3128138ba6064989e15b6 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 8 Oct 2021 10:30:46 +0100 Subject: [PATCH 31/31] Mock usage of getRoomUpgradeHistory in SpaceStore tests --- test/stores/SpaceStore-test.ts | 1 + test/test-utils.js | 1 + 2 files changed, 2 insertions(+) diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts index e7ca727e28..cdc3e58a4f 100644 --- a/test/stores/SpaceStore-test.ts +++ b/test/stores/SpaceStore-test.ts @@ -77,6 +77,7 @@ describe("SpaceStore", () => { const run = async () => { client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId)); + client.getRoomUpgradeHistory.mockImplementation(roomId => [rooms.find(room => room.roomId === roomId)]); await testUtils.setupAsyncStoreWithClient(store, client); jest.runAllTimers(); }; diff --git a/test/test-utils.js b/test/test-utils.js index 2091a6e0ed..d43a08ab3a 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -103,6 +103,7 @@ export function createTestClient() { isUserIgnored: jest.fn().mockReturnValue(false), getCapabilities: jest.fn().mockResolvedValue({}), supportsExperimentalThreads: () => false, + getRoomUpgradeHistory: jest.fn().mockReturnValue([]), }; }