Introduce room knocks bar (#11475)

* Introduce room knocks bar

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

* Apply PR 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-31 15:43:38 +02:00 committed by GitHub
parent f948a8f798
commit 45094bda7c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 522 additions and 1 deletions

View file

@ -298,6 +298,7 @@
@import "./views/rooms/_RoomCallBanner.pcss";
@import "./views/rooms/_RoomHeader.pcss";
@import "./views/rooms/_RoomInfoLine.pcss";
@import "./views/rooms/_RoomKnocksBar.pcss";
@import "./views/rooms/_RoomList.pcss";
@import "./views/rooms/_RoomListHeader.pcss";
@import "./views/rooms/_RoomPreviewBar.pcss";

View file

@ -0,0 +1,50 @@
/*
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_RoomKnocksBar {
background-color: var(--cpd-color-bg-subtle-secondary);
display: flex;
padding: var(--cpd-space-2x) var(--cpd-space-4x);
}
.mx_RoomKnocksBar_content {
flex-grow: 1;
margin: 0 var(--cpd-space-3x);
}
.mx_RoomKnocksBar_paragraph {
color: $secondary-content;
font-size: var(--cpd-font-size-body-sm);
margin: 0;
}
.mx_RoomKnocksBar_link {
margin-left: var(--cpd-space-3x);
}
.mx_RoomKnocksBar_action,
.mx_RoomKnocksBar_avatar {
align-self: center;
flex-shrink: 0;
}
.mx_RoomKnocksBar_action + .mx_RoomKnocksBar_action {
margin-left: var(--cpd-space-3x);
}
.mx_RoomKnocksBar_avatar + .mx_RoomKnocksBar_avatar {
margin-left: calc(var(--cpd-space-4x) * -1);
}

View file

@ -36,6 +36,7 @@ import RoomTopic from "../elements/RoomTopic";
import RoomName from "../elements/RoomName";
import { E2EStatus } from "../../../utils/ShieldUtils";
import { IOOBData } from "../../../stores/ThreepidInviteStore";
import { RoomKnocksBar } from "./RoomKnocksBar";
import { SearchScope } from "./SearchBar";
import { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
import RoomContextMenu from "../context_menus/RoomContextMenu";
@ -820,6 +821,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
</div>
{!isVideoRoom && <RoomCallBanner roomId={this.props.room.roomId} />}
<RoomLiveShareWarning roomId={this.props.room.roomId} />
<RoomKnocksBar room={this.props.room} />
</header>
);
}

View file

@ -0,0 +1,159 @@
/*
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, JoinRule, MatrixError, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import React, { ReactElement, ReactNode, 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 dis from "../../../dispatcher/dispatcher";
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import MemberAvatar from "../avatars/MemberAvatar";
import ErrorDialog from "../dialogs/ErrorDialog";
import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog";
import AccessibleButton from "../elements/AccessibleButton";
import Heading from "../typography/Heading";
export const RoomKnocksBar: VFC<{ room: Room }> = ({ room }) => {
const [disabled, setDisabled] = useState(false);
const knockMembers = useTypedEventEmitterState(
room,
RoomStateEvent.Members,
useCallback(() => room.getMembersWithMembership("knock"), [room]),
);
const knockMembersCount = knockMembers.length;
if (room.getJoinRule() !== JoinRule.Knock || knockMembersCount === 0) return null;
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;
if (!canInvite && !canKick) return null;
const onError = (error: MatrixError): void => {
Modal.createDialog(ErrorDialog, { title: error.name, description: error.message });
};
const handleApprove = (userId: string): void => {
setDisabled(true);
client
.invite(room.roomId, userId)
.catch(onError)
.finally(() => setDisabled(false));
};
const handleDeny = (userId: string): void => {
setDisabled(true);
client
.kick(room.roomId, userId)
.catch(onError)
.finally(() => setDisabled(false));
};
const handleOpenRoomSettings = (): void =>
dis.dispatch({ action: "open_room_settings", room_id: room.roomId, initial_tab_id: RoomSettingsTab.People });
let buttons: ReactElement = (
<AccessibleButton
className="mx_RoomKnocksBar_action"
kind="primary"
onClick={handleOpenRoomSettings}
title={_t("action|view")}
>
{_t("action|view")}
</AccessibleButton>
);
let names: string = knockMembers
.slice(0, 2)
.map((knockMember) => knockMember.name)
.join(", ");
let link: ReactNode = null;
switch (knockMembersCount) {
case 1: {
buttons = (
<>
<AccessibleButton
className="mx_RoomKnocksBar_action"
disabled={!canKick || disabled}
kind="icon_primary_outline"
onClick={() => handleDeny(knockMembers[0].userId)}
title={_t("action|deny")}
>
<XIcon width={18} height={18} />
</AccessibleButton>
<AccessibleButton
className="mx_RoomKnocksBar_action"
disabled={!canInvite || disabled}
kind="icon_primary"
onClick={() => handleApprove(knockMembers[0].userId)}
title={_t("action|approve")}
>
<CheckIcon width={18} height={18} />
</AccessibleButton>
</>
);
names = `${knockMembers[0].name} (${knockMembers[0].userId})`;
link = (
<AccessibleButton
className="mx_RoomKnocksBar_link"
element="a"
kind="link_inline"
onClick={handleOpenRoomSettings}
>
{_t("action|view_message")}
</AccessibleButton>
);
break;
}
case 2: {
names = _t("%(names)s and %(name)s", { names: knockMembers[0].name, name: knockMembers[1].name });
break;
}
case 3: {
names = _t("%(names)s and %(name)s", { names, name: knockMembers[2].name });
break;
}
default:
names = _t("%(names)s and %(count)s others", { names, count: knockMembersCount - 2 });
}
return (
<div className="mx_RoomKnocksBar">
{knockMembers.slice(0, 2).map((knockMember) => (
<MemberAvatar
className="mx_RoomKnocksBar_avatar"
key={knockMember.userId}
member={knockMember}
size="32px"
/>
))}
<div className="mx_RoomKnocksBar_content">
<Heading size="4">{_t("%(count)s people asking to join", { count: knockMembersCount })}</Heading>
<p className="mx_RoomKnocksBar_paragraph">
{names}
{link}
</p>
</div>
{buttons}
</div>
);
};

View file

@ -67,6 +67,8 @@
"search": "Search",
"quote": "Quote",
"unpin": "Unpin",
"view": "View",
"view_message": "View message",
"start_chat": "Start chat",
"invites_list": "Invites",
"reject": "Reject",
@ -87,7 +89,6 @@
"report_content": "Report Content",
"resend": "Resend",
"next": "Next",
"view": "View",
"ask_to_join": "Ask to join",
"forward": "Forward",
"copy_link": "Copy link",
@ -1861,6 +1862,15 @@
"Public room": "Public room",
"Private space": "Private space",
"Private room": "Private room",
"%(names)s and %(name)s": "%(names)s and %(name)s",
"%(names)s and %(count)s others": {
"other": "%(names)s and %(count)s others",
"one": "%(names)s and %(count)s other"
},
"%(count)s people asking to join": {
"other": "%(count)s people asking to join",
"one": "Asking to join"
},
"Start new chat": "Start new chat",
"Invite to space": "Invite to space",
"You do not have permissions to invite people to this space": "You do not have permissions to invite people to this space",

View file

@ -0,0 +1,299 @@
/*
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 } from "@testing-library/react";
import {
EventTimeline,
EventType,
JoinRule,
MatrixError,
MatrixEvent,
Room,
RoomMember,
RoomStateEvent,
} from "matrix-js-sdk/src/matrix";
import React from "react";
import ErrorDialog from "../../../../src/components/views/dialogs/ErrorDialog";
import { RoomSettingsTab } from "../../../../src/components/views/dialogs/RoomSettingsDialog";
import { RoomKnocksBar } from "../../../../src/components/views/rooms/RoomKnocksBar";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import dis from "../../../../src/dispatcher/dispatcher";
import Modal from "../../../../src/Modal";
import {
clearAllModals,
flushPromises,
getMockClientWithEventEmitter,
mockClientMethodsUser,
} from "../../../test-utils";
describe("RoomKnocksBar", () => {
const userId = "@alice:example.org";
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
invite: jest.fn(),
kick: jest.fn(),
});
const roomId = "#ask-to-join:example.org";
const member = new RoomMember(roomId, userId);
const room = new Room(roomId, client, userId);
const state = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
type ButtonNames = "Approve" | "Deny" | "View" | "View message";
const getButton = (name: ButtonNames) => screen.getByRole("button", { name });
const getComponent = (room: Room) =>
render(
<MatrixClientContext.Provider value={client}>
<RoomKnocksBar room={room} />
</MatrixClientContext.Provider>,
);
beforeEach(() => {
jest.spyOn(room, "getMember").mockReturnValue(member);
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
});
it("does not render if the room join rule is not knock", () => {
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Invite);
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([member]);
jest.spyOn(room, "canInvite").mockReturnValue(true);
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(true);
expect(getComponent(room).container.firstChild).toBeNull();
});
describe("without requests to join", () => {
beforeEach(() => {
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([]);
jest.spyOn(room, "canInvite").mockReturnValue(true);
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(true);
});
it("does not render if user can neither approve nor deny", () => {
jest.spyOn(room, "canInvite").mockReturnValue(false);
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(false);
expect(getComponent(room).container.firstChild).toBeNull();
});
it("does not render if user cannot approve", () => {
jest.spyOn(room, "canInvite").mockReturnValue(false);
expect(getComponent(room).container.firstChild).toBeNull();
});
it("does not render if user cannot deny", () => {
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(false);
expect(getComponent(room).container.firstChild).toBeNull();
});
it("does not render if user can approve and deny", () => {
expect(getComponent(room).container.firstChild).toBeNull();
});
});
describe("with requests to join", () => {
const error = new MatrixError();
const bob = new RoomMember(roomId, "@bob:example.org");
const jane = new RoomMember(roomId, "@jane:example.org");
const john = new RoomMember(roomId, "@john:example.org");
const other = new RoomMember(roomId, "@doe:example.org");
bob.setMembershipEvent(
new MatrixEvent({ content: { displayname: "Bob", membership: "knock" }, type: EventType.RoomMember }),
);
jane.setMembershipEvent(
new MatrixEvent({ content: { displayname: "Jane", membership: "knock" }, type: EventType.RoomMember }),
);
john.setMembershipEvent(
new MatrixEvent({ content: { displayname: "John", membership: "knock" }, type: EventType.RoomMember }),
);
other.setMembershipEvent(new MatrixEvent({ content: { membership: "knock" }, type: EventType.RoomMember }));
beforeEach(async () => {
await clearAllModals();
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob]);
jest.spyOn(room, "canInvite").mockReturnValue(true);
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(true);
jest.spyOn(Modal, "createDialog");
jest.spyOn(dis, "dispatch");
});
it("does not render if user can neither approve nor deny", () => {
jest.spyOn(room, "canInvite").mockReturnValue(false);
jest.spyOn(state, "hasSufficientPowerLevelFor").mockReturnValue(false);
expect(getComponent(room).container.firstChild).toBeNull();
});
it("unhides the bar when a new knock request appears", () => {
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([]);
const { container } = getComponent(room);
expect(container.firstChild).toBeNull();
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob]);
act(() => {
room.emit(RoomStateEvent.Members, new MatrixEvent(), state, bob);
});
expect(container.firstChild).not.toBeNull();
});
it("updates when the list of knocking users changes", () => {
getComponent(room);
expect(screen.getByRole("heading")).toHaveTextContent("Asking to join");
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob, jane]);
act(() => {
room.emit(RoomStateEvent.Members, new MatrixEvent(), state, jane);
});
expect(screen.getByRole("heading")).toHaveTextContent("2 people asking to join");
});
describe("when knock members count is 1", () => {
beforeEach(() => jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob]));
it("renders a heading and a paragraph with name and user ID", () => {
getComponent(room);
expect(screen.getByRole("heading")).toHaveTextContent("Asking to join");
expect(screen.getByRole("paragraph")).toHaveTextContent(`${bob.name} (${bob.userId})`);
});
it("renders a link to open the room settings people tab", () => {
getComponent(room);
fireEvent.click(getButton("View message"));
expect(dis.dispatch).toHaveBeenCalledWith({
action: "open_room_settings",
initial_tab_id: RoomSettingsTab.People,
room_id: roomId,
});
});
type TestCase = [string, ButtonNames, () => void];
it.each<TestCase>([
["deny request fails", "Deny", () => jest.spyOn(client, "kick").mockRejectedValue(error)],
["deny request succeeds", "Deny", () => jest.spyOn(client, "kick").mockResolvedValue({})],
["approve request fails", "Approve", () => jest.spyOn(client, "invite").mockRejectedValue(error)],
["approve request succeeds", "Approve", () => jest.spyOn(client, "invite").mockResolvedValue({})],
])("toggles the disabled attribute for the buttons when a %s", async (_, buttonName, setup) => {
setup();
getComponent(room);
fireEvent.click(getButton(buttonName));
expect(getButton("Deny")).toHaveAttribute("disabled");
expect(getButton("Approve")).toHaveAttribute("disabled");
await act(() => flushPromises());
expect(getButton("Deny")).not.toHaveAttribute("disabled");
expect(getButton("Approve")).not.toHaveAttribute("disabled");
});
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", async () => {
jest.spyOn(client, "kick").mockResolvedValue({});
getComponent(room);
fireEvent.click(getButton("Deny"));
await act(() => flushPromises());
expect(client.kick).toHaveBeenCalledWith(roomId, bob.userId);
});
it("displays an error when a deny request fails", 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("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", async () => {
jest.spyOn(client, "invite").mockResolvedValue({});
getComponent(room);
fireEvent.click(getButton("Approve"));
await act(() => flushPromises());
expect(client.invite).toHaveBeenCalledWith(roomId, bob.userId);
});
it("displays an error when an approval fails", 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("hides the bar when someone else approves or denies the waiting person", () => {
getComponent(room);
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([]);
act(() => {
room.emit(RoomStateEvent.Members, new MatrixEvent(), state, bob);
});
expect(getComponent(room).container.firstChild).toBeNull();
});
});
describe("when knock members count is greater than 1", () => {
beforeEach(() => {
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob, jane]);
getComponent(room);
});
it("renders a heading with count", () => {
expect(screen.getByRole("heading")).toHaveTextContent("2 people asking to join");
});
it("renders a button to open the room settings people tab", () => {
fireEvent.click(getButton("View"));
expect(dis.dispatch).toHaveBeenCalledWith({
action: "open_room_settings",
initial_tab_id: RoomSettingsTab.People,
room_id: roomId,
});
});
});
describe("when knock members count is 2", () => {
it("renders a paragraph with two names", () => {
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob, jane]);
getComponent(room);
expect(screen.getByRole("paragraph")).toHaveTextContent(`${bob.name} and ${jane.name}`);
});
});
describe("when knock members count is 3", () => {
it("renders a paragraph with three names", () => {
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob, jane, john]);
getComponent(room);
expect(screen.getByRole("paragraph")).toHaveTextContent(`${bob.name}, ${jane.name} and ${john.name}`);
});
});
describe("when knock count is greater than 3", () => {
it("renders a paragraph with two names and a count", () => {
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob, jane, john, other]);
getComponent(room);
expect(screen.getByRole("paragraph")).toHaveTextContent(`${bob.name}, ${jane.name} and 2 others`);
});
});
});
});