Allow knocking rooms (#11353)

Signed-off-by: Charly Nguyen <charly.nguyen@nordeck.net>
This commit is contained in:
Charly Nguyen 2023-08-07 08:27:09 +02:00 committed by GitHub
parent e6af09e424
commit 5152aad059
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 689 additions and 7 deletions

View file

@ -30,6 +30,7 @@ limitations under the License.
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
margin: 0;
} }
} }
@ -148,3 +149,12 @@ a.mx_RoomPreviewBar_inviter {
text-decoration: underline; text-decoration: underline;
cursor: pointer; cursor: pointer;
} }
.mx_RoomPreviewBar_icon {
margin-right: 8px;
vertical-align: text-top;
}
.mx_RoomPreviewBar_fullWidth {
width: 100%;
}

View file

@ -37,7 +37,7 @@ import { MatrixError } from "matrix-js-sdk/src/http-api";
import { ClientEvent } from "matrix-js-sdk/src/client"; import { ClientEvent } from "matrix-js-sdk/src/client";
import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; 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 { ISearchResults } from "matrix-js-sdk/src/@types/search";
import { IRoomTimelineData } from "matrix-js-sdk/src/models/event-timeline-set"; 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 { shouldEncryptRoomWithSingle3rdPartyInvite } from "../../utils/room/shouldEncryptRoomWithSingle3rdPartyInvite";
import { WaitingForThirdPartyRoomView } from "./WaitingForThirdPartyRoomView"; import { WaitingForThirdPartyRoomView } from "./WaitingForThirdPartyRoomView";
import { isNotUndefined } from "../../Typeguards"; import { isNotUndefined } from "../../Typeguards";
import { CancelAskToJoinPayload } from "../../dispatcher/payloads/CancelAskToJoinPayload";
import { SubmitAskToJoinPayload } from "../../dispatcher/payloads/SubmitAskToJoinPayload";
const DEBUG = false; const DEBUG = false;
const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000;
@ -238,6 +240,10 @@ export interface IRoomState {
liveTimeline?: EventTimeline; liveTimeline?: EventTimeline;
narrow: boolean; narrow: boolean;
msc3946ProcessDynamicPredecessor: boolean; msc3946ProcessDynamicPredecessor: boolean;
canAskToJoin: boolean;
promptAskToJoin: boolean;
knocked: boolean;
} }
interface LocalRoomViewProps { interface LocalRoomViewProps {
@ -384,6 +390,7 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement
} }
export class RoomView extends React.Component<IRoomProps, IRoomState> { export class RoomView extends React.Component<IRoomProps, IRoomState> {
private readonly askToJoinEnabled: boolean;
private readonly dispatcherRef: string; private readonly dispatcherRef: string;
private settingWatchers: string[]; private settingWatchers: string[];
@ -401,6 +408,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
public constructor(props: IRoomProps, context: React.ContextType<typeof SDKContext>) { public constructor(props: IRoomProps, context: React.ContextType<typeof SDKContext>) {
super(props, context); super(props, context);
this.askToJoinEnabled = SettingsStore.getValue("feature_ask_to_join");
if (!context.client) { if (!context.client) {
throw new Error("Unable to create RoomView without MatrixClient"); throw new Error("Unable to create RoomView without MatrixClient");
} }
@ -445,6 +454,9 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
liveTimeline: undefined, liveTimeline: undefined,
narrow: false, narrow: false,
msc3946ProcessDynamicPredecessor: SettingsStore.getValue("feature_dynamic_room_predecessors"), msc3946ProcessDynamicPredecessor: SettingsStore.getValue("feature_dynamic_room_predecessors"),
canAskToJoin: this.askToJoinEnabled,
promptAskToJoin: false,
knocked: false,
}; };
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
@ -649,6 +661,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
) )
: false, : false,
activeCall: roomId ? CallStore.instance.getActiveCall(roomId) : null, activeCall: roomId ? CallStore.instance.getActiveCall(roomId) : null,
promptAskToJoin: this.context.roomViewStore.promptAskToJoin(),
knocked: this.context.roomViewStore.knocked(),
}; };
if ( if (
@ -891,6 +905,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.setState({ this.setState({
room: room, room: room,
peekLoading: false, peekLoading: false,
canAskToJoin: this.askToJoinEnabled && room.getJoinRule() === JoinRule.Knock,
}); });
this.onRoomLoaded(room); this.onRoomLoaded(room);
}) })
@ -919,7 +934,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
} else if (room) { } else if (room) {
// Stop peeking because we have joined this room previously // Stop peeking because we have joined this room previously
this.context.client?.stopPeeking(); 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<IRoomProps, IRoomState> {
roomId, roomId,
opts: { inviteSignUrl: signUrl }, opts: { inviteSignUrl: signUrl },
metricsTrigger: this.state.room?.getMyMembership() === "invite" ? "Invite" : "RoomPreview", metricsTrigger: this.state.room?.getMyMembership() === "invite" ? "Invite" : "RoomPreview",
canAskToJoin: this.state.canAskToJoin,
}); });
} }
@ -1997,6 +2016,40 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
); );
} }
/**
* 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<SubmitAskToJoinPayload>({
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<CancelAskToJoinPayload>({
action: Action.CancelAskToJoin,
roomId,
});
}
};
public render(): ReactNode { public render(): ReactNode {
if (!this.context.client) return null; if (!this.context.client) return null;
@ -2062,6 +2115,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
oobData={this.props.oobData} oobData={this.props.oobData}
signUrl={this.props.threepidInvite?.signUrl} signUrl={this.props.threepidInvite?.signUrl}
roomId={this.state.roomId} roomId={this.state.roomId}
promptAskToJoin={this.state.promptAskToJoin}
knocked={this.state.knocked}
onSubmitAskToJoin={this.onSubmitAskToJoin}
onCancelAskToJoin={this.onCancelAskToJoin}
/> />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
@ -2136,6 +2193,22 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
} }
} }
if (this.state.canAskToJoin && ["knock", "leave"].includes(myMembership)) {
return (
<div className="mx_RoomView">
<ErrorBoundary>
<RoomPreviewBar
room={this.state.room}
promptAskToJoin={myMembership === "leave" || this.state.promptAskToJoin}
knocked={myMembership === "knock" || this.state.knocked}
onSubmitAskToJoin={this.onSubmitAskToJoin}
onCancelAskToJoin={this.onCancelAskToJoin}
/>
</ErrorBoundary>
</div>
);
}
// We have successfully loaded this room, and are not previewing. // We have successfully loaded this room, and are not previewing.
// Display the "normal" room view. // Display the "normal" room view.

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. 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 { Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { MatrixError } from "matrix-js-sdk/src/http-api"; import { MatrixError } from "matrix-js-sdk/src/http-api";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; 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 SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature"; import { UIFeature } from "../../../settings/UIFeature";
import { ModuleRunner } from "../../../modules/ModuleRunner"; 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"; const MemberEventHtmlReasonField = "io.element.html_reason";
@ -53,6 +55,8 @@ enum MessageCase {
ViewingRoom = "ViewingRoom", ViewingRoom = "ViewingRoom",
RoomNotFound = "RoomNotFound", RoomNotFound = "RoomNotFound",
OtherError = "OtherError", OtherError = "OtherError",
PromptAskToJoin = "PromptAskToJoin",
Knocked = "Knocked",
} }
interface IProps { interface IProps {
@ -95,6 +99,11 @@ interface IProps {
onRejectClick?(): void; onRejectClick?(): void;
onRejectAndIgnoreClick?(): void; onRejectAndIgnoreClick?(): void;
onForgetClick?(): void; onForgetClick?(): void;
promptAskToJoin?: boolean;
knocked?: boolean;
onSubmitAskToJoin?(reason?: string): void;
onCancelAskToJoin?(): void;
} }
interface IState { interface IState {
@ -102,6 +111,7 @@ interface IState {
accountEmails?: string[]; accountEmails?: string[];
invitedEmailMxid?: string; invitedEmailMxid?: string;
threePidFetchError?: MatrixError; threePidFetchError?: MatrixError;
reason?: string;
} }
export default class RoomPreviewBar extends React.Component<IProps, IState> { export default class RoomPreviewBar extends React.Component<IProps, IState> {
@ -186,6 +196,10 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
return MessageCase.Rejecting; return MessageCase.Rejecting;
} else if (this.props.loading || this.state.busy) { } else if (this.props.loading || this.state.busy) {
return MessageCase.Loading; return MessageCase.Loading;
} else if (this.props.knocked) {
return MessageCase.Knocked;
} else if (this.props.promptAskToJoin) {
return MessageCase.PromptAskToJoin;
} }
if (this.props.inviterName) { if (this.props.inviterName) {
@ -281,6 +295,10 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
dis.dispatch({ action: "start_registration", screenAfterLogin: this.makeScreenAfterLogin() }); dis.dispatch({ action: "start_registration", screenAfterLogin: this.makeScreenAfterLogin() });
}; };
private onChangeReason = (event: ChangeEvent<HTMLTextAreaElement>): void => {
this.setState({ reason: event.target.value });
};
public render(): React.ReactNode { public render(): React.ReactNode {
const brand = SdkConfig.get().brand; const brand = SdkConfig.get().brand;
const roomName = this.props.room?.name ?? this.props.roomAlias ?? ""; const roomName = this.props.room?.name ?? this.props.roomAlias ?? "";
@ -581,6 +599,54 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
]; ];
break; break;
} }
case MessageCase.PromptAskToJoin: {
if (roomName) {
title = _t("Ask to join %(roomName)s?", { roomName });
} else {
title = _t("Ask to join?");
}
const avatar = <RoomAvatar room={this.props.room} oobData={this.props.oobData} />;
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 = (
<Field
autoFocus
className="mx_RoomPreviewBar_fullWidth"
element="textarea"
onChange={this.onChangeReason}
placeholder={_t("Message (optional)")}
type="text"
value={this.state.reason ?? ""}
/>
);
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 = [
<>
<AskToJoinIcon className="mx_Icon mx_Icon_16 mx_RoomPreviewBar_icon" />
{_t("Your request to join is pending.")}
</>,
];
secondaryActionHandler = this.props.onCancelAskToJoin;
secondaryActionLabel = _t("Cancel request");
break;
}
} }
let subTitleElements; let subTitleElements;
@ -650,7 +716,13 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
{subTitleElements} {subTitleElements}
</div> </div>
{reasonElement} {reasonElement}
<div className="mx_RoomPreviewBar_actions">{actions}</div> <div
className={classNames("mx_RoomPreviewBar_actions", {
mx_RoomPreviewBar_fullWidth: messageCase === MessageCase.PromptAskToJoin,
})}
>
{actions}
</div>
<div className="mx_RoomPreviewBar_footer">{footer}</div> <div className="mx_RoomPreviewBar_footer">{footer}</div>
</div> </div>
); );

View file

@ -71,6 +71,9 @@ const RoomContext = createContext<
narrow: false, narrow: false,
activeCall: null, activeCall: null,
msc3946ProcessDynamicPredecessor: false, msc3946ProcessDynamicPredecessor: false,
canAskToJoin: false,
promptAskToJoin: false,
knocked: false,
}); });
RoomContext.displayName = "RoomContext"; RoomContext.displayName = "RoomContext";
export default RoomContext; export default RoomContext;

View file

@ -351,4 +351,19 @@ export enum Action {
* Fired when we want to view a thread, either a new one or an existing one * Fired when we want to view a thread, either a new one or an existing one
*/ */
ShowThread = "show_thread", 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",
} }

