Allow managing room knocks (#11404)

* Allow managing room knocks

Signed-off-by: Charly Nguyen <charly.nguyen@nordeck.net>

* Apply PR feedback

Signed-off-by: Charly Nguyen <charly.nguyen@nordeck.net>

* Apply Sonar feedback

Signed-off-by: Charly Nguyen <charly.nguyen@nordeck.net>

---------

Signed-off-by: Charly Nguyen <charly.nguyen@nordeck.net>
This commit is contained in:
Charly Nguyen 2023-08-16 10:16:19 +02:00 committed by GitHub
parent 4f138ed041
commit d569ba0cfe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 711 additions and 7 deletions

View file

@ -341,6 +341,7 @@
@import "./views/settings/tabs/_SettingsSection.pcss"; @import "./views/settings/tabs/_SettingsSection.pcss";
@import "./views/settings/tabs/_SettingsTab.pcss"; @import "./views/settings/tabs/_SettingsTab.pcss";
@import "./views/settings/tabs/room/_NotificationSettingsTab.pcss"; @import "./views/settings/tabs/room/_NotificationSettingsTab.pcss";
@import "./views/settings/tabs/room/_PeopleRoomSettingsTab.pcss";
@import "./views/settings/tabs/room/_RolesRoomSettingsTab.pcss"; @import "./views/settings/tabs/room/_RolesRoomSettingsTab.pcss";
@import "./views/settings/tabs/room/_SecurityRoomSettingsTab.pcss"; @import "./views/settings/tabs/room/_SecurityRoomSettingsTab.pcss";
@import "./views/settings/tabs/user/_AppearanceUserSettingsTab.pcss"; @import "./views/settings/tabs/user/_AppearanceUserSettingsTab.pcss";

View file

@ -50,6 +50,10 @@ limitations under the License.
mask-image: url("$(res)/img/element-icons/room/settings/advanced.svg"); mask-image: url("$(res)/img/element-icons/room/settings/advanced.svg");
} }
.mx_RoomSettingsDialog_peopleIcon::before {
mask-image: url("$(res)/img/element-icons/group-members.svg");
}
.mx_RoomSettingsDialog .mx_Dialog_title { .mx_RoomSettingsDialog .mx_Dialog_title {
-ms-text-overflow: ellipsis; -ms-text-overflow: ellipsis;
text-overflow: ellipsis; text-overflow: ellipsis;

View file

@ -20,6 +20,8 @@ limitations under the License.
&.mx_AccessibleButton_disabled { &.mx_AccessibleButton_disabled {
cursor: not-allowed; cursor: not-allowed;
&.mx_AccessibleButton_kind_icon_primary,
&.mx_AccessibleButton_kind_icon_primary_outline,
&.mx_AccessibleButton_kind_primary, &.mx_AccessibleButton_kind_primary,
&.mx_AccessibleButton_kind_primary_outline, &.mx_AccessibleButton_kind_primary_outline,
&.mx_AccessibleButton_kind_primary_sm, &.mx_AccessibleButton_kind_primary_sm,
@ -80,29 +82,37 @@ limitations under the License.
} }
} }
&.mx_AccessibleButton_kind_icon { &.mx_AccessibleButton_kind_icon,
&.mx_AccessibleButton_kind_icon_primary,
&.mx_AccessibleButton_kind_icon_primary_outline {
padding: 0; padding: 0;
height: 32px; height: 32px;
width: 32px; width: 32px;
} }
} }
&.mx_AccessibleButton_kind_icon_primary,
&.mx_AccessibleButton_kind_icon_primary_outline,
&.mx_AccessibleButton_kind_primary, &.mx_AccessibleButton_kind_primary,
&.mx_AccessibleButton_kind_primary_outline, &.mx_AccessibleButton_kind_primary_outline,
&.mx_AccessibleButton_kind_secondary { &.mx_AccessibleButton_kind_secondary {
font-weight: var(--cpd-font-weight-semibold); font-weight: var(--cpd-font-weight-semibold);
} }
&.mx_AccessibleButton_kind_icon_primary,
&.mx_AccessibleButton_kind_icon_primary_outline,
&.mx_AccessibleButton_kind_primary, &.mx_AccessibleButton_kind_primary,
&.mx_AccessibleButton_kind_primary_outline { &.mx_AccessibleButton_kind_primary_outline {
border: 1px solid $accent; border: 1px solid $accent;
} }
&.mx_AccessibleButton_kind_icon_primary,
&.mx_AccessibleButton_kind_primary { &.mx_AccessibleButton_kind_primary {
color: $button-primary-fg-color; color: $button-primary-fg-color;
background-color: $accent; background-color: $accent;
} }
&.mx_AccessibleButton_kind_icon_primary_outline,
&.mx_AccessibleButton_kind_primary_outline { &.mx_AccessibleButton_kind_primary_outline {
color: $accent; color: $accent;
} }

