Only conditionally render most of content action dropdown and workaround for tippy warning (#2422)

* Avoid destroyed tippy warning

Tippy doesn't remove its onDocumentPress listener when destroyed.
Instead the listener removes itself after calling hide for hideOnClick.

It doesn't look like there is a way to reliable work around this.

This skips the warning for the first hide call on a destroyed tippy
instance.

Cleanup is only performed after at least ten tippy instances have been
created.

* Hide tooltips for elements that are no longer connected to the document

* Only render action modals after first show

* Only render action dropdown after first show

* Modals fix for quick unmount

Modals use `await import("bootstrap/js/dist/modal")` when being mounted.
This means its possible that the component unmounts before the promise
resolves.

* bind() dropdown toggle click handler

* Modal mixin
This commit is contained in:
matc-pub 2024-04-13 17:15:29 +02:00 committed by GitHub
parent 6e33395572
commit fdeb9244db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 522 additions and 474 deletions

View file

@ -1,10 +1,18 @@
import { Component, RefObject, createRef, linkEvent } from "inferno";
import {
Component,
InfernoNode,
RefObject,
createRef,
linkEvent,
} from "inferno";
import { I18NextService } from "../../services";
import type { Modal } from "bootstrap";
import { Spinner } from "./icon";
import { LoadingEllipses } from "./loading-ellipses";
import { modalMixin } from "../mixins/modal-mixin";
interface ConfirmationModalProps {
children?: InfernoNode;
onYes: () => Promise<void>;
onNo: () => void;
message: string;
@ -22,13 +30,14 @@ async function handleYes(i: ConfirmationModal) {
i.setState({ loading: false });
}
@modalMixin
export default class ConfirmationModal extends Component<
ConfirmationModalProps,
ConfirmationModalState
> {
readonly modalDivRef: RefObject<HTMLDivElement>;
readonly yesButtonRef: RefObject<HTMLButtonElement>;
modal: Modal;
modal?: Modal;
state: ConfirmationModalState = {
loading: false,
};
@ -38,41 +47,6 @@ export default class ConfirmationModal extends Component<
this.modalDivRef = createRef();
this.yesButtonRef = createRef();
this.handleShow = this.handleShow.bind(this);
}
async componentDidMount() {
this.modalDivRef.current?.addEventListener(
"shown.bs.modal",
this.handleShow,
);
const Modal = (await import("bootstrap/js/dist/modal")).default;
this.modal = new Modal(this.modalDivRef.current!);
if (this.props.show) {
this.modal.show();
}
}
componentWillUnmount() {
this.modalDivRef.current?.removeEventListener(
"shown.bs.modal",
this.handleShow,
);
this.modal.dispose();
}
componentDidUpdate({ show: prevShow }: ConfirmationModalProps) {
if (!!prevShow !== !!this.props.show) {
if (this.props.show) {
this.modal.show();
} else {
this.modal.hide();
}
}
}
render() {

View file

@ -59,24 +59,35 @@ export type ContentPostProps = {
type ContentActionDropdownProps = ContentCommentProps | ContentPostProps;
const dialogTypes = [
"showBanDialog",
"showRemoveDialog",
"showPurgeDialog",
"showReportDialog",
"showTransferCommunityDialog",
"showAppointModDialog",
"showAppointAdminDialog",
"showViewVotesDialog",
] as const;
type DialogType =
| "BanDialog"
| "RemoveDialog"
| "PurgeDialog"
| "ReportDialog"
| "TransferCommunityDialog"
| "AppointModDialog"
| "AppointAdminDialog"
| "ViewVotesDialog";
type DialogType = (typeof dialogTypes)[number];
type ContentActionDropdownState = {
type ActionTypeState = {
banType?: BanType;
purgeType?: PurgeType;
mounted: boolean;
} & { [key in DialogType]: boolean };
};
type ShowState = {
[key in `show${DialogType}`]: boolean;
};
type RenderState = {
[key in `render${DialogType}`]: boolean;
};
type DropdownState = { dropdownOpenedOnce: boolean };
type ContentActionDropdownState = ActionTypeState &
ShowState &
RenderState &
DropdownState;
@tippyMixin
export default class ContentActionDropdown extends Component<
@ -92,7 +103,15 @@ export default class ContentActionDropdown extends Component<
showReportDialog: false,
showTransferCommunityDialog: false,
showViewVotesDialog: false,
mounted: false,
renderAppointAdminDialog: false,
renderAppointModDialog: false,
renderBanDialog: false,
renderPurgeDialog: false,
renderRemoveDialog: false,
renderReportDialog: false,
renderTransferCommunityDialog: false,
renderViewVotesDialog: false,
dropdownOpenedOnce: false,
};
constructor(props: ContentActionDropdownProps, context: any) {
@ -113,10 +132,7 @@ export default class ContentActionDropdown extends Component<
this.toggleAppointAdminShow = this.toggleAppointAdminShow.bind(this);
this.toggleViewVotesShow = this.toggleViewVotesShow.bind(this);
this.wrapHandler = this.wrapHandler.bind(this);
}
componentDidMount() {
this.setState({ mounted: true });
this.handleDropdownToggleClick = this.handleDropdownToggleClick.bind(this);
}
render() {
@ -174,132 +190,300 @@ export default class ContentActionDropdown extends Component<
aria-expanded="false"
aria-controls={dropdownId}
aria-label={I18NextService.i18n.t("more")}
onClick={this.handleDropdownToggleClick}
>
<Icon icon="more-vertical" inline />
</button>
<ul className="dropdown-menu" id={dropdownId}>
{type === "post" && (
<li>
<ActionButton
icon={this.props.postView.hidden ? "eye" : "eye-slash"}
label={I18NextService.i18n.t(
this.props.postView.hidden ? "unhide_post" : "hide_post",
)}
onClick={this.props.onHidePost}
/>
</li>
)}
{this.amCreator ? (
{this.state.dropdownOpenedOnce && (
<>
<li>
<ActionButton
icon="edit"
label={I18NextService.i18n.t("edit")}
noLoading
onClick={onEdit}
/>
</li>
<li>
<ActionButton
onClick={onDelete}
icon={deleted ? "undo-trash" : "trash"}
label={I18NextService.i18n.t(
deleted ? "undelete" : "delete",
)}
iconClass={`text-${deleted ? "success" : "danger"}`}
/>
</li>
</>
) : (
<>
{type === "comment" && (
{type === "post" && (
<li>
<Link
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
to={`/create_private_message/${creator.id}`}
title={I18NextService.i18n.t("message")}
aria-label={I18NextService.i18n.t("message")}
data-tippy-content={I18NextService.i18n.t("message")}
>
<Icon icon="mail" inline classes="me-2" />
{I18NextService.i18n.t("message")}
</Link>
<ActionButton
icon={this.props.postView.hidden ? "eye" : "eye-slash"}
label={I18NextService.i18n.t(
this.props.postView.hidden
? "unhide_post"
: "hide_post",
)}
onClick={this.props.onHidePost}
/>
</li>
)}
<li>
<ActionButton
icon="flag"
label={I18NextService.i18n.t("create_report")}
onClick={this.toggleReportDialogShow}
noLoading
/>
</li>
<li>
<ActionButton
icon="slash"
label={I18NextService.i18n.t("block_user")}
onClick={onBlock}
/>
</li>
</>
)}
{amAdmin() && (
<li>
<ActionButton
onClick={this.toggleViewVotesShow}
label={I18NextService.i18n.t("view_votes")}
icon={"arrow-up"}
noLoading
/>
</li>
)}
{(amMod(community.id) || amAdmin()) && (
<>
<li>
<hr className="dropdown-divider" />
</li>
{type === "post" && (
{this.amCreator ? (
<>
<li>
<ActionButton
onClick={this.props.onLock}
label={I18NextService.i18n.t(
locked ? "unlock" : "lock",
)}
icon={locked ? "unlock" : "lock"}
icon="edit"
label={I18NextService.i18n.t("edit")}
noLoading
onClick={onEdit}
/>
</li>
<li>
<ActionButton
onClick={this.props.onFeatureCommunity}
onClick={onDelete}
icon={deleted ? "undo-trash" : "trash"}
label={I18NextService.i18n.t(
this.props.postView.post.featured_community
? "unfeature_from_community"
: "feature_in_community",
deleted ? "undelete" : "delete",
)}
icon={
this.props.postView.post.featured_community
? "pin-off"
: "pin"
}
iconClass={`text-${deleted ? "success" : "danger"}`}
/>
</li>
{amAdmin() && (
</>
) : (
<>
{type === "comment" && (
<li>
<Link
className="btn btn-link btn-sm d-flex align-items-center rounded-0 dropdown-item"
to={`/create_private_message/${creator.id}`}
title={I18NextService.i18n.t("message")}
aria-label={I18NextService.i18n.t("message")}
data-tippy-content={I18NextService.i18n.t("message")}
>
<Icon icon="mail" inline classes="me-2" />
{I18NextService.i18n.t("message")}
</Link>
</li>
)}
<li>
<ActionButton
icon="flag"
label={I18NextService.i18n.t("create_report")}
onClick={this.toggleReportDialogShow}
noLoading
/>
</li>
<li>
<ActionButton
icon="slash"
label={I18NextService.i18n.t("block_user")}
onClick={onBlock}
/>
</li>
</>
)}
{amAdmin() && (
<li>
<ActionButton
onClick={this.toggleViewVotesShow}
label={I18NextService.i18n.t("view_votes")}
icon={"arrow-up"}
noLoading
/>
</li>
)}
{(amMod(community.id) || amAdmin()) && (
<>
<li>
<hr className="dropdown-divider" />
</li>
{type === "post" && (
<>
<li>
<ActionButton
onClick={this.props.onLock}
label={I18NextService.i18n.t(
locked ? "unlock" : "lock",
)}
icon={locked ? "unlock" : "lock"}
/>
</li>
<li>
<ActionButton
onClick={this.props.onFeatureCommunity}
label={I18NextService.i18n.t(
this.props.postView.post.featured_community
? "unfeature_from_community"
: "feature_in_community",
)}
icon={
this.props.postView.post.featured_community
? "pin-off"
: "pin"
}
/>
</li>
{amAdmin() && (
<li>
<ActionButton
onClick={this.props.onFeatureLocal}
label={I18NextService.i18n.t(
this.props.postView.post.featured_local
? "unfeature_from_local"
: "feature_in_local",
)}
icon={
this.props.postView.post.featured_local
? "pin-off"
: "pin"
}
/>
</li>
)}
</>
)}
</>
)}
{type === "comment" &&
this.amCreator &&
(this.canModOnSelf || this.canAdminOnSelf) && (
<li>
<ActionButton
onClick={this.props.onDistinguish}
icon={
this.props.commentView.comment.distinguished
? "shield-off"
: "shield"
}
label={I18NextService.i18n.t(
this.props.commentView.comment.distinguished
? "undistinguish"
: "distinguish",
)}
/>
</li>
)}
{(this.canMod || this.canAdmin) && (
<li>
<ActionButton
label={
removed
? `${I18NextService.i18n.t(
"restore",
)} ${I18NextService.i18n.t(
type === "post" ? "post" : "comment",
)}`
: I18NextService.i18n.t(
type === "post"
? "remove_post"
: "remove_comment",
)
}
icon={removed ? "restore" : "x"}
noLoading
onClick={this.toggleRemoveShow}
iconClass={`text-${removed ? "success" : "danger"}`}
/>
</li>
)}
{this.canMod &&
(!creator_is_moderator || canAppointCommunityMod) && (
<>
<li>
<hr className="dropdown-divider" />
</li>
{!creator_is_moderator && (
<li>
<ActionButton
onClick={this.toggleBanFromCommunityShow}
label={I18NextService.i18n.t(
creator_banned_from_community
? "unban_from_community"
: "ban_from_community",
)}
icon={
creator_banned_from_community ? "unban" : "ban"
}
noLoading
iconClass={`text-${
creator_banned_from_community
? "success"
: "danger"
}`}
/>
</li>
)}
{canAppointCommunityMod && (
<li>
<ActionButton
onClick={this.toggleAppointModShow}
label={I18NextService.i18n.t(
`${
creator_is_moderator ? "remove" : "appoint"
}_as_mod`,
)}
icon={creator_is_moderator ? "demote" : "promote"}
iconClass={`text-${
creator_is_moderator ? "danger" : "success"
}`}
noLoading
/>
</li>
)}
</>
)}
{(amCommunityCreator(this.id, moderators) || this.canAdmin) &&
creator_is_moderator && (
<li>
<ActionButton
label={I18NextService.i18n.t("transfer_community")}
onClick={this.toggleTransferCommunityShow}
icon="transfer"
noLoading
/>
</li>
)}
{this.canAdmin && (showToggleAdmin || !creator_is_admin) && (
<>
<li>
<hr className="dropdown-divider" />
</li>
{!creator_is_admin && (
<>
<li>
<ActionButton
label={I18NextService.i18n.t(
creatorBannedFromLocal
? "unban_from_site"
: "ban_from_site",
)}
onClick={this.toggleBanFromSiteShow}
icon={creatorBannedFromLocal ? "unban" : "ban"}
iconClass={`text-${
creatorBannedFromLocal ? "success" : "danger"
}`}
noLoading
/>
</li>
<li>
<ActionButton
label={I18NextService.i18n.t("purge_user")}
onClick={this.togglePurgePersonShow}
icon="purge"
noLoading
iconClass="text-danger"
/>
</li>
<li>
<ActionButton
label={I18NextService.i18n.t(
`purge_${type === "post" ? "post" : "comment"}`,
)}
onClick={this.togglePurgeContentShow}
icon="purge"
noLoading
iconClass="text-danger"
/>
</li>
</>
)}
{showToggleAdmin && (
<li>
<ActionButton
onClick={this.props.onFeatureLocal}
label={I18NextService.i18n.t(
this.props.postView.post.featured_local
? "unfeature_from_local"
: "feature_in_local",
`${creator_is_admin ? "remove" : "appoint"}_as_admin`,
)}
icon={
this.props.postView.post.featured_local
? "pin-off"
: "pin"
}
onClick={this.toggleAppointAdminShow}
icon={creator_is_admin ? "demote" : "promote"}
iconClass={`text-${
creator_is_admin ? "danger" : "success"
}`}
noLoading
/>
</li>
)}
@ -307,161 +491,6 @@ export default class ContentActionDropdown extends Component<
)}
</>
)}
{type === "comment" &&
this.amCreator &&
(this.canModOnSelf || this.canAdminOnSelf) && (
<li>
<ActionButton
onClick={this.props.onDistinguish}
icon={
this.props.commentView.comment.distinguished
? "shield-off"
: "shield"
}
label={I18NextService.i18n.t(
this.props.commentView.comment.distinguished
? "undistinguish"
: "distinguish",
)}
/>
</li>
)}
{(this.canMod || this.canAdmin) && (
<li>
<ActionButton
label={
removed
? `${I18NextService.i18n.t(
"restore",
)} ${I18NextService.i18n.t(
type === "post" ? "post" : "comment",
)}`
: I18NextService.i18n.t(
type === "post" ? "remove_post" : "remove_comment",
)
}
icon={removed ? "restore" : "x"}
noLoading
onClick={this.toggleRemoveShow}
iconClass={`text-${removed ? "success" : "danger"}`}
/>
</li>
)}
{this.canMod &&
(!creator_is_moderator || canAppointCommunityMod) && (
<>
<li>
<hr className="dropdown-divider" />
</li>
{!creator_is_moderator && (
<li>
<ActionButton
onClick={this.toggleBanFromCommunityShow}
label={I18NextService.i18n.t(
creator_banned_from_community
? "unban_from_community"
: "ban_from_community",
)}
icon={creator_banned_from_community ? "unban" : "ban"}
noLoading
iconClass={`text-${
creator_banned_from_community ? "success" : "danger"
}`}
/>
</li>
)}
{canAppointCommunityMod && (
<li>
<ActionButton
onClick={this.toggleAppointModShow}
label={I18NextService.i18n.t(
`${
creator_is_moderator ? "remove" : "appoint"
}_as_mod`,
)}
icon={creator_is_moderator ? "demote" : "promote"}
iconClass={`text-${
creator_is_moderator ? "danger" : "success"
}`}
noLoading
/>
</li>
)}
</>
)}
{(amCommunityCreator(this.id, moderators) || this.canAdmin) &&
creator_is_moderator && (
<li>
<ActionButton
label={I18NextService.i18n.t("transfer_community")}
onClick={this.toggleTransferCommunityShow}
icon="transfer"
noLoading
/>
</li>
)}
{this.canAdmin && (showToggleAdmin || !creator_is_admin) && (
<>
<li>
<hr className="dropdown-divider" />
</li>
{!creator_is_admin && (
<>
<li>
<ActionButton
label={I18NextService.i18n.t(
creatorBannedFromLocal
? "unban_from_site"
: "ban_from_site",
)}
onClick={this.toggleBanFromSiteShow}
icon={creatorBannedFromLocal ? "unban" : "ban"}
iconClass={`text-${
creatorBannedFromLocal ? "success" : "danger"
}`}
noLoading
/>
</li>
<li>
<ActionButton
label={I18NextService.i18n.t("purge_user")}
onClick={this.togglePurgePersonShow}
icon="purge"
noLoading
iconClass="text-danger"
/>
</li>
<li>
<ActionButton
label={I18NextService.i18n.t(
`purge_${type === "post" ? "post" : "comment"}`,
)}
onClick={this.togglePurgeContentShow}
icon="purge"
noLoading
iconClass="text-danger"
/>
</li>
</>
)}
{showToggleAdmin && (
<li>
<ActionButton
label={I18NextService.i18n.t(
`${creator_is_admin ? "remove" : "appoint"}_as_admin`,
)}
onClick={this.toggleAppointAdminShow}
icon={creator_is_admin ? "demote" : "promote"}
iconClass={`text-${
creator_is_admin ? "danger" : "success"
}`}
noLoading
/>
</li>
)}
</>
)}
</ul>
</div>
{this.moderationDialogs}
@ -469,28 +498,34 @@ export default class ContentActionDropdown extends Component<
);
}
handleDropdownToggleClick() {
// This only renders the dropdown. Bootstrap handles the show/hide part.
this.setState({ dropdownOpenedOnce: true });
}
toggleModDialogShow(
dialogType: DialogType,
stateOverride: Partial<ContentActionDropdownState> = {},
stateOverride: Partial<ActionTypeState> = {},
) {
this.setState(prev => ({
...prev,
[dialogType]: !prev[dialogType],
...dialogTypes
.filter(dt => dt !== dialogType)
.reduce(
(acc, dt) => ({
...acc,
[dt]: false,
}),
{},
),
const showKey: keyof ShowState = `show${dialogType}`;
const renderKey: keyof RenderState = `render${dialogType}`;
this.setState<keyof ShowState>({
showBanDialog: false,
showRemoveDialog: false,
showPurgeDialog: false,
showReportDialog: false,
showTransferCommunityDialog: false,
showAppointModDialog: false,
showAppointAdminDialog: false,
showViewVotesDialog: false,
[showKey]: !this.state[showKey],
[renderKey]: true, // for fade out just keep rendering after show becomes false
...stateOverride,
}));
});
}
hideAllDialogs() {
this.setState({
this.setState<keyof ShowState>({
showBanDialog: false,
showPurgeDialog: false,
showRemoveDialog: false,
@ -503,52 +538,52 @@ export default class ContentActionDropdown extends Component<
}
toggleReportDialogShow() {
this.toggleModDialogShow("showReportDialog");
this.toggleModDialogShow("ReportDialog");
}
toggleRemoveShow() {
this.toggleModDialogShow("showRemoveDialog");
this.toggleModDialogShow("RemoveDialog");
}
toggleBanFromCommunityShow() {
this.toggleModDialogShow("showBanDialog", {
this.toggleModDialogShow("BanDialog", {
banType: BanType.Community,
});
}
toggleBanFromSiteShow() {
this.toggleModDialogShow("showBanDialog", {
this.toggleModDialogShow("BanDialog", {
banType: BanType.Site,
});
}
togglePurgePersonShow() {
this.toggleModDialogShow("showPurgeDialog", {
this.toggleModDialogShow("PurgeDialog", {
purgeType: PurgeType.Person,
});
}
togglePurgeContentShow() {
this.toggleModDialogShow("showPurgeDialog", {
this.toggleModDialogShow("PurgeDialog", {
purgeType:
this.props.type === "post" ? PurgeType.Post : PurgeType.Comment,
});
}
toggleTransferCommunityShow() {
this.toggleModDialogShow("showTransferCommunityDialog");
this.toggleModDialogShow("TransferCommunityDialog");
}
toggleAppointModShow() {
this.toggleModDialogShow("showAppointModDialog");
this.toggleModDialogShow("AppointModDialog");
}
toggleAppointAdminShow() {
this.toggleModDialogShow("showAppointAdminDialog");
this.toggleModDialogShow("AppointAdminDialog");
}
toggleViewVotesShow() {
this.toggleModDialogShow("showViewVotesDialog");
this.toggleModDialogShow("ViewVotesDialog");
}
get moderationDialogs() {
@ -563,7 +598,14 @@ export default class ContentActionDropdown extends Component<
showAppointModDialog,
showAppointAdminDialog,
showViewVotesDialog,
mounted,
renderBanDialog,
renderPurgeDialog,
renderRemoveDialog,
renderReportDialog,
renderTransferCommunityDialog,
renderAppointModDialog,
renderAppointAdminDialog,
renderViewVotesDialog,
} = this.state;
const {
removed,
@ -589,8 +631,8 @@ export default class ContentActionDropdown extends Component<
// Wait until componentDidMount runs (which only happens on the browser) to prevent sending over a gratuitous amount of markup
return (
mounted && (
<>
<>
{renderRemoveDialog && (
<ModActionFormModal
onSubmit={this.wrapHandler(onRemove)}
modActionType={
@ -600,6 +642,8 @@ export default class ContentActionDropdown extends Component<
onCancel={this.hideAllDialogs}
show={showRemoveDialog}
/>
)}
{renderBanDialog && (
<ModActionFormModal
onSubmit={this.wrapHandler(
banType === BanType.Community
@ -621,6 +665,8 @@ export default class ContentActionDropdown extends Component<
community={community}
show={showBanDialog}
/>
)}
{renderReportDialog && (
<ModActionFormModal
onSubmit={this.wrapHandler(onReport)}
modActionType={
@ -629,6 +675,8 @@ export default class ContentActionDropdown extends Component<
onCancel={this.hideAllDialogs}
show={showReportDialog}
/>
)}
{renderPurgeDialog && (
<ModActionFormModal
onSubmit={this.wrapHandler(
purgeType === PurgeType.Person ? onPurgeUser : onPurgeContent,
@ -644,6 +692,8 @@ export default class ContentActionDropdown extends Component<
onCancel={this.hideAllDialogs}
show={showPurgeDialog}
/>
)}
{renderTransferCommunityDialog && (
<ConfirmationModal
show={showTransferCommunityDialog}
message={I18NextService.i18n.t("transfer_community_are_you_sure", {
@ -654,6 +704,8 @@ export default class ContentActionDropdown extends Component<
onNo={this.hideAllDialogs}
onYes={this.wrapHandler(onTransferCommunity)}
/>
)}
{renderAppointModDialog && (
<ConfirmationModal
show={showAppointModDialog}
message={I18NextService.i18n.t(
@ -671,6 +723,8 @@ export default class ContentActionDropdown extends Component<
onNo={this.hideAllDialogs}
onYes={this.wrapHandler(onAppointCommunityMod)}
/>
)}
{renderAppointAdminDialog && (
<ConfirmationModal
show={showAppointAdminDialog}
message={I18NextService.i18n.t(
@ -688,14 +742,16 @@ export default class ContentActionDropdown extends Component<
onNo={this.hideAllDialogs}
onYes={this.wrapHandler(onAppointAdmin)}
/>
)}
{renderViewVotesDialog && (
<ViewVotesModal
type={type}
id={id}
show={showViewVotesDialog}
onCancel={this.hideAllDialogs}
/>
</>
)
)}
</>
);
}

View file

@ -1,4 +1,10 @@
import { Component, RefObject, createRef, linkEvent } from "inferno";
import {
Component,
InfernoNode,
RefObject,
createRef,
linkEvent,
} from "inferno";
import { I18NextService } from "../../services/I18NextService";
import { PurgeWarning, Spinner } from "./icon";
import { getApubName, randomStr } from "@utils/helpers";
@ -6,6 +12,7 @@ import type { Modal } from "bootstrap";
import classNames from "classnames";
import { Community, Person } from "lemmy-js-client";
import { LoadingEllipses } from "./loading-ellipses";
import { modalMixin } from "../mixins/modal-mixin";
export interface BanUpdateForm {
reason?: string;
@ -56,7 +63,7 @@ type ModActionFormModalProps = (
| ModActionFormModalPropsRest
| ModActionFormModalPropsPurgePerson
| ModActionFormModalPropsRemove
) & { onCancel: () => void; show: boolean };
) & { onCancel: () => void; show: boolean; children?: InfernoNode };
interface ModActionFormFormState {
loading: boolean;
@ -109,13 +116,14 @@ async function handleSubmit(i: ModActionFormModal, event: any) {
});
}
@modalMixin
export default class ModActionFormModal extends Component<
ModActionFormModalProps,
ModActionFormFormState
> {
private modalDivRef: RefObject<HTMLDivElement>;
modalDivRef: RefObject<HTMLDivElement>;
private reasonRef: RefObject<HTMLInputElement>;
modal: Modal;
modal?: Modal;
state: ModActionFormFormState = {
loading: false,
reason: "",
@ -129,41 +137,6 @@ export default class ModActionFormModal extends Component<
if (this.isBanModal) {
this.state.shouldRemoveData = false;
}
this.handleShow = this.handleShow.bind(this);
}
async componentDidMount() {
this.modalDivRef.current?.addEventListener(
"shown.bs.modal",
this.handleShow,
);
const Modal = (await import("bootstrap/js/dist/modal")).default;
this.modal = new Modal(this.modalDivRef.current!);
if (this.props.show) {
this.modal.show();
}
}
componentWillUnmount() {
this.modalDivRef.current?.removeEventListener(
"shown.bs.modal",
this.handleShow,
);
this.modal.dispose();
}
componentDidUpdate({ show: prevShow }: ModActionFormModalProps) {
if (!!prevShow !== !!this.props.show) {
if (this.props.show) {
this.modal.show();
} else {
this.modal.hide();
}
}
}
render() {

View file

@ -1,5 +1,6 @@
import {
Component,
InfernoNode,
MouseEventHandler,
RefObject,
createRef,
@ -8,14 +9,16 @@ import {
import { I18NextService } from "../../services";
import { toast } from "../../toast";
import type { Modal } from "bootstrap";
import { modalMixin } from "../mixins/modal-mixin";
interface TotpModalProps {
children?: InfernoNode;
/**Takes totp as param, returns whether submit was successful*/
onSubmit: (totp: string) => Promise<boolean>;
onClose: MouseEventHandler;
type: "login" | "remove" | "generate";
secretUrl?: string;
show?: boolean;
show: boolean;
}
interface TotpModalState {
@ -68,13 +71,14 @@ function handlePaste(i: TotpModal, event: any) {
}
}
@modalMixin
export default class TotpModal extends Component<
TotpModalProps,
TotpModalState
> {
readonly modalDivRef: RefObject<HTMLDivElement>;
readonly inputRef: RefObject<HTMLInputElement>;
modal: Modal;
modal?: Modal;
state: TotpModalState = {
totp: "",
pending: false,
@ -85,52 +89,6 @@ export default class TotpModal extends Component<
this.modalDivRef = createRef();
this.inputRef = createRef();
this.clearTotp = this.clearTotp.bind(this);
this.handleShow = this.handleShow.bind(this);
}
async componentDidMount() {
this.modalDivRef.current?.addEventListener(
"shown.bs.modal",
this.handleShow,
);
this.modalDivRef.current?.addEventListener(
"hidden.bs.modal",
this.clearTotp,
);
const Modal = (await import("bootstrap/js/dist/modal")).default;
this.modal = new Modal(this.modalDivRef.current!);
if (this.props.show) {
this.modal.show();
}
}
componentWillUnmount() {
this.modalDivRef.current?.removeEventListener(
"shown.bs.modal",
this.handleShow,
);
this.modalDivRef.current?.removeEventListener(
"hidden.bs.modal",
this.clearTotp,
);
this.modal.dispose();
}
componentDidUpdate({ show: prevShow }: TotpModalProps) {
if (!!prevShow !== !!this.props.show) {
if (this.props.show) {
this.modal.show();
} else {
this.modal.hide();
}
}
}
render() {
@ -254,4 +212,8 @@ export default class TotpModal extends Component<
});
}
}
handleHide() {
this.clearTotp();
}
}

View file

@ -1,4 +1,10 @@
import { Component, RefObject, createRef, linkEvent } from "inferno";
import {
Component,
InfernoNode,
RefObject,
createRef,
linkEvent,
} from "inferno";
import { I18NextService } from "../../services";
import type { Modal } from "bootstrap";
import { Icon, Spinner } from "./icon";
@ -16,8 +22,10 @@ import {
} from "../../services/HttpService";
import { fetchLimit } from "../../config";
import { PersonListing } from "../person/person-listing";
import { modalMixin } from "../mixins/modal-mixin";
interface ViewVotesModalProps {
children?: InfernoNode;
type: "comment" | "post";
id: number;
show: boolean;
@ -57,13 +65,14 @@ function scoreToIcon(score: number) {
);
}
@modalMixin
export default class ViewVotesModal extends Component<
ViewVotesModalProps,
ViewVotesModalState
> {
readonly modalDivRef: RefObject<HTMLDivElement>;
readonly yesButtonRef: RefObject<HTMLButtonElement>;
modal: Modal;
modal?: Modal;
state: ViewVotesModalState = {
postLikesRes: EMPTY_REQUEST,
commentLikesRes: EMPTY_REQUEST,
@ -76,42 +85,20 @@ export default class ViewVotesModal extends Component<
this.modalDivRef = createRef();
this.yesButtonRef = createRef();
this.handleShow = this.handleShow.bind(this);
this.handleDismiss = this.handleDismiss.bind(this);
this.handlePageChange = this.handlePageChange.bind(this);
}
async componentDidMount() {
this.modalDivRef.current?.addEventListener(
"shown.bs.modal",
this.handleShow,
);
const Modal = (await import("bootstrap/js/dist/modal")).default;
this.modal = new Modal(this.modalDivRef.current!);
if (this.props.show) {
this.modal.show();
await this.refetch();
}
}
componentWillUnmount() {
this.modalDivRef.current?.removeEventListener(
"shown.bs.modal",
this.handleShow,
);
this.modal.dispose();
}
async componentDidUpdate({ show: prevShow }: ViewVotesModalProps) {
if (!!prevShow !== !!this.props.show) {
if (this.props.show) {
this.modal.show();
async componentWillReceiveProps({ show: nextShow }: ViewVotesModalProps) {
if (nextShow !== this.props.show) {
if (nextShow) {
await this.refetch();
} else {
this.modal.hide();
}
}
}
@ -191,7 +178,7 @@ export default class ViewVotesModal extends Component<
handleDismiss() {
this.props.onCancel();
this.modal.hide();
this.modal?.hide();
}
async handlePageChange(page: number) {

View file

@ -0,0 +1,81 @@
import { Modal } from "bootstrap";
import { Component, InfernoNode, RefObject } from "inferno";
export function modalMixin<
P extends { show: boolean },
S,
Base extends new (...args: any[]) => Component<P, S> & {
readonly modalDivRef: RefObject<HTMLDivElement>;
handleShow?(): void;
handleHide?(): void;
},
>(base: Base, _context?: ClassDecoratorContext<Base>) {
return class extends base {
modal?: Modal;
constructor(...args: any[]) {
super(...args);
this.handleHide = this.handleHide?.bind(this);
this.handleShow = this.handleShow?.bind(this);
}
private addModalListener(type: string, listener?: () => void) {
if (listener) {
this.modalDivRef.current?.addEventListener(type, listener);
}
}
private removeModalListener(type: string, listener?: () => void) {
if (listener) {
this.modalDivRef.current?.addEventListener(type, listener);
}
}
componentDidMount() {
// Keeping this sync to allow the super implementation to be sync
import("bootstrap/js/dist/modal").then(
(res: { default: typeof Modal }) => {
if (!this.modalDivRef.current) {
return;
}
// bootstrap tries to touch `document` during import, which makes
// the import fail on the server.
const Modal = res.default;
this.addModalListener("shown.bs.modal", this.handleShow);
this.addModalListener("hidden.bs.modal", this.handleHide);
this.modal = new Modal(this.modalDivRef.current!);
if (this.props.show) {
this.modal.show();
}
},
);
return super.componentDidMount?.();
}
componentWillUnmount() {
this.removeModalListener("shown.bs.modal", this.handleShow);
this.removeModalListener("hidden.bs.modal", this.handleHide);
this.modal?.dispose();
return super.componentWillUnmount?.();
}
componentWillReceiveProps(
nextProps: Readonly<{ children?: InfernoNode } & P>,
nextContext: any,
) {
if (nextProps.show !== this.props.show) {
if (nextProps.show) {
this.modal?.show();
} else {
this.modal?.hide();
}
}
return super.componentWillReceiveProps?.(nextProps, nextContext);
}
};
}

View file

@ -9,6 +9,7 @@ import {
let instance: TippyDelegateInstance<TippyProps> | undefined;
const tippySelector = "[data-tippy-content]";
const shownInstances: Set<TippyInstance<TippyProps>> = new Set();
let instanceCounter = 0;
const tippyDelegateOptions: Partial<TippyProps> & { target: string } = {
delay: [500, 0],
@ -21,6 +22,19 @@ const tippyDelegateOptions: Partial<TippyProps> & { target: string } = {
onHidden(i: TippyInstance<TippyProps>) {
shownInstances.delete(i);
},
onCreate() {
instanceCounter++;
},
onDestroy(i: TippyInstance<TippyProps>) {
// Tippy doesn't remove its onDocumentPress listener when destroyed.
// Instead the listener removes itself after calling hide for hideOnClick.
const origHide = i.hide;
// This silences the first warning when hiding a destroyed tippy instance.
// hide() is otherwise a noop for destroyed instances.
i.hide = () => {
i.hide = origHide;
};
},
};
export function setupTippy(root: RefObject<Element>) {
@ -29,24 +43,25 @@ export function setupTippy(root: RefObject<Element>) {
}
}
let requested = false;
export function cleanupTippy() {
if (requested) {
// Hide tooltips for elements that are no longer connected to the document.
shownInstances.forEach(i => {
if (!i.reference.isConnected) {
console.assert(!i.state.isDestroyed, "hide called on destroyed tippy");
i.hide();
}
});
if (shownInstances.size || instanceCounter < 10) {
// Avoid randomly closing tooltips.
return;
}
requested = true;
queueMicrotask(() => {
requested = false;
if (shownInstances.size) {
// Avoid randomly closing tooltips.
return;
}
// delegate from tippy.js creates tippy instances when needed, but only
// destroys them when the delegate instance is destroyed.
const current = instance?.reference ?? null;
destroyTippy();
setupTippy({ current });
});
instanceCounter = 0;
const current = instance?.reference ?? null;
// delegate from tippy.js creates tippy instances when needed, but only
// destroys them when the delegate instance is destroyed.
destroyTippy();
setupTippy({ current });
}
export function destroyTippy() {