diff --git a/res/css/views/rooms/_RoomPreviewBar.pcss b/res/css/views/rooms/_RoomPreviewBar.pcss index 59e4f686e4..373a335d0d 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_fullWidth { + width: 100%; +} diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 2d575389a3..156264063c 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -37,7 +37,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"; @@ -125,6 +125,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; @@ -238,6 +240,10 @@ export interface IRoomState { liveTimeline?: EventTimeline; narrow: boolean; msc3946ProcessDynamicPredecessor: boolean; + + canAskToJoin: boolean; + promptAskToJoin: boolean; + knocked: boolean; } interface LocalRoomViewProps { @@ -384,6 +390,7 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement } export class RoomView extends React.Component { + private readonly askToJoinEnabled: boolean; private readonly dispatcherRef: string; private settingWatchers: string[]; @@ -401,6 +408,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"); } @@ -445,6 +454,9 @@ export class RoomView extends React.Component { liveTimeline: undefined, narrow: false, msc3946ProcessDynamicPredecessor: SettingsStore.getValue("feature_dynamic_room_predecessors"), + canAskToJoin: this.askToJoinEnabled, + promptAskToJoin: false, + knocked: false, }; this.dispatcherRef = dis.register(this.onAction); @@ -649,6 +661,8 @@ export class RoomView extends React.Component { ) : false, activeCall: roomId ? CallStore.instance.getActiveCall(roomId) : null, + promptAskToJoin: this.context.roomViewStore.promptAskToJoin(), + knocked: this.context.roomViewStore.knocked(), }; if ( @@ -891,6 +905,7 @@ export class RoomView extends React.Component { this.setState({ room: room, peekLoading: false, + canAskToJoin: this.askToJoinEnabled && room.getJoinRule() === JoinRule.Knock, }); this.onRoomLoaded(room); }) @@ -919,7 +934,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, + }); } } } @@ -1593,6 +1611,7 @@ export class RoomView extends React.Component { roomId, opts: { inviteSignUrl: signUrl }, metricsTrigger: this.state.room?.getMyMembership() === "invite" ? "Invite" : "RoomPreview", + canAskToJoin: this.state.canAskToJoin, }); } @@ -1997,6 +2016,40 @@ export class RoomView extends React.Component { ); } + /** + * Handles the submission of a request to join a room. + * + * @param {string} reason - An optional reason for the request to join. + * @returns {void} + */ + private onSubmitAskToJoin = (reason?: string): void => { + const roomId = this.getRoomId(); + + if (isNotUndefined(roomId)) { + dis.dispatch({ + action: Action.SubmitAskToJoin, + roomId, + opts: { reason }, + }); + } + }; + + /** + * Handles the cancellation of a request to join a room. + * + * @returns {void} + */ + 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; @@ -2062,6 +2115,10 @@ export class RoomView extends React.Component { oobData={this.props.oobData} signUrl={this.props.threepidInvite?.signUrl} roomId={this.state.roomId} + promptAskToJoin={this.state.promptAskToJoin} + knocked={this.state.knocked} + onSubmitAskToJoin={this.onSubmitAskToJoin} + onCancelAskToJoin={this.onCancelAskToJoin} /> @@ -2136,6 +2193,22 @@ export class RoomView extends React.Component { } } + if (this.state.canAskToJoin && ["knock", "leave"].includes(myMembership)) { + 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 74a5be68b5..68d53b7c0e 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, RoomMember } from "matrix-js-sdk/src/matrix"; import { MatrixError } from "matrix-js-sdk/src/http-api"; import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; @@ -35,6 +35,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"; @@ -53,6 +55,8 @@ enum MessageCase { ViewingRoom = "ViewingRoom", RoomNotFound = "RoomNotFound", OtherError = "OtherError", + PromptAskToJoin = "PromptAskToJoin", + Knocked = "Knocked", } interface IProps { @@ -95,6 +99,11 @@ interface IProps { onRejectClick?(): void; onRejectAndIgnoreClick?(): void; onForgetClick?(): void; + + promptAskToJoin?: boolean; + knocked?: boolean; + onSubmitAskToJoin?(reason?: string): void; + onCancelAskToJoin?(): void; } interface IState { @@ -102,6 +111,7 @@ interface IState { accountEmails?: string[]; invitedEmailMxid?: string; threePidFetchError?: MatrixError; + reason?: string; } export default class RoomPreviewBar extends React.Component { @@ -186,6 +196,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.promptAskToJoin) { + return MessageCase.PromptAskToJoin; } if (this.props.inviterName) { @@ -281,6 +295,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 ?? ""; @@ -581,6 +599,54 @@ export default class RoomPreviewBar extends React.Component { ]; break; } + case MessageCase.PromptAskToJoin: { + 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; @@ -650,7 +716,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 5654aed471..e0a2c8799f 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, + promptAskToJoin: false, + knocked: false, }); RoomContext.displayName = "RoomContext"; export default RoomContext; diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 219a38ead7..b08e09141d 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 prompt for ask to join a room. + */ + PromptAskToJoin = "prompt_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 0000000000..1e71b55e01 --- /dev/null +++ b/src/dispatcher/payloads/CancelAskToJoinPayload.ts @@ -0,0 +1,24 @@ +/* +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 { 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 b393c14b32..a77fb43a9d 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 61a1ca0e66..bb3ab532cc 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 0000000000..814739861f --- /dev/null +++ b/src/dispatcher/payloads/SubmitAskToJoinPayload.ts @@ -0,0 +1,27 @@ +/* +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 { KnockRoomOpts } 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?: KnockRoomOpts; +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index cc822c9091..200e9f3d46 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 b9effeb687..162a52474b 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; + + promptAskToJoin: boolean; + knocked: boolean; } const INITIAL_STATE: State = { @@ -138,6 +143,8 @@ const INITIAL_STATE: State = { viaServers: [], wasContextSwitch: false, viewingCall: false, + promptAskToJoin: false, + knocked: false, }; type Listener = (isActive: boolean) => void; @@ -356,6 +363,18 @@ export class RoomViewStore extends EventEmitter { } } break; + case Action.PromptAskToJoin: { + this.setState({ promptAskToJoin: 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.PromptAskToJoin }); + } } } @@ -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,57 @@ export class RoomViewStore extends EventEmitter { public isViewingCall(): boolean { return this.state.viewingCall; } + + /** + * Gets the current state of the 'promptForAskToJoin' property. + * + * @returns {boolean} The value of the 'promptForAskToJoin' property. + */ + public promptAskToJoin(): boolean { + return this.state.promptAskToJoin; + } + + /** + * Gets the current state of the 'knocked' property. + * + * @returns {boolean} The value of the 'knocked' property. + */ + public knocked(): boolean { + return this.state.knocked; + } + + /** + * Submits a request to join a room by sending a knock request. + * + * @param {SubmitAskToJoinPayload} payload - The payload containing information to submit the request. + * @returns {void} + */ + private submitAskToJoin(payload: SubmitAskToJoinPayload): void { + MatrixClientPeg.safeGet() + .knockRoom(payload.roomId, { viaServers: this.state.viaServers, ...payload.opts }) + .then(() => this.setState({ promptAskToJoin: false, knocked: true })) + .catch((err: MatrixError) => { + this.setState({ promptAskToJoin: false }); + + Modal.createDialog(ErrorDialog, { + title: _t("Failed to join"), + description: err.httpStatus === 403 ? _t("You need an invite to access this room.") : err.message, + }); + }); + } + + /** + * Cancels a request to join a room by sending a leave request. + * + * @param {CancelAskToJoinPayload} payload - The payload containing information to cancel the request. + * @returns {void} + */ + 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 04fce1e91e..4b774ee852 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -17,7 +17,7 @@ limitations under the License. import React, { createRef, RefObject } from "react"; import { mocked, MockedObject } from "jest-mock"; import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client"; -import { Room, RoomEvent, EventType, MatrixError, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { Room, RoomEvent, EventType, JoinRule, MatrixError, RoomStateEvent } from "matrix-js-sdk/src/matrix"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MEGOLM_ALGORITHM } from "matrix-js-sdk/src/crypto/olmlib"; import { fireEvent, render, screen, RenderResult } from "@testing-library/react"; @@ -34,10 +34,12 @@ import { mkRoomMemberJoinEvent, mkThirdPartyInviteEvent, emitPromise, + createTestClient, + untilDispatch, } from "../../test-utils"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { Action } from "../../../src/dispatcher/actions"; -import { defaultDispatcher } from "../../../src/dispatcher/dispatcher"; +import dis, { defaultDispatcher } from "../../../src/dispatcher/dispatcher"; import { ViewRoomPayload } from "../../../src/dispatcher/payloads/ViewRoomPayload"; import { RoomView as _RoomView } from "../../../src/components/structures/RoomView"; import ResizeNotifier from "../../../src/utils/ResizeNotifier"; @@ -543,4 +545,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 d0132f7e9e..b2ed656924 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({ promptAskToJoin: 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({ promptAskToJoin: true }); + expect(getActions(component)).toMatchSnapshot(); + }); + + it("triggers the primary action callback", () => { + const onSubmitAskToJoin = jest.fn(); + const component = getComponent({ promptAskToJoin: 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({ promptAskToJoin: 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 50336ad379..00c6d6714d 100644 --- a/test/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/components/views/rooms/SendMessageComposer-test.tsx @@ -83,6 +83,9 @@ describe("", () => { narrow: false, activeCall: null, msc3946ProcessDynamicPredecessor: false, + canAskToJoin: false, + promptAskToJoin: 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 7c82a4040d..f1857cbf5b 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.PromptAskToJoin }); + await untilDispatch(Action.PromptAskToJoin, 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.PromptAskToJoin, 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: "prompt_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.promptAskToJoin()).toBe(false); + }); + + it("returns true", async () => { + await dispatchPromptAskToJoin(); + expect(roomViewStore.promptAskToJoin()).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 dispatchPromptAskToJoin()); + + 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.promptAskToJoin()).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.promptAskToJoin()).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 2b5491dc78..5cd2778242 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, + promptAskToJoin: false, + knocked: false, ...override, }; diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index d503652d1b..3f4724ea4c 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);