From 794a35dbe4c43d6e6155022ffd83a67c6cbf1835 Mon Sep 17 00:00:00 2001 From: Charly Nguyen Date: Tue, 29 Aug 2023 08:15:11 +0200 Subject: [PATCH] Introduce room knocks bar Signed-off-by: Charly Nguyen --- res/css/_components.pcss | 1 + res/css/views/rooms/_RoomKnocksBar.pcss | 50 ++++ .../views/rooms/LegacyRoomHeader.tsx | 2 + src/components/views/rooms/RoomKnocksBar.tsx | 152 ++++++++++ src/i18n/strings/en_EN.json | 12 +- .../views/rooms/RoomKnocksBar-test.tsx | 262 ++++++++++++++++++ 6 files changed, 478 insertions(+), 1 deletion(-) create mode 100644 res/css/views/rooms/_RoomKnocksBar.pcss create mode 100644 src/components/views/rooms/RoomKnocksBar.tsx create mode 100644 test/components/views/rooms/RoomKnocksBar-test.tsx diff --git a/res/css/_components.pcss b/res/css/_components.pcss index a3ec4057361c..4414a422d5f9 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -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"; diff --git a/res/css/views/rooms/_RoomKnocksBar.pcss b/res/css/views/rooms/_RoomKnocksBar.pcss new file mode 100644 index 000000000000..90b7d6b3f7c0 --- /dev/null +++ b/res/css/views/rooms/_RoomKnocksBar.pcss @@ -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); +} diff --git a/src/components/views/rooms/LegacyRoomHeader.tsx b/src/components/views/rooms/LegacyRoomHeader.tsx index 1de0edd48f0d..8bee8f3fad31 100644 --- a/src/components/views/rooms/LegacyRoomHeader.tsx +++ b/src/components/views/rooms/LegacyRoomHeader.tsx @@ -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 { {!isVideoRoom && } + ); } diff --git a/src/components/views/rooms/RoomKnocksBar.tsx b/src/components/views/rooms/RoomKnocksBar.tsx new file mode 100644 index 000000000000..8c10842687e4 --- /dev/null +++ b/src/components/views/rooms/RoomKnocksBar.tsx @@ -0,0 +1,152 @@ +/* +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 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 handleApprove = (userId: string): void => { + setDisabled(true); + client.invite(room.roomId, userId).catch(onError); + }; + + const handleDeny = (userId: string): void => { + setDisabled(true); + client.kick(room.roomId, userId).catch(onError); + }; + + const handleOpenRoomSettings = (): void => + dis.dispatch({ action: "open_room_settings", room_id: room.roomId, initial_tab_id: RoomSettingsTab.People }); + + const onError = (error: MatrixError): void => { + setDisabled(false); + Modal.createDialog(ErrorDialog, { title: error.name, description: error.message }); + }; + + 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 || (!canInvite && !canKick)) return null; + + let buttons: ReactElement = ( + + {_t("action|view")} + + ); + let names: string = knockMembers + .slice(0, 2) + .map((knockMember) => knockMember.name) + .join(", "); + let link: ReactNode = null; + switch (knockMembersCount) { + case 1: { + buttons = ( + <> + handleDeny(knockMembers[0].userId)} + title={_t("Deny")} + > + + + handleApprove(knockMembers[0].userId)} + title={_t("Approve")} + > + + + + ); + names = `${knockMembers[0].name} (${knockMembers[0].userId})`; + link = ( + + {_t("action|view_message")} + + ); + 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 ( +
+ {knockMembers.slice(0, 2).map((knockMember) => ( + + ))} +
+ {_t("%(count)s people asking to join", { count: knockMembersCount })} +

+ {names} + {link} +

+
+ {buttons} +
+ ); +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f5a5a6a1e553..c95d8a86584b 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -53,6 +53,8 @@ "search": "Search", "quote": "Quote", "unpin": "Unpin", + "view": "View", + "view_message": "View message", "start_chat": "Start chat", "invites_list": "Invites", "reject": "Reject", @@ -73,7 +75,6 @@ "report_content": "Report Content", "resend": "Resend", "next": "Next", - "view": "View", "ask_to_join": "Ask to join", "forward": "Forward", "copy_link": "Copy link", @@ -1830,6 +1831,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", diff --git a/test/components/views/rooms/RoomKnocksBar-test.tsx b/test/components/views/rooms/RoomKnocksBar-test.tsx new file mode 100644 index 000000000000..0256947d5658 --- /dev/null +++ b/test/components/views/rooms/RoomKnocksBar-test.tsx @@ -0,0 +1,262 @@ +/* +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)!; + + const getButton = (name: "Approve" | "Deny" | "View" | "View message") => screen.getByRole("button", { name }); + const getComponent = (room: Room) => + render( + + + , + ); + + 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(); + }); + + describe("when knock members count is 1", () => { + beforeEach(() => jest.spyOn(room, "getMembersWithMembership").mockReturnValue([bob])); + + it("renders a heading", () => { + getComponent(room); + expect(screen.getByRole("heading")).toHaveTextContent("Asking to join"); + }); + + it("renders a paragraph", () => { + getComponent(room); + 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, + }); + }); + + 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, bob.userId); + }); + + 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("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.kick).toHaveBeenCalledWith(roomId, bob.userId); + }); + + 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 deny/approve a request", () => { + 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`); + }); + }); + }); +});