Refactor InviteDialog (#30540)

* Remove unreferenced CSS class `mx_InviteDialog_hasFooter`

This is never used in the CSS (or elsewhere), so let's remove it

* Move `consultConnectSection` initialization

Since this only used and set when `kind === InviteKind.CallTransfer`, we can
simplify

* Factor out `title` logic

Move the logic for caclulating the title to a separate method. I want to be
able to reference it from a couple of places, so it will be easier if it is a
separate method.

(We'll actually be inlining it again later in this PR)

* Factor out `renderMainTab` method

Break the big `render` method in half by pulling the `usersSection` out into a
separate method.

* Split out `renderRegularDialog` and `renderCallTransferDialog`

`render` is now almost entirely two separate flows, so let's spit it into two
separate methods. Recommend reviewing this commit with whitespace changes
hidden.

* Inline `getTitle`

This method has served its purpose: we can now inline it again.

* Factor out `renderSuggestions`

Break up `renderMainTab` a bit more: pull out a new method which renders the
"suggestions" bit of the dialog, together with the associated warnings and footer.
This commit is contained in:
Richard van der Hoff
2025-08-11 17:49:52 +01:00
committed by GitHub
parent 01c4ba8893
commit b897006899

View File

@@ -6,9 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, createRef, type ReactNode, type SyntheticEvent } from "react";
import classNames from "classnames";
import { RoomMember, type Room, MatrixError, EventType } from "matrix-js-sdk/src/matrix";
import React, { createRef, type JSX, type ReactNode, type SyntheticEvent } from "react";
import { EventType, MatrixError, type Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { type MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { logger } from "matrix-js-sdk/src/logger";
@@ -1260,30 +1259,89 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
});
}
public render(): React.ReactNode {
private hasSelection(): boolean {
return this.state.targets.length > 0 || (!!this.state.filterText && this.state.filterText.includes("@"));
}
/**
* Render the "suggestions" section, which shows a list of people you might want to invite, together with any
* errors from the previous iteration.
*/
private renderSuggestions(): JSX.Element {
// If we're starting a DM, add a footer which showing our matrix.to link, for copying & pasting.
let footer;
if (this.props.kind === InviteKind.Dm) {
const link = makeUserPermalink(MatrixClientPeg.safeGet().getSafeUserId());
footer = (
<div className="mx_InviteDialog_footer">
<h3>{_t("invite|send_link_prompt")}</h3>
<CopyableText getTextToCopy={() => makeUserPermalink(MatrixClientPeg.safeGet().getSafeUserId())}>
<a className="mx_InviteDialog_footer_link" href={link} onClick={this.onLinkClick}>
{link}
</a>
</CopyableText>
</div>
);
}
let results: React.ReactNode | null = null;
let onlyOneThreepidNote: React.ReactNode | null = null;
if (!this.canInviteMore() || (this.hasFilterAtLeastOneEmail() && !this.canInviteThirdParty())) {
// We are in DM case here, because of the checks in canInviteMore() / canInviteThirdParty().
// Show a note saying "Invites by email can only be sent one at a time".
onlyOneThreepidNote = <div className="mx_InviteDialog_oneThreepid">{_t("invite|email_limit_one")}</div>;
} else {
let extraSection;
if (this.props.kind === InviteKind.Dm) {
// Some extra words saying "Some suggestions may be hidden for privacy"
extraSection = (
<div className="mx_InviteDialog_section_hidden_suggestions_disclaimer">
<span>{_t("invite|suggestions_disclaimer")}</span>
<p>{_t("invite|suggestions_disclaimer_prompt")}</p>
</div>
);
}
results = (
<div className="mx_InviteDialog_userSections">
{this.renderSection("recents")}
{this.renderSection("suggestions")}
{extraSection}
</div>
);
}
return (
<React.Fragment>
{this.renderIdentityServerWarning()}
<div className="error">{this.state.errorText}</div>
{onlyOneThreepidNote}
{results}
{footer}
</React.Fragment>
);
}
/**
* Render content of the common "users" tab that is shown whether we have a regular invite dialog or a
* "CallTransfer" one.
*/
private renderMainTab(): JSX.Element {
let spinner: JSX.Element | undefined;
if (this.state.busy) {
spinner = <Spinner w={20} h={20} />;
}
let title;
let helpText;
let buttonText;
let goButtonFn: (() => Promise<void>) | null = null;
let consultConnectSection;
let extraSection;
let footer;
const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer);
const hasSelection =
this.state.targets.length > 0 || (this.state.filterText && this.state.filterText.includes("@"));
const cli = MatrixClientPeg.safeGet();
const userId = cli.getUserId()!;
if (this.props.kind === InviteKind.Dm) {
title = _t("space|add_existing_room_space|dm_heading");
if (identityServersEnabled) {
helpText = _t(
"invite|start_conversation_name_email_mxid_prompt",
@@ -1316,34 +1374,10 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
buttonText = _t("action|go");
goButtonFn = this.checkProfileAndStartDm;
extraSection = (
<div className="mx_InviteDialog_section_hidden_suggestions_disclaimer">
<span>{_t("invite|suggestions_disclaimer")}</span>
<p>{_t("invite|suggestions_disclaimer_prompt")}</p>
</div>
);
const link = makeUserPermalink(MatrixClientPeg.safeGet().getSafeUserId());
footer = (
<div className="mx_InviteDialog_footer">
<h3>{_t("invite|send_link_prompt")}</h3>
<CopyableText getTextToCopy={() => makeUserPermalink(MatrixClientPeg.safeGet().getSafeUserId())}>
<a className="mx_InviteDialog_footer_link" href={link} onClick={this.onLinkClick}>
{link}
</a>
</CopyableText>
</div>
);
} else if (this.props.kind === InviteKind.Invite) {
const roomId = this.props.roomId;
const room = MatrixClientPeg.get()?.getRoom(roomId);
const isSpace = room?.isSpaceRoom();
title = isSpace
? _t("invite|to_space", {
spaceName: room?.name || _t("common|unnamed_space"),
})
: _t("invite|to_room", {
roomName: room?.name || _t("common|unnamed_room"),
});
let helpTextUntranslated;
if (isSpace) {
@@ -1384,31 +1418,6 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
buttonText = _t("action|invite");
goButtonFn = this.inviteUsers;
} else if (this.props.kind === InviteKind.CallTransfer) {
title = _t("action|transfer");
consultConnectSection = (
<div className="mx_InviteDialog_transferConsultConnect">
<label>
<input type="checkbox" checked={this.state.consultFirst} onChange={this.onConsultFirstChange} />
{_t("voip|transfer_consult_first_label")}
</label>
<AccessibleButton
kind="secondary"
onClick={this.onCancel}
className="mx_InviteDialog_transferConsultConnect_pushRight"
>
{_t("action|cancel")}
</AccessibleButton>
<AccessibleButton
kind="primary"
onClick={this.transferCall}
disabled={!hasSelection && this.state.dialPadValue === ""}
>
{_t("action|transfer")}
</AccessibleButton>
</div>
);
}
const goButton =
@@ -1417,29 +1426,13 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
kind="primary"
onClick={goButtonFn}
className="mx_InviteDialog_goButton"
disabled={this.state.busy || !hasSelection}
disabled={this.state.busy || !this.hasSelection()}
>
{buttonText}
</AccessibleButton>
);
let results: React.ReactNode | null = null;
let onlyOneThreepidNote: React.ReactNode | null = null;
if (!this.canInviteMore() || (this.hasFilterAtLeastOneEmail() && !this.canInviteThirdParty())) {
// We are in DM case here, because of the checks in canInviteMore() / canInviteThirdParty().
onlyOneThreepidNote = <div className="mx_InviteDialog_oneThreepid">{_t("invite|email_limit_one")}</div>;
} else {
results = (
<div className="mx_InviteDialog_userSections">
{this.renderSection("recents")}
{this.renderSection("suggestions")}
{extraSection}
</div>
);
}
const usersSection = (
return (
<React.Fragment>
<p className="mx_InviteDialog_helpText">{helpText}</p>
<div className="mx_InviteDialog_addressBar">
@@ -1449,102 +1442,155 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
{spinner}
</div>
</div>
{this.renderIdentityServerWarning()}
<div className="error">{this.state.errorText}</div>
{onlyOneThreepidNote}
{results}
{footer}
{this.renderSuggestions()}
</React.Fragment>
);
}
let dialogContent;
if (this.props.kind === InviteKind.CallTransfer) {
const tabs: NonEmptyArray<Tab<TabId>> = [
new Tab(
TabId.UserDirectory,
_td("invite|transfer_user_directory_tab"),
"mx_InviteDialog_userDirectoryIcon",
usersSection,
),
];
const backspaceButton = <DialPadBackspaceButton onBackspacePress={this.onDeletePress} />;
// Only show the backspace button if the field has content
let dialPadField;
if (this.state.dialPadValue.length !== 0) {
dialPadField = (
<Field
ref={this.numberEntryFieldRef}
className="mx_InviteDialog_dialPadField"
id="dialpad_number"
value={this.state.dialPadValue}
autoFocus={true}
onChange={this.onDialChange}
postfixComponent={backspaceButton}
/>
);
} else {
dialPadField = (
<Field
ref={this.numberEntryFieldRef}
className="mx_InviteDialog_dialPadField"
id="dialpad_number"
value={this.state.dialPadValue}
autoFocus={true}
onChange={this.onDialChange}
/>
);
}
const dialPadSection = (
<div className="mx_InviteDialog_dialPad">
<form onSubmit={this.onDialFormSubmit}>{dialPadField}</form>
<Dialpad hasDial={false} onDigitPress={this.onDigitPress} onDeletePress={this.onDeletePress} />
</div>
);
tabs.push(
new Tab(
TabId.DialPad,
_td("invite|transfer_dial_pad_tab"),
"mx_InviteDialog_dialPadIcon",
dialPadSection,
),
);
dialogContent = (
<React.Fragment>
<TabbedView<TabId>
tabs={tabs}
activeTabId={this.state.currentTabId}
tabLocation={TabLocation.TOP}
onChange={this.onTabChange}
/>
{consultConnectSection}
</React.Fragment>
);
} else {
dialogContent = (
<React.Fragment>
{usersSection}
{consultConnectSection}
</React.Fragment>
);
/**
* Render the complete dialog, given this is not a call transfer dialog.
*
* See also: {@link renderCallTransferDialog}.
*/
private renderRegularDialog(): React.ReactNode {
let title;
if (this.props.kind === InviteKind.Dm) {
title = _t("space|add_existing_room_space|dm_heading");
} else if (this.props.kind === InviteKind.Invite) {
const roomId = this.props.roomId;
const room = MatrixClientPeg.get()?.getRoom(roomId);
const isSpace = room?.isSpaceRoom();
title = isSpace
? _t("invite|to_space", {
spaceName: room?.name || _t("common|unnamed_space"),
})
: _t("invite|to_room", {
roomName: room?.name || _t("common|unnamed_room"),
});
}
return (
<BaseDialog
className={classNames({
mx_InviteDialog_transfer: this.props.kind === InviteKind.CallTransfer,
mx_InviteDialog_other: this.props.kind !== InviteKind.CallTransfer,
mx_InviteDialog_hasFooter: !!footer,
})}
className="mx_InviteDialog_other"
hasCancel={true}
onFinished={this.props.onFinished}
title={title}
screenName={this.screenName}
>
<div className="mx_InviteDialog_content">{this.renderMainTab()}</div>
</BaseDialog>
);
}
/**
* Render the complete call transfer dialog.
*
* See also: {@link renderRegularDialog}.
*/
private renderCallTransferDialog(): React.ReactNode {
const usersSection = this.renderMainTab();
const tabs: NonEmptyArray<Tab<TabId>> = [
new Tab(
TabId.UserDirectory,
_td("invite|transfer_user_directory_tab"),
"mx_InviteDialog_userDirectoryIcon",
usersSection,
),
];
const backspaceButton = <DialPadBackspaceButton onBackspacePress={this.onDeletePress} />;
// Only show the backspace button if the field has content
let dialPadField;
if (this.state.dialPadValue.length !== 0) {
dialPadField = (
<Field
ref={this.numberEntryFieldRef}
className="mx_InviteDialog_dialPadField"
id="dialpad_number"
value={this.state.dialPadValue}
autoFocus={true}
onChange={this.onDialChange}
postfixComponent={backspaceButton}
/>
);
} else {
dialPadField = (
<Field
ref={this.numberEntryFieldRef}
className="mx_InviteDialog_dialPadField"
id="dialpad_number"
value={this.state.dialPadValue}
autoFocus={true}
onChange={this.onDialChange}
/>
);
}
const dialPadSection = (
<div className="mx_InviteDialog_dialPad">
<form onSubmit={this.onDialFormSubmit}>{dialPadField}</form>
<Dialpad hasDial={false} onDigitPress={this.onDigitPress} onDeletePress={this.onDeletePress} />
</div>
);
tabs.push(
new Tab(TabId.DialPad, _td("invite|transfer_dial_pad_tab"), "mx_InviteDialog_dialPadIcon", dialPadSection),
);
const consultConnectSection = (
<div className="mx_InviteDialog_transferConsultConnect">
<label>
<input type="checkbox" checked={this.state.consultFirst} onChange={this.onConsultFirstChange} />
{_t("voip|transfer_consult_first_label")}
</label>
<AccessibleButton
kind="secondary"
onClick={this.onCancel}
className="mx_InviteDialog_transferConsultConnect_pushRight"
>
{_t("action|cancel")}
</AccessibleButton>
<AccessibleButton
kind="primary"
onClick={this.transferCall}
disabled={!this.hasSelection() && this.state.dialPadValue === ""}
>
{_t("action|transfer")}
</AccessibleButton>
</div>
);
const dialogContent = (
<React.Fragment>
<TabbedView<TabId>
tabs={tabs}
activeTabId={this.state.currentTabId}
tabLocation={TabLocation.TOP}
onChange={this.onTabChange}
/>
{consultConnectSection}
</React.Fragment>
);
return (
<BaseDialog
className="mx_InviteDialog_transfer"
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("action|transfer")}
screenName={this.screenName}
>
<div className="mx_InviteDialog_content">{dialogContent}</div>
</BaseDialog>
);
}
public render(): React.ReactNode {
if (this.props.kind === InviteKind.CallTransfer) {
return this.renderCallTransferDialog();
} else {
return this.renderRegularDialog();
}
}
}