View file

@ -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<ActionPayload, "action"> {
action: Action.CancelAskToJoin;
roomId: string;
}

View file

@ -24,4 +24,6 @@ export interface JoinRoomErrorPayload extends Pick<ActionPayload, "action"> {
roomId: string; roomId: string;
err: MatrixError; err: MatrixError;
canAskToJoin?: boolean;
} }

View file

@ -29,5 +29,7 @@ export interface JoinRoomPayload extends Pick<ActionPayload, "action"> {
// additional parameters for the purpose of metrics & instrumentation // additional parameters for the purpose of metrics & instrumentation
metricsTrigger: JoinedRoomEvent["trigger"]; metricsTrigger: JoinedRoomEvent["trigger"];
canAskToJoin?: boolean;
} }
/* eslint-enable camelcase */ /* eslint-enable camelcase */

View file

@ -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<ActionPayload, "action"> {
action: Action.SubmitAskToJoin;
roomId: string;
opts?: KnockRoomOpts;
}

View file

@ -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.", "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.", "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", "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", "Connection lost": "Connection lost",
"You were disconnected from the call. (Error: %(message)s)": "You were disconnected from the call. (Error: %(message)s)", "You were disconnected from the call. (Error: %(message)s)": "You were disconnected from the call. (Error: %(message)s)",
"All rooms": "All rooms", "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.", "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.", "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 <issueLink>submit a bug report</issueLink>.": "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please <issueLink>submit a bug report</issueLink>.", "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please <issueLink>submit a bug report</issueLink>.": "%(errcode)s was returned while trying to access the room or space. If you think you're seeing this message in error, please <issueLink>submit a bug report</issueLink>.",
"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", "Leave": "Leave",
"<inviter/> invites you": "<inviter/> invites you", "<inviter/> invites you": "<inviter/> invites you",
"To view %(roomName)s, you need an invite": "To view %(roomName)s, you need an invite", "To view %(roomName)s, you need an invite": "To view %(roomName)s, you need an invite",

