Modal: remove support for onFinished callback (#29852)

* Fix up type for `finished` result of Modal

The `finished` promise can be called with an empty array, for example if the
dialog is closed by a background click. This was not correctly represented in
the typing. Fix that, and add some documentation while we're at it.

* Type fixes to onFinished callbacks from Modal

These can all be called with zero arguments, despite what the type annotations
may say, so mark them accordingly.

* Remove uses of Modal `onFinished` property

... because it is confusing.

Instead, use the `finished` promise returned by `createDialog`.

* Modal: remove support for now-unused `onFinished` prop

* StopGapWidgetDriver: use `await` instead of promise chaining

* Fix up unit tests
This commit is contained in:
Richard van der Hoff
2025-04-30 16:56:21 +01:00
committed by GitHub
parent ce1055f5fe
commit f25fbdebc7
41 changed files with 345 additions and 315 deletions

View File

@@ -1229,7 +1229,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const warnings = this.leaveRoomWarnings(roomId);
const isSpace = roomToLeave?.isSpaceRoom();
Modal.createDialog(QuestionDialog, {
const { finished } = Modal.createDialog(QuestionDialog, {
title: isSpace ? _t("space|leave_dialog_action") : _t("action|leave_room"),
description: (
<span>
@@ -1245,16 +1245,17 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
),
button: _t("action|leave"),
danger: warnings.length > 0,
onFinished: async (shouldLeave) => {
if (shouldLeave) {
await leaveRoomBehaviour(cli, roomId);
});
dis.dispatch<AfterLeaveRoomPayload>({
action: Action.AfterLeaveRoom,
room_id: roomId,
});
}
},
finished.then(async ([shouldLeave]) => {
if (shouldLeave) {
await leaveRoomBehaviour(cli, roomId);
dis.dispatch<AfterLeaveRoomPayload>({
action: Action.AfterLeaveRoom,
room_id: roomId,
});
}
});
}
@@ -1558,7 +1559,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
});
cli.on(HttpApiEvent.NoConsent, function (message, consentUri) {
Modal.createDialog(
const { finished } = Modal.createDialog(
QuestionDialog,
{
title: _t("terms|tac_title"),
@@ -1569,16 +1570,16 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
),
button: _t("terms|tac_button"),
cancelButton: _t("action|dismiss"),
onFinished: (confirmed) => {
if (confirmed) {
const wnd = window.open(consentUri, "_blank")!;
wnd.opener = null;
}
},
},
undefined,
true,
);
finished.then(([confirmed]) => {
if (confirmed) {
const wnd = window.open(consentUri, "_blank")!;
wnd.opener = null;
}
});
});
DecryptionFailureTracker.instance

View File

@@ -1753,7 +1753,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
if (reportRoom !== false) {
actions.push(this.context.client.reportRoom(this.state.room.roomId, reportRoom));
actions.push(this.context.client.reportRoom(this.state.room.roomId, reportRoom!));
}
actions.push(this.context.client.leave(this.state.room.roomId));

View File

