Implement new memberlist design with MVVM architecture (#28874)

* Add new e2e icon for the member tile

* Add new presence icon for member tile

* Implement new member tile

* Implement memberlist view model

* Implement new memberlist header view

* Support the new memberlist in Diasambiguated profile

1. Use MemberInfo instead of RoomMember
2. CSS changes to reflect the new design

* Implement new memberlist view

* Add and use a new overflow component

We used the EntityTile component as a pretend overflow tile in some
places. This new lighter component is added so  that we can remove the
complex EntityTile component.

* Remove old code

* Add/remove css files from _components.pcss

* Increase minimum width as per design

* Actually use the new memberlist view

* Fix broken jest tests

* Add jest tests

* Playwright: Make it possible to disable presence

* Add playwright tests

* Fix lint error

* Undo translation changes that must be done via localazy

* Update license header

* Use waitFor instead of setTimeout

* Remove comment

* Switch over from template to container hs

* Revert unintended change

* Move config to top level
develop
R Midhun Suresh 2025-01-08 22:45:06 +05:30 committed by GitHub
parent f1899b9eb1
commit ebef0d353e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 2456 additions and 1788 deletions

View File

@ -90,7 +90,7 @@
"@matrix-org/spec": "^1.7.0",
"@sentry/browser": "^8.0.0",
"@types/png-chunks-extract": "^1.0.2",
"@vector-im/compound-design-tokens": "^2.0.1",
"@vector-im/compound-design-tokens": "^2.1.0",
"@vector-im/compound-web": "^7.5.0",
"@vector-im/matrix-wysiwyg": "2.38.0",
"@zxcvbn-ts/core": "^3.0.4",
@ -151,7 +151,9 @@
"temporal-polyfill": "^0.2.5",
"ua-parser-js": "^1.0.2",
"uuid": "^11.0.0",
"what-input": "^5.2.10"
"what-input": "^5.2.10",
"@types/react-virtualized": "^9.21.30",
"react-virtualized": "^9.22.5"
},
"devDependencies": {
"@action-validator/cli": "^0.6.0",

View File

@ -16,7 +16,7 @@ const ROOM_NAME = "Test room";
const NAME = "Alice";
function getMemberTileByName(page: Page, name: string): Locator {
return page.locator(`.mx_EntityTile, [title="${name}"]`);
return page.locator(`.mx_MemberTileView, [title="${name}"]`);
}
test.use({
@ -88,7 +88,7 @@ test.describe("Dehydration", () => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
await expect(page.locator(".mx_MemberListView")).toBeVisible();
await getMemberTileByName(page, NAME).click();
await page.locator(".mx_UserInfo_devices .mx_UserInfo_expand").click();

View File

@ -78,7 +78,7 @@ test.describe("Lazy Loading", () => {
}
function getMemberInMemberlist(page: Page, name: string): Locator {
return page.locator(".mx_MemberList .mx_EntityTile_name").filter({ hasText: name });
return page.locator(".mx_MemberListView .mx_MemberTileView_name").filter({ hasText: name });
}
async function checkMemberList(page: Page, charlies: Bot[]) {

View File

@ -0,0 +1,48 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { test, expect } from "../../element-web-test";
import { Bot } from "../../pages/bot";
const ROOM_NAME = "Test room";
const NAME = "Alice";
test.use({
synapseConfigOptions: {
presence: {
enabled: false,
include_offline_users_on_sync: false,
},
},
displayName: NAME,
disablePresence: true,
});
test.describe("Memberlist", () => {
test.beforeEach(async ({ app, user, page, homeserver }, testInfo) => {
testInfo.setTimeout(testInfo.timeout + 30_000);
const id = await app.client.createRoom({ name: ROOM_NAME });
const newBots: Bot[] = [];
const names = ["Bob", "Bob", "Susan"];
for (let i = 0; i < 3; i++) {
const displayName = names[i];
const autoAcceptInvites = displayName !== "Susan";
const bot = new Bot(page, homeserver, { displayName, startClient: true, autoAcceptInvites });
await bot.prepareClient();
await app.client.inviteUser(id, bot.credentials?.userId);
newBots.push(bot);
}
});
test("Renders correctly", { tag: "@screenshot" }, async ({ page, app }) => {
await app.viewRoomByName(ROOM_NAME);
const memberlist = await app.toggleMemberlistPanel();
await expect(memberlist.locator(".mx_MemberTileView")).toHaveCount(4);
await expect(memberlist.getByText("(Invited)")).toHaveCount(1);
await expect(page.locator(".mx_MemberListView")).toMatchScreenshot("with-four-members.png");
});
});

View File

@ -24,7 +24,7 @@ const ROOM_ADDRESS_LONG =
"loremIpsumDolorSitAmetConsecteturAdipisicingElitSedDoEiusmodTemporIncididuntUtLaboreEtDoloreMagnaAliqua";
function getMemberTileByName(page: Page, name: string): Locator {
return page.locator(`.mx_EntityTile, [title="${name}"]`);
return page.locator(`.mx_MemberTileView, [title="${name}"]`);
}
test.describe("RightPanel", () => {
@ -107,14 +107,14 @@ test.describe("RightPanel", () => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
await expect(page.locator(".mx_MemberListView")).toBeVisible();
await getMemberTileByName(page, NAME).click();
await expect(page.locator(".mx_UserInfo")).toBeVisible();
await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible();
await page.getByTestId("base-card-back-button").click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
await expect(page.locator(".mx_MemberListView")).toBeVisible();
await page.getByLabel("Room info").nth(1).click();
await checkRoomSummaryCard(page, ROOM_NAME);
@ -130,14 +130,14 @@ test.describe("RightPanel", () => {
.locator(".mx_RoomInfoLine_private")
.getByRole("button", { name: /\d member/ })
.click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
await expect(page.locator(".mx_MemberListView")).toBeVisible();
await getMemberTileByName(page, NAME).click();
await expect(page.locator(".mx_UserInfo")).toBeVisible();
await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible();
await page.getByTestId("base-card-back-button").click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
await expect(page.locator(".mx_MemberListView")).toBeVisible();
});
});
});

View File

@ -99,6 +99,7 @@ export interface Fixtures {
bot: Bot;
labsFlags: string[];
webserver: Webserver;
disablePresence: boolean;
}
export const test = base.extend<Fixtures>({
@ -110,8 +111,9 @@ export const test = base.extend<Fixtures>({
);
await use(context);
},
disablePresence: false,
config: {}, // We merge this atop the default CONFIG_JSON in the page fixture to make extending it easier
page: async ({ homeserver, context, page, config, labsFlags }, use) => {
page: async ({ homeserver, context, page, config, labsFlags, disablePresence }, use) => {
await context.route(`http://localhost:8080/config.json*`, async (route) => {
const json = {
...CONFIG_JSON,
@ -131,6 +133,11 @@ export const test = base.extend<Fixtures>({
return obj;
}, {}),
};
if (disablePresence) {
json["enable_presence_by_hs_url"] = {
[homeserver.baseUrl]: false,
};
}
await route.fulfill({ json });
});
await use(page);

View File

@ -177,6 +177,18 @@ export class ElementAppPage {
return this.page.locator(".mx_RightPanel");
}
/**
* Opens/closes the memberlist panel
* @returns locator to the memberlist panel
*/
public async toggleMemberlistPanel(): Promise<Locator> {
const locator = this.page.locator(".mx_FacePile");
await locator.click();
const memberlist = this.page.locator(".mx_MemberListView");
await memberlist.waitFor();
return memberlist;
}
/**
* Get a locator for the tooltip associated with an element
* @param e The element with the tooltip

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -132,6 +132,10 @@ const DEFAULT_CONFIG = {
experimental_features: {},
oidc_providers: [],
serve_server_wellknown: true,
presence: {
enabled: true,
include_offline_users_on_sync: true,
},
};
export type SynapseConfigOptions = Partial<typeof DEFAULT_CONFIG>;

View File

@ -278,9 +278,9 @@
@import "./views/rooms/_CallGuestLinkButton.pcss";
@import "./views/rooms/_DecryptionFailureBar.pcss";
@import "./views/rooms/_E2EIcon.pcss";
@import "./views/rooms/_E2EIconView.pcss";
@import "./views/rooms/_EditMessageComposer.pcss";
@import "./views/rooms/_EmojiButton.pcss";
@import "./views/rooms/_EntityTile.pcss";
@import "./views/rooms/_EventBubbleTile.pcss";
@import "./views/rooms/_EventPreview.pcss";
@import "./views/rooms/_EventTile.pcss";
@ -290,13 +290,17 @@
@import "./views/rooms/_LinkPreviewGroup.pcss";
@import "./views/rooms/_LinkPreviewWidget.pcss";
@import "./views/rooms/_LiveContentSummary.pcss";
@import "./views/rooms/_MemberList.pcss";
@import "./views/rooms/_MemberListHeaderView.pcss";
@import "./views/rooms/_MemberListView.pcss";
@import "./views/rooms/_MemberTileView.pcss";
@import "./views/rooms/_MessageComposer.pcss";
@import "./views/rooms/_MessageComposerFormatBar.pcss";
@import "./views/rooms/_NewRoomIntro.pcss";
@import "./views/rooms/_NotificationBadge.pcss";
@import "./views/rooms/_OverflowTile.pcss";
@import "./views/rooms/_PinnedEventTile.pcss";
@import "./views/rooms/_PinnedMessageBanner.pcss";
@import "./views/rooms/_PresenceIconView.pcss";
@import "./views/rooms/_PresenceLabel.pcss";
@import "./views/rooms/_ReadReceiptGroup.pcss";
@import "./views/rooms/_ReplyPreview.pcss";

View File

@ -21,8 +21,29 @@ Please see LICENSE files in the repository root for full details.
}
.mx_DisambiguatedProfile_mxid {
margin-inline-start: 5px;
color: $secondary-content;
font-size: var(--cpd-font-size-body-sm);
margin-inline-start: 5px;
}
}
/** Disambiguated profile needs to have a different layout in the member tile */
.mx_MemberTileView .mx_DisambiguatedProfile {
display: flex;
flex-direction: column;
.mx_DisambiguatedProfile_mxid {
margin-inline-start: 0;
font: var(--cpd-font-body-sm-regular);
}
span:not(.mx_DisambiguatedProfile_mxid) {
/**
In a member tile, this span element is a flex child and so
we need the following for text overflow to work.
**/
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View File

@ -0,0 +1,20 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_E2EIconView {
display: flex;
justify-content: center;
align-items: center;
}
.mx_E2EIconView_warning {
color: var(--cpd-color-icon-critical-primary);
}
.mx_E2EIconView_verified {
color: var(--cpd-color-icon-success-primary);
}

View File

@ -1,128 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_EntityTile {
display: flex;
align-items: center;
color: $primary-content;
cursor: pointer;
.mx_E2EIcon {
margin: 0;
position: absolute;
bottom: 2px;
right: 7px;
}
}
.mx_EntityTile:hover {
padding-right: 30px;
position: relative; /* to keep the chevron aligned */
}
.mx_EntityTile:hover::before {
content: "";
position: absolute;
top: calc(50% - 8px); /* center */
right: -8px;
mask: url("@vector-im/compound-design-tokens/icons/chevron-right.svg");
mask-repeat: no-repeat;
mask-position: center;
width: 16px;
height: 16px;
background-color: $header-panel-text-primary-color;
}
.mx_EntityTile:not(.mx_EntityTile_unreachable) .mx_PresenceLabel {
display: none;
}
.mx_EntityTile:hover .mx_PresenceLabel {
display: block;
}
.mx_EntityTile_invite {
display: table-cell;
vertical-align: middle;
margin-left: 10px;
width: 26px;
}
.mx_EntityTile_avatar {
padding-left: 3px;
padding-right: 12px;
padding-top: 4px;
padding-bottom: 4px;
position: relative;
line-height: 0;
}
.mx_EntityTile_name {
flex: 1 1 0;
overflow: hidden;
font: var(--cpd-font-body-md-regular);
text-overflow: ellipsis;
white-space: nowrap;
}
.mx_EntityTile_details {
overflow: hidden;
flex: 1;
}
.mx_EntityTile_ellipsis .mx_EntityTile_name {
font-style: italic;
color: $primary-content;
}
.mx_EntityTile_invitePlaceholder .mx_EntityTile_name {
font-style: italic;
color: $primary-content;
}
.mx_EntityTile_unavailable .mx_EntityTile_avatar,
.mx_EntityTile_unavailable .mx_EntityTile_name,
.mx_EntityTile_offline_beenactive .mx_EntityTile_avatar,
.mx_EntityTile_offline_beenactive .mx_EntityTile_name {
opacity: 0.5;
}
.mx_EntityTile_offline_neveractive .mx_EntityTile_avatar,
.mx_EntityTile_offline_neveractive .mx_EntityTile_name {
opacity: 0.25;
}
.mx_EntityTile_unknown .mx_EntityTile_avatar,
.mx_EntityTile_unknown .mx_EntityTile_name,
.mx_EntityTile_unreachable .mx_EntityTile_avatar,
.mx_EntityTile_unreachable .mx_EntityTile_name {
opacity: 0.25;
}
.mx_EntityTile_subtext {
font-size: $font-11px;
opacity: 0.5;
overflow: hidden;
white-space: nowrap;
text-overflow: clip;
}
.mx_EntityTile_power {
padding-inline-start: 6px;
font-size: $font-10px;
color: $secondary-content;
max-width: 6em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mx_EntityTile:hover .mx_EntityTile_power {
display: none;
}

View File

@ -1,69 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_MemberList {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
.mx_Spinner {
flex: 1 0 auto;
}
.mx_SearchBox {
margin-bottom: 5px;
}
h2 {
text-transform: uppercase;
color: $h3-color;
font-weight: var(--cpd-font-weight-semibold);
font-size: $font-13px;
padding-left: 3px;
padding-right: 12px;
margin-top: 8px;
margin-bottom: 4px;
}
.mx_AutoHideScrollbar {
flex: 1 1 0;
margin-top: var(--cpd-space-3x);
}
}
.mx_MemberList_chevron {
position: absolute;
right: 35px;
margin-top: -15px;
}
.mx_MemberList_border {
overflow-y: auto;
order: 1;
flex: 1 1 0px;
}
.mx_MemberList_query {
height: 16px;
/* stricter rule to override the one in _common.pcss */
&[type="text"] {
font-size: $font-12px;
}
}
.mx_MemberList_wrapper {
padding: 10px;
}
.mx_MemberList_invite {
margin: 0 var(--cpd-space-2x);
width: calc(100% - var(--cpd-space-4x));
}

View File

@ -0,0 +1,37 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_MemberListHeaderView {
border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-gray-400);
max-height: 112px;
.mx_MemberListHeaderView_container {
margin-top: var(--cpd-space-6x);
width: 100%;
}
.mx_MemberListHeaderView_invite_small {
margin-left: var(--cpd-space-3x);
}
.mx_MemberListHeaderView_invite_large {
width: 288px;
height: 36px;
}
.mx_MemberListHeaderView_label {
padding: var(--cpd-space-6x) 0 var(--cpd-space-2x) var(--cpd-space-4x);
box-sizing: border-box;
width: 100%;
color: var(--cpd-color-text-secondary);
font: var(--cpd-font-body-sm-semibold);
}
.mx_MemberListHeaderView_search {
width: 240px;
}
}

View File

@ -0,0 +1,17 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_MemberListView {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
.mx_MemberListView_container {
height: 100%;
}
}

View File

@ -0,0 +1,58 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_MemberTileView {
display: flex;
padding: var(--cpd-space-3x) var(--cpd-space-3x) var(--cpd-space-3x) var(--cpd-space-4x);
box-sizing: border-box;
height: 56px;
border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-gray-300);
.mx_MemberTileView_left,
.mx_MemberTileView_right {
display: flex;
align-items: center;
gap: var(--cpd-space-2x);
}
.mx_MemberTileView_left {
flex-basis: 209px;
flex-grow: 1;
min-width: 0;
}
.mx_MemberTileView_name {
font: var(--cpd-font-body-md-medium);
font-size: 15px;
min-width: 0;
}
.mx_MemberTileView_user_label {
font: var(--cpd-font-body-sm-regular);
font-size: 13px;
}
.mx_MemberTileView_avatar {
position: relative;
height: 32px;
width: 32px;
}
.mx_E2EIconView {
display: flex;
justify-content: center;
align-items: center;
}
.mx_E2EIconView_warning {
color: var(--cpd-color-icon-critical-primary);
}
.mx_E2EIconView_verified {
color: var(--cpd-color-icon-success-primary);
}
}

View File

@ -0,0 +1,51 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_OverflowTileView {
display: flex;
align-items: center;
color: $primary-content;
cursor: pointer;
}
.mx_OverflowTileView_text {
flex: 1 1 0;
overflow: hidden;
font: var(--cpd-font-body-md-regular);
text-overflow: ellipsis;
white-space: nowrap;
font-style: italic;
}
.mx_OverflowTileView:hover {
padding-right: 30px;
position: relative; /* to keep the chevron aligned */
}
.mx_OverflowTileView:hover::before {
content: "";
position: absolute;
top: calc(50% - 8px); /* center */
right: -8px;
mask: url("@vector-im/compound-design-tokens/icons/chevron-right.svg");
mask-repeat: no-repeat;
mask-position: center;
width: 16px;
height: 16px;
background-color: $header-panel-text-primary-color;
}
.mx_OverflowTileView_icon {
padding-left: 3px;
padding-right: 12px;
padding-top: 4px;
padding-bottom: 4px;
position: relative;
line-height: 0;
}

View File

@ -0,0 +1,32 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_PresenceIconView {
position: absolute;
top: 24px;
left: 24px;
width: 12px;
height: 12px;
display: flex;
justify-content: center;
align-items: center;
background: var(--cpd-color-bg-canvas-default);
border-radius: 100%;
.mx_PresenceIconView_online {
color: var(--cpd-color-icon-accent-primary);
}
.mx_PresenceIconView_offline,
.mx_PresenceIconView_dnd {
color: var(--cpd-color-icon-tertiary);
}
.mx_PresenceIconView_unavailable {
color: var(--cpd-color-icon-quaternary);
}
}

View File

@ -99,7 +99,7 @@ export default class MainSplit extends React.Component<IProps> {
<Resizable
key={this.props.sizeKey}
defaultSize={this.loadSidePanelSize()}
minWidth={264}
minWidth={320}
maxWidth="50%"
enable={{
top: false,

View File

@ -17,7 +17,6 @@ import RightPanelStore from "../../stores/right-panel/RightPanelStore";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
import WidgetCard from "../views/right_panel/WidgetCard";
import MemberList from "../views/rooms/MemberList";
import UserInfo from "../views/right_panel/UserInfo";
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
import FilePanel from "./FilePanel";
@ -34,6 +33,7 @@ import { IRightPanelCard, IRightPanelCardState } from "../../stores/right-panel/
import { Action } from "../../dispatcher/actions";
import { XOR } from "../../@types/common";
import ExtensionsCard from "../views/right_panel/ExtensionsCard";
import MemberListView from "../views/rooms/MemberList/MemberListView";
interface BaseProps {
overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView)
@ -57,7 +57,6 @@ type Props = XOR<RoomlessProps, RoomProps>;
interface IState {
phase?: RightPanelPhases;
searchQuery: string;
cardState?: IRightPanelCardState;
}
@ -67,10 +66,6 @@ export default class RightPanel extends React.Component<Props, IState> {
public constructor(props: Props, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
this.state = {
searchQuery: "",
};
}
private readonly delayedUpdate = throttle(
@ -147,10 +142,6 @@ export default class RightPanel extends React.Component<Props, IState> {
}
};
private onSearchQueryChanged = (searchQuery: string): void => {
this.setState({ searchQuery });
};
public render(): React.ReactNode {
let card = <div />;
const roomId = this.props.room?.roomId;
@ -159,15 +150,7 @@ export default class RightPanel extends React.Component<Props, IState> {
switch (phase) {
case RightPanelPhases.MemberList:
if (!!roomId) {
card = (
<MemberList
roomId={roomId}
key={roomId}
onClose={this.onClose}
searchQuery={this.state.searchQuery}
onSearchQueryChanged={this.onSearchQueryChanged}
/>
);
card = <MemberListView roomId={roomId} onClose={this.onClose} />;
}
break;

View File

@ -0,0 +1,263 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import {
ClientEvent,
EventType,
MatrixEvent,
Room,
RoomEvent,
RoomMemberEvent,
RoomState,
RoomStateEvent,
RoomMember as SdkRoomMember,
User,
UserEvent,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { throttle } from "lodash";
import { RoomMember } from "../../../models/rooms/RoomMember";
import { mediaFromMxc } from "../../../customisations/Media";
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { PresenceState } from "../../../models/rooms/PresenceState";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { SDKContext } from "../../../contexts/SDKContext";
import PosthogTrackers from "../../../PosthogTrackers";
import { ButtonEvent } from "../../views/elements/AccessibleButton";
import { inviteToRoom } from "../../../utils/room/inviteToRoom";
import { canInviteTo } from "../../../utils/room/canInviteTo";
import { isValid3pidInvite } from "../../../RoomInvite";
import { ThreePIDInvite } from "../../../models/rooms/ThreePIDInvite";
import { XOR } from "../../../@types/common";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
type Member = XOR<{ member: RoomMember }, { threePidInvite: ThreePIDInvite }>;
export function getPending3PidInvites(room: Room, searchQuery?: string): Member[] {
// include 3pid invites (m.room.third_party_invite) state events.
// The HS may have already converted these into m.room.member invites so
// we shouldn't add them if the 3pid invite state key (token) is in the
// member invite (content.third_party_invite.signed.token)
const inviteEvents = room.currentState.getStateEvents("m.room.third_party_invite").filter(function (e) {
if (!isValid3pidInvite(e)) return false;
if (searchQuery && !(e.getContent().display_name as string)?.includes(searchQuery)) return false;
// discard all invites which have a m.room.member event since we've
// already added them.
const memberEvent = room.currentState.getInviteForThreePidToken(e.getStateKey()!);
if (memberEvent) return false;
return true;
});
const invites: Member[] = inviteEvents.map((e) => {
return {
threePidInvite: {
event: e,
},
};
});
return invites;
}
export function sdkRoomMemberToRoomMember(member: SdkRoomMember): Member {
const displayUserId =
UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, {
roomId: member.roomId,
}) ?? member.userId;
const mxcAvatarURL = member.getMxcAvatarUrl();
const avatarThumbnailUrl =
(mxcAvatarURL && mediaFromMxc(mxcAvatarURL).getThumbnailOfSourceHttp(96, 96, "crop")) ?? undefined;
const user = member.user;
let presenceState: PresenceState | undefined;
if (user) {
presenceState = (user.presence as PresenceState) || undefined;
}
return {
member: {
roomId: member.roomId,
userId: member.userId,
displayUserId: displayUserId,
name: member.name,
rawDisplayName: member.rawDisplayName,
disambiguate: member.disambiguate,
avatarThumbnailUrl: avatarThumbnailUrl,
powerLevel: member.powerLevel,
lastModifiedTime: member.getLastModifiedTime(),
presenceState,
isInvite: member.membership === KnownMembership.Invite,
},
};
}
export interface MemberListViewState {
members: Member[];
search: (searchQuery: string) => void;
isPresenceEnabled: boolean;
shouldShowInvite: boolean;
shouldShowSearch: boolean;
isLoading: boolean;
canInvite: boolean;
onInviteButtonClick: (ev: ButtonEvent) => void;
}
export function useMemberListViewModel(roomId: string): MemberListViewState {
const cli = useMatrixClientContext();
const room = useMemo(() => cli.getRoom(roomId), [roomId, cli]);
if (!room) {
throw new Error(`Room with id ${roomId} does not exist!`);
}
const sdkContext = useContext(SDKContext);
const [memberMap, setMemberMap] = useState<Map<string, Member>>(new Map());
const [isLoading, setIsLoading] = useState<boolean>(true);
// This is the last known total number of members in this room.
const totalMemberCount = useRef<number>(0);
const searchQuery = useRef("");
const loadMembers = useMemo(
() =>
throttle(
async (): Promise<void> => {
const { joined: joinedSdk, invited: invitedSdk } = await sdkContext.memberListStore.loadMemberList(
roomId,
searchQuery.current,
);
const newMemberMap = new Map<string, Member>();
// First add the invited room members
for (const member of invitedSdk) {
const roomMember = sdkRoomMemberToRoomMember(member);
newMemberMap.set(member.userId, roomMember);
}
// Then add the third party invites
const threePidInvited = getPending3PidInvites(room, searchQuery.current);
for (const invited of threePidInvited) {
const key = invited.threePidInvite!.event.getContent().display_name;
newMemberMap.set(key, invited);
}
// Finally add the joined room members
for (const member of joinedSdk) {
const roomMember = sdkRoomMemberToRoomMember(member);
newMemberMap.set(member.userId, roomMember);
}
setMemberMap(newMemberMap);
if (!searchQuery.current) {
/**
* Since searching for members only gives you the relevant
* members matching the query, do not update the totalMemberCount!
**/
totalMemberCount.current = newMemberMap.size;
}
},
500,
{ leading: true, trailing: true },
),
[roomId, sdkContext.memberListStore, room],
);
const search = useCallback(
(query: string) => {
searchQuery.current = query;
loadMembers();
},
[loadMembers],
);
const isPresenceEnabled = useMemo(
() => sdkContext.memberListStore.isPresenceEnabled(),
[sdkContext.memberListStore],
);
// Determines whether the rendered invite button is enabled or disabled
const getCanUserInviteToThisRoom = useCallback((): boolean => !!room && canInviteTo(room), [room]);
const [canInvite, setCanInvite] = useState<boolean>(getCanUserInviteToThisRoom());
// Determines whether the invite button should be shown or not.
const getShouldShowInvite = useCallback(
(): boolean => room?.getMyMembership() === KnownMembership.Join && shouldShowComponent(UIComponent.InviteUsers),
[room],
);
const [shouldShowInvite, setShouldShowInvite] = useState<boolean>(getShouldShowInvite());
const onInviteButtonClick = (ev: ButtonEvent): void => {
PosthogTrackers.trackInteraction("WebRightPanelMemberListInviteButton", ev);
ev.preventDefault();
inviteToRoom(room);
};
useTypedEventEmitter(cli, RoomStateEvent.Events, (event: MatrixEvent) => {
if (event.getRoomId() === roomId && event.getType() === EventType.RoomThirdPartyInvite) {
loadMembers();
const newCanInvite = getCanUserInviteToThisRoom();
setCanInvite(newCanInvite);
}
});
useTypedEventEmitter(cli, RoomStateEvent.Update, (state: RoomState) => {
if (state.roomId === roomId) loadMembers();
});
useTypedEventEmitter(cli, RoomMemberEvent.Name, (_: MatrixEvent, member: SdkRoomMember) => {
if (member.roomId === roomId) loadMembers();
});
useTypedEventEmitter(cli, ClientEvent.Room, (room: Room) => {
// We listen for room events because when we accept an invite
// we need to wait till the room is fully populated with state
// before refreshing the member list else we get a stale list.
if (room.roomId === roomId) loadMembers();
});
useTypedEventEmitter(cli, RoomEvent.MyMembership, (room: Room, membership: string, oldMembership?: string) => {
if (room.roomId !== roomId) return;
if (membership === KnownMembership.Join && oldMembership !== KnownMembership.Join) {
// we just joined the room, load the member list
loadMembers();
const newShouldShowInvite = getShouldShowInvite();
setShouldShowInvite(newShouldShowInvite);
}
});
useTypedEventEmitter(cli, UserEvent.Presence, (_: MatrixEvent | undefined, user: User) => {
if (memberMap.has(user.userId)) loadMembers();
});
useTypedEventEmitter(cli, UserEvent.CurrentlyActive, (_: MatrixEvent | undefined, user: User) => {
if (memberMap.has(user.userId)) loadMembers();
});
// Initial load of the memberlist
useEffect(() => {
(async () => {
await loadMembers();
/**
* isLoading is used to render a spinner on initial call.
* Further calls need not mutate this state since it's perfectly fine to
* show the existing memberlist until the new one loads.
*/
setIsLoading(false);
})();
}, [loadMembers]);
return {
members: Array.from(memberMap.values()),
search,
shouldShowInvite,
isPresenceEnabled,
isLoading,
onInviteButtonClick,
shouldShowSearch: totalMemberCount.current >= 20,
canInvite,
};
}

View File

@ -0,0 +1,160 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { useEffect, useMemo, useState } from "react";
import { RoomStateEvent, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import { UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import dis from "../../../../dispatcher/dispatcher";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import { Action } from "../../../../dispatcher/actions";
import { asyncSome } from "../../../../utils/arrays";
import { getUserDeviceIds } from "../../../../utils/crypto/deviceInfo";
import { RoomMember } from "../../../../models/rooms/RoomMember";
import { E2EState } from "../../../views/rooms/E2EIcon";
import { _t, _td, TranslationKey } from "../../../../languageHandler";
import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier";
interface MemberTileViewModelProps {
member: RoomMember;
showPresence?: boolean;
}
export interface MemberTileViewState extends MemberTileViewModelProps {
e2eStatus?: E2EState;
name: string;
onClick: () => void;
title?: string;
userLabel?: string;
}
export enum PowerStatus {
Admin = "admin",
Moderator = "moderator",
}
const PowerLabel: Record<PowerStatus, TranslationKey> = {
[PowerStatus.Admin]: _td("power_level|admin"),
[PowerStatus.Moderator]: _td("power_level|moderator"),
};
export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberTileViewState {
const [e2eStatus, setE2eStatus] = useState<E2EState | undefined>();
useEffect(() => {
const cli = MatrixClientPeg.safeGet();
const updateE2EStatus = async (): Promise<void> => {
const { userId } = props.member;
const isMe = userId === cli.getUserId();
const userTrust = await cli.getCrypto()?.getUserVerificationStatus(userId);
if (!userTrust?.isCrossSigningVerified()) {
setE2eStatus(userTrust?.wasCrossSigningVerified() ? E2EState.Warning : E2EState.Normal);
return;
}
const deviceIDs = await getUserDeviceIds(cli, userId);
const anyDeviceUnverified = await asyncSome(deviceIDs, async (deviceId) => {
// For your own devices, we use the stricter check of cross-signing
// verification to encourage everyone to trust their own devices via
// cross-signing so that other users can then safely trust you.
// For other people's devices, the more general verified check that
// includes locally verified devices can be used.
const deviceTrust = await cli.getCrypto()?.getDeviceVerificationStatus(userId, deviceId);
return !deviceTrust || (isMe ? !deviceTrust.crossSigningVerified : !deviceTrust.isVerified());
});
setE2eStatus(anyDeviceUnverified ? E2EState.Warning : E2EState.Verified);
};
const onRoomStateEvents = (ev: MatrixEvent): void => {
if (ev.getType() !== EventType.RoomEncryption) return;
const { roomId } = props.member;
if (ev.getRoomId() !== roomId) return;
// The room is encrypted now.
cli.removeListener(RoomStateEvent.Events, onRoomStateEvents);
updateE2EStatus();
};
const onUserTrustStatusChanged = (userId: string, trustStatus: UserVerificationStatus): void => {
if (userId !== props.member.userId) return;
updateE2EStatus();
};
const { roomId } = props.member;
if (roomId) {
const isRoomEncrypted = cli.isRoomEncrypted(roomId);
if (isRoomEncrypted) {
cli.on(CryptoEvent.UserTrustStatusChanged, onUserTrustStatusChanged);
updateE2EStatus();
} else {
// Listen for room to become encrypted
cli.on(RoomStateEvent.Events, onRoomStateEvents);
}
}
return () => {
if (cli) {
cli.removeListener(RoomStateEvent.Events, onRoomStateEvents);
cli.removeListener(CryptoEvent.UserTrustStatusChanged, onUserTrustStatusChanged);
}
};
}, [props.member]);
const onClick = (): void => {
dis.dispatch({
action: Action.ViewUser,
member: props.member,
push: true,
});
};
const member = props.member;
const name = props.member.name;
const powerStatusMap = new Map([
[100, PowerStatus.Admin],
[50, PowerStatus.Moderator],
]);
// Find the nearest power level with a badge
let powerLevel = props.member.powerLevel;
for (const [pl] of powerStatusMap) {
if (props.member.powerLevel >= pl) {
powerLevel = pl;
break;
}
}
const title = useMemo(() => {
return _t("member_list|power_label", {
userName: UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, {
roomId: member.roomId,
}),
powerLevelNumber: member.powerLevel,
}).trim();
}, [member.powerLevel, member.roomId, member.userId]);
let userLabel;
const powerStatus = powerStatusMap.get(powerLevel);
if (powerStatus) {
userLabel = _t(PowerLabel[powerStatus]);
}
if (props.member.isInvite) {
userLabel = `(${_t("member_list|invited_label")})`;
}
return {
title,
member,
name,
onClick,
e2eStatus,
showPresence: props.showPresence,
userLabel,
};
}

View File

@ -0,0 +1,35 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import dis from "../../../../dispatcher/dispatcher";
import { Action } from "../../../../dispatcher/actions";
import { ThreePIDInvite } from "../../../../models/rooms/ThreePIDInvite";
interface ThreePidTileViewModelProps {
threePidInvite: ThreePIDInvite;
}
export interface ThreePidTileViewState {
name: string;
onClick: () => void;
}
export function useThreePidTileViewModel(props: ThreePidTileViewModelProps): ThreePidTileViewState {
const invite = props.threePidInvite;
const name = invite.event.getContent().display_name;
const onClick = (): void => {
dis.dispatch({
action: Action.View3pidInvite,
event: invite.event,
});
};
return {
name,
onClick,
};
}

View File

@ -23,8 +23,6 @@ import {
TimelineEvents,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
// eslint-disable-next-line no-restricted-imports
import OverflowHorizontalSvg from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
@ -42,8 +40,6 @@ import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
import { Action } from "../../../dispatcher/actions";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
@ -60,6 +56,7 @@ import {
} from "../../../accessibility/RovingTabIndex";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { OverflowTileView } from "../rooms/OverflowTileView";
const AVATAR_SIZE = 30;
@ -275,17 +272,9 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
}
const [truncateAt, setTruncateAt] = useState(20);
function overflowTile(overflowCount: number, totalCount: number): JSX.Element {
const text = _t("common|and_n_others", { count: overflowCount });
return (
<EntityTile
className="mx_EntityTile_ellipsis"
avatarJsx={<BaseAvatar url={OverflowHorizontalSvg} name="..." size="36px" />}
name={text}
showPresence={false}
onClick={() => setTruncateAt(totalCount)}
/>
);
return <OverflowTileView remaining={overflowCount} onClick={() => setTruncateAt(totalCount)} />;
}
const onKeyDown = (ev: React.KeyboardEvent, state: IState): void => {

View File

@ -8,15 +8,21 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { RoomMember } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import UserIdentifier from "../../../customisations/UserIdentifier";
interface MemberInfo {
userId: string;
roomId: string;
rawDisplayName?: string;
disambiguate: boolean;
}
interface IProps {
member?: RoomMember | null;
member?: MemberInfo | null;
fallbackName: string;
onClick?(): void;
colored?: boolean;

View File

@ -22,7 +22,7 @@ export enum E2EState {
Normal = "normal",
}
const crossSigningUserTitles: { [key in E2EState]?: TranslationKey } = {
export const crossSigningUserTitles: { [key in E2EState]?: TranslationKey } = {
[E2EState.Warning]: _td("encryption|cross_signing_user_warning"),
[E2EState.Normal]: _td("encryption|cross_signing_user_normal"),
[E2EState.Verified]: _td("encryption|cross_signing_user_verified"),

View File

@ -1,170 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import classNames from "classnames";
import AccessibleButton from "../elements/AccessibleButton";
import { _t, _td, TranslationKey } from "../../../languageHandler";
import E2EIcon, { E2EState } from "./E2EIcon";
import BaseAvatar from "../avatars/BaseAvatar";
import PresenceLabel from "./PresenceLabel";
export enum PowerStatus {
Admin = "admin",
Moderator = "moderator",
}
const PowerLabel: Record<PowerStatus, TranslationKey> = {
[PowerStatus.Admin]: _td("power_level|admin"),
[PowerStatus.Moderator]: _td("power_level|mod"),
};
export type PresenceState = "offline" | "online" | "unavailable" | "io.element.unreachable";
const PRESENCE_CLASS: Record<PresenceState, string> = {
"offline": "mx_EntityTile_offline",
"online": "mx_EntityTile_online",
"unavailable": "mx_EntityTile_unavailable",
"io.element.unreachable": "mx_EntityTile_unreachable",
};
function presenceClassForMember(presenceState?: PresenceState, lastActiveAgo?: number, showPresence?: boolean): string {
if (showPresence === false) {
return "mx_EntityTile_online_beenactive";
}
// offline is split into two categories depending on whether we have
// a last_active_ago for them.
if (presenceState === "offline") {
if (lastActiveAgo) {
return PRESENCE_CLASS["offline"] + "_beenactive";
} else {
return PRESENCE_CLASS["offline"] + "_neveractive";
}
} else if (presenceState) {
return PRESENCE_CLASS[presenceState];
} else {
return PRESENCE_CLASS["offline"] + "_neveractive";
}
}
interface IProps {
name?: string;
nameJSX?: JSX.Element;
title?: string;
avatarJsx?: JSX.Element; // <BaseAvatar />
className?: string;
presenceState: PresenceState;
presenceLastActiveAgo: number;
presenceLastTs: number;
presenceCurrentlyActive?: boolean;
onClick(): void;
showPresence: boolean;
subtextLabel?: string;
e2eStatus?: E2EState;
powerStatus?: PowerStatus;
}
interface IState {
hover: boolean;
}
export default class EntityTile extends React.PureComponent<IProps, IState> {
public static defaultProps = {
onClick: () => {},
presenceState: "offline",
presenceLastActiveAgo: 0,
presenceLastTs: 0,
showInviteButton: false,
showPresence: true,
};
public constructor(props: IProps) {
super(props);
this.state = {
hover: false,
};
}
/**
* Creates the PresenceLabel component if needed
* @returns The PresenceLabel component if we need to render it, undefined otherwise
*/
private getPresenceLabel(): JSX.Element | undefined {
if (!this.props.showPresence) return;
const activeAgo = this.props.presenceLastActiveAgo
? Date.now() - (this.props.presenceLastTs - this.props.presenceLastActiveAgo)
: -1;
return (
<PresenceLabel
activeAgo={activeAgo}
currentlyActive={this.props.presenceCurrentlyActive}
presenceState={this.props.presenceState}
/>
);
}
public render(): React.ReactNode {
const mainClassNames: Record<string, boolean> = {
mx_EntityTile: true,
};
if (this.props.className) mainClassNames[this.props.className] = true;
const presenceClass = presenceClassForMember(
this.props.presenceState,
this.props.presenceLastActiveAgo,
this.props.showPresence,
);
mainClassNames[presenceClass] = true;
const name = this.props.nameJSX || this.props.name;
const nameAndPresence = (
<div className="mx_EntityTile_details">
<div className="mx_EntityTile_name">{name}</div>
{this.getPresenceLabel()}
</div>
);
let powerLabel;
const powerStatus = this.props.powerStatus;
if (powerStatus) {
const powerText = _t(PowerLabel[powerStatus]);
powerLabel = <div className="mx_EntityTile_power">{powerText}</div>;
}
let e2eIcon;
const { e2eStatus } = this.props;
if (e2eStatus) {
e2eIcon = <E2EIcon status={e2eStatus} isUser={true} bordered={true} />;
}
const av = this.props.avatarJsx || <BaseAvatar name={this.props.name} size="36px" aria-hidden="true" />;
// The wrapping div is required to make the magic mouse listener work, for some reason.
return (
<div>
<AccessibleButton
className={classNames(mainClassNames)}
title={this.props.title}
onClick={this.props.onClick}
>
<div className="mx_EntityTile_avatar">
{av}
{e2eIcon}
</div>
{nameAndPresence}
{powerLabel}
</AccessibleButton>
</div>
);
}
}

View File

@ -1,450 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2017, 2018 New Vector Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import {
MatrixEvent,
Room,
RoomEvent,
RoomMember,
RoomMemberEvent,
RoomState,
RoomStateEvent,
User,
UserEvent,
EventType,
ClientEvent,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { throttle } from "lodash";
import { Button, Tooltip } from "@vector-im/compound-web";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add-solid";
// eslint-disable-next-line no-restricted-imports
import OverflowHorizontalSvg from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import { isValid3pidInvite } from "../../../RoomInvite";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import BaseCard from "../right_panel/BaseCard";
import TruncatedList from "../elements/TruncatedList";
import Spinner from "../elements/Spinner";
import SearchBox from "../../structures/SearchBox";
import { ButtonEvent } from "../elements/AccessibleButton";
import EntityTile from "./EntityTile";
import MemberTile from "./MemberTile";
import BaseAvatar from "../avatars/BaseAvatar";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import PosthogTrackers from "../../../PosthogTrackers";
import { SDKContext } from "../../../contexts/SDKContext";
import { canInviteTo } from "../../../utils/room/canInviteTo";
import { inviteToRoom } from "../../../utils/room/inviteToRoom";
import { Action } from "../../../dispatcher/actions";
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
const SHOW_MORE_INCREMENT = 100;
interface IProps {
roomId: string;
searchQuery: string;
onClose(): void;
onSearchQueryChanged: (query: string) => void;
}
interface IState {
loading: boolean;
filteredJoinedMembers: Array<RoomMember>;
filteredInvitedMembers: Array<RoomMember | MatrixEvent>;
canInvite: boolean;
truncateAtJoined: number;
truncateAtInvited: number;
}
export default class MemberList extends React.Component<IProps, IState> {
private readonly showPresence: boolean;
private unmounted = false;
public static contextType = SDKContext;
declare public context: React.ContextType<typeof SDKContext>;
private tiles: Map<string, MemberTile> = new Map();
public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
super(props, context);
this.state = this.getMembersState([], []);
this.showPresence = context?.memberListStore.isPresenceEnabled() ?? true;
}
private listenForMembersChanges(): void {
const cli = MatrixClientPeg.safeGet();
cli.on(RoomStateEvent.Update, this.onRoomStateUpdate);
cli.on(RoomMemberEvent.Name, this.onRoomMemberName);
cli.on(RoomStateEvent.Events, this.onRoomStateEvent);
// We listen for changes to the lastPresenceTs which is essentially
// listening for all presence events (we display most of not all of
// the information contained in presence events).
cli.on(UserEvent.LastPresenceTs, this.onUserPresenceChange);
cli.on(UserEvent.Presence, this.onUserPresenceChange);
cli.on(UserEvent.CurrentlyActive, this.onUserPresenceChange);
cli.on(ClientEvent.Room, this.onRoom); // invites & joining after peek
cli.on(RoomEvent.MyMembership, this.onMyMembership);
}
public componentDidMount(): void {
this.unmounted = false;
this.listenForMembersChanges();
this.updateListNow(true);
}
public componentWillUnmount(): void {
this.unmounted = true;
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate);
cli.removeListener(RoomMemberEvent.Name, this.onRoomMemberName);
cli.removeListener(RoomEvent.MyMembership, this.onMyMembership);
cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvent);
cli.removeListener(ClientEvent.Room, this.onRoom);
cli.removeListener(UserEvent.LastPresenceTs, this.onUserPresenceChange);
cli.removeListener(UserEvent.Presence, this.onUserPresenceChange);
cli.removeListener(UserEvent.CurrentlyActive, this.onUserPresenceChange);
}
// cancel any pending calls to the rate_limited_funcs
this.updateList.cancel();
}
private get canInvite(): boolean {
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(this.props.roomId);
return !!room && canInviteTo(room);
}
private getMembersState(invitedMembers: Array<RoomMember>, joinedMembers: Array<RoomMember>): IState {
return {
loading: false,
filteredJoinedMembers: joinedMembers,
filteredInvitedMembers: invitedMembers,
canInvite: this.canInvite,
// ideally we'd size this to the page height, but
// in practice I find that a little constraining
truncateAtJoined: INITIAL_LOAD_NUM_MEMBERS,
truncateAtInvited: INITIAL_LOAD_NUM_INVITED,
};
}
private onUserPresenceChange = (event: MatrixEvent | undefined, user: User): void => {
// Attach a SINGLE listener for global presence changes then locate the
// member tile and re-render it. This is more efficient than every tile
// ever attaching their own listener.
const tile = this.tiles.get(user.userId);
if (tile) {
this.updateList(); // reorder the membership list
}
};
private onRoom = (room: Room): void => {
if (room.roomId !== this.props.roomId) {
return;
}
// We listen for room events because when we accept an invite
// we need to wait till the room is fully populated with state
// before refreshing the member list else we get a stale list.
this.updateListNow(true);
};
private onMyMembership = (room: Room, membership: string, oldMembership?: string): void => {
if (
room.roomId === this.props.roomId &&
membership === KnownMembership.Join &&
oldMembership !== KnownMembership.Join
) {
// we just joined the room, load the member list
this.updateListNow(true);
}
};
private onRoomStateUpdate = (state: RoomState): void => {
if (state.roomId !== this.props.roomId) return;
this.updateList();
};
private onRoomMemberName = (ev: MatrixEvent, member: RoomMember): void => {
if (member.roomId !== this.props.roomId) {
return;
}
this.updateList();
};
private onRoomStateEvent = (event: MatrixEvent): void => {
if (event.getRoomId() === this.props.roomId && event.getType() === EventType.RoomThirdPartyInvite) {
this.updateList();
}
if (this.canInvite !== this.state.canInvite) this.setState({ canInvite: this.canInvite });
};
private updateList = throttle(
() => {
this.updateListNow(false);
},
500,
{ leading: true, trailing: true },
);
// XXX: exported for tests
public async updateListNow(showLoadingSpinner?: boolean): Promise<void> {
if (this.unmounted) {
return;
}
if (showLoadingSpinner) {
this.setState({ loading: true });
}
const { joined, invited } = await this.context.memberListStore.loadMemberList(
this.props.roomId,
this.props.searchQuery,
);
if (this.unmounted) {
return;
}
this.setState({
loading: false,
filteredJoinedMembers: joined,
filteredInvitedMembers: invited,
});
}
private createOverflowTileJoined = (overflowCount: number, totalCount: number): JSX.Element => {
return this.createOverflowTile(overflowCount, totalCount, this.showMoreJoinedMemberList);
};
private createOverflowTileInvited = (overflowCount: number, totalCount: number): JSX.Element => {
return this.createOverflowTile(overflowCount, totalCount, this.showMoreInvitedMemberList);
};
private createOverflowTile = (overflowCount: number, totalCount: number, onClick: () => void): JSX.Element => {
// For now we'll pretend this is any entity. It should probably be a separate tile.
const text = _t("common|and_n_others", { count: overflowCount });
return (
<EntityTile
className="mx_EntityTile_ellipsis"
avatarJsx={<BaseAvatar url={OverflowHorizontalSvg} name="..." size="36px" />}
name={text}
showPresence={false}
onClick={onClick}
/>
);
};
private showMoreJoinedMemberList = (): void => {
this.setState({
truncateAtJoined: this.state.truncateAtJoined + SHOW_MORE_INCREMENT,
});
};
private showMoreInvitedMemberList = (): void => {
this.setState({
truncateAtInvited: this.state.truncateAtInvited + SHOW_MORE_INCREMENT,
});
};
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>, snapshot?: any): void {
if (prevProps.searchQuery !== this.props.searchQuery) {
this.updateListNow(false);
}
}
private onSearchQueryChanged = (searchQuery: string): void => {
this.props.onSearchQueryChanged(searchQuery);
};
private onPending3pidInviteClick = (inviteEvent: MatrixEvent): void => {
dis.dispatch({
action: Action.View3pidInvite,
event: inviteEvent,
});
};
private getPending3PidInvites(): MatrixEvent[] {
// include 3pid invites (m.room.third_party_invite) state events.
// The HS may have already converted these into m.room.member invites so
// we shouldn't add them if the 3pid invite state key (token) is in the
// member invite (content.third_party_invite.signed.token)
const room = MatrixClientPeg.safeGet().getRoom(this.props.roomId);
if (room) {
return room.currentState.getStateEvents("m.room.third_party_invite").filter(function (e) {
if (!isValid3pidInvite(e)) return false;
// discard all invites which have a m.room.member event since we've
// already added them.
const memberEvent = room.currentState.getInviteForThreePidToken(e.getStateKey()!);
if (memberEvent) return false;
return true;
});
}
return [];
}
private makeMemberTiles(members: Array<RoomMember | MatrixEvent>): JSX.Element[] {
return members.map((m) => {
if (m instanceof RoomMember) {
// Is a Matrix invite
return (
<MemberTile
key={m.userId}
member={m}
ref={(tile) => {
if (tile) this.tiles.set(m.userId, tile);
else this.tiles.delete(m.userId);
}}
showPresence={this.showPresence}
/>
);
} else {
// Is a 3pid invite
return (
<EntityTile
key={m.getStateKey()}
name={m.getContent().display_name}
showPresence={false}
onClick={() => this.onPending3pidInviteClick(m)}
/>
);
}
});
}
private getChildrenJoined = (start: number, end: number): Array<JSX.Element> => {
return this.makeMemberTiles(this.state.filteredJoinedMembers.slice(start, end));
};
private getChildCountJoined = (): number => this.state.filteredJoinedMembers.length;
private getChildrenInvited = (start: number, end: number): Array<JSX.Element> => {
let targets = this.state.filteredInvitedMembers;
if (end > this.state.filteredInvitedMembers.length) {
targets = targets.concat(this.getPending3PidInvites());
}
return this.makeMemberTiles(targets.slice(start, end));
};
private getChildCountInvited = (): number => {
return this.state.filteredInvitedMembers.length + (this.getPending3PidInvites() || []).length;
};
public render(): React.ReactNode {
if (this.state.loading) {
return (
<BaseCard
id="memberlist-panel"
className="mx_MemberList"
ariaLabelledBy="memberlist-panel-tab"
role="tabpanel"
header={_t("common|people")}
onClose={this.props.onClose}
>
<Spinner />
</BaseCard>
);
}
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(this.props.roomId);
let inviteButton: JSX.Element | undefined;
if (room?.getMyMembership() === KnownMembership.Join && shouldShowComponent(UIComponent.InviteUsers)) {
const inviteButtonText = room.isSpaceRoom() ? _t("space|invite_this_space") : _t("room|invite_this_room");
const button = (
<Button
size="sm"
kind="secondary"
className="mx_MemberList_invite"
onClick={this.onInviteButtonClick}
disabled={!this.state.canInvite}
>
<UserAddIcon width="1em" height="1em" />
{inviteButtonText}
</Button>
);
if (this.state.canInvite) {
inviteButton = button;
} else {
inviteButton = <Tooltip label={_t("member_list|invite_button_no_perms_tooltip")}>{button}</Tooltip>;
}
}
let invitedHeader;
let invitedSection;
if (this.getChildCountInvited() > 0) {
invitedHeader = <h2>{_t("member_list|invited_list_heading")}</h2>;
invitedSection = (
<TruncatedList
className="mx_MemberList_section mx_MemberList_invited"
truncateAt={this.state.truncateAtInvited}
createOverflowElement={this.createOverflowTileInvited}
getChildren={this.getChildrenInvited}
getChildCount={this.getChildCountInvited}
/>
);
}
const footer = (
<SearchBox
className="mx_MemberList_query mx_textinput_icon mx_textinput_search"
placeholder={_t("member_list|filter_placeholder")}
onSearch={this.onSearchQueryChanged}
initialValue={this.props.searchQuery}
/>
);
return (
<BaseCard
id="memberlist-panel"
className="mx_MemberList"
ariaLabelledBy="memberlist-panel-tab"
role="tabpanel"
header={_t("common|people")}
footer={footer}
onClose={this.props.onClose}
>
{inviteButton}
<div className="mx_MemberList_wrapper">
<TruncatedList
className="mx_MemberList_section mx_MemberList_joined"
truncateAt={this.state.truncateAtJoined}
createOverflowElement={this.createOverflowTileJoined}
getChildren={this.getChildrenJoined}
getChildCount={this.getChildCountJoined}
/>
{invitedHeader}
{invitedSection}
</div>
</BaseCard>
);
}
private onInviteButtonClick = (ev: ButtonEvent): void => {
PosthogTrackers.trackInteraction("WebRightPanelMemberListInviteButton", ev);
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(this.props.roomId)!;
inviteToRoom(room);
};
}

View File

@ -0,0 +1,137 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { Search, Text, Button, Tooltip, InlineSpinner } from "@vector-im/compound-web";
import React from "react";
import InviteIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add-solid";
import { UserAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Flex } from "../../../utils/Flex";
import { MemberListViewState } from "../../../viewmodels/memberlist/MemberListViewModel";
import { _t } from "../../../../languageHandler";
interface TooltipProps {
canInvite: boolean;
children: React.ReactNode;
}
const OptionalTooltip: React.FC<TooltipProps> = ({ canInvite, children }) => {
if (canInvite) return children;
// If the user isn't allowed to invite others to this room, wrap with a relevant tooltip.
return <Tooltip description={_t("member_list|invite_button_no_perms_tooltip")}>{children}</Tooltip>;
};
interface Props {
vm: MemberListViewState;
}
const InviteButton: React.FC<Props> = ({ vm }) => {
const shouldShowInvite = vm.shouldShowInvite;
const shouldShowSearch = vm.shouldShowSearch;
const disabled = !vm.canInvite;
if (!shouldShowInvite) {
// In this case, invite button should not be rendered.
return null;
}
if (shouldShowSearch) {
/// When rendered alongside a search box, the invite button is just an icon.
return (
<OptionalTooltip canInvite={vm.canInvite}>
<Button
className="mx_MemberListHeaderView_invite_small"
kind="primary"
onClick={vm.onInviteButtonClick}
size="sm"
iconOnly={true}
Icon={InviteIcon}
disabled={disabled}
aria-label={_t("action|invite")}
/>
</OptionalTooltip>
);
}
// Without a search box, invite button is a full size button.
return (
<OptionalTooltip canInvite={vm.canInvite}>
<Button
kind="secondary"
size="sm"
Icon={UserAddIcon}
className="mx_MemberListHeaderView_invite_large"
disabled={!vm.canInvite}
onClick={vm.onInviteButtonClick}
>
{_t("action|invite")}
</Button>
</OptionalTooltip>
);
};
/**
* This should be:
* A loading text with spinner while the memberlist loads.
* Member count of the room when there's nothing in the search field.
* Number of matching members during search or 'No result' if search found nothing.
*/
function getHeaderLabelJSX(vm: MemberListViewState): React.ReactNode {
if (vm.isLoading) {
return (
<Flex align="center" gap="8px">
<InlineSpinner /> {_t("common|loading")}
</Flex>
);
}
const filteredMemberCount = vm.members.length;
if (filteredMemberCount === 0) {
return _t("member_list|no_matches");
}
return _t("member_list|count", { count: filteredMemberCount });
}
export const MemberListHeaderView: React.FC<Props> = (props: Props) => {
const vm = props.vm;
let contentJSX: React.ReactNode;
if (vm.shouldShowSearch) {
// When we need to show the search box
contentJSX = (
<Flex justify="center" className="mx_MemberListHeaderView_container">
<Search
className="mx_MemberListHeaderView_search mx_no_textinput"
name="searchMembers"
placeholder={_t("member_list|filter_placeholder")}
onChange={(e) => vm.search((e as React.ChangeEvent<HTMLInputElement>).target.value)}
/>
<InviteButton vm={vm} />
</Flex>
);
} else if (!vm.shouldShowSearch && vm.shouldShowInvite) {
// When we don't need to show the search box but still need an invite button
contentJSX = (
<Flex justify="center" className="mx_MemberListHeaderView_container">
<InviteButton vm={vm} />
</Flex>
);
} else {
// No search box and no invite icon, so nothing to render!
contentJSX = null;
}
return (
<Flex className="mx_MemberListHeaderView" as="header" align="center" justify="space-between" direction="column">
{!vm.isLoading && contentJSX}
<Text as="div" size="sm" weight="semibold" className="mx_MemberListHeaderView_label">
{getHeaderLabelJSX(vm)}
</Text>
</Flex>
);
};

View File

@ -0,0 +1,82 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { Form } from "@vector-im/compound-web";
import React from "react";
import { List, ListRowProps } from "react-virtualized/dist/commonjs/List";
import { AutoSizer } from "react-virtualized";
import { Flex } from "../../../utils/Flex";
import { useMemberListViewModel } from "../../../viewmodels/memberlist/MemberListViewModel";
import { RoomMemberTileView } from "./tiles/RoomMemberTileView";
import { ThreePidInviteTileView } from "./tiles/ThreePidInviteTileView";
import { MemberListHeaderView } from "./MemberListHeaderView";
import BaseCard from "../../right_panel/BaseCard";
import { _t } from "../../../../languageHandler";
interface IProps {
roomId: string;
onClose: () => void;
}
const MemberListView: React.FC<IProps> = (props: IProps) => {
const vm = useMemberListViewModel(props.roomId);
const memberCount = vm.members.length;
const rowRenderer = ({ key, index, style }: ListRowProps): React.JSX.Element => {
if (index === memberCount) {
// We've rendered all the members,
// now we render an empty div to add some space to the end of the list.
return <div key={key} style={style} />;
}
const item = vm.members[index];
return (
<div key={key} style={style}>
{item.member ? (
<RoomMemberTileView member={item.member} showPresence={vm.isPresenceEnabled} />
) : (
<ThreePidInviteTileView threePidInvite={item.threePidInvite} />
)}
</div>
);
};
return (
<BaseCard
id="memberlist-panel"
className="mx_MemberListView"
ariaLabelledBy="memberlist-panel-tab"
role="tabpanel"
header={_t("common|people")}
onClose={props.onClose}
>
<Flex align="stretch" direction="column" className="mx_MemberListView_container">
<Form.Root>
<MemberListHeaderView vm={vm} />
</Form.Root>
<AutoSizer>
{({ height, width }) => (
<List
rowRenderer={rowRenderer}
// All the member tiles will have a height of 56px.
// The additional empty div at the end of the list should have a height of 32px.
rowHeight={({ index }) => (index === memberCount ? 32 : 56)}
// The +1 refers to the additional empty div that we render at the end of the list.
rowCount={memberCount + 1}
// Subtract the height of MemberlistHeaderView so that the parent div does not overflow.
height={height - 113}
width={width}
/>
)}
</AutoSizer>
</Flex>
</BaseCard>
);
};
export default MemberListView;

View File

@ -0,0 +1,67 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import DisambiguatedProfile from "../../../messages/DisambiguatedProfile";
import { RoomMember } from "../../../../../models/rooms/RoomMember";
import { useMemberTileViewModel } from "../../../../viewmodels/memberlist/tiles/MemberTileViewModel";
import { E2EIconView } from "./common/E2EIconView";
import AvatarPresenceIconView from "./common/PresenceIconView";
import BaseAvatar from "../../../avatars/BaseAvatar";
import { _t } from "../../../../../languageHandler";
import { MemberTileLayout } from "./common/MemberTileLayout";
interface IProps {
member: RoomMember;
showPresence?: boolean;
}
export function RoomMemberTileView(props: IProps): JSX.Element {
const vm = useMemberTileViewModel(props);
const member = vm.member;
const av = (
<BaseAvatar
size="32px"
name={member.name}
idName={member.userId}
title={member.displayUserId}
url={member.avatarThumbnailUrl}
altText={_t("common|user_avatar")}
/>
);
const name = vm.name;
const nameJSX = <DisambiguatedProfile member={member} fallbackName={name || ""} />;
const presenceState = member.presenceState;
let presenceJSX: JSX.Element | undefined;
if (vm.showPresence && presenceState) {
presenceJSX = <AvatarPresenceIconView presenceState={presenceState} />;
}
let userLabelJSX;
if (vm.userLabel) {
userLabelJSX = <div className="mx_MemberTileView_user_label">{vm.userLabel}</div>;
}
let e2eIcon;
if (vm.e2eStatus) {
e2eIcon = <E2EIconView status={vm.e2eStatus} />;
}
return (
<MemberTileLayout
title={vm.title}
onClick={vm.onClick}
avatarJsx={av}
presenceJsx={presenceJSX}
nameJsx={nameJSX}
userLabelJsx={userLabelJSX}
e2eIconJsx={e2eIcon}
/>
);
}

View File

@ -0,0 +1,23 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { useThreePidTileViewModel } from "../../../../viewmodels/memberlist/tiles/ThreePidTileViewModel";
import { ThreePIDInvite } from "../../../../../models/rooms/ThreePIDInvite";
import BaseAvatar from "../../../avatars/BaseAvatar";
import { MemberTileLayout } from "./common/MemberTileLayout";
interface Props {
threePidInvite: ThreePIDInvite;
}
export function ThreePidInviteTileView(props: Props): JSX.Element {
const vm = useThreePidTileViewModel(props);
const av = <BaseAvatar name={vm.name} size="32px" aria-hidden="true" />;
return <MemberTileLayout nameJsx={vm.name} avatarJsx={av} onClick={vm.onClick} />;
}

View File

@ -0,0 +1,47 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { Tooltip } from "@vector-im/compound-web";
import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
import { _t } from "../../../../../../languageHandler";
import { E2EStatus } from "../../../../../../utils/ShieldUtils";
import { E2EState, crossSigningUserTitles } from "../../../E2EIcon";
function getIconFromStatus(status: E2EState | E2EStatus): React.JSX.Element | undefined {
switch (status) {
case E2EState.Normal:
case E2EStatus.Normal:
return undefined;
case E2EState.Verified:
case E2EStatus.Verified:
return <VerifiedIcon height="16px" width="16px" className="mx_E2EIconView_verified" />;
case E2EState.Warning:
case E2EStatus.Warning:
return <ErrorIcon height="16px" width="16px" className="mx_E2EIconView_warning" />;
}
}
interface Props {
status: E2EState | E2EStatus;
}
export const E2EIconView: React.FC<Props> = ({ status }) => {
const e2eTitle = crossSigningUserTitles[status];
const label = e2eTitle ? _t(e2eTitle) : "";
const icon = getIconFromStatus(status);
if (!icon) return null;
return (
<Tooltip label={label}>
<div className="mx_E2EIconView">{icon}</div>
</Tooltip>
);
};

View File

@ -0,0 +1,40 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import AccessibleButton from "../../../../elements/AccessibleButton";
interface Props {
avatarJsx: JSX.Element;
nameJsx: JSX.Element | string;
onClick: () => void;
title?: string;
presenceJsx?: JSX.Element;
userLabelJsx?: JSX.Element;
e2eIconJsx?: JSX.Element;
}
export function MemberTileLayout(props: Props): JSX.Element {
return (
// The wrapping div is required to make the magic mouse listener work, for some reason.
<div>
<AccessibleButton className="mx_MemberTileView" title={props.title} onClick={props.onClick}>
<div className="mx_MemberTileView_left">
<div className="mx_MemberTileView_avatar">
{props.avatarJsx} {props.presenceJsx}
</div>
<div className="mx_MemberTileView_name">{props.nameJsx}</div>
</div>
<div className="mx_MemberTileView_right">
{props.userLabelJsx}
{props.e2eIconJsx}
</div>
</AccessibleButton>
</div>
);
}

View File

@ -0,0 +1,44 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import OnlineOrUnavailableIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-solid-8x8";
import OfflineIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-outline-8x8";
import DNDIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-strikethrough-8x8";
import classNames from "classnames";
import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue";
interface Props {
className?: string;
presenceState: string;
}
export const BUSY_PRESENCE_NAME = new UnstableValue("busy", "org.matrix.msc3026.busy");
function getIconForPresenceState(state: string): React.JSX.Element {
switch (state) {
case "online":
return <OnlineOrUnavailableIcon height="8px" width="8px" className="mx_PresenceIconView_online" />;
case "offline":
return <OfflineIcon height="8px" width="8px" className="mx_PresenceIconView_offline" />;
case "unavailable":
case "io.element.unreachable":
return <OnlineOrUnavailableIcon height="8px" width="8px" className="mx_PresenceIconView_unavailable" />;
case BUSY_PRESENCE_NAME.name:
case BUSY_PRESENCE_NAME.altName:
return <DNDIcon height="8px" width="8px" className="mx_PresenceIconView_dnd" />;
default:
throw new Error(`Presence state "${state}" is unknown.`);
}
}
const AvatarPresenceIconView: React.FC<Props> = ({ className, presenceState }) => {
const names = classNames("mx_PresenceIconView", className);
return <div className={names}>{getIconForPresenceState(presenceState)}</div>;
};
export default AvatarPresenceIconView;

View File

@ -1,220 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { RoomMember, RoomStateEvent, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import { CryptoEvent, UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import dis from "../../../dispatcher/dispatcher";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Action } from "../../../dispatcher/actions";
import EntityTile, { PowerStatus, PresenceState } from "./EntityTile";
import MemberAvatar from "./../avatars/MemberAvatar";
import DisambiguatedProfile from "../messages/DisambiguatedProfile";
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
import { E2EState } from "./E2EIcon";
import { asyncSome } from "../../../utils/arrays";
import { getUserDeviceIds } from "../../../utils/crypto/deviceInfo";
interface IProps {
member: RoomMember;
showPresence?: boolean;
}
interface IState {
isRoomEncrypted: boolean;
e2eStatus?: E2EState;
}
export default class MemberTile extends React.Component<IProps, IState> {
private userLastModifiedTime?: number;
private memberLastModifiedTime?: number;
public static defaultProps = {
showPresence: true,
};
public constructor(props: IProps) {
super(props);
this.state = {
isRoomEncrypted: false,
};
}
public componentDidMount(): void {
const cli = MatrixClientPeg.safeGet();
const { roomId } = this.props.member;
if (roomId) {
const isRoomEncrypted = cli.isRoomEncrypted(roomId);
this.setState({
isRoomEncrypted,
});
if (isRoomEncrypted) {
cli.on(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
this.updateE2EStatus();
} else {
// Listen for room to become encrypted
cli.on(RoomStateEvent.Events, this.onRoomStateEvents);
}
}
}
public componentWillUnmount(): void {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
cli.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserTrustStatusChanged);
}
}
private onRoomStateEvents = (ev: MatrixEvent): void => {
if (ev.getType() !== EventType.RoomEncryption) return;
const { roomId } = this.props.member;
if (ev.getRoomId() !== roomId) return;
// The room is encrypted now.
const cli = MatrixClientPeg.safeGet();
cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
this.setState({
isRoomEncrypted: true,
});
this.updateE2EStatus();
};
private onUserTrustStatusChanged = (userId: string, trustStatus: UserVerificationStatus): void => {
if (userId !== this.props.member.userId) return;
this.updateE2EStatus();
};
private async updateE2EStatus(): Promise<void> {
const cli = MatrixClientPeg.safeGet();
const { userId } = this.props.member;
const isMe = userId === cli.getUserId();
const userTrust = await cli.getCrypto()?.getUserVerificationStatus(userId);
if (!userTrust?.isCrossSigningVerified()) {
this.setState({
e2eStatus: userTrust?.wasCrossSigningVerified() ? E2EState.Warning : E2EState.Normal,
});
return;
}
const deviceIDs = await getUserDeviceIds(cli, userId);
const anyDeviceUnverified = await asyncSome(deviceIDs, async (deviceId) => {
// For your own devices, we use the stricter check of cross-signing
// verification to encourage everyone to trust their own devices via
// cross-signing so that other users can then safely trust you.
// For other people's devices, the more general verified check that
// includes locally verified devices can be used.
const deviceTrust = await cli.getCrypto()?.getDeviceVerificationStatus(userId, deviceId);
return !deviceTrust || (isMe ? !deviceTrust.crossSigningVerified : !deviceTrust.isVerified());
});
this.setState({
e2eStatus: anyDeviceUnverified ? E2EState.Warning : E2EState.Verified,
});
}
public shouldComponentUpdate(nextProps: IProps, nextState: IState): boolean {
if (
this.memberLastModifiedTime === undefined ||
this.memberLastModifiedTime < nextProps.member.getLastModifiedTime()
) {
return true;
}
if (
nextProps.member.user &&
(this.userLastModifiedTime === undefined ||
this.userLastModifiedTime < nextProps.member.user.getLastModifiedTime())
) {
return true;
}
if (nextState.isRoomEncrypted !== this.state.isRoomEncrypted || nextState.e2eStatus !== this.state.e2eStatus) {
return true;
}
return false;
}
private onClick = (): void => {
dis.dispatch({
action: Action.ViewUser,
member: this.props.member,
push: true,
});
};
private getDisplayName(): string {
return this.props.member.name;
}
private getPowerLabel(): string {
return _t("member_list|power_label", {
userName: UserIdentifierCustomisations.getDisplayUserIdentifier(this.props.member.userId, {
roomId: this.props.member.roomId,
}),
powerLevelNumber: this.props.member.powerLevel,
}).trim();
}
public render(): React.ReactNode {
const member = this.props.member;
const name = this.getDisplayName();
const presenceState = member.user?.presence as PresenceState | undefined;
const av = <MemberAvatar member={member} size="36px" aria-hidden="true" />;
if (member.user) {
this.userLastModifiedTime = member.user.getLastModifiedTime();
}
this.memberLastModifiedTime = member.getLastModifiedTime();
const powerStatusMap = new Map([
[100, PowerStatus.Admin],
[50, PowerStatus.Moderator],
]);
// Find the nearest power level with a badge
let powerLevel = this.props.member.powerLevel;
for (const [pl] of powerStatusMap) {
if (this.props.member.powerLevel >= pl) {
powerLevel = pl;
break;
}
}
const powerStatus = powerStatusMap.get(powerLevel);
let e2eStatus: E2EState | undefined;
if (this.state.isRoomEncrypted) {
e2eStatus = this.state.e2eStatus;
}
const nameJSX = <DisambiguatedProfile member={member} fallbackName={name || ""} />;
return (
<EntityTile
{...this.props}
presenceState={presenceState}
presenceLastActiveAgo={member.user ? member.user.lastActiveAgo : 0}
presenceLastTs={member.user ? member.user.lastPresenceTs : 0}
presenceCurrentlyActive={member.user ? member.user.currentlyActive : false}
avatarJsx={av}
title={this.getPowerLabel()}
name={name}
nameJSX={nameJSX}
powerStatus={powerStatus}
showPresence={this.props.showPresence}
e2eStatus={e2eStatus}
onClick={this.onClick}
/>
);
}
}

View File

@ -0,0 +1,32 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import Icon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
interface Props {
// The number of remaining items
remaining: number;
onClick(): void;
}
/**
* @deprecated Only used in ForwardDialog component; newer designs have moved away from this.
*/
export const OverflowTileView: React.FC<Props> = ({ remaining, onClick }) => {
return (
<AccessibleButton onClick={onClick} className="mx_OverflowTileView">
<div className="mx_OverflowTileView_icon">
<Icon height="36px" width="36px" />
</div>
<div className="mx_OverflowTileView_text">{_t("common|and_n_others", { count: remaining })}</div>
</AccessibleButton>
);
};

View File

@ -1579,9 +1579,14 @@
"toggle_attribution": "Toggle attribution"
},
"member_list": {
"count": {
"one": "%(count)s Member",
"other": "%(count)s Members"
},
"filter_placeholder": "Filter room members",
"invite_button_no_perms_tooltip": "You do not have permission to invite users",
"invited_list_heading": "Invited",
"invited_label": "Invited",
"no_matches": "No matches",
"power_label": "%(userName)s (power %(powerLevelNumber)s)"
},
"member_list_back_action_label": "Room members",
@ -1734,7 +1739,6 @@
"custom_level": "Custom level",
"default": "Default",
"label": "Power level",
"mod": "Mod",
"moderator": "Moderator",
"restricted": "Restricted"
},
@ -3066,7 +3070,6 @@
"invite": "Invite people",
"invite_description": "Invite with email or username",
"invite_link": "Share invite link",
"invite_this_space": "Invite to this space",
"joining_space": "Joining",
"landing_welcome": "Welcome to <name/>",
"leave_dialog_action": "Leave space",

View File

@ -0,0 +1,8 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export type PresenceState = "offline" | "online" | "unavailable" | "io.element.unreachable";

View File

@ -0,0 +1,22 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { PresenceState } from "./PresenceState";
export type RoomMember = {
roomId: string;
userId: string;
displayUserId: string;
name: string;
rawDisplayName?: string;
disambiguate: boolean;
avatarThumbnailUrl?: string;
powerLevel: number;
lastModifiedTime: number;
presenceState?: PresenceState;
isInvite: boolean;
};

View File

@ -0,0 +1,12 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
export type ThreePIDInvite = {
event: MatrixEvent;
};

View File

@ -800,6 +800,8 @@ export const mkThirdPartyInviteEvent = (user: string, displayName: string, room:
type: EventType.RoomThirdPartyInvite,
content: {
display_name: displayName,
public_key: "foo",
key_validity_url: "bar",
},
skey: "test" + Math.random(),
user,

View File

@ -124,7 +124,7 @@ describe("RightPanel", () => {
await waitFor(() => expect(screen.queryByTestId("spinner")).not.toBeInTheDocument());
// room one will be in the MemberList phase - confirm this is rendered
expect(container.getElementsByClassName("mx_MemberList")).toHaveLength(1);
expect(container.getElementsByClassName("mx_MemberListView")).toHaveLength(1);
// wait for RPS room 2 updates to fire, then rerender
const _rpsUpdated = waitForRpsUpdate();
@ -146,7 +146,7 @@ describe("RightPanel", () => {
// the correct right panel state for whichever room we are showing, so we
// confirm we do not have the MemberList class on the page and that we have
// the expected room title
expect(container.getElementsByClassName("mx_MemberList")).toHaveLength(0);
expect(container.getElementsByClassName("mx_MemberListView")).toHaveLength(0);
expect(screen.getByRole("heading", { name: "r2" })).toBeInTheDocument();
});
});

View File

@ -14,7 +14,7 @@ exports[`<MainSplit/> renders 1`] = `
</div>
<div
class="mx_RightPanel_ResizeWrapper"
style="position: relative; user-select: auto; width: 320px; height: 100%; max-width: 50%; min-width: 264px; box-sizing: border-box; flex-shrink: 0;"
style="position: relative; user-select: auto; width: 320px; height: 100%; max-width: 50%; min-width: 320px; box-sizing: border-box; flex-shrink: 0;"
>
<div>
Right panel
@ -44,7 +44,7 @@ exports[`<MainSplit/> respects defaultSize prop 1`] = `
</div>
<div
class="mx_RightPanel_ResizeWrapper"
style="position: relative; user-select: auto; width: 500px; height: 100%; max-width: 50%; min-width: 264px; box-sizing: border-box; flex-shrink: 0;"
style="position: relative; user-select: auto; width: 500px; height: 100%; max-width: 50%; min-width: 320px; box-sizing: border-box; flex-shrink: 0;"
>
<div>
Right panel

View File

@ -1994,7 +1994,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</div>
<div
class="mx_RightPanel_ResizeWrapper"
style="position: relative; user-select: auto; width: 420px; height: 100%; max-width: 50%; min-width: 264px; box-sizing: border-box; flex-shrink: 0;"
style="position: relative; user-select: auto; width: 420px; height: 100%; max-width: 50%; min-width: 320px; box-sizing: border-box; flex-shrink: 0;"
>
<aside
class="mx_RightPanel"

View File

@ -1,452 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import {
act,
fireEvent,
render,
RenderResult,
screen,
waitFor,
waitForElementToBeRemoved,
cleanup,
} from "jest-matrix-react";
import { Room, MatrixClient, RoomState, RoomMember, User, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { mocked, MockedObject } from "jest-mock";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import * as TestUtils from "../../../../test-utils";
import MemberList from "../../../../../src/components/views/rooms/MemberList";
import { SDKContext } from "../../../../../src/contexts/SDKContext";
import { TestSdkContext } from "../../../TestSdkContext";
import {
filterConsole,
flushPromises,
getMockClientWithEventEmitter,
mockClientMethodsRooms,
mockClientMethodsUser,
} from "../../../../test-utils";
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
function generateRoomId() {
return "!" + Math.random().toString().slice(2, 10) + ":domain";
}
describe("MemberList", () => {
filterConsole(
"Age for event was not available, using `now - origin_server_ts` as a fallback. If the device clock is not correct issues might occur.",
);
function createRoom(opts = {}) {
const room = new Room(generateRoomId(), client, client.getUserId()!);
if (opts) {
Object.assign(room, opts);
}
return room;
}
let client: MatrixClient;
let root: RenderResult;
let memberListRoom: Room;
let memberList: MemberList;
let adminUsers: RoomMember[] = [];
let moderatorUsers: RoomMember[] = [];
let defaultUsers: RoomMember[] = [];
function memberString(member: RoomMember): string {
if (!member) {
return "(null)";
} else {
const u = member.user;
return (
"(" +
member.name +
", " +
member.powerLevel +
", " +
(u ? u.lastActiveAgo : "<null>") +
", " +
(u ? u.getLastActiveTs() : "<null>") +
", " +
(u ? u.currentlyActive : "<null>") +
", " +
(u ? u.presence : "<null>") +
")"
);
}
}
function expectOrderedByPresenceAndPowerLevel(memberTiles: NodeListOf<Element>, isPresenceEnabled: boolean) {
let prevMember: RoomMember | undefined;
for (const tile of memberTiles) {
const memberA = prevMember;
const memberB = memberListRoom.currentState.members[tile.getAttribute("aria-label")!.split(" ")[0]];
prevMember = memberB; // just in case an expect fails, set this early
if (!memberA) {
continue;
}
console.log("COMPARING A VS B:", memberString(memberA), memberString(memberB));
const userA = memberA.user!;
const userB = memberB.user!;
let groupChange = false;
if (isPresenceEnabled) {
const convertPresence = (p: string) => (p === "unavailable" ? "online" : p);
const presenceIndex = (p: string) => {
const order = ["active", "online", "offline"];
const idx = order.indexOf(convertPresence(p));
return idx === -1 ? order.length : idx; // unknown states at the end
};
const idxA = presenceIndex(userA.currentlyActive ? "active" : userA.presence);
const idxB = presenceIndex(userB.currentlyActive ? "active" : userB.presence);
console.log("Comparing presence groups...");
expect(idxB).toBeGreaterThanOrEqual(idxA);
groupChange = idxA !== idxB;
} else {
console.log("Skipped presence groups");
}
if (!groupChange) {
console.log("Comparing power levels...");
expect(memberA.powerLevel).toBeGreaterThanOrEqual(memberB.powerLevel);
groupChange = memberA.powerLevel !== memberB.powerLevel;
} else {
console.log("Skipping power level check due to group change");
}
if (!groupChange) {
if (isPresenceEnabled) {
console.log("Comparing last active timestamp...");
expect(userB.getLastActiveTs()).toBeLessThanOrEqual(userA.getLastActiveTs());
groupChange = userA.getLastActiveTs() !== userB.getLastActiveTs();
} else {
console.log("Skipping last active timestamp");
}
} else {
console.log("Skipping last active timestamp check due to group change");
}
if (!groupChange) {
const nameA = memberA.name[0] === "@" ? memberA.name.slice(1) : memberA.name;
const nameB = memberB.name[0] === "@" ? memberB.name.slice(1) : memberB.name;
const collator = new Intl.Collator();
const nameCompare = collator.compare(nameB, nameA);
console.log("Comparing name");
expect(nameCompare).toBeGreaterThanOrEqual(0);
} else {
console.log("Skipping name check due to group change");
}
}
}
function renderMemberList(enablePresence: boolean): void {
TestUtils.stubClient();
client = MatrixClientPeg.safeGet();
client.hasLazyLoadMembersEnabled = () => false;
// Make room
memberListRoom = createRoom();
expect(memberListRoom.roomId).toBeTruthy();
// Make users
adminUsers = [];
moderatorUsers = [];
defaultUsers = [];
const usersPerLevel = 2;
for (let i = 0; i < usersPerLevel; i++) {
const adminUser = new RoomMember(memberListRoom.roomId, `@admin${i}:localhost`);
adminUser.membership = KnownMembership.Join;
adminUser.powerLevel = 100;
adminUser.user = User.createUser(adminUser.userId, client);
adminUser.user.currentlyActive = true;
adminUser.user.presence = "online";
adminUser.user.lastPresenceTs = 1000;
adminUser.user.lastActiveAgo = 10;
adminUsers.push(adminUser);
const moderatorUser = new RoomMember(memberListRoom.roomId, `@moderator${i}:localhost`);
moderatorUser.membership = KnownMembership.Join;
moderatorUser.powerLevel = 50;
moderatorUser.user = User.createUser(moderatorUser.userId, client);
moderatorUser.user.currentlyActive = true;
moderatorUser.user.presence = "online";
moderatorUser.user.lastPresenceTs = 1000;
moderatorUser.user.lastActiveAgo = 10;
moderatorUsers.push(moderatorUser);
const defaultUser = new RoomMember(memberListRoom.roomId, `@default${i}:localhost`);
defaultUser.membership = KnownMembership.Join;
defaultUser.powerLevel = 0;
defaultUser.user = User.createUser(defaultUser.userId, client);
defaultUser.user.currentlyActive = true;
defaultUser.user.presence = "online";
defaultUser.user.lastPresenceTs = 1000;
defaultUser.user.lastActiveAgo = 10;
defaultUsers.push(defaultUser);
}
client.getRoom = (roomId) => {
if (roomId === memberListRoom.roomId) return memberListRoom;
else return null;
};
memberListRoom.currentState = {
members: {},
getMember: jest.fn(),
getStateEvents: ((eventType, stateKey) =>
stateKey === undefined ? [] : null) as RoomState["getStateEvents"], // ignore 3pid invites
} as unknown as RoomState;
for (const member of [...adminUsers, ...moderatorUsers, ...defaultUsers]) {
memberListRoom.currentState.members[member.userId] = member;
}
const gatherWrappedRef = (r: MemberList) => {
memberList = r;
};
const context = new TestSdkContext();
context.client = client;
context.memberListStore.isPresenceEnabled = jest.fn().mockReturnValue(enablePresence);
root = render(
<SDKContext.Provider value={context}>
<MemberList
searchQuery=""
onClose={jest.fn()}
onSearchQueryChanged={jest.fn()}
roomId={memberListRoom.roomId}
ref={gatherWrappedRef}
/>
</SDKContext.Provider>,
);
}
describe.each([false, true])("does order members correctly (presence %s)", (enablePresence) => {
beforeEach(function () {
renderMemberList(enablePresence);
});
describe("does order members correctly", () => {
// Note: even if presence is disabled, we still expect that the presence
// tests will pass. All expectOrderedByPresenceAndPowerLevel does is ensure
// the order is perceived correctly, regardless of what we did to the members.
// Each of the 4 tests here is done to prove that the member list can meet
// all 4 criteria independently. Together, they should work.
it("by presence state", async () => {
// Intentionally pick users that will confuse the power level sorting
const activeUsers = [defaultUsers[0]];
const onlineUsers = [adminUsers[0]];
const offlineUsers = [...moderatorUsers, ...adminUsers.slice(1), ...defaultUsers.slice(1)];
activeUsers.forEach((u) => {
u.user!.currentlyActive = true;
u.user!.presence = "online";
});
onlineUsers.forEach((u) => {
u.user!.currentlyActive = false;
u.user!.presence = "online";
});
offlineUsers.forEach((u) => {
u.user!.currentlyActive = false;
u.user!.presence = "offline";
});
// Bypass all the event listeners and skip to the good part
await act(() => memberList.updateListNow(true));
const tiles = root.container.querySelectorAll(".mx_EntityTile");
expectOrderedByPresenceAndPowerLevel(tiles, enablePresence);
});
it("by power level", async () => {
// We already have admin, moderator, and default users so leave them alone
// Bypass all the event listeners and skip to the good part
await act(() => memberList.updateListNow(true));
const tiles = root.container.querySelectorAll(".mx_EntityTile");
expectOrderedByPresenceAndPowerLevel(tiles, enablePresence);
});
it("by last active timestamp", async () => {
// Intentionally pick users that will confuse the power level sorting
// lastActiveAgoTs == lastPresenceTs - lastActiveAgo
const activeUsers = [defaultUsers[0]];
const semiActiveUsers = [adminUsers[0]];
const inactiveUsers = [...moderatorUsers, ...adminUsers.slice(1), ...defaultUsers.slice(1)];
activeUsers.forEach((u) => {
u.powerLevel = 100; // set everyone to the same PL to avoid running that check
u.user!.lastPresenceTs = 1000;
u.user!.lastActiveAgo = 0;
});
semiActiveUsers.forEach((u) => {
u.powerLevel = 100;
u.user!.lastPresenceTs = 1000;
u.user!.lastActiveAgo = 50;
});
inactiveUsers.forEach((u) => {
u.powerLevel = 100;
u.user!.lastPresenceTs = 1000;
u.user!.lastActiveAgo = 100;
});
// Bypass all the event listeners and skip to the good part
await act(() => memberList.updateListNow(true));
const tiles = root.container.querySelectorAll(".mx_EntityTile");
expectOrderedByPresenceAndPowerLevel(tiles, enablePresence);
});
it("by name", async () => {
// Intentionally put everyone on the same level to force a name comparison
const allUsers = [...adminUsers, ...moderatorUsers, ...defaultUsers];
allUsers.forEach((u) => {
u.user!.currentlyActive = true;
u.user!.presence = "online";
u.user!.lastPresenceTs = 1000;
u.user!.lastActiveAgo = 0;
u.powerLevel = 100;
});
// Bypass all the event listeners and skip to the good part
await act(() => memberList.updateListNow(true));
const tiles = root.container.querySelectorAll(".mx_EntityTile");
expectOrderedByPresenceAndPowerLevel(tiles, enablePresence);
});
});
});
describe("memberlist is rendered correctly", () => {
beforeEach(function () {
renderMemberList(true);
});
it("memberlist is re-rendered on unreachable presence event", async () => {
defaultUsers[0].user?.setPresenceEvent(
new MatrixEvent({
type: "m.presence",
sender: defaultUsers[0].userId,
content: {
presence: "io.element.unreachable",
currently_active: false,
},
}),
);
expect(await screen.findByText(/User's server unreachable/)).toBeInTheDocument();
});
describe("Invite button", () => {
const roomId = "!room:server.org";
let client!: MockedObject<MatrixClient>;
let room!: Room;
beforeEach(function () {
mocked(shouldShowComponent).mockReturnValue(true);
client = getMockClientWithEventEmitter({
...mockClientMethodsUser(),
...mockClientMethodsRooms(),
getRoom: jest.fn(),
hasLazyLoadMembersEnabled: jest.fn(),
});
room = new Room(roomId, client, client.getSafeUserId());
client.getRoom.mockReturnValue(room);
});
afterEach(() => {
jest.restoreAllMocks();
cleanup();
});
const renderComponent = () => {
const context = new TestSdkContext();
context.client = client;
return render(
<SDKContext.Provider value={context}>
<MemberList
searchQuery=""
onClose={jest.fn()}
onSearchQueryChanged={jest.fn()}
roomId={room.roomId}
/>
</SDKContext.Provider>,
);
};
it("does not render invite button when current user is not a member", async () => {
renderComponent();
await flushPromises();
expect(screen.queryByText("Invite to this room")).not.toBeInTheDocument();
});
it("does not render invite button UI customisation hides invites", async () => {
mocked(shouldShowComponent).mockReturnValue(false);
renderComponent();
await flushPromises();
expect(screen.queryByText("Invite to this room")).not.toBeInTheDocument();
});
it("renders disabled invite button when current user is a member but does not have rights to invite", async () => {
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
jest.spyOn(room, "canInvite").mockReturnValue(false);
const { findByLabelText } = renderComponent();
// button rendered but disabled
await expect(findByLabelText("You do not have permission to invite users")).resolves.toHaveAttribute(
"aria-disabled",
"true",
);
});
it("renders enabled invite button when current user is a member and has rights to invite", async () => {
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
jest.spyOn(room, "canInvite").mockReturnValue(true);
const { findByText } = renderComponent();
await expect(findByText("Invite to this room")).resolves.not.toBeDisabled();
});
it("opens room inviter on button click", async () => {
jest.spyOn(defaultDispatcher, "dispatch");
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Join);
jest.spyOn(room, "canInvite").mockReturnValue(true);
const { getByRole } = renderComponent();
await waitForElementToBeRemoved(() => screen.queryAllByRole("progressbar"));
await waitFor(() =>
expect(getByRole("button", { name: "Invite to this room" })).not.toHaveAttribute(
"aria-disabled",
"true",
),
);
fireEvent.click(getByRole("button", { name: "Invite to this room" }));
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({
action: "view_invite",
roomId,
});
});
});
});
});

View File

@ -1,73 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2023 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen, waitFor } from "jest-matrix-react";
import { MatrixClient, RoomMember, Device } from "matrix-js-sdk/src/matrix";
import { UserVerificationStatus, DeviceVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import { mocked } from "jest-mock";
import userEvent from "@testing-library/user-event";
import * as TestUtils from "../../../../test-utils";
import MemberTile from "../../../../../src/components/views/rooms/MemberTile";
describe("MemberTile", () => {
let matrixClient: MatrixClient;
let member: RoomMember;
beforeEach(() => {
matrixClient = TestUtils.stubClient();
mocked(matrixClient.isRoomEncrypted).mockReturnValue(true);
member = new RoomMember("roomId", matrixClient.getUserId()!);
});
it("should not display an E2EIcon when the e2E status = normal", () => {
const { container } = render(<MemberTile member={member} />);
expect(container).toMatchSnapshot();
});
it("should display an warning E2EIcon when the e2E status = Warning", async () => {
mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({
isCrossSigningVerified: jest.fn().mockReturnValue(false),
wasCrossSigningVerified: jest.fn().mockReturnValue(true),
} as unknown as UserVerificationStatus);
const { container } = render(<MemberTile member={member} />);
expect(container).toMatchSnapshot();
await waitFor(async () => {
await userEvent.hover(container.querySelector(".mx_E2EIcon")!);
expect(screen.getByText("This user has not verified all of their sessions.")).toBeInTheDocument();
});
});
it("should display an verified E2EIcon when the e2E status = Verified", async () => {
// Mock all the required crypto methods
const deviceMap = new Map<string, Map<string, Device>>();
deviceMap.set(member.userId, new Map([["deviceId", {} as Device]]));
// Return a DeviceMap = Map<string, Map<string, Device>>
mocked(matrixClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(deviceMap);
mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({
isCrossSigningVerified: jest.fn().mockReturnValue(true),
} as unknown as UserVerificationStatus);
mocked(matrixClient.getCrypto()!.getDeviceVerificationStatus).mockResolvedValue({
crossSigningVerified: true,
} as DeviceVerificationStatus);
const { container } = render(<MemberTile member={member} />);
expect(container).toMatchSnapshot();
await waitFor(async () => {
await userEvent.hover(container.querySelector(".mx_E2EIcon")!);
expect(
screen.getByText("You have verified this user. This user has verified all of their sessions."),
).toBeInTheDocument();
});
});
});

View File

@ -1,160 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MemberTile should display an verified E2EIcon when the e2E status = Verified 1`] = `
<div>
<div>
<div
aria-label="@userId:matrix.org (power 0)"
class="mx_AccessibleButton mx_EntityTile mx_EntityTile_offline_neveractive"
role="button"
tabindex="0"
>
<div
class="mx_EntityTile_avatar"
>
<span
aria-hidden="true"
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 36px;"
title="@userId:matrix.org"
>
u
</span>
</div>
<div
class="mx_EntityTile_details"
>
<div
class="mx_EntityTile_name"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class=""
dir="auto"
>
@userId:matrix.org
</span>
</div>
</div>
<div
class="mx_PresenceLabel"
>
Offline
</div>
</div>
</div>
</div>
</div>
`;
exports[`MemberTile should display an warning E2EIcon when the e2E status = Warning 1`] = `
<div>
<div>
<div
aria-label="@userId:matrix.org (power 0)"
class="mx_AccessibleButton mx_EntityTile mx_EntityTile_offline_neveractive"
role="button"
tabindex="0"
>
<div
class="mx_EntityTile_avatar"
>
<span
aria-hidden="true"
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 36px;"
title="@userId:matrix.org"
>
u
</span>
</div>
<div
class="mx_EntityTile_details"
>
<div
class="mx_EntityTile_name"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class=""
dir="auto"
>
@userId:matrix.org
</span>
</div>
</div>
<div
class="mx_PresenceLabel"
>
Offline
</div>
</div>
</div>
</div>
</div>
`;
exports[`MemberTile should not display an E2EIcon when the e2E status = normal 1`] = `
<div>
<div>
<div
aria-label="@userId:matrix.org (power 0)"
class="mx_AccessibleButton mx_EntityTile mx_EntityTile_offline_neveractive"
role="button"
tabindex="0"
>
<div
class="mx_EntityTile_avatar"
>
<span
aria-hidden="true"
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 36px;"
title="@userId:matrix.org"
>
u
</span>
</div>
<div
class="mx_EntityTile_details"
>
<div
class="mx_EntityTile_name"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class=""
dir="auto"
>
@userId:matrix.org
</span>
</div>
</div>
<div
class="mx_PresenceLabel"
>
Offline
</div>
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,120 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { act, fireEvent, screen, waitFor } from "jest-matrix-react";
import { RoomMember, User, RoomEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { mocked } from "jest-mock";
import { shouldShowComponent } from "../../../../../../src/customisations/helpers/UIComponents";
import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher";
import { Rendered, renderMemberList } from "./common";
jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
type Children = (args: { height: number; width: number }) => React.JSX.Element;
jest.mock("react-virtualized", () => {
const ReactVirtualized = jest.requireActual("react-virtualized");
return {
...ReactVirtualized,
AutoSizer: ({ children }: { children: Children }) => children({ height: 1000, width: 1000 }),
};
});
jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(1500);
jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(1500);
describe("Does not render invite button in memberlist header", () => {
it("when user is not a member", async () => {
await renderMemberList(true, (room) => room.updateMyMembership(KnownMembership.Leave));
expect(screen.queryByRole("button", { name: "Invite" })).toBeNull();
});
it("when UI customisation hides invites", async () => {
mocked(shouldShowComponent).mockReturnValue(false);
const { client, memberListRoom } = await renderMemberList(true);
// Needs this specific event...
act(() => {
client.emit(RoomEvent.MyMembership, memberListRoom, KnownMembership.Join, KnownMembership.Invite);
});
await waitFor(() => expect(screen.queryByRole("button", { name: "Invite" })).toBeNull());
});
});
describe("MemberListHeaderView", () => {
let rendered: Rendered;
beforeEach(async function () {
mocked(shouldShowComponent).mockReturnValue(true);
rendered = await renderMemberList(true);
});
it("Shows the correct member count", async () => {
expect(await screen.findByText("6 Members")).toBeVisible();
});
it("Does not show search box when there's less than 20 members", async () => {
expect(screen.queryByPlaceholderText("Filter room members")).toBeNull();
});
it("Shows search box when there's more than 20 members", async () => {
const { memberListRoom, client, reRender } = rendered;
// Memberlist already has 6 members, add 14 more to make the total 20
for (let i = 0; i < 14; ++i) {
const newMember = new RoomMember(memberListRoom.roomId, `@new${i}:localhost`);
newMember.membership = KnownMembership.Join;
newMember.powerLevel = 0;
newMember.user = User.createUser(newMember.userId, client);
newMember.user.currentlyActive = true;
newMember.user.presence = "online";
newMember.user.lastPresenceTs = 1000;
newMember.user.lastActiveAgo = 10;
memberListRoom.currentState.members[newMember.userId] = newMember;
}
await reRender();
expect(screen.queryByPlaceholderText("Filter room members")).toBeVisible();
});
describe("Invite button functionality", () => {
afterEach(() => {
jest.restoreAllMocks();
});
it("Renders disabled invite button when current user is a member but does not have rights to invite", async () => {
const { memberListRoom, reRender } = rendered;
jest.spyOn(memberListRoom, "getMyMembership").mockReturnValue(KnownMembership.Join);
jest.spyOn(memberListRoom, "canInvite").mockReturnValue(false);
await reRender();
expect(screen.getByRole("button", { name: "Invite" })).toHaveAttribute("aria-disabled", "true");
});
it("Renders enabled invite button when current user is a member and has rights to invite", async () => {
const { memberListRoom, reRender } = rendered;
jest.spyOn(memberListRoom, "getMyMembership").mockReturnValue(KnownMembership.Join);
jest.spyOn(memberListRoom, "canInvite").mockReturnValue(true);
await reRender();
expect(screen.getByRole("button", { name: "Invite" })).not.toHaveAttribute("aria-disabled", "true");
});
it("Opens room inviter on button click", async () => {
const { memberListRoom, reRender } = rendered;
jest.spyOn(defaultDispatcher, "dispatch");
jest.spyOn(memberListRoom, "canInvite").mockReturnValue(true);
await reRender();
fireEvent.click(screen.getByRole("button", { name: "Invite" }));
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({
action: "view_invite",
roomId: memberListRoom.roomId,
});
});
});
});

View File

@ -0,0 +1,255 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { act } from "react";
import { waitFor } from "jest-matrix-react";
import { Room, RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { filterConsole } from "../../../../../test-utils";
import { Rendered, renderMemberList } from "./common";
jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
type Children = (args: { height: number; width: number }) => React.JSX.Element;
jest.mock("react-virtualized", () => {
const ReactVirtualized = jest.requireActual("react-virtualized");
return {
...ReactVirtualized,
AutoSizer: ({ children }: { children: Children }) => children({ height: 1000, width: 1000 }),
};
});
jest.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockReturnValue(1500);
jest.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(1500);
describe("MemberListView and MemberlistHeaderView", () => {
filterConsole(
"Age for event was not available, using `now - origin_server_ts` as a fallback. If the device clock is not correct issues might occur.",
);
function memberString(member: RoomMember): string {
if (!member) {
return "(null)";
} else {
const u = member.user;
return (
"(" +
member.name +
", " +
member.powerLevel +
", " +
(u ? u.lastActiveAgo : "<null>") +
", " +
(u ? u.getLastActiveTs() : "<null>") +
", " +
(u ? u.currentlyActive : "<null>") +
", " +
(u ? u.presence : "<null>") +
")"
);
}
}
function expectOrderedByPresenceAndPowerLevel(
memberListRoom: Room,
memberTiles: NodeListOf<Element>,
isPresenceEnabled: boolean,
) {
let prevMember: RoomMember | undefined;
for (const tile of memberTiles) {
const memberA = prevMember;
const memberB = memberListRoom.currentState.members[tile.getAttribute("aria-label")!.split(" ")[0]];
prevMember = memberB; // just in case an expect fails, set this early
if (!memberA) {
continue;
}
console.log("COMPARING A VS B:", memberString(memberA), memberString(memberB));
const userA = memberA.user!;
const userB = memberB.user!;
let groupChange = false;
if (isPresenceEnabled) {
const convertPresence = (p: string) => (p === "unavailable" ? "online" : p);
const presenceIndex = (p: string) => {
const order = ["active", "online", "offline"];
const idx = order.indexOf(convertPresence(p));
return idx === -1 ? order.length : idx; // unknown states at the end
};
const idxA = presenceIndex(userA.currentlyActive ? "active" : userA.presence);
const idxB = presenceIndex(userB.currentlyActive ? "active" : userB.presence);
console.log("Comparing presence groups...");
expect(idxB).toBeGreaterThanOrEqual(idxA);
groupChange = idxA !== idxB;
} else {
console.log("Skipped presence groups");
}
if (!groupChange) {
console.log("Comparing power levels...");
expect(memberA.powerLevel).toBeGreaterThanOrEqual(memberB.powerLevel);
groupChange = memberA.powerLevel !== memberB.powerLevel;
} else {
console.log("Skipping power level check due to group change");
}
if (!groupChange) {
if (isPresenceEnabled) {
console.log("Comparing last active timestamp...");
expect(userB.getLastActiveTs()).toBeLessThanOrEqual(userA.getLastActiveTs());
groupChange = userA.getLastActiveTs() !== userB.getLastActiveTs();
} else {
console.log("Skipping last active timestamp");
}
} else {
console.log("Skipping last active timestamp check due to group change");
}
if (!groupChange) {
const nameA = memberA.name[0] === "@" ? memberA.name.slice(1) : memberA.name;
const nameB = memberB.name[0] === "@" ? memberB.name.slice(1) : memberB.name;
const collator = new Intl.Collator();
const nameCompare = collator.compare(nameB, nameA);
console.log("Comparing name");
expect(nameCompare).toBeGreaterThanOrEqual(0);
} else {
console.log("Skipping name check due to group change");
}
}
}
describe("MemberListView", () => {
let rendered: Rendered;
beforeEach(async function () {
rendered = await renderMemberList(true);
});
it("Memberlist is re-rendered on unreachable presence event", async () => {
const { root, defaultUsers } = rendered;
await act(async () => {
defaultUsers[0].user?.setPresenceEvent(
new MatrixEvent({
type: "m.presence",
sender: defaultUsers[0].userId,
content: {
presence: "io.element.unreachable",
currently_active: false,
},
}),
);
});
await waitFor(() => {
expect(root.container.querySelector(".mx_PresenceIconView_unavailable")).not.toBeNull();
});
});
});
describe.each([true, false])("does order members correctly (presence %s)", (enablePresence) => {
let rendered: Rendered;
beforeEach(async function () {
rendered = await renderMemberList(enablePresence);
});
describe("does order members correctly", () => {
// Note: even if presence is disabled, we still expect that the presence
// tests will pass. All expectOrderedByPresenceAndPowerLevel does is ensure
// the order is perceived correctly, regardless of what we did to the members.
// Each of the 4 tests here is done to prove that the member list can meet
// all 4 criteria independently. Together, they should work.
it("by presence state", async () => {
const { adminUsers, defaultUsers, moderatorUsers, reRender, root, memberListRoom } = rendered;
// Intentionally pick users that will confuse the power level sorting
const activeUsers = [defaultUsers[0]];
const onlineUsers = [adminUsers[0]];
const offlineUsers = [...moderatorUsers, ...adminUsers.slice(1), ...defaultUsers.slice(1)];
activeUsers.forEach((u) => {
u.user!.currentlyActive = true;
u.user!.presence = "online";
});
onlineUsers.forEach((u) => {
u.user!.currentlyActive = false;
u.user!.presence = "online";
});
offlineUsers.forEach((u) => {
u.user!.currentlyActive = false;
u.user!.presence = "offline";
});
await reRender();
const tiles = root.container.querySelectorAll(".mx_MemberTileView");
expectOrderedByPresenceAndPowerLevel(memberListRoom, tiles, enablePresence);
});
it("by power level", async () => {
const { reRender, root, memberListRoom } = rendered;
// We already have admin, moderator, and default users so leave them alone
await reRender();
const tiles = root.container.querySelectorAll(".mx_EntityTile");
expectOrderedByPresenceAndPowerLevel(memberListRoom, tiles, enablePresence);
});
it("by last active timestamp", async () => {
const { adminUsers, defaultUsers, moderatorUsers, reRender, root, memberListRoom } = rendered;
// Intentionally pick users that will confuse the power level sorting
const activeUsers = [defaultUsers[0]];
const semiActiveUsers = [adminUsers[0]];
const inactiveUsers = [...moderatorUsers, ...adminUsers.slice(1), ...defaultUsers.slice(1)];
activeUsers.forEach((u) => {
u.powerLevel = 100; // set everyone to the same PL to avoid running that check
u.user!.lastPresenceTs = 1000;
u.user!.lastActiveAgo = 0;
});
semiActiveUsers.forEach((u) => {
u.powerLevel = 100;
u.user!.lastPresenceTs = 1000;
u.user!.lastActiveAgo = 50;
});
inactiveUsers.forEach((u) => {
u.powerLevel = 100;
u.user!.lastPresenceTs = 1000;
u.user!.lastActiveAgo = 100;
});
await reRender();
const tiles = root.container.querySelectorAll(".mx_EntityTile");
expectOrderedByPresenceAndPowerLevel(memberListRoom, tiles, enablePresence);
});
it("by name", async () => {
const { adminUsers, defaultUsers, moderatorUsers, reRender, root, memberListRoom } = rendered;
// Intentionally put everyone on the same level to force a name comparison
const allUsers = [...adminUsers, ...moderatorUsers, ...defaultUsers];
allUsers.forEach((u) => {
u.user!.currentlyActive = true;
u.user!.presence = "online";
u.user!.lastPresenceTs = 1000;
u.user!.lastActiveAgo = 0;
u.powerLevel = 100;
});
await reRender();
const tiles = root.container.querySelectorAll(".mx_EntityTile");
expectOrderedByPresenceAndPowerLevel(memberListRoom, tiles, enablePresence);
});
});
});
});

View File

@ -0,0 +1,116 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2023 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen, waitFor } from "jest-matrix-react";
import { MatrixClient, RoomMember as SdkRoomMember, Device, Room } from "matrix-js-sdk/src/matrix";
import { UserVerificationStatus, DeviceVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import { mocked } from "jest-mock";
import userEvent from "@testing-library/user-event";
import * as TestUtils from "../../../../../test-utils";
import { RoomMember } from "../../../../../../src/models/rooms/RoomMember";
import {
getPending3PidInvites,
sdkRoomMemberToRoomMember,
} from "../../../../../../src/components/viewmodels/memberlist/MemberListViewModel";
import { RoomMemberTileView } from "../../../../../../src/components/views/rooms/MemberList/tiles/RoomMemberTileView";
import { ThreePidInviteTileView } from "../../../../../../src/components/views/rooms/MemberList/tiles/ThreePidInviteTileView";
describe("MemberTileView", () => {
describe("RoomMemberTileView", () => {
let matrixClient: MatrixClient;
let member: RoomMember;
beforeEach(() => {
matrixClient = TestUtils.stubClient();
mocked(matrixClient.isRoomEncrypted).mockReturnValue(true);
const sdkMember = new SdkRoomMember("roomId", matrixClient.getUserId()!);
member = sdkRoomMemberToRoomMember(sdkMember)!.member!;
});
it("should not display an E2EIcon when the e2E status = normal", () => {
const { container } = render(<RoomMemberTileView member={member} />);
const e2eIcon = container.querySelector(".mx_E2EIconView");
expect(e2eIcon).toBeNull();
expect(container).toMatchSnapshot();
});
it("should display an warning E2EIcon when the e2E status = Warning", async () => {
mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({
isCrossSigningVerified: jest.fn().mockReturnValue(false),
wasCrossSigningVerified: jest.fn().mockReturnValue(true),
} as unknown as UserVerificationStatus);
const { container } = render(<RoomMemberTileView member={member} />);
await waitFor(async () => {
await userEvent.hover(container.querySelector(".mx_E2EIcon")!);
expect(screen.getByText("This user has not verified all of their sessions.")).toBeInTheDocument();
});
expect(container).toMatchSnapshot();
});
it("should display an verified E2EIcon when the e2E status = Verified", async () => {
// Mock all the required crypto methods
const deviceMap = new Map<string, Map<string, Device>>();
deviceMap.set(member.userId, new Map([["deviceId", {} as Device]]));
// Return a DeviceMap = Map<string, Map<string, Device>>
mocked(matrixClient.getCrypto()!.getUserDeviceInfo).mockResolvedValue(deviceMap);
mocked(matrixClient.getCrypto()!.getUserVerificationStatus).mockResolvedValue({
isCrossSigningVerified: jest.fn().mockReturnValue(true),
} as unknown as UserVerificationStatus);
mocked(matrixClient.getCrypto()!.getDeviceVerificationStatus).mockResolvedValue({
crossSigningVerified: true,
} as DeviceVerificationStatus);
const { container } = render(<RoomMemberTileView member={member} />);
await waitFor(async () => {
await userEvent.hover(container.querySelector(".mx_E2EIcon")!);
expect(
screen.getByText("You have verified this user. This user has verified all of their sessions."),
).toBeInTheDocument();
});
expect(container).toMatchSnapshot();
});
it("renders user labels correctly", async () => {
member.powerLevel = 50;
const { container: container1 } = render(<RoomMemberTileView member={member} />);
expect(container1).toHaveTextContent("Moderator");
member.powerLevel = 100;
const { container: container2 } = render(<RoomMemberTileView member={member} />);
expect(container2).toHaveTextContent("Admin");
member.isInvite = true;
const { container: container3 } = render(<RoomMemberTileView member={member} />);
expect(container3).toHaveTextContent("Invited");
});
});
describe("ThreePidInviteTileView", () => {
let cli: MatrixClient;
let room: Room;
beforeEach(() => {
cli = TestUtils.stubClient();
room = new Room("!mytestroom:foo.org", cli, cli.getSafeUserId());
room.getLiveTimeline().addEvent(
TestUtils.mkThirdPartyInviteEvent(cli.getSafeUserId(), "Foobar", room.roomId),
{ toStartOfTimeline: false, addToState: true },
);
});
it("renders ThreePidInvite correctly", async () => {
const [{ threePidInvite }] = getPending3PidInvites(room);
const { container } = render(<ThreePidInviteTileView threePidInvite={threePidInvite!} />);
expect(container).toMatchSnapshot();
});
});
});

View File

@ -0,0 +1,42 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render } from "jest-matrix-react";
import AvatarPresenceIconView from "../../../../../../src/components/views/rooms/MemberList/tiles/common/PresenceIconView";
describe("<PresenceIconView/>", () => {
it("renders correctly for presence=online", () => {
const { container } = render(<AvatarPresenceIconView presenceState="online" />);
expect(container.querySelector(".mx_PresenceIconView_online")).toBeDefined();
expect(container).toMatchSnapshot();
});
it("renders correctly for presence=offline", () => {
const { container } = render(<AvatarPresenceIconView presenceState="offline" />);
expect(container.querySelector(".mx_PresenceIconView_offline")).toBeDefined();
expect(container).toMatchSnapshot();
});
it("renders correctly for presence=unavailable/unreachable", () => {
const { container: container1 } = render(<AvatarPresenceIconView presenceState="unavailable" />);
expect(container1.querySelector(".mx_PresenceIconView_unavailable")).toBeDefined();
expect(container1).toMatchSnapshot();
const { container: container2 } = render(<AvatarPresenceIconView presenceState="io.element.unreachable" />);
expect(container2.querySelector(".mx_PresenceIconView_unavailable")).toBeDefined();
expect(container2).toMatchSnapshot();
});
it("renders correctly for presence=busy", () => {
const { container } = render(<AvatarPresenceIconView presenceState="busy" />);
expect(container.querySelector(".mx_PresenceIconView_dnd")).toBeDefined();
expect(container).toMatchSnapshot();
});
});

View File

@ -0,0 +1,231 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MemberTileView RoomMemberTileView should display an verified E2EIcon when the e2E status = Verified 1`] = `
<div>
<div>
<div
aria-label="@userId:matrix.org (power 0)"
class="mx_AccessibleButton mx_MemberTileView"
role="button"
tabindex="0"
>
<div
class="mx_MemberTileView_left"
>
<div
class="mx_MemberTileView_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
title="@userId:matrix.org"
>
u
</span>
</div>
<div
class="mx_MemberTileView_name"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class=""
dir="auto"
>
@userId:matrix.org
</span>
</div>
</div>
</div>
<div
class="mx_MemberTileView_right"
>
<div
aria-labelledby=":ri:"
class="mx_E2EIconView"
>
<svg
class="mx_E2EIconView_verified"
fill="currentColor"
height="16px"
viewBox="0 0 24 24"
width="16px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.15 21.75 6.7 19.3l-2.75-.6a.943.943 0 0 1-.6-.387.928.928 0 0 1-.175-.688L3.45 14.8l-1.875-2.15a.934.934 0 0 1-.25-.65c0-.25.083-.467.25-.65L3.45 9.2l-.275-2.825a.928.928 0 0 1 .175-.688.943.943 0 0 1 .6-.387l2.75-.6 1.45-2.45a.983.983 0 0 1 .55-.438.97.97 0 0 1 .7.038l2.6 1.1 2.6-1.1a.97.97 0 0 1 .7-.038.983.983 0 0 1 .55.438L17.3 4.7l2.75.6c.25.05.45.18.6.388.15.208.208.437.175.687L20.55 9.2l1.875 2.15c.167.183.25.4.25.65s-.083.467-.25.65L20.55 14.8l.275 2.825a.928.928 0 0 1-.175.688.943.943 0 0 1-.6.387l-2.75.6-1.45 2.45a.983.983 0 0 1-.55.438.97.97 0 0 1-.7-.038l-2.6-1.1-2.6 1.1a.97.97 0 0 1-.7.038.983.983 0 0 1-.55-.438Zm2.8-9.05L9.5 11.275A.933.933 0 0 0 8.813 11c-.275 0-.513.1-.713.3a.948.948 0 0 0-.275.7.95.95 0 0 0 .275.7l2.15 2.15c.2.2.433.3.7.3.267 0 .5-.1.7-.3l4.25-4.25c.2-.2.296-.433.287-.7a1.055 1.055 0 0 0-.287-.7 1.02 1.02 0 0 0-.713-.313.93.93 0 0 0-.712.288L10.95 12.7Z"
/>
</svg>
</div>
</div>
</div>
</div>
</div>
`;
exports[`MemberTileView RoomMemberTileView should display an warning E2EIcon when the e2E status = Warning 1`] = `
<div>
<div>
<div
aria-label="@userId:matrix.org (power 0)"
class="mx_AccessibleButton mx_MemberTileView"
role="button"
tabindex="0"
>
<div
class="mx_MemberTileView_left"
>
<div
class="mx_MemberTileView_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
title="@userId:matrix.org"
>
u
</span>
</div>
<div
class="mx_MemberTileView_name"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class=""
dir="auto"
>
@userId:matrix.org
</span>
</div>
</div>
</div>
<div
class="mx_MemberTileView_right"
>
<div
aria-labelledby=":r8:"
class="mx_E2EIconView"
>
<svg
class="mx_E2EIconView_warning"
fill="currentColor"
height="16px"
viewBox="0 0 24 24"
width="16px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17a.97.97 0 0 0 .713-.288A.968.968 0 0 0 13 16a.968.968 0 0 0-.287-.713A.968.968 0 0 0 12 15a.968.968 0 0 0-.713.287A.968.968 0 0 0 11 16c0 .283.096.52.287.712.192.192.43.288.713.288Zm0-4c.283 0 .52-.096.713-.287A.968.968 0 0 0 13 12V8a.967.967 0 0 0-.287-.713A.968.968 0 0 0 12 7a.968.968 0 0 0-.713.287A.967.967 0 0 0 11 8v4c0 .283.096.52.287.713.192.191.43.287.713.287Zm0 9a9.738 9.738 0 0 1-3.9-.788 10.099 10.099 0 0 1-3.175-2.137c-.9-.9-1.612-1.958-2.137-3.175A9.738 9.738 0 0 1 2 12a9.74 9.74 0 0 1 .788-3.9 10.099 10.099 0 0 1 2.137-3.175c.9-.9 1.958-1.612 3.175-2.137A9.738 9.738 0 0 1 12 2a9.74 9.74 0 0 1 3.9.788 10.098 10.098 0 0 1 3.175 2.137c.9.9 1.613 1.958 2.137 3.175A9.738 9.738 0 0 1 22 12a9.738 9.738 0 0 1-.788 3.9 10.098 10.098 0 0 1-2.137 3.175c-.9.9-1.958 1.613-3.175 2.137A9.738 9.738 0 0 1 12 22Z"
/>
</svg>
</div>
</div>
</div>
</div>
</div>
`;
exports[`MemberTileView RoomMemberTileView should not display an E2EIcon when the e2E status = normal 1`] = `
<div>
<div>
<div
aria-label="@userId:matrix.org (power 0)"
class="mx_AccessibleButton mx_MemberTileView"
role="button"
tabindex="0"
>
<div
class="mx_MemberTileView_left"
>
<div
class="mx_MemberTileView_avatar"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
title="@userId:matrix.org"
>
u
</span>
</div>
<div
class="mx_MemberTileView_name"
>
<div
class="mx_DisambiguatedProfile"
>
<span
class=""
dir="auto"
>
@userId:matrix.org
</span>
</div>
</div>
</div>
<div
class="mx_MemberTileView_right"
/>
</div>
</div>
</div>
`;
exports[`MemberTileView ThreePidInviteTileView renders ThreePidInvite correctly 1`] = `
<div>
<div>
<div
class="mx_AccessibleButton mx_MemberTileView"
role="button"
tabindex="0"
>
<div
class="mx_MemberTileView_left"
>
<div
class="mx_MemberTileView_avatar"
>
<span
aria-hidden="true"
class="_avatar_mcap2_17 mx_BaseAvatar _avatar-imageless_mcap2_61"
data-color="1"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
F
</span>
</div>
<div
class="mx_MemberTileView_name"
>
Foobar
</div>
</div>
<div
class="mx_MemberTileView_right"
/>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,175 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<PresenceIconView/> renders correctly for presence=busy 1`] = `
<div>
<div
class="mx_PresenceIconView"
>
<svg
class="mx_PresenceIconView_dnd"
fill="currentColor"
height="8px"
viewBox="0 0 8 8"
width="8px"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
clip-rule="evenodd"
d="M8 4a4 4 0 1 1-8 0 4 4 0 0 1 8 0ZM5.435 6.048A2.5 2.5 0 0 1 1.687 3.05l3.748 2.998Zm.914-1.19L2.648 1.897a2.5 2.5 0 0 1 3.701 2.961Z"
fill-rule="evenodd"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 0h8v8H0z"
/>
</clippath>
</defs>
</svg>
</div>
</div>
`;
exports[`<PresenceIconView/> renders correctly for presence=offline 1`] = `
<div>
<div
class="mx_PresenceIconView"
>
<svg
class="mx_PresenceIconView_offline"
fill="currentColor"
height="8px"
viewBox="0 0 8 8"
width="8px"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
clip-rule="evenodd"
d="M4 6.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5ZM4 8a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"
fill-rule="evenodd"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 0h8v8H0z"
/>
</clippath>
</defs>
</svg>
</div>
</div>
`;
exports[`<PresenceIconView/> renders correctly for presence=online 1`] = `
<div>
<div
class="mx_PresenceIconView"
>
<svg
class="mx_PresenceIconView_online"
fill="currentColor"
height="8px"
viewBox="0 0 8 8"
width="8px"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M8 4a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 0h8v8H0z"
/>
</clippath>
</defs>
</svg>
</div>
</div>
`;
exports[`<PresenceIconView/> renders correctly for presence=unavailable/unreachable 1`] = `
<div>
<div
class="mx_PresenceIconView"
>
<svg
class="mx_PresenceIconView_unavailable"
fill="currentColor"
height="8px"
viewBox="0 0 8 8"
width="8px"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M8 4a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 0h8v8H0z"
/>
</clippath>
</defs>
</svg>
</div>
</div>
`;
exports[`<PresenceIconView/> renders correctly for presence=unavailable/unreachable 2`] = `
<div>
<div
class="mx_PresenceIconView"
>
<svg
class="mx_PresenceIconView_unavailable"
fill="currentColor"
height="8px"
viewBox="0 0 8 8"
width="8px"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
d="M8 4a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 0h8v8H0z"
/>
</clippath>
</defs>
</svg>
</div>
</div>
`;

View File

@ -0,0 +1,146 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { act } from "react";
import { render, RenderResult, waitFor } from "jest-matrix-react";
import { Room, MatrixClient, RoomState, RoomMember, User, EventType, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
import * as TestUtils from "../../../../../test-utils";
import { SDKContext } from "../../../../../../src/contexts/SDKContext";
import { TestSdkContext } from "../../../../TestSdkContext";
import MemberListView from "../../../../../../src/components/views/rooms/MemberList/MemberListView";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
export function createRoom(client: MatrixClient, opts = {}) {
const roomId = "!" + Math.random().toString().slice(2, 10) + ":domain";
const room = new Room(roomId, client, client.getUserId()!);
room.updateMyMembership(KnownMembership.Join);
if (opts) {
Object.assign(room, opts);
}
return room;
}
export type Rendered = {
client: MatrixClient;
root: RenderResult;
memberListRoom: Room;
adminUsers: RoomMember[];
moderatorUsers: RoomMember[];
defaultUsers: RoomMember[];
reRender: () => Promise<void>;
};
export async function renderMemberList(
enablePresence: boolean,
roomSetup?: (room: Room) => void,
usersPerLevel: number = 2,
): Promise<Rendered> {
TestUtils.stubClient();
const client = MatrixClientPeg.safeGet();
client.hasLazyLoadMembersEnabled = () => false;
// Make room
const memberListRoom = createRoom(client);
expect(memberListRoom.roomId).toBeTruthy();
// Give the test an opportunity to make changes to room before first render
roomSetup?.(memberListRoom);
// Make users
const adminUsers = [];
const moderatorUsers = [];
const defaultUsers = [];
for (let i = 0; i < usersPerLevel; i++) {
const adminUser = new RoomMember(memberListRoom.roomId, `@admin${i}:localhost`);
adminUser.membership = KnownMembership.Join;
adminUser.powerLevel = 100;
adminUser.user = User.createUser(adminUser.userId, client);
adminUser.user.currentlyActive = true;
adminUser.user.presence = "online";
adminUser.user.lastPresenceTs = 1000;
adminUser.user.lastActiveAgo = 10;
adminUsers.push(adminUser);
const moderatorUser = new RoomMember(memberListRoom.roomId, `@moderator${i}:localhost`);
moderatorUser.membership = KnownMembership.Join;
moderatorUser.powerLevel = 50;
moderatorUser.user = User.createUser(moderatorUser.userId, client);
moderatorUser.user.currentlyActive = true;
moderatorUser.user.presence = "online";
moderatorUser.user.lastPresenceTs = 1000;
moderatorUser.user.lastActiveAgo = 10;
moderatorUsers.push(moderatorUser);
const defaultUser = new RoomMember(memberListRoom.roomId, `@default${i}:localhost`);
defaultUser.membership = KnownMembership.Join;
defaultUser.powerLevel = 0;
defaultUser.user = User.createUser(defaultUser.userId, client);
defaultUser.user.currentlyActive = true;
defaultUser.user.presence = "online";
defaultUser.user.lastPresenceTs = 1000;
defaultUser.user.lastActiveAgo = 10;
defaultUsers.push(defaultUser);
}
client.getRoom = (roomId) => {
if (roomId === memberListRoom.roomId) return memberListRoom;
else return null;
};
memberListRoom.currentState = {
members: {},
getMember: jest.fn(),
getStateEvents: ((eventType, stateKey) => (stateKey === undefined ? [] : null)) as RoomState["getStateEvents"], // ignore 3pid invites
} as unknown as RoomState;
for (const member of [...adminUsers, ...moderatorUsers, ...defaultUsers]) {
memberListRoom.currentState.members[member.userId] = member;
}
const context = new TestSdkContext();
context.client = client;
context.memberListStore.isPresenceEnabled = jest.fn().mockReturnValue(enablePresence);
const root = render(
<MatrixClientContext.Provider value={client}>
<SDKContext.Provider value={context}>
<MemberListView roomId={memberListRoom.roomId} onClose={() => {}} />
</SDKContext.Provider>
</MatrixClientContext.Provider>,
);
await waitFor(async () => {
expect(root.container.querySelectorAll(".mx_MemberTileView")).toHaveLength(usersPerLevel * 3);
});
const reRender = createReRenderFunction(client, memberListRoom);
return {
client,
root,
memberListRoom,
adminUsers,
moderatorUsers,
defaultUsers,
reRender,
};
}
function createReRenderFunction(client: MatrixClient, memberListRoom: Room): Rendered["reRender"] {
return async function (): Promise<void> {
await act(async () => {
//@ts-ignore
client.emit(RoomStateEvent.Events, {
//@ts-ignore
getType: () => EventType.RoomThirdPartyInvite,
getRoomId: () => memberListRoom.roomId,
});
});
await new Promise((r) => setTimeout(r, 1000));
};
}

View File

@ -1089,6 +1089,13 @@
dependencies:
regenerator-runtime "^0.14.0"
"@babel/runtime@^7.7.2":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.9.tgz#65884fd6dc255a775402cc1d9811082918f4bf00"
integrity sha512-4zpTHZ9Cm6L9L+uIqghQX8ZXg8HKFcjYO3qHoO8zTmRm6HQUJ8SSJ+KRvbMBZn0EGVlT4DRYeQ/6hjlyXBh+Kg==
dependencies:
regenerator-runtime "^0.14.0"
"@babel/template@^7.25.7", "@babel/template@^7.3.3":
version "7.25.7"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.25.7.tgz#27f69ce382855d915b14ab0fe5fb4cbf88fa0769"
@ -3238,6 +3245,14 @@
dependencies:
"@types/react" "*"
"@types/react-virtualized@^9.21.30":
version "9.22.0"
resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.22.0.tgz#2ff9b3692fa04a429df24ffc7d181d9f33b3831d"
integrity sha512-JL/YCCFZ123za//cj10Apk54F0UGFMrjOE0QHTuXt1KBMFrzLOGv9/x6Uc/pZ0Gaf4o6w61Fostvlw0DwuPXig==
dependencies:
"@types/prop-types" "*"
"@types/react" "*"
"@types/react@*", "@types/react@18.3.18":
version "18.3.18"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.18.tgz#9b382c4cd32e13e463f97df07c2ee3bbcd26904b"
@ -3508,10 +3523,10 @@
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"
integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==
"@vector-im/compound-design-tokens@^2.0.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-2.1.1.tgz#d6175a99fe4b97688464126f255386990f3048d6"
integrity sha512-QnUi2K14D9KTXxcLQKUU3V75cforZLMwhaaJDNftT8F5mG86950hAM+qhgDNEpEU+pkTffQj0/g/5859YmqWzQ==
"@vector-im/compound-design-tokens@^2.1.0":
version "2.1.3"
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-2.1.3.tgz#8205ffb455a09d71a02d838f3dbb8503c4e6ec27"
integrity sha512-U4UF7MVguENf0lQnkU2a9p/3llTsLXzbzmFFOxi0h6ny2igNxZj/kROP/jXTxxV9xD4TNn3z098Bos4J/qJpBA==
"@vector-im/compound-web@^7.5.0":
version "7.5.0"
@ -3529,7 +3544,7 @@
ts-xor "^1.3.0"
vaul "^1.0.0"
"@vector-im/matrix-wysiwyg-wasm@link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.0-af862ffd231dc0a6b8d6f2cb3601e68456c0ff24-integrity/node_modules/bindings/wysiwyg-wasm":
"@vector-im/matrix-wysiwyg-wasm@link:../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.0-af862ffd231dc0a6b8d6f2cb3601e68456c0ff24-integrity/node_modules/bindings/wysiwyg-wasm":
version "0.0.0"
"@vector-im/matrix-wysiwyg@2.38.0":
@ -3537,7 +3552,7 @@
resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.38.0.tgz#af862ffd231dc0a6b8d6f2cb3601e68456c0ff24"
integrity sha512-cMEVicFYVzFxuSyWON0aVGjAJMcgJZ+LxuLTEp8EGuu8cRacuh0RN5rapb11YVZygzFvE7X1cMedJ/fKd5vRLA==
dependencies:
"@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.0-af862ffd231dc0a6b8d6f2cb3601e68456c0ff24-integrity/node_modules/bindings/wysiwyg-wasm"
"@vector-im/matrix-wysiwyg-wasm" "link:../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.0-af862ffd231dc0a6b8d6f2cb3601e68456c0ff24-integrity/node_modules/bindings/wysiwyg-wasm"
"@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1":
version "1.14.1"
@ -4701,6 +4716,11 @@ clone@^1.0.2:
resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==
clsx@^1.0.4:
version "1.2.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@ -5512,7 +5532,7 @@ dom-converter@^0.2.0:
dependencies:
utila "~0.4"
dom-helpers@^5.0.1:
dom-helpers@^5.0.1, dom-helpers@^5.1.3:
version "5.2.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==
@ -10380,6 +10400,11 @@ react-is@^18.0.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
react-lifecycles-compat@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
react-redux@^7.2.0:
version "7.2.9"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.9.tgz#09488fbb9416a4efe3735b7235055442b042481d"
@ -10430,6 +10455,18 @@ react-transition-group@^4.4.1:
loose-envify "^1.4.0"
prop-types "^15.6.2"
react-virtualized@^9.22.5:
version "9.22.5"
resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.22.5.tgz#bfb96fed519de378b50d8c0064b92994b3b91620"
integrity sha512-YqQMRzlVANBv1L/7r63OHa2b0ZsAaDp1UhVNEdUaXI8A5u6hTpA5NYtUueLH2rFuY/27mTGIBl7ZhqFKzw18YQ==
dependencies:
"@babel/runtime" "^7.7.2"
clsx "^1.0.4"
dom-helpers "^5.1.3"
loose-envify "^1.4.0"
prop-types "^15.7.2"
react-lifecycles-compat "^3.0.4"
react@^18.3.1:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"