View file

@ -61,6 +61,8 @@ import { IRoomStateEventsActionPayload } from "../actions/MatrixActionCreators";
import { showCantStartACallDialog } from "../voice-broadcast/utils/showCantStartACallDialog"; import { showCantStartACallDialog } from "../voice-broadcast/utils/showCantStartACallDialog";
import { pauseNonLiveBroadcastFromOtherRoom } from "../voice-broadcast/utils/pauseNonLiveBroadcastFromOtherRoom"; import { pauseNonLiveBroadcastFromOtherRoom } from "../voice-broadcast/utils/pauseNonLiveBroadcastFromOtherRoom";
import { ActionPayload } from "../dispatcher/payloads"; import { ActionPayload } from "../dispatcher/payloads";
import { CancelAskToJoinPayload } from "../dispatcher/payloads/CancelAskToJoinPayload";
import { SubmitAskToJoinPayload } from "../dispatcher/payloads/SubmitAskToJoinPayload";
const NUM_JOIN_RETRY = 5; const NUM_JOIN_RETRY = 5;
@ -118,6 +120,9 @@ interface State {
* Whether we're viewing a call or call lobby in this room * Whether we're viewing a call or call lobby in this room
*/ */
viewingCall: boolean; viewingCall: boolean;
promptAskToJoin: boolean;
knocked: boolean;
} }
const INITIAL_STATE: State = { const INITIAL_STATE: State = {
@ -138,6 +143,8 @@ const INITIAL_STATE: State = {
viaServers: [], viaServers: [],
wasContextSwitch: false, wasContextSwitch: false,
viewingCall: false, viewingCall: false,
promptAskToJoin: false,
knocked: false,
}; };
type Listener = (isActive: boolean) => void; type Listener = (isActive: boolean) => void;
@ -356,6 +363,18 @@ export class RoomViewStore extends EventEmitter {
} }
} }
break; 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, action: Action.JoinRoomError,
roomId, roomId,
err, err,
canAskToJoin: payload.canAskToJoin,
}); });
if (payload.canAskToJoin) {
this.dis?.dispatch({ action: Action.PromptAskToJoin });
}
} }
} }
@ -632,7 +656,7 @@ export class RoomViewStore extends EventEmitter {
joining: false, joining: false,
joinError: payload.err, joinError: payload.err,
}); });
if (payload.err) { if (payload.err && !payload.canAskToJoin) {
this.showJoinRoomError(payload.err, payload.roomId); this.showJoinRoomError(payload.err, payload.roomId);
} }
} }
@ -746,4 +770,57 @@ export class RoomViewStore extends EventEmitter {
public isViewingCall(): boolean { public isViewingCall(): boolean {
return this.state.viewingCall; 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 }),
);
}
} }

