diff --git a/res/css/_components.pcss b/res/css/_components.pcss index a3ec4057361..4414a422d5f 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 00000000000..90b7d6b3f7c --- /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 1de0edd48f0..8bee8f3fad3 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 00000000000..877e7a7c723 --- /dev/null +++ b/src/components/views/rooms/RoomKnocksBar.tsx @@ -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 = ( + + {_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("action|deny")} + > + + + handleApprove(knockMembers[0].userId)} + title={_t("action|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 9110545b4ca..df6f023a735 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", diff --git a/test/components/views/rooms/RoomKnocksBar-test.tsx b/test/components/views/rooms/RoomKnocksBar-test.tsx new file mode 100644 index 00000000000..6a323a6d2e5 --- /dev/null +++ b/test/components/views/rooms/RoomKnocksBar-test.tsx @@ -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( + + + , + ); + + 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([ + ["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`); + }); + }); + }); +});