View file

@ -0,0 +1,56 @@
/*
Copyright 2023 Nordeck IT + Consulting GmbH
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_PeopleRoomSettingsTab_knock {
display: flex;
margin-top: var(--cpd-space-2x);
}
.mx_PeopleRoomSettingsTab_content {
flex-grow: 1;
margin: 0 var(--cpd-space-4x);
}
.mx_PeopleRoomSettingsTab_name {
font-weight: var(--cpd-font-weight-semibold);
}
.mx_PeopleRoomSettingsTab_timestamp {
color: $secondary-content;
margin-left: var(--cpd-space-1x);
}
.mx_PeopleRoomSettingsTab_userId {
color: $secondary-content;
display: block;
font-size: var(--cpd-font-size-body-sm);
}
.mx_PeopleRoomSettingsTab_seeMoreOrLess {
margin: var(--cpd-space-3x) 0 0;
}
.mx_PeopleRoomSettingsTab_action {
flex-shrink: 0;
+ .mx_PeopleRoomSettingsTab_action {
margin-left: var(--cpd-space-3x);
}
}
.mx_PeopleRoomSettingsTab_paragraph {
margin: 0;
}

View file

@ -1,3 +1,3 @@
<svg fill="none" height="24" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"> <svg fill="none" height="24" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="m20 6-11 11-5-5"/> <path d="m20 6-11 11-5-5"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 213 B

After

Width:  |  Height:  |  Size: 221 B

View file

@ -1,4 +1,4 @@
<svg fill="none" height="24" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"> <svg fill="none" height="24" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="m18 6-12 12"/> <path d="m18 6-12 12"/>
<path d="m6 6 12 12"/> <path d="m6 6 12 12"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 236 B

After

Width:  |  Height:  |  Size: 244 B

View file

@ -18,7 +18,7 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { RoomEvent, Room } from "matrix-js-sdk/src/matrix"; import { RoomEvent, Room, RoomStateEvent, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import TabbedView, { Tab } from "../../structures/TabbedView"; import TabbedView, { Tab } from "../../structures/TabbedView";
import { _t, _td } from "../../../languageHandler"; import { _t, _td } from "../../../languageHandler";
@ -39,9 +39,11 @@ import { ActionPayload } from "../../../dispatcher/payloads";
import { NonEmptyArray } from "../../../@types/common"; import { NonEmptyArray } from "../../../@types/common";
import { PollHistoryTab } from "../settings/tabs/room/PollHistoryTab"; import { PollHistoryTab } from "../settings/tabs/room/PollHistoryTab";
import ErrorBoundary from "../elements/ErrorBoundary"; import ErrorBoundary from "../elements/ErrorBoundary";
import { PeopleRoomSettingsTab } from "../settings/tabs/room/PeopleRoomSettingsTab";
export const enum RoomSettingsTab { export const enum RoomSettingsTab {
General = "ROOM_GENERAL_TAB", General = "ROOM_GENERAL_TAB",
People = "ROOM_PEOPLE_TAB",
Voip = "ROOM_VOIP_TAB", Voip = "ROOM_VOIP_TAB",
Security = "ROOM_SECURITY_TAB", Security = "ROOM_SECURITY_TAB",
Roles = "ROOM_ROLES_TAB", Roles = "ROOM_ROLES_TAB",
@ -74,6 +76,7 @@ class RoomSettingsDialog extends React.Component<IProps, IState> {
public componentDidMount(): void { public componentDidMount(): void {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.safeGet().on(RoomEvent.Name, this.onRoomName); MatrixClientPeg.safeGet().on(RoomEvent.Name, this.onRoomName);
MatrixClientPeg.safeGet().on(RoomStateEvent.Events, this.onStateEvent);
this.onRoomName(); this.onRoomName();
} }
@ -90,6 +93,7 @@ class RoomSettingsDialog extends React.Component<IProps, IState> {
} }
MatrixClientPeg.get()?.removeListener(RoomEvent.Name, this.onRoomName); MatrixClientPeg.get()?.removeListener(RoomEvent.Name, this.onRoomName);
MatrixClientPeg.get()?.removeListener(RoomStateEvent.Events, this.onStateEvent);
} }
/** /**
@ -120,6 +124,10 @@ class RoomSettingsDialog extends React.Component<IProps, IState> {
this.forceUpdate(); this.forceUpdate();
}; };
private onStateEvent = (event: MatrixEvent): void => {
if (event.getType() === EventType.RoomJoinRules) this.forceUpdate();
};
private getTabs(): NonEmptyArray<Tab<RoomSettingsTab>> { private getTabs(): NonEmptyArray<Tab<RoomSettingsTab>> {
const tabs: Tab<RoomSettingsTab>[] = []; const tabs: Tab<RoomSettingsTab>[] = [];
@ -132,6 +140,16 @@ class RoomSettingsDialog extends React.Component<IProps, IState> {
"RoomSettingsGeneral", "RoomSettingsGeneral",
), ),
); );
if (SettingsStore.getValue("feature_ask_to_join") && this.state.room.getJoinRule() === "knock") {
tabs.push(
new Tab(
RoomSettingsTab.People,
_td("People"),
"mx_RoomSettingsDialog_peopleIcon",
<PeopleRoomSettingsTab room={this.state.room} />,
),
);
}
if (SettingsStore.getValue("feature_group_calls")) { if (SettingsStore.getValue("feature_group_calls")) {
tabs.push( tabs.push(
new Tab( new Tab(

View file

@ -38,7 +38,9 @@ type AccessibleButtonKind =
| "link_sm" | "link_sm"
| "confirm_sm" | "confirm_sm"
| "cancel_sm" | "cancel_sm"
| "icon"; | "icon"
| "icon_primary"
| "icon_primary_outline";
/** /**
* This type construct allows us to specifically pass those props down to the element were creating that the element * This type construct allows us to specifically pass those props down to the element were creating that the element

View file

@ -0,0 +1,173 @@
/*
Copyright 2023 Nordeck IT + Consulting GmbH
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventTimeline, MatrixError, Room, RoomMember, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import React, { useCallback, useState, VFC } from "react";
import { Icon as CheckIcon } from "../../../../../../res/img/feather-customised/check.svg";
import { Icon as XIcon } from "../../../../../../res/img/feather-customised/x.svg";
import { formatRelativeTime } from "../../../../../DateUtils";
import { useTypedEventEmitterState } from "../../../../../hooks/useEventEmitter";
import { _t } from "../../../../../languageHandler";
import Modal, { IHandle } from "../../../../../Modal";
import MemberAvatar from "../../../avatars/MemberAvatar";
import ErrorDialog from "../../../dialogs/ErrorDialog";
import AccessibleButton from "../../../elements/AccessibleButton";
import SettingsFieldset from "../../SettingsFieldset";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsTab from "../SettingsTab";
const Timestamp: VFC<{ roomMember: RoomMember }> = ({ roomMember }) => {
const timestamp = roomMember.events.member?.event.origin_server_ts;
if (!timestamp) return null;
return <time className="mx_PeopleRoomSettingsTab_timestamp">{formatRelativeTime(new Date(timestamp))}</time>;
};
const SeeMoreOrLess: VFC<{ roomMember: RoomMember }> = ({ roomMember }) => {
const [seeMore, setSeeMore] = useState(false);
const reason = roomMember.events.member?.getContent().reason;
if (!reason) return null;
const truncateAt = 120;
const shouldTruncate = reason.length > truncateAt;
return (
<>
<p className="mx_PeopleRoomSettingsTab_seeMoreOrLess">
{seeMore || !shouldTruncate ? reason : `${reason.substring(0, truncateAt)}`}
</p>
{shouldTruncate && (
<AccessibleButton kind="link" onClick={() => setSeeMore(!seeMore)}>
{seeMore ? _t("See less") : _t("See more")}
</AccessibleButton>
)}
</>
);
};
const Knock: VFC<{
canInvite: boolean;
canKick: boolean;
onApprove: (userId: string) => Promise<void>;
onDeny: (userId: string) => Promise<void>;
roomMember: RoomMember;
}> = ({ canKick, canInvite, onApprove, onDeny, roomMember }) => {
const [disabled, setDisabled] = useState(false);
const handleApprove = (userId: string): void => {
setDisabled(true);
onApprove(userId).catch(onError);
};
const handleDeny = (userId: string): void => {
setDisabled(true);
onDeny(userId).catch(onError);
};
const onError = (): void => setDisabled(false);
return (
<div className="mx_PeopleRoomSettingsTab_knock">
<MemberAvatar height={42} member={roomMember} width={42} />
<div className="mx_PeopleRoomSettingsTab_content">
<span className="mx_PeopleRoomSettingsTab_name">{roomMember.name}</span>
<Timestamp roomMember={roomMember} />
<span className="mx_PeopleRoomSettingsTab_userId">{roomMember.userId}</span>
<SeeMoreOrLess roomMember={roomMember} />
</div>
<AccessibleButton
className="mx_PeopleRoomSettingsTab_action"
disabled={!canKick || disabled}
kind="icon_primary_outline"
onClick={() => handleDeny(roomMember.userId)}
title={_t("Deny")}
>
<XIcon width={18} height={18} />
</AccessibleButton>
<AccessibleButton
className="mx_PeopleRoomSettingsTab_action"
disabled={!canInvite || disabled}
kind="icon_primary"
onClick={() => handleApprove(roomMember.userId)}
title={_t("Approve")}
>
<CheckIcon width={18} height={18} />
</AccessibleButton>
</div>
);
};
export const PeopleRoomSettingsTab: VFC<{ room: Room }> = ({ room }) => {
const client = room.client;
const userId = client.getUserId() || "";
const canInvite = room.canInvite(userId);
const member = room.getMember(userId);
const state = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
const canKick = member && state ? state.hasSufficientPowerLevelFor("kick", member.powerLevel) : false;
const roomId = room.roomId;
const handleApprove = (userId: string): Promise<void> =>
new Promise((_, reject) =>
client.invite(roomId, userId).catch((error) => {
onError(error);
reject(error);
}),
);
const handleDeny = (userId: string): Promise<void> =>
new Promise((_, reject) =>
client.kick(roomId, userId).catch((error) => {
onError(error);
reject(error);
}),
);
const onError = (error: MatrixError): IHandle<typeof ErrorDialog> =>
Modal.createDialog(ErrorDialog, {
title: error.name,
description: error.message,
});
const knockMembers = useTypedEventEmitterState(
room,
RoomStateEvent.Members,
useCallback(() => room.getMembersWithMembership("knock"), [room]),
);
return (
<SettingsTab>
<SettingsSection heading={_t("People")}>
<SettingsFieldset legend={_t("Asking to join")}>
{knockMembers.length ? (
knockMembers.map((knockMember) => (
<Knock
canInvite={canInvite}
canKick={canKick}
key={knockMember.userId}
onApprove={handleApprove}
onDeny={handleDeny}
roomMember={knockMember}
/>
))
) : (
<p className="mx_PeopleRoomSettingsTab_paragraph">{_t("No requests")}</p>
)}
</SettingsFieldset>
</SettingsSection>
</SettingsTab>
);
};

View file

@ -1698,6 +1698,12 @@
"Set a new custom sound": "Set a new custom sound", "Set a new custom sound": "Set a new custom sound",
"Upload custom sound": "Upload custom sound", "Upload custom sound": "Upload custom sound",
"Browse": "Browse", "Browse": "Browse",
"See less": "See less",
"See more": "See more",
"Deny": "Deny",
"Approve": "Approve",
"Asking to join": "Asking to join",
"No requests": "No requests",
"Failed to unban": "Failed to unban", "Failed to unban": "Failed to unban",
"Banned by %(displayName)s": "Banned by %(displayName)s", "Banned by %(displayName)s": "Banned by %(displayName)s",
"Reason": "Reason", "Reason": "Reason",
@ -3123,7 +3129,6 @@
"Verification Request": "Verification Request", "Verification Request": "Verification Request",
"Approve widget permissions": "Approve widget permissions", "Approve widget permissions": "Approve widget permissions",
"This widget would like to:": "This widget would like to:", "This widget would like to:": "This widget would like to:",
"Approve": "Approve",
"Decline All": "Decline All", "Decline All": "Decline All",
"Remember my selection for this widget": "Remember my selection for this widget", "Remember my selection for this widget": "Remember my selection for this widget",
"Allow this widget to verify your identity": "Allow this widget to verify your identity", "Allow this widget to verify your identity": "Allow this widget to verify your identity",

View file

@ -16,7 +16,16 @@ limitations under the License.
import React from "react"; import React from "react";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { Room, Visibility } from "matrix-js-sdk/src/matrix"; import { mkEvent } from "matrix-js-sdk/spec/test-utils/test-utils";
import {
EventTimeline,
EventType,
JoinRule,
MatrixEvent,
Room,
RoomStateEvent,
Visibility,
} from "matrix-js-sdk/src/matrix";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../test-utils"; import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../test-utils";
import RoomSettingsDialog from "../../../../src/components/views/dialogs/RoomSettingsDialog"; import RoomSettingsDialog from "../../../../src/components/views/dialogs/RoomSettingsDialog";
@ -84,6 +93,54 @@ describe("<RoomSettingsDialog />", () => {
expect(container.querySelectorAll(".mx_TabbedView_tabLabel")).toMatchSnapshot(); expect(container.querySelectorAll(".mx_TabbedView_tabLabel")).toMatchSnapshot();
}); });
describe("people settings tab", () => {
it("does not render when disabled and room join rule is not knock", () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Invite);
getComponent();
expect(screen.queryByTestId("settings-tab-ROOM_PEOPLE_TAB")).not.toBeInTheDocument();
});
it("does not render when disabled and room join rule is knock", () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
getComponent();
expect(screen.queryByTestId("settings-tab-ROOM_PEOPLE_TAB")).not.toBeInTheDocument();
});
it("does not render when enabled and room join rule is not knock", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(setting) => setting === "feature_ask_to_join",
);
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Invite);
getComponent();
expect(screen.queryByTestId("settings-tab-ROOM_PEOPLE_TAB")).not.toBeInTheDocument();
});
it("renders when enabled and room join rule is knock", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(setting) => setting === "feature_ask_to_join",
);
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
getComponent();
expect(screen.getByTestId("settings-tab-ROOM_PEOPLE_TAB")).toBeInTheDocument();
});
it("re-renders on room join rule changes", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(setting) => setting === "feature_ask_to_join",
);
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
getComponent();
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Invite);
mockClient.emit(
RoomStateEvent.Events,
new MatrixEvent(mkEvent({ content: {}, type: EventType.RoomJoinRules })),
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
null,
);
expect(screen.queryByTestId("settings-tab-ROOM_PEOPLE_TAB")).not.toBeInTheDocument();
});
});
it("renders voip settings tab when enabled", () => { it("renders voip settings tab when enabled", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation( jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_group_calls", (settingName) => settingName === "feature_group_calls",

View file

@ -0,0 +1,217 @@
/*
Copyright 2023 Nordeck IT + Consulting GmbH
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import {
EventTimeline,
EventType,
MatrixError,
MatrixEvent,
Room,
RoomMember,
RoomStateEvent,
} from "matrix-js-sdk/src/matrix";
import React from "react";
import ErrorDialog from "../../../../../../src/components/views/dialogs/ErrorDialog";
import { PeopleRoomSettingsTab } from "../../../../../../src/components/views/settings/tabs/room/PeopleRoomSettingsTab";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import Modal from "../../../../../../src/Modal";
import { flushPromises, getMockClientWithEventEmitter } from "../../../../../test-utils";
describe("PeopleRoomSettingsTab", () => {
const client = getMockClientWithEventEmitter({
getUserId: jest.fn(),
invite: jest.fn(),
kick: jest.fn(),
mxcUrlToHttp: (mxcUrl: string) => mxcUrl,
});
const roomId = "#ask-to-join:example.org";
const userId = "@alice:example.org";
const member = new RoomMember(roomId, userId);
const room = new Room(roomId, client, userId);
const state = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const getButton = (name: "Approve" | "Deny" | "See less" | "See more") => screen.getByRole("button", { name });
const getComponent = (room: Room) =>
render(
<MatrixClientContext.Provider value={client}>
<PeopleRoomSettingsTab room={room} />
</MatrixClientContext.Provider>,
);
const getGroup = () => screen.getByRole("group", { name: "Asking to join" });
const getParagraph = () => screen.getByRole("paragraph");
it("renders a heading", () => {
getComponent(room);
expect(screen.getByRole("heading")).toHaveTextContent("People");
});
it('renders a group "asking to join"', () => {
getComponent(room);
expect(getGroup()).toBeInTheDocument();
});
describe("without requests to join", () => {
it('renders a paragraph "no requests"', () => {
getComponent(room);
expect(getParagraph()).toHaveTextContent("No requests");
});
});
describe("with requests to join", () => {
const error = new MatrixError();
const knockUserId = "@albert.einstein:example.org";
const knockMember = new RoomMember(roomId, knockUserId);
const reason =
"There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a miracle.";
beforeEach(() => {
jest.spyOn(Modal, "createDialog");
jest.spyOn(room, "canInvite").mockReturnValue(true);
jest.spyOn(room, "getMember").mockReturnValue(member);
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([knockMember]);
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(true);
knockMember.setMembershipEvent(
new MatrixEvent({
content: {
avatar_url: "mxc://example.org/albert-einstein.png",
displayname: "Albert Einstein",
membership: "knock",
reason,
},
origin_server_ts: -464140800000,
type: EventType.RoomMember,
}),
);
});
it("renders requests fully", () => {
getComponent(room);
expect(getGroup()).toMatchSnapshot();
});
it("renders requests reduced", () => {
knockMember.setMembershipEvent(
new MatrixEvent({
content: {
displayname: "albert.einstein",
membership: "knock",
},
type: EventType.RoomMember,
}),
);
getComponent(room);
expect(getGroup()).toMatchSnapshot();
});
it("allows to expand a reason", () => {
getComponent(room);
fireEvent.click(getButton("See more"));
expect(within(getGroup()).getByRole("paragraph")).toHaveTextContent(reason);
});
it("allows to collapse a reason", () => {
getComponent(room);
fireEvent.click(getButton("See more"));
fireEvent.click(getButton("See less"));
expect(getParagraph()).toHaveTextContent(`${reason.substring(0, 120)}`);
});
it("does not truncate a reason unnecessarily", () => {
const reason = "I have no special talents. I am only passionately curious.";
knockMember.setMembershipEvent(
new MatrixEvent({
content: {
displayname: "albert.einstein",
membership: "knock",
reason,
},
type: EventType.RoomMember,
}),
);
getComponent(room);
expect(getParagraph()).toHaveTextContent(reason);
});
it("disables the deny button if the power level is insufficient", () => {
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(false);
getComponent(room);
expect(getButton("Deny")).toHaveAttribute("disabled");
});
it("calls kick on deny", () => {
jest.spyOn(client, "kick").mockResolvedValue({});
getComponent(room);
fireEvent.click(getButton("Deny"));
expect(client.kick).toHaveBeenCalledWith(roomId, knockUserId);
});
it("fails to deny a request", async () => {
jest.spyOn(client, "kick").mockRejectedValue(error);
getComponent(room);
fireEvent.click(getButton("Deny"));
await act(() => flushPromises());
expect(Modal.createDialog).toHaveBeenCalledWith(ErrorDialog, {
title: error.name,
description: error.message,
});
});
it("succeeds to deny a request", () => {
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([]);
getComponent(room);
act(() => {
room.emit(RoomStateEvent.Members, new MatrixEvent(), state, knockMember);
});
expect(getParagraph()).toHaveTextContent("No requests");
});
it("disables the approve button if the power level is insufficient", () => {
jest.spyOn(room, "canInvite").mockReturnValue(false);
getComponent(room);
expect(getButton("Approve")).toHaveAttribute("disabled");
});
it("calls invite on approve", () => {
jest.spyOn(client, "invite").mockResolvedValue({});
getComponent(room);
fireEvent.click(getButton("Approve"));
expect(client.invite).toHaveBeenCalledWith(roomId, knockUserId);
});
it("fails to approve a request", async () => {
jest.spyOn(client, "invite").mockRejectedValue(error);
getComponent(room);
fireEvent.click(getButton("Approve"));
await act(() => flushPromises());
expect(Modal.createDialog).toHaveBeenCalledWith(ErrorDialog, {
title: error.name,
description: error.message,
});
});
it("succeeds to approve a request", () => {
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([]);
getComponent(room);
act(() => {
room.emit(RoomStateEvent.Members, new MatrixEvent(), state, knockMember);
});
expect(getParagraph()).toHaveTextContent("No requests");
});
});
});

View file

@ -0,0 +1,161 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PeopleRoomSettingsTab with requests to join renders requests fully 1`] = `
<fieldset
class="mx_SettingsFieldset"
>
<legend
class="mx_SettingsFieldset_legend"
>
Asking to join
</legend>
<div
class="mx_SettingsFieldset_content"
>
<div
class="mx_PeopleRoomSettingsTab_knock"
>
<img
alt=""
class="mx_BaseAvatar mx_BaseAvatar_image"
data-testid="avatar-img"
loading="lazy"
src="mxc://example.org/albert-einstein.png"
style="width: 42px; height: 42px;"
title="@albert.einstein:example.org"
/>
<div
class="mx_PeopleRoomSettingsTab_content"
>
<span
class="mx_PeopleRoomSettingsTab_name"
>
Albert Einstein
</span>
<time
class="mx_PeopleRoomSettingsTab_timestamp"
>
Apr 18, 1955
</time>
<span
class="mx_PeopleRoomSettingsTab_userId"
>
@albert.einstein:example.org
</span>
<p
class="mx_PeopleRoomSettingsTab_seeMoreOrLess"
>
There are only two ways to live your life. One is as though nothing is a miracle. The other is as though everything is a…
</p>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
>
See more
</div>
</div>
<div
class="mx_AccessibleButton mx_PeopleRoomSettingsTab_action mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon_primary_outline"
role="button"
tabindex="0"
title="Deny"
>
<div
height="18"
width="18"
/>
</div>
<div
class="mx_AccessibleButton mx_PeopleRoomSettingsTab_action mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon_primary"
role="button"
tabindex="0"
title="Approve"
>
<div
height="18"
width="18"
/>
</div>
</div>
</div>
</fieldset>
`;
exports[`PeopleRoomSettingsTab with requests to join renders requests reduced 1`] = `
<fieldset
class="mx_SettingsFieldset"
>
<legend
class="mx_SettingsFieldset_legend"
>
Asking to join
</legend>
<div
class="mx_SettingsFieldset_content"
>
<div
class="mx_PeopleRoomSettingsTab_knock"
>
<span
class="mx_BaseAvatar"
role="presentation"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 27.3px; width: 42px; line-height: 42px;"
>
A
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
loading="lazy"
src="data:image/png;base64,00"
style="width: 42px; height: 42px;"
title="@albert.einstein:example.org"
/>
</span>
<div
class="mx_PeopleRoomSettingsTab_content"
>
<span
class="mx_PeopleRoomSettingsTab_name"
>
albert.einstein
</span>
<span
class="mx_PeopleRoomSettingsTab_userId"
>
@albert.einstein:example.org
</span>
</div>
<div
class="mx_AccessibleButton mx_PeopleRoomSettingsTab_action mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon_primary_outline"
role="button"
tabindex="0"
title="Deny"
>
<div
height="18"
width="18"
/>
</div>
<div
class="mx_AccessibleButton mx_PeopleRoomSettingsTab_action mx_AccessibleButton_hasKind mx_AccessibleButton_kind_icon_primary"
role="button"
tabindex="0"
title="Approve"
>
<div
height="18"
width="18"
/>
</div>
</div>
</div>
</fieldset>
`;