View file

@ -17,7 +17,7 @@ limitations under the License.
import React, { createRef, RefObject } from "react"; import React, { createRef, RefObject } from "react";
import { mocked, MockedObject } from "jest-mock"; import { mocked, MockedObject } from "jest-mock";
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client"; 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 { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MEGOLM_ALGORITHM } from "matrix-js-sdk/src/crypto/olmlib"; import { MEGOLM_ALGORITHM } from "matrix-js-sdk/src/crypto/olmlib";
import { fireEvent, render, screen, RenderResult } from "@testing-library/react"; import { fireEvent, render, screen, RenderResult } from "@testing-library/react";
@ -34,10 +34,12 @@ import {
mkRoomMemberJoinEvent, mkRoomMemberJoinEvent,
mkThirdPartyInviteEvent, mkThirdPartyInviteEvent,
emitPromise, emitPromise,
createTestClient,
untilDispatch,
} from "../../test-utils"; } from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { Action } from "../../../src/dispatcher/actions"; 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 { ViewRoomPayload } from "../../../src/dispatcher/payloads/ViewRoomPayload";
import { RoomView as _RoomView } from "../../../src/components/structures/RoomView"; import { RoomView as _RoomView } from "../../../src/components/structures/RoomView";
import ResizeNotifier from "../../../src/utils/ResizeNotifier"; import ResizeNotifier from "../../../src/utils/ResizeNotifier";
@ -543,4 +545,41 @@ describe("RoomView", () => {
expect(screen.queryByLabelText("Forget room")).not.toBeInTheDocument(); 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 });
});
});
}); });