@@ -1505,11 +1505,13 @@ class TimelinePanel extends React.Component<IProps, IState> {
description = _t("timeline|load_error|unable_to_find");
}
Modal.createDialog(ErrorDialog, {
const { finished } = Modal.createDialog(ErrorDialog, {
title: _t("timeline|load_error|title"),
description,
onFinished,
});
if (onFinished) {
finished.then(onFinished);
}
};
// if we already have the event in question, TimelineWindow.load

View File

@@ -90,14 +90,15 @@ export default class SetupEncryptionBody extends React.Component<IProps, IState>
// We need to call onFinished now to close this dialog, and
// again later to signal that the verification is complete.
this.props.onFinished();
Modal.createDialog(VerificationRequestDialog, {
const { finished: verificationFinished } = Modal.createDialog(VerificationRequestDialog, {
verificationRequestPromise: requestPromise,
member: cli.getUser(userId) ?? undefined,
onFinished: async (): Promise<void> => {
const request = await requestPromise;
request.cancel();
this.props.onFinished();
},
});
verificationFinished.then(async () => {
const request = await requestPromise;
request.cancel();
this.props.onFinished();
});
};

View File

@@ -89,13 +89,12 @@ export default class SoftLogout extends React.Component<IProps, IState> {
}
private onClearAll = (): void => {
Modal.createDialog(ConfirmWipeDeviceDialog, {
onFinished: (wipeData) => {
if (!wipeData) return;
const { finished } = Modal.createDialog(ConfirmWipeDeviceDialog);
finished.then(([wipeData]) => {
if (!wipeData) return;
logger.log("Clearing data from soft-logged-out session");
Lifecycle.logout(this.context.oidcClientStore);
},
logger.log("Clearing data from soft-logged-out session");
Lifecycle.logout(this.context.oidcClientStore);
});
};

View File

@@ -127,19 +127,11 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
if (this.state.email === "") {
if (this.showEmail()) {
Modal.createDialog(RegistrationEmailPromptDialog, {
onFinished: async (confirmed: boolean, email?: string): Promise<void> => {
if (confirmed && email !== undefined) {
this.setState(
{
email,
},
() => {
this.doSubmit(ev);
},
);
}
},
const { finished } = Modal.createDialog(RegistrationEmailPromptDialog);
finished.then(async ([confirmed, email]) => {
if (confirmed && email !== undefined) {
this.setState({ email }, () => this.doSubmit(ev));
}
});
} else {
// user can't set an e-mail so don't prompt them to

View File

@@ -25,7 +25,6 @@ export const DeveloperToolsOption: React.FC<Props> = ({ onFinished, roomId }) =>
Modal.createDialog(
DevtoolsDialog,
{
onFinished: () => {},
roomId: roomId,
},
"mx_DevtoolsDialog_wrapper",

View File

@@ -187,14 +187,15 @@ export const WidgetContextMenu: React.FC<IProps> = ({
onDeleteClick();
} else if (roomId) {
// Show delete confirmation dialog
Modal.createDialog(QuestionDialog, {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("widget|context_menu|delete"),
description: _t("widget|context_menu|delete_warning"),
button: _t("widget|context_menu|delete"),
onFinished: (confirmed) => {
if (!confirmed) return;
WidgetUtils.setRoomWidget(cli, roomId, app.id);
},
});
finished.then(([confirmed]) => {
if (!confirmed) return;
WidgetUtils.setRoomWidget(cli, roomId, app.id);
});
}

View File

@@ -11,7 +11,7 @@ import React from "react";
import BaseDialog from "./BaseDialog";
import { _t } from "../../../languageHandler";
import DialogButtons from "../elements/DialogButtons";
import Modal, { type ComponentProps } from "../../../Modal";
import Modal, { type ComponentProps, type IHandle } from "../../../Modal";
import SdkConfig from "../../../SdkConfig";
import { getPolicyUrl } from "../../../toasts/AnalyticsToast";
import ExternalLink from "../elements/ExternalLink";
@@ -91,10 +91,10 @@ export const AnalyticsLearnMoreDialog: React.FC<IProps> = ({
export const showDialog = (
props: Omit<ComponentProps<typeof AnalyticsLearnMoreDialog>, "cookiePolicyUrl" | "analyticsOwner">,
): void => {
): IHandle<typeof AnalyticsLearnMoreDialog> => {
const privacyPolicyUrl = getPolicyUrl();
const analyticsOwner = SdkConfig.get("analytics_owner") ?? SdkConfig.get("brand");
Modal.createDialog(
return Modal.createDialog(
AnalyticsLearnMoreDialog,
{
privacyPolicyUrl,

View File

@@ -58,37 +58,32 @@ export function createRedactEventDialog({
const roomId = mxEvent.getRoomId();
if (!roomId) throw new Error(`cannot redact event ${mxEvent.getId()} without room ID`);
Modal.createDialog(
ConfirmRedactDialog,
{
event: mxEvent,
onFinished: async (proceed, reason): Promise<void> => {
if (!proceed) return;
const { finished } = Modal.createDialog(ConfirmRedactDialog, { event: mxEvent }, "mx_Dialog_confirmredact");
const cli = MatrixClientPeg.safeGet();
const withRelTypes: Pick<IRedactOpts, "with_rel_types"> = {};
finished.then(async ([proceed, reason]) => {
if (!proceed) return;
try {
onCloseDialog?.();
await cli.redactEvent(roomId, eventId, undefined, {
...(reason ? { reason } : {}),
...withRelTypes,
});
} catch (e: any) {
const code = e.errcode || e.statusCode;
// only show the dialog if failing for something other than a network error
// (e.g. no errcode or statusCode) as in that case the redactions end up in the
// detached queue and we show the room status bar to allow retry
if (typeof code !== "undefined") {
// display error message stating you couldn't delete this.
Modal.createDialog(ErrorDialog, {
title: _t("common|error"),
description: _t("redact|error", { code }),
});
}
}
},
},
"mx_Dialog_confirmredact",
);
const cli = MatrixClientPeg.safeGet();
const withRelTypes: Pick<IRedactOpts, "with_rel_types"> = {};
try {
onCloseDialog?.();
await cli.redactEvent(roomId, eventId, undefined, {
...(reason ? { reason } : {}),
...withRelTypes,
});
} catch (e: any) {
const code = e.errcode || e.statusCode;
// only show the dialog if failing for something other than a network error
// (e.g. no errcode or statusCode) as in that case the redactions end up in the
// detached queue and we show the room status bar to allow retry
if (typeof code !== "undefined") {
// display error message stating you couldn't delete this.
Modal.createDialog(ErrorDialog, {
title: _t("common|error"),
description: _t("redact|error", { code }),
});
}
}
});
}

View File

@@ -31,13 +31,13 @@ export default class SessionRestoreErrorDialog extends React.Component<IProps> {
};
private onClearStorageClick = (): void => {
Modal.createDialog(QuestionDialog, {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("action|sign_out"),
description: <div>{_t("error|session_restore|clear_storage_description")}</div>,
button: _t("action|sign_out"),
danger: true,
onFinished: this.props.onFinished,
});
finished.then(([ok]) => this.props.onFinished(ok));
};
private onRefreshClick = (): void => {

View File

@@ -66,12 +66,12 @@ export default class SetEmailDialog extends React.Component<IProps, IState> {
this.addThreepid = new AddThreepid(MatrixClientPeg.safeGet());
this.addThreepid.addEmailAddress(emailAddress).then(
() => {
Modal.createDialog(QuestionDialog, {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("auth|set_email|verification_pending_title"),
description: _t("auth|set_email|verification_pending_description"),
button: _t("action|continue"),
onFinished: this.onEmailDialogFinished,
});
finished.then(([ok]) => this.onEmailDialogFinished(ok));
},
(err) => {
this.setState({ emailBusy: false });
@@ -89,7 +89,7 @@ export default class SetEmailDialog extends React.Component<IProps, IState> {
this.props.onFinished(false);
};
private onEmailDialogFinished = (ok: boolean): void => {
private onEmailDialogFinished = (ok?: boolean): void => {
if (ok) {
this.verifyEmailAddress();
} else {
@@ -115,12 +115,12 @@ export default class SetEmailDialog extends React.Component<IProps, IState> {
_t("settings|general|error_email_verification") +
" " +
_t("auth|set_email|verification_pending_description");
Modal.createDialog(QuestionDialog, {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("auth|set_email|verification_pending_title"),
description: message,
button: _t("action|continue"),
onFinished: this.onEmailDialogFinished,
});
finished.then(([ok]) => this.onEmailDialogFinished(ok));
} else {
logger.error("Unable to verify email address: " + err);
Modal.createDialog(ErrorDialog, {

View File

@@ -222,10 +222,10 @@ export const NetworkDropdown: React.FC<IProps> = ({ protocols, config, setConfig
const [ok, newServer] = await finished;
if (!ok) return;
if (!allServers.includes(newServer)) {
setUserDefinedServers([...userDefinedServers, newServer]);
if (!allServers.includes(newServer!)) {
setUserDefinedServers([...userDefinedServers, newServer!]);
setConfig({
roomServer: newServer,
roomServer: newServer!,
});
}
}}

View File

@@ -167,18 +167,18 @@ export default class PollCreateDialog extends ScrollableBaseModal<IProps, IState
.then(() => this.props.onFinished(true))
.catch((e) => {
console.error("Failed to post poll:", e);
Modal.createDialog(QuestionDialog, {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("poll|failed_send_poll_title"),
description: _t("poll|failed_send_poll_description"),
button: _t("action|try_again"),
cancelButton: _t("action|cancel"),
onFinished: (tryAgain: boolean) => {
if (!tryAgain) {
this.cancel();
} else {
this.setState({ busy: false, canSubmit: true });
}
},
});
finished.then(([tryAgain]) => {
if (!tryAgain) {
this.cancel();
} else {
this.setState({ busy: false, canSubmit: true });
}
});
});
}

View File

@@ -28,9 +28,10 @@ interface IProps {
const showPickerDialog = (
title: string | undefined,
serverConfig: ValidatedServerConfig,
onFinished: (config: ValidatedServerConfig) => void,
onFinished: (config?: ValidatedServerConfig) => void,
): void => {
Modal.createDialog(ServerPickerDialog, { title, serverConfig, onFinished });
const { finished } = Modal.createDialog(ServerPickerDialog, { title, serverConfig });
finished.then(([config]) => onFinished(config));
};
const onHelpClick = (): void => {

View File

@@ -235,7 +235,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
scalarClient?.connect().then(() => {
const completeUrl = scalarClient.getStarterLink(starterLink);
const integrationsUrl = integrationManager!.uiUrl;
Modal.createDialog(QuestionDialog, {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("timeline|scalar_starter_link|dialog_title"),
description: (
<div>
@@ -243,18 +243,19 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
</div>
),
button: _t("action|continue"),
onFinished(confirmed) {
if (!confirmed) {
return;
}
const width = window.screen.width > 1024 ? 1024 : window.screen.width;
const height = window.screen.height > 800 ? 800 : window.screen.height;
const left = (window.screen.width - width) / 2;
const top = (window.screen.height - height) / 2;
const features = `height=${height}, width=${width}, top=${top}, left=${left},`;
const wnd = window.open(completeUrl, "_blank", features)!;
wnd.opener = null;
},
});
finished.then(([confirmed]) => {
if (!confirmed) {
return;
}
const width = window.screen.width > 1024 ? 1024 : window.screen.width;
const height = window.screen.height > 800 ? 800 : window.screen.height;
const left = (window.screen.width - width) / 2;
const top = (window.screen.height - height) / 2;
const features = `height=${height}, width=${width}, top=${top}, left=${left},`;
const wnd = window.open(completeUrl, "_blank", features)!;
wnd.opener = null;
});
});
};

View File

@@ -119,15 +119,14 @@ export default class EventIndexPanel extends React.Component<EmptyObject, IState
};
private confirmEventStoreReset = (): void => {
const { close } = Modal.createDialog(SeshatResetDialog, {
onFinished: async (success): Promise<void> => {
if (success) {
await SettingsStore.setValue("enableEventIndexing", null, SettingLevel.DEVICE, false);
await EventIndexPeg.deleteEventIndex();
await this.onEnable();
close();
}
},
const { finished, close } = Modal.createDialog(SeshatResetDialog);
finished.then(async ([success]) => {
if (success) {
await SettingsStore.setValue("enableEventIndexing", null, SettingLevel.DEVICE, false);
await EventIndexPeg.deleteEventIndex();
await this.onEnable();
close();
}
});
};

View File

@@ -11,7 +11,6 @@ import { type AuthDict, type IAuthData } from "matrix-js-sdk/src/interactive-aut
import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal";
import { type InteractiveAuthCallback } from "../../../structures/InteractiveAuth";
import { SSOAuthEntry } from "../../auth/InteractiveAuthEntryComponents";
import InteractiveAuthDialog from "../../dialogs/InteractiveAuthDialog";
@@ -24,7 +23,7 @@ const makeDeleteRequest =
export const deleteDevicesWithInteractiveAuth = async (
matrixClient: MatrixClient,
deviceIds: string[],
onFinished: InteractiveAuthCallback<void>,
onFinished: (success?: boolean) => Promise<void>,
): Promise<void> => {
if (!deviceIds.length) {
return;
@@ -32,7 +31,7 @@ export const deleteDevicesWithInteractiveAuth = async (
try {
await makeDeleteRequest(matrixClient, deviceIds)(null);
// no interactive auth needed
await onFinished(true, undefined);
await onFinished(true);
} catch (error) {
if (!(error instanceof MatrixError) || error.httpStatus !== 401 || !error.data?.flows) {
// doesn't look like an interactive-auth failure
@@ -62,16 +61,16 @@ export const deleteDevicesWithInteractiveAuth = async (
continueKind: "danger",
},
};
Modal.createDialog(InteractiveAuthDialog, {
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("common|authentication"),
matrixClient: matrixClient,
authData: error.data as IAuthData,
onFinished,
makeRequest: makeDeleteRequest(matrixClient, deviceIds),
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
});
finished.then(([success]) => onFinished(success));
}
};

View File

@@ -155,7 +155,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
if (!confirm) return;
}
Modal.createDialog(QuestionDialog, {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("room_settings|security|enable_encryption_confirm_title"),
description: _t(
"room_settings|security|enable_encryption_confirm_description",
@@ -164,23 +164,23 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
a: (sub) => <ExternalLink href={SdkConfig.get("help_encryption_url")}>{sub}</ExternalLink>,
},
),
onFinished: (confirm) => {
if (!confirm) {
this.setState({ encrypted: false });
return;
}
});
finished.then(([confirm]) => {
if (!confirm) {
this.setState({ encrypted: false });
return;
}
const beforeEncrypted = this.state.encrypted;
this.setState({ encrypted: true });
this.context
.sendStateEvent(this.props.room.roomId, EventType.RoomEncryption, {
algorithm: MEGOLM_ENCRYPTION_ALGORITHM,
})
.catch((e) => {
logger.error(e);
this.setState({ encrypted: beforeEncrypted });
});
},
const beforeEncrypted = this.state.encrypted;
this.setState({ encrypted: true });
this.context
.sendStateEvent(this.props.room.roomId, EventType.RoomEncryption, {
algorithm: MEGOLM_ENCRYPTION_ALGORITHM,
})
.catch((e) => {
logger.error(e);
this.setState({ encrypted: beforeEncrypted });
});
});
};
@@ -213,9 +213,9 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
const [shouldCreate, opts] = await modal.finished;
if (shouldCreate) {
await createRoom(this.context, opts);
await createRoom(this.context, opts!);
}
return shouldCreate;
return shouldCreate ?? false;
};
private onHistoryRadioToggle = (history: HistoryVisibility): void => {

View File

@@ -165,10 +165,9 @@ const AccountUserSettingsTab: React.FC<IProps> = ({ closeSettingsFn }) => {
}, []);
const onDeactivateClicked = useCallback((): void => {
Modal.createDialog(DeactivateAccountDialog, {
onFinished: (success) => {
if (success) closeSettingsFn();
},
const { finished } = Modal.createDialog(DeactivateAccountDialog);
finished.then(([success]) => {
if (success) closeSettingsFn();
});
}, [closeSettingsFn]);

View File

@@ -247,7 +247,10 @@ function SetUpEncryptionPanel({ onFinish }: SetUpEncryptionPanelProps): JSX.Elem
<Button
size="sm"
Icon={ComputerIcon}
onClick={() => Modal.createDialog(SetupEncryptionDialog, { onFinished: onFinish })}
onClick={() => {
const { finished } = Modal.createDialog(SetupEncryptionDialog);
finished.then(onFinish);
}}
>
{_t("settings|encryption|device_not_verified_button")}
</Button>

View File

@@ -100,7 +100,7 @@ const useSignOut = (
} else {
const deferredSuccess = defer<boolean>();
await deleteDevicesWithInteractiveAuth(matrixClient, deviceIds, async (success) => {
deferredSuccess.resolve(success);
deferredSuccess.resolve(!!success);
});
success = await deferredSuccess.promise;
}
@@ -203,7 +203,8 @@ const SessionManagerTab: React.FC<{
const shouldShowOtherSessions = otherSessionsCount > 0;
const onVerifyCurrentDevice = (): void => {
Modal.createDialog(SetupEncryptionDialog, { onFinished: refreshDevices });
const { finished } = Modal.createDialog(SetupEncryptionDialog);
finished.then(refreshDevices);
};
const onTriggerDeviceVerification = useCallback(
@@ -212,14 +213,14 @@ const SessionManagerTab: React.FC<{
return;
}
const verificationRequestPromise = requestDeviceVerification(deviceId);
Modal.createDialog(VerificationRequestDialog, {
const { finished } = Modal.createDialog(VerificationRequestDialog, {
verificationRequestPromise,
member: currentUserMember,
onFinished: async (): Promise<void> => {
const request = await verificationRequestPromise;
request.cancel();
await refreshDevices();
},
});
finished.then(async () => {
const request = await verificationRequestPromise;
request.cancel();
await refreshDevices();
});
},
[requestDeviceVerification, refreshDevices, currentUserMember],

View File

@@ -124,18 +124,16 @@ export default class VerificationRequestToast extends React.PureComponent<IProps
request.roomId,
);
} else {
Modal.createDialog(
const { finished } = Modal.createDialog(
VerificationRequestDialog,
{
verificationRequest: request,
onFinished: () => {
request.cancel();
},
},
undefined,
/* priority = */ false,
/* static = */ true,
);
finished.then(() => request.cancel());
}
await request.accept();
} catch (err) {