From f3dbf299ded08e32f97997bd2776eaf9df9c5fce Mon Sep 17 00:00:00 2001 From: Charly Nguyen Date: Sun, 30 Jul 2023 10:30:36 +0200 Subject: [PATCH] Allow knocking rooms Signed-off-by: Charly Nguyen --- res/css/views/rooms/_RoomPreviewBar.pcss | 10 ++ src/components/structures/RoomView.tsx | 66 +++++++- src/components/views/rooms/RoomPreviewBar.tsx | 76 ++++++++- src/contexts/RoomContext.ts | 3 + src/dispatcher/actions.ts | 15 ++ .../payloads/CancelAskToJoinPayload.ts | 24 +++ .../payloads/JoinRoomErrorPayload.ts | 2 + src/dispatcher/payloads/JoinRoomPayload.ts | 2 + .../payloads/SubmitAskToJoinPayload.ts | 27 ++++ src/i18n/strings/en_EN.json | 10 ++ src/stores/RoomViewStore.tsx | 57 ++++++- test/components/structures/RoomView-test.tsx | 42 ++++- .../views/rooms/RoomPreviewBar-test.tsx | 56 +++++++ .../views/rooms/SendMessageComposer-test.tsx | 3 + .../RoomPreviewBar-test.tsx.snap | 116 ++++++++++++++ test/stores/RoomViewStore-test.ts | 148 ++++++++++++++++++ test/test-utils/room.ts | 3 + test/test-utils/test-utils.ts | 2 + 18 files changed, 656 insertions(+), 6 deletions(-) create mode 100644 src/dispatcher/payloads/CancelAskToJoinPayload.ts create mode 100644 src/dispatcher/payloads/SubmitAskToJoinPayload.ts diff --git a/res/css/views/rooms/_RoomPreviewBar.pcss b/res/css/views/rooms/_RoomPreviewBar.pcss index 59e4f686e416..010f16f03bc6 100644 --- a/res/css/views/rooms/_RoomPreviewBar.pcss +++ b/res/css/views/rooms/_RoomPreviewBar.pcss @@ -30,6 +30,7 @@ limitations under the License. display: flex; flex-direction: row; align-items: center; + margin: 0; } } @@ -148,3 +149,12 @@ a.mx_RoomPreviewBar_inviter { text-decoration: underline; cursor: pointer; } + +.mx_RoomPreviewBar_icon { + margin-right: 8px; + vertical-align: text-top; +} + +.mx_RoomPreviewBar_full { + width: 100%; +} diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index bdbb827a0024..d4272152be14 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -31,7 +31,7 @@ import { MatrixError } from "matrix-js-sdk/src/http-api"; import { ClientEvent } from "matrix-js-sdk/src/client"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; -import { HistoryVisibility } from "matrix-js-sdk/src/@types/partials"; +import { HistoryVisibility, JoinRule } from "matrix-js-sdk/src/@types/partials"; import { ISearchResults } from "matrix-js-sdk/src/@types/search"; import { IRoomTimelineData } from "matrix-js-sdk/src/models/event-timeline-set"; @@ -119,6 +119,8 @@ import WidgetUtils from "../../utils/WidgetUtils"; import { shouldEncryptRoomWithSingle3rdPartyInvite } from "../../utils/room/shouldEncryptRoomWithSingle3rdPartyInvite"; import { WaitingForThirdPartyRoomView } from "./WaitingForThirdPartyRoomView"; import { isNotUndefined } from "../../Typeguards"; +import { CancelAskToJoinPayload } from "../../dispatcher/payloads/CancelAskToJoinPayload"; +import { SubmitAskToJoinPayload } from "../../dispatcher/payloads/SubmitAskToJoinPayload"; const DEBUG = false; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; @@ -232,6 +234,10 @@ export interface IRoomState { liveTimeline?: EventTimeline; narrow: boolean; msc3946ProcessDynamicPredecessor: boolean; + + canAskToJoin: boolean; + askToJoin: boolean; + knocked: boolean; } interface LocalRoomViewProps { @@ -379,6 +385,7 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement } export class RoomView extends React.Component { + private readonly askToJoinEnabled: boolean; private readonly dispatcherRef: string; private settingWatchers: string[]; @@ -396,6 +403,8 @@ export class RoomView extends React.Component { public constructor(props: IRoomProps, context: React.ContextType) { super(props, context); + this.askToJoinEnabled = SettingsStore.getValue("feature_ask_to_join"); + if (!context.client) { throw new Error("Unable to create RoomView without MatrixClient"); } @@ -440,6 +449,9 @@ export class RoomView extends React.Component { liveTimeline: undefined, narrow: false, msc3946ProcessDynamicPredecessor: SettingsStore.getValue("feature_dynamic_room_predecessors"), + canAskToJoin: this.askToJoinEnabled, + askToJoin: false, + knocked: false, }; this.dispatcherRef = dis.register(this.onAction); @@ -644,6 +656,8 @@ export class RoomView extends React.Component { ) : false, activeCall: roomId ? CallStore.instance.getActiveCall(roomId) : null, + askToJoin: this.context.roomViewStore.askToJoin(), + knocked: this.context.roomViewStore.knocked(), }; if ( @@ -886,6 +900,7 @@ export class RoomView extends React.Component { this.setState({ room: room, peekLoading: false, + canAskToJoin: this.askToJoinEnabled && room.getJoinRule() === JoinRule.Knock, }); this.onRoomLoaded(room); }) @@ -914,7 +929,10 @@ export class RoomView extends React.Component { } else if (room) { // Stop peeking because we have joined this room previously this.context.client?.stopPeeking(); - this.setState({ isPeeking: false }); + this.setState({ + isPeeking: false, + canAskToJoin: this.askToJoinEnabled && room.getJoinRule() === JoinRule.Knock, + }); } } } @@ -1588,6 +1606,7 @@ export class RoomView extends React.Component { roomId, opts: { inviteSignUrl: signUrl }, metricsTrigger: this.state.room?.getMyMembership() === "invite" ? "Invite" : "RoomPreview", + canAskToJoin: this.state.canAskToJoin, }); } @@ -1992,6 +2011,29 @@ export class RoomView extends React.Component { ); } + private onSubmitAskToJoin = (reason?: string): void => { + const roomId = this.getRoomId(); + + if (isNotUndefined(roomId)) { + dis.dispatch({ + action: Action.SubmitAskToJoin, + roomId, + opts: { reason }, + }); + } + }; + + private onCancelAskToJoin = (): void => { + const roomId = this.getRoomId(); + + if (isNotUndefined(roomId)) { + dis.dispatch({ + action: Action.CancelAskToJoin, + roomId, + }); + } + }; + public render(): ReactNode { if (!this.context.client) return null; @@ -2057,6 +2099,10 @@ export class RoomView extends React.Component { oobData={this.props.oobData} signUrl={this.props.threepidInvite?.signUrl} roomId={this.state.roomId} + askToJoin={this.state.askToJoin} + knocked={this.state.knocked} + onSubmitAskToJoin={this.onSubmitAskToJoin} + onCancelAskToJoin={this.onCancelAskToJoin} /> @@ -2131,6 +2177,22 @@ export class RoomView extends React.Component { } } + if (this.state.canAskToJoin) { + return ( +
+ + + +
+ ); + } + // We have successfully loaded this room, and are not previewing. // Display the "normal" room view. diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx index ee2a5e1b420f..4e8fdaa506b8 100644 --- a/src/components/views/rooms/RoomPreviewBar.tsx +++ b/src/components/views/rooms/RoomPreviewBar.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode } from "react"; +import React, { ChangeEvent, ReactNode } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixError } from "matrix-js-sdk/src/http-api"; import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; @@ -36,6 +36,8 @@ import RoomAvatar from "../avatars/RoomAvatar"; import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import { ModuleRunner } from "../../../modules/ModuleRunner"; +import { Icon as AskToJoinIcon } from "../../../../res/img/element-icons/ask-to-join.svg"; +import Field from "../elements/Field"; const MemberEventHtmlReasonField = "io.element.html_reason"; @@ -54,6 +56,8 @@ enum MessageCase { ViewingRoom = "ViewingRoom", RoomNotFound = "RoomNotFound", OtherError = "OtherError", + AskToJoin = "AskToJoin", + Knocked = "Knocked", } interface IProps { @@ -96,6 +100,11 @@ interface IProps { onRejectClick?(): void; onRejectAndIgnoreClick?(): void; onForgetClick?(): void; + + askToJoin?: boolean; + knocked?: boolean; + onSubmitAskToJoin?(reason?: string): void; + onCancelAskToJoin?(): void; } interface IState { @@ -103,6 +112,7 @@ interface IState { accountEmails?: string[]; invitedEmailMxid?: string; threePidFetchError?: MatrixError; + reason?: string; } export default class RoomPreviewBar extends React.Component { @@ -187,6 +197,10 @@ export default class RoomPreviewBar extends React.Component { return MessageCase.Rejecting; } else if (this.props.loading || this.state.busy) { return MessageCase.Loading; + } else if (this.props.knocked) { + return MessageCase.Knocked; + } else if (this.props.askToJoin) { + return MessageCase.AskToJoin; } if (this.props.inviterName) { @@ -282,6 +296,10 @@ export default class RoomPreviewBar extends React.Component { dis.dispatch({ action: "start_registration", screenAfterLogin: this.makeScreenAfterLogin() }); }; + private onChangeReason = (event: ChangeEvent): void => { + this.setState({ reason: event.target.value }); + }; + public render(): React.ReactNode { const brand = SdkConfig.get().brand; const roomName = this.props.room?.name ?? this.props.roomAlias ?? ""; @@ -582,6 +600,54 @@ export default class RoomPreviewBar extends React.Component { ]; break; } + case MessageCase.AskToJoin: { + if (roomName) { + title = _t("Ask to join %(roomName)s?", { roomName }); + } else { + title = _t("Ask to join?"); + } + + const avatar = ; + subTitle = [ + avatar, + _t( + "You need to be granted access to this room in order to view or participate in the conversation. You can send a request to join below.", + ), + ]; + + reasonElement = ( + + ); + + primaryActionHandler = () => + this.props.onSubmitAskToJoin && this.props.onSubmitAskToJoin(this.state.reason); + primaryActionLabel = _t("Request access"); + + break; + } + case MessageCase.Knocked: { + title = _t("Request to join sent"); + + subTitle = [ + <> + + {_t("Your request to join is pending.")} + , + ]; + + secondaryActionHandler = this.props.onCancelAskToJoin; + secondaryActionLabel = _t("Cancel request"); + + break; + } } let subTitleElements; @@ -651,7 +717,13 @@ export default class RoomPreviewBar extends React.Component { {subTitleElements} {reasonElement} -
{actions}
+
+ {actions} +
{footer}
); diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index 5654aed47175..295545cb0d28 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -71,6 +71,9 @@ const RoomContext = createContext< narrow: false, activeCall: null, msc3946ProcessDynamicPredecessor: false, + canAskToJoin: false, + askToJoin: false, + knocked: false, }); RoomContext.displayName = "RoomContext"; export default RoomContext; diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 219a38ead7f8..23c4bee24bd2 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -351,4 +351,19 @@ export enum Action { * Fired when we want to view a thread, either a new one or an existing one */ ShowThread = "show_thread", + + /** + * Fired when requesting to ask to join a room. + */ + AskToJoin = "ask_to_join", + + /** + * Fired when requesting to submit an ask to join a room. Use with a SubmitAskToJoinPayload. + */ + SubmitAskToJoin = "submit_ask_to_join", + + /** + * Fired when requesting to cancel an ask to join a room. Use with a CancelAskToJoinPayload. + */ + CancelAskToJoin = "cancel_ask_to_join", } diff --git a/src/dispatcher/payloads/CancelAskToJoinPayload.ts b/src/dispatcher/payloads/CancelAskToJoinPayload.ts new file mode 100644 index 000000000000..a7864519c2c4 --- /dev/null +++ b/src/dispatcher/payloads/CancelAskToJoinPayload.ts @@ -0,0 +1,24 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +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 { Action } from "../actions"; +import { ActionPayload } from "../payloads"; + +export interface CancelAskToJoinPayload extends Pick { + action: Action.CancelAskToJoin; + + roomId: string; +} diff --git a/src/dispatcher/payloads/JoinRoomErrorPayload.ts b/src/dispatcher/payloads/JoinRoomErrorPayload.ts index b393c14b3288..a77fb43a9d49 100644 --- a/src/dispatcher/payloads/JoinRoomErrorPayload.ts +++ b/src/dispatcher/payloads/JoinRoomErrorPayload.ts @@ -24,4 +24,6 @@ export interface JoinRoomErrorPayload extends Pick { roomId: string; err: MatrixError; + + canAskToJoin?: boolean; } diff --git a/src/dispatcher/payloads/JoinRoomPayload.ts b/src/dispatcher/payloads/JoinRoomPayload.ts index 61a1ca0e6644..bb3ab532cc03 100644 --- a/src/dispatcher/payloads/JoinRoomPayload.ts +++ b/src/dispatcher/payloads/JoinRoomPayload.ts @@ -29,5 +29,7 @@ export interface JoinRoomPayload extends Pick { // additional parameters for the purpose of metrics & instrumentation metricsTrigger: JoinedRoomEvent["trigger"]; + + canAskToJoin?: boolean; } /* eslint-enable camelcase */ diff --git a/src/dispatcher/payloads/SubmitAskToJoinPayload.ts b/src/dispatcher/payloads/SubmitAskToJoinPayload.ts new file mode 100644 index 000000000000..1b78348a4006 --- /dev/null +++ b/src/dispatcher/payloads/SubmitAskToJoinPayload.ts @@ -0,0 +1,27 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +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 { IKnockRoomOpts } from "matrix-js-sdk/src/@types/requests"; + +import { Action } from "../actions"; +import { ActionPayload } from "../payloads"; + +export interface SubmitAskToJoinPayload extends Pick { + action: Action.SubmitAskToJoin; + + roomId: string; + opts?: IKnockRoomOpts; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 06caa0c72367..3050e6b9b425 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -893,6 +893,8 @@ "You attempted to join using a room ID without providing a list of servers to join through. Room IDs are internal identifiers and cannot be used to join a room without additional information.": "You attempted to join using a room ID without providing a list of servers to join through. Room IDs are internal identifiers and cannot be used to join a room without additional information.", "If you know a room address, try joining through that instead.": "If you know a room address, try joining through that instead.", "Failed to join": "Failed to join", + "You need an invite to access this room.": "You need an invite to access this room.", + "Failed to cancel": "Failed to cancel", "Connection lost": "Connection lost", "You were disconnected from the call. (Error: %(message)s)": "You were disconnected from the call. (Error: %(message)s)", "All rooms": "All rooms", @@ -2124,6 +2126,14 @@ "This room or space is not accessible at this time.": "This room or space is not accessible at this time.", "Try again later, or ask a room or space admin to check if you have access.": "Try again later, or ask a room or space admin to check if you have access.", "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please submit a bug report.": "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please submit a bug report.", + "Ask to join %(roomName)s?": "Ask to join %(roomName)s?", + "Ask to join?": "Ask to join?", + "You need to be granted access to this room in order to view or participate in the conversation. You can send a request to join below.": "You need to be granted access to this room in order to view or participate in the conversation. You can send a request to join below.", + "Message (optional)": "Message (optional)", + "Request access": "Request access", + "Request to join sent": "Request to join sent", + "Your request to join is pending.": "Your request to join is pending.", + "Cancel request": "Cancel request", "Leave": "Leave", " invites you": " invites you", "To view %(roomName)s, you need an invite": "To view %(roomName)s, you need an invite", diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index a26d2732195a..2e7068bd726b 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -61,6 +61,8 @@ import { IRoomStateEventsActionPayload } from "../actions/MatrixActionCreators"; import { showCantStartACallDialog } from "../voice-broadcast/utils/showCantStartACallDialog"; import { pauseNonLiveBroadcastFromOtherRoom } from "../voice-broadcast/utils/pauseNonLiveBroadcastFromOtherRoom"; import { ActionPayload } from "../dispatcher/payloads"; +import { CancelAskToJoinPayload } from "../dispatcher/payloads/CancelAskToJoinPayload"; +import { SubmitAskToJoinPayload } from "../dispatcher/payloads/SubmitAskToJoinPayload"; const NUM_JOIN_RETRY = 5; @@ -118,6 +120,9 @@ interface State { * Whether we're viewing a call or call lobby in this room */ viewingCall: boolean; + + askToJoin: boolean; + knocked: boolean; } const INITIAL_STATE: State = { @@ -138,6 +143,8 @@ const INITIAL_STATE: State = { viaServers: [], wasContextSwitch: false, viewingCall: false, + askToJoin: false, + knocked: false, }; type Listener = (isActive: boolean) => void; @@ -356,6 +363,18 @@ export class RoomViewStore extends EventEmitter { } } break; + case Action.AskToJoin: { + this.setState({ askToJoin: true }); + break; + } + case Action.SubmitAskToJoin: { + this.submitAskToJoin(payload as SubmitAskToJoinPayload); + break; + } + case Action.CancelAskToJoin: { + this.cancelAskToJoin(payload as CancelAskToJoinPayload); + break; + } } } @@ -563,7 +582,12 @@ export class RoomViewStore extends EventEmitter { action: Action.JoinRoomError, roomId, err, + canAskToJoin: payload.canAskToJoin, }); + + if (payload.canAskToJoin) { + this.dis?.dispatch({ action: Action.AskToJoin }); + } } } @@ -632,7 +656,7 @@ export class RoomViewStore extends EventEmitter { joining: false, joinError: payload.err, }); - if (payload.err) { + if (payload.err && !payload.canAskToJoin) { this.showJoinRoomError(payload.err, payload.roomId); } } @@ -746,4 +770,35 @@ export class RoomViewStore extends EventEmitter { public isViewingCall(): boolean { return this.state.viewingCall; } + + public askToJoin(): boolean { + return this.state.askToJoin; + } + + public knocked(): boolean { + return this.state.knocked; + } + + private submitAskToJoin(payload: SubmitAskToJoinPayload): void { + MatrixClientPeg.safeGet() + .knockRoom(payload.roomId, { viaServers: this.state.viaServers, ...payload.opts }) + .then(() => this.setState({ askToJoin: false, knocked: true })) + .catch((err: MatrixError) => { + this.setState({ askToJoin: false }); + + Modal.createDialog(ErrorDialog, { + title: _t("Failed to join"), + description: err.httpStatus === 403 ? _t("You need an invite to access this room.") : err.message, + }); + }); + } + + private cancelAskToJoin(payload: CancelAskToJoinPayload): void { + MatrixClientPeg.safeGet() + .leave(payload.roomId) + .then(() => this.setState({ knocked: false })) + .catch((err: MatrixError) => + Modal.createDialog(ErrorDialog, { title: _t("Failed to cancel"), description: err.message }), + ); + } } diff --git a/test/components/structures/RoomView-test.tsx b/test/components/structures/RoomView-test.tsx index aab93eefa83e..1d3a4fcd07ad 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -19,7 +19,7 @@ import { mocked, MockedObject } from "jest-mock"; import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client"; import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { EventType, MatrixError, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { EventType, JoinRule, MatrixError, RoomStateEvent } from "matrix-js-sdk/src/matrix"; import { MEGOLM_ALGORITHM } from "matrix-js-sdk/src/crypto/olmlib"; import { fireEvent, render, screen, RenderResult } from "@testing-library/react"; @@ -35,6 +35,8 @@ import { mkRoomMemberJoinEvent, mkThirdPartyInviteEvent, emitPromise, + createTestClient, + untilDispatch, } from "../../test-utils"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { Action } from "../../../src/dispatcher/actions"; @@ -57,6 +59,7 @@ import WidgetUtils from "../../../src/utils/WidgetUtils"; import { WidgetType } from "../../../src/widgets/WidgetType"; import WidgetStore from "../../../src/stores/WidgetStore"; import { ViewRoomErrorPayload } from "../../../src/dispatcher/payloads/ViewRoomErrorPayload"; +import dis from "../../../src/dispatcher/dispatcher"; // Fake random strings to give a predictable snapshot for IDs jest.mock("matrix-js-sdk/src/randomstring", () => ({ @@ -544,4 +547,41 @@ describe("RoomView", () => { expect(screen.queryByLabelText("Forget room")).not.toBeInTheDocument(); }); }); + + describe("knock rooms", () => { + const client = createTestClient(); + + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => setting === "feature_ask_to_join"); + jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock); + jest.spyOn(dis, "dispatch"); + }); + + it("allows to request to join", async () => { + jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client); + jest.spyOn(client, "knockRoom").mockResolvedValue({ room_id: room.roomId }); + + await mountRoomView(); + fireEvent.click(screen.getByRole("button", { name: "Request access" })); + await untilDispatch(Action.SubmitAskToJoin, dis); + + expect(dis.dispatch).toHaveBeenCalledWith({ + action: "submit_ask_to_join", + roomId: room.roomId, + opts: { reason: undefined }, + }); + }); + + it("allows to cancel a join request", async () => { + jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(client); + jest.spyOn(client, "leave").mockResolvedValue({}); + jest.spyOn(room, "getMyMembership").mockReturnValue("knock"); + + await mountRoomView(); + fireEvent.click(screen.getByRole("button", { name: "Cancel request" })); + await untilDispatch(Action.CancelAskToJoin, dis); + + expect(dis.dispatch).toHaveBeenCalledWith({ action: "cancel_ask_to_join", roomId: room.roomId }); + }); + }); }); diff --git a/test/components/views/rooms/RoomPreviewBar-test.tsx b/test/components/views/rooms/RoomPreviewBar-test.tsx index d0132f7e9ee3..e42d35c9c124 100644 --- a/test/components/views/rooms/RoomPreviewBar-test.tsx +++ b/test/components/views/rooms/RoomPreviewBar-test.tsx @@ -425,4 +425,60 @@ describe("", () => { }); }); }); + + describe("message case AskToJoin", () => { + it("renders the corresponding message", () => { + const component = getComponent({ askToJoin: true }); + expect(getMessage(component)).toMatchSnapshot(); + }); + + it("renders the corresponding message with a generic title", () => { + const component = render(); + expect(getMessage(component)).toMatchSnapshot(); + }); + + it("renders the corresponding actions", () => { + const component = getComponent({ askToJoin: true }); + expect(getActions(component)).toMatchSnapshot(); + }); + + it("triggers the primary action callback", () => { + const onSubmitAskToJoin = jest.fn(); + const component = getComponent({ askToJoin: true, onSubmitAskToJoin }); + + fireEvent.click(getPrimaryActionButton(component)!); + expect(onSubmitAskToJoin).toHaveBeenCalled(); + }); + + it("triggers the primary action callback with a reason", () => { + const onSubmitAskToJoin = jest.fn(); + const reason = "some reason"; + const component = getComponent({ askToJoin: true, onSubmitAskToJoin }); + + fireEvent.change(component.container.querySelector("textarea")!, { target: { value: reason } }); + fireEvent.click(getPrimaryActionButton(component)!); + + expect(onSubmitAskToJoin).toHaveBeenCalledWith(reason); + }); + }); + + describe("message case Knocked", () => { + it("renders the corresponding message", () => { + const component = getComponent({ knocked: true }); + expect(getMessage(component)).toMatchSnapshot(); + }); + + it("renders the corresponding actions", () => { + const component = getComponent({ knocked: true, onCancelAskToJoin: () => {} }); + expect(getActions(component)).toMatchSnapshot(); + }); + + it("triggers the secondary action callback", () => { + const onCancelAskToJoin = jest.fn(); + const component = getComponent({ knocked: true, onCancelAskToJoin }); + + fireEvent.click(getSecondaryActionButton(component)!); + expect(onCancelAskToJoin).toHaveBeenCalled(); + }); + }); }); diff --git a/test/components/views/rooms/SendMessageComposer-test.tsx b/test/components/views/rooms/SendMessageComposer-test.tsx index 39489b3dd278..e893799a30db 100644 --- a/test/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/components/views/rooms/SendMessageComposer-test.tsx @@ -84,6 +84,9 @@ describe("", () => { narrow: false, activeCall: null, msc3946ProcessDynamicPredecessor: false, + canAskToJoin: false, + askToJoin: false, + knocked: false, }; describe("createMessageContent", () => { const permalinkCreator = jest.fn() as any; diff --git a/test/components/views/rooms/__snapshots__/RoomPreviewBar-test.tsx.snap b/test/components/views/rooms/__snapshots__/RoomPreviewBar-test.tsx.snap index 7c82a4040dfc..8da737e8cda8 100644 --- a/test/components/views/rooms/__snapshots__/RoomPreviewBar-test.tsx.snap +++ b/test/components/views/rooms/__snapshots__/RoomPreviewBar-test.tsx.snap @@ -1,5 +1,121 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[` message case AskToJoin renders the corresponding actions 1`] = ` +
+
+ Request access +
+
+`; + +exports[` message case AskToJoin renders the corresponding message 1`] = ` +
+

+ Ask to join RoomPreviewBar-test-room? +

+

+ + + + +

+

+ You need to be granted access to this room in order to view or participate in the conversation. You can send a request to join below. +

+
+`; + +exports[` message case AskToJoin renders the corresponding message with a generic title 1`] = ` +
+

+ Ask to join? +

+

+ + + + +

+

+ You need to be granted access to this room in order to view or participate in the conversation. You can send a request to join below. +

+
+`; + +exports[` message case Knocked renders the corresponding actions 1`] = ` +
+
+ Cancel request +
+
+`; + +exports[` message case Knocked renders the corresponding message 1`] = ` +
+

+ Request to join sent +

+

+

+ Your request to join is pending. +

+
+`; + exports[` renders banned message 1`] = `
{ + dis.dispatch({ action: Action.AskToJoin }); + await untilDispatch(Action.AskToJoin, dis); + }; + + const dispatchSubmitAskToJoin = async (roomId: string, reason?: string) => { + dis.dispatch({ action: Action.SubmitAskToJoin, roomId, opts: { reason } }); + await untilDispatch(Action.SubmitAskToJoin, dis); + }; + + const dispatchCancelAskToJoin = async (roomId: string) => { + dis.dispatch({ action: Action.CancelAskToJoin, roomId }); + await untilDispatch(Action.CancelAskToJoin, dis); + }; + let roomViewStore: RoomViewStore; let slidingSyncManager: SlidingSyncManager; let dis: MatrixDispatcher; @@ -436,4 +457,131 @@ describe("RoomViewStore", function () { }); }); }); + + describe("Action.JoinRoom", () => { + it("dispatches Action.JoinRoomError and Action.AskToJoin when the join fails", async () => { + const err = new MatrixError(); + + jest.spyOn(dis, "dispatch"); + jest.spyOn(mockClient, "joinRoom").mockRejectedValueOnce(err); + + dis.dispatch({ action: Action.JoinRoom, canAskToJoin: true }); + await untilDispatch(Action.AskToJoin, dis); + + expect(mocked(dis.dispatch).mock.calls[0][0]).toEqual({ action: "join_room", canAskToJoin: true }); + expect(mocked(dis.dispatch).mock.calls[1][0]).toEqual({ + action: "join_room_error", + roomId: null, + err, + canAskToJoin: true, + }); + expect(mocked(dis.dispatch).mock.calls[2][0]).toEqual({ action: "ask_to_join" }); + }); + }); + + describe("Action.JoinRoomError", () => { + const err = new MatrixError(); + beforeEach(() => jest.spyOn(roomViewStore, "showJoinRoomError")); + + it("calls showJoinRoomError()", async () => { + dis.dispatch({ action: Action.JoinRoomError, roomId, err }); + await untilDispatch(Action.JoinRoomError, dis); + expect(roomViewStore.showJoinRoomError).toHaveBeenCalledWith(err, roomId); + }); + + it("does not call showJoinRoomError() when canAskToJoin is true", async () => { + dis.dispatch({ action: Action.JoinRoomError, roomId, err, canAskToJoin: true }); + await untilDispatch(Action.JoinRoomError, dis); + expect(roomViewStore.showJoinRoomError).not.toHaveBeenCalled(); + }); + }); + + describe("askToJoin()", () => { + it("returns false", () => { + expect(roomViewStore.askToJoin()).toBe(false); + }); + + it("returns true", async () => { + await dispatchAskToJoin(); + expect(roomViewStore.askToJoin()).toBe(true); + }); + }); + + describe("knocked()", () => { + it("returns false", () => { + expect(roomViewStore.knocked()).toBe(false); + }); + + it("returns true", async () => { + jest.spyOn(mockClient, "knockRoom").mockResolvedValue({ room_id: roomId }); + await dispatchSubmitAskToJoin(roomId); + expect(roomViewStore.knocked()).toBe(true); + }); + }); + + describe("Action.SubmitAskToJoin", () => { + const reason = "some reason"; + beforeEach(async () => await dispatchAskToJoin()); + + it("calls knockRoom(), sets askToJoin state to false and knocked state to true", async () => { + jest.spyOn(mockClient, "knockRoom").mockResolvedValue({ room_id: roomId }); + await dispatchSubmitAskToJoin(roomId, reason); + + expect(mockClient.knockRoom).toHaveBeenCalledWith(roomId, { reason, viaServers: [] }); + expect(roomViewStore.askToJoin()).toBe(false); + expect(roomViewStore.knocked()).toBe(true); + }); + + it("calls knockRoom(), sets askToJoin to false, keeps knocked state false and shows an error dialog", async () => { + const error = new MatrixError(undefined, 403); + jest.spyOn(mockClient, "knockRoom").mockRejectedValue(error); + await dispatchSubmitAskToJoin(roomId, reason); + + expect(mockClient.knockRoom).toHaveBeenCalledWith(roomId, { reason, viaServers: [] }); + expect(roomViewStore.askToJoin()).toBe(false); + expect(roomViewStore.knocked()).toBe(false); + expect(Modal.createDialog).toHaveBeenCalledWith(ErrorDialog, { + description: "You need an invite to access this room.", + title: "Failed to join", + }); + }); + + it("shows an error dialog with a generic error message", async () => { + const error = new MatrixError(); + jest.spyOn(mockClient, "knockRoom").mockRejectedValue(error); + await dispatchSubmitAskToJoin(roomId); + expect(Modal.createDialog).toHaveBeenCalledWith(ErrorDialog, { + description: error.message, + title: "Failed to join", + }); + }); + }); + + describe("Action.CancelAskToJoin", () => { + beforeEach(async () => { + jest.spyOn(mockClient, "knockRoom").mockResolvedValue({ room_id: roomId }); + await dispatchSubmitAskToJoin(roomId); + }); + + it("calls leave() and sets knocked state to false", async () => { + jest.spyOn(mockClient, "leave").mockResolvedValue({}); + await dispatchCancelAskToJoin(roomId); + + expect(mockClient.leave).toHaveBeenCalledWith(roomId); + expect(roomViewStore.knocked()).toBe(false); + }); + + it("calls leave(), keeps knocked state true and shows an error dialog", async () => { + const error = new MatrixError(); + jest.spyOn(mockClient, "leave").mockRejectedValue(error); + await dispatchCancelAskToJoin(roomId); + + expect(mockClient.leave).toHaveBeenCalledWith(roomId); + expect(roomViewStore.knocked()).toBe(true); + expect(Modal.createDialog).toHaveBeenCalledWith(ErrorDialog, { + description: error.message, + title: "Failed to cancel", + }); + }); + }); }); diff --git a/test/test-utils/room.ts b/test/test-utils/room.ts index 2b5491dc7898..9efd16f24271 100644 --- a/test/test-utils/room.ts +++ b/test/test-utils/room.ts @@ -88,6 +88,9 @@ export function getRoomContext(room: Room, override: Partial): IRoom narrow: false, activeCall: null, msc3946ProcessDynamicPredecessor: false, + canAskToJoin: false, + askToJoin: false, + knocked: false, ...override, }; diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index d503652d1b62..3f4724ea4c85 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -242,6 +242,8 @@ export function createTestClient(): MatrixClient { getSyncStateData: jest.fn(), getDehydratedDevice: jest.fn(), exportRoomKeys: jest.fn(), + knockRoom: jest.fn(), + leave: jest.fn(), } as unknown as MatrixClient; client.reEmitter = new ReEmitter(client);