View file

@ -425,4 +425,60 @@ describe("<RoomPreviewBar />", () => {
}); });
}); });
}); });
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(<RoomPreviewBar promptAskToJoin />);
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();
});
});
}); });

View file

@ -83,6 +83,9 @@ describe("<SendMessageComposer/>", () => {
narrow: false, narrow: false,
activeCall: null, activeCall: null,
msc3946ProcessDynamicPredecessor: false, msc3946ProcessDynamicPredecessor: false,
canAskToJoin: false,
promptAskToJoin: false,
knocked: false,
}; };
describe("createMessageContent", () => { describe("createMessageContent", () => {
const permalinkCreator = jest.fn() as any; const permalinkCreator = jest.fn() as any;

View file

@ -1,5 +1,121 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<RoomPreviewBar /> message case AskToJoin renders the corresponding actions 1`] = `
<div
class="mx_RoomPreviewBar_actions mx_RoomPreviewBar_fullWidth"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Request access
</div>
</div>
`;
exports[`<RoomPreviewBar /> message case AskToJoin renders the corresponding message 1`] = `
<div
class="mx_RoomPreviewBar_message"
>
<h3>
Ask to join RoomPreviewBar-test-room?
</h3>
<p>
<span
class="mx_BaseAvatar"
role="presentation"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 23.400000000000002px; width: 36px; line-height: 36px;"
>
R
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
loading="lazy"
src="data:image/png;base64,00"
style="width: 36px; height: 36px;"
/>
</span>
</p>
<p>
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.
</p>
</div>
`;
exports[`<RoomPreviewBar /> message case AskToJoin renders the corresponding message with a generic title 1`] = `
<div
class="mx_RoomPreviewBar_message"
>
<h3>
Ask to join?
</h3>
<p>
<span
class="mx_BaseAvatar"
role="presentation"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 23.400000000000002px; width: 36px; line-height: 36px;"
>
?
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
loading="lazy"
src="data:image/png;base64,00"
style="width: 36px; height: 36px;"
/>
</span>
</p>
<p>
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.
</p>
</div>
`;
exports[`<RoomPreviewBar /> message case Knocked renders the corresponding actions 1`] = `
<div
class="mx_RoomPreviewBar_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_secondary"
role="button"
tabindex="0"
>
Cancel request
</div>
</div>
`;
exports[`<RoomPreviewBar /> message case Knocked renders the corresponding message 1`] = `
<div
class="mx_RoomPreviewBar_message"
>
<h3>
Request to join sent
</h3>
<p>
<div
class="mx_Icon mx_Icon_16 mx_RoomPreviewBar_icon"
/>
Your request to join is pending.
</p>
</div>
`;
exports[`<RoomPreviewBar /> renders banned message 1`] = ` exports[`<RoomPreviewBar /> renders banned message 1`] = `
<div <div
class="mx_RoomPreviewBar_message" class="mx_RoomPreviewBar_message"

View file

@ -39,6 +39,10 @@ import {
} from "../../src/voice-broadcast"; } from "../../src/voice-broadcast";
import { mkVoiceBroadcastInfoStateEvent } from "../voice-broadcast/utils/test-utils"; import { mkVoiceBroadcastInfoStateEvent } from "../voice-broadcast/utils/test-utils";
import Modal from "../../src/Modal"; import Modal from "../../src/Modal";
import ErrorDialog from "../../src/components/views/dialogs/ErrorDialog";
import { CancelAskToJoinPayload } from "../../src/dispatcher/payloads/CancelAskToJoinPayload";
import { JoinRoomErrorPayload } from "../../src/dispatcher/payloads/JoinRoomErrorPayload";
import { SubmitAskToJoinPayload } from "../../src/dispatcher/payloads/SubmitAskToJoinPayload";
jest.mock("../../src/Modal"); jest.mock("../../src/Modal");
@ -97,6 +101,8 @@ describe("RoomViewStore", function () {
supportsThreads: jest.fn(), supportsThreads: jest.fn(),
isInitialSyncComplete: jest.fn().mockResolvedValue(false), isInitialSyncComplete: jest.fn().mockResolvedValue(false),
relations: jest.fn(), relations: jest.fn(),
knockRoom: jest.fn(),
leave: jest.fn(),
}); });
const room = new Room(roomId, mockClient, userId); const room = new Room(roomId, mockClient, userId);
const room2 = new Room(roomId2, mockClient, userId); const room2 = new Room(roomId2, mockClient, userId);
@ -111,6 +117,21 @@ describe("RoomViewStore", function () {
await untilDispatch(Action.ViewRoom, dis); await untilDispatch(Action.ViewRoom, dis);
}; };
const dispatchPromptAskToJoin = async () => {
dis.dispatch({ action: Action.PromptAskToJoin });
await untilDispatch(Action.PromptAskToJoin, dis);
};
const dispatchSubmitAskToJoin = async (roomId: string, reason?: string) => {
dis.dispatch<SubmitAskToJoinPayload>({ action: Action.SubmitAskToJoin, roomId, opts: { reason } });
await untilDispatch(Action.SubmitAskToJoin, dis);
};
const dispatchCancelAskToJoin = async (roomId: string) => {
dis.dispatch<CancelAskToJoinPayload>({ action: Action.CancelAskToJoin, roomId });
await untilDispatch(Action.CancelAskToJoin, dis);
};
let roomViewStore: RoomViewStore; let roomViewStore: RoomViewStore;
let slidingSyncManager: SlidingSyncManager; let slidingSyncManager: SlidingSyncManager;
let dis: MatrixDispatcher; 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<JoinRoomErrorPayload>({ 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<JoinRoomErrorPayload>({ 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",
});
});
});
}); });

View file

@ -88,6 +88,9 @@ export function getRoomContext(room: Room, override: Partial<IRoomState>): IRoom
narrow: false, narrow: false,
activeCall: null, activeCall: null,
msc3946ProcessDynamicPredecessor: false, msc3946ProcessDynamicPredecessor: false,
canAskToJoin: false,
promptAskToJoin: false,
knocked: false,
...override, ...override,
}; };

View file

@ -242,6 +242,8 @@ export function createTestClient(): MatrixClient {
getSyncStateData: jest.fn(), getSyncStateData: jest.fn(),
getDehydratedDevice: jest.fn(), getDehydratedDevice: jest.fn(),
exportRoomKeys: jest.fn(), exportRoomKeys: jest.fn(),
knockRoom: jest.fn(),
leave: jest.fn(),
} as unknown as MatrixClient; } as unknown as MatrixClient;
client.reEmitter = new ReEmitter(client); client.reEmitter = new ReEmitter(client);