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 { I18NextService } from "../../services";
import type { Modal } from "bootstrap"; import type { Modal } from "bootstrap";
import { Spinner } from "./icon"; import { Spinner } from "./icon";
import { LoadingEllipses } from "./loading-ellipses"; import { LoadingEllipses } from "./loading-ellipses";
import { modalMixin } from "../mixins/modal-mixin";
interface ConfirmationModalProps { interface ConfirmationModalProps {
children?: InfernoNode;
onYes: () => Promise<void>; onYes: () => Promise<void>;
onNo: () => void; onNo: () => void;
message: string; message: string;
@ -22,13 +30,14 @@ async function handleYes(i: ConfirmationModal) {
i.setState({ loading: false }); i.setState({ loading: false });
} }
@modalMixin
export default class ConfirmationModal extends Component< export default class ConfirmationModal extends Component<
ConfirmationModalProps, ConfirmationModalProps,
ConfirmationModalState ConfirmationModalState
> { > {
readonly modalDivRef: RefObject<HTMLDivElement>; readonly modalDivRef: RefObject<HTMLDivElement>;
readonly yesButtonRef: RefObject<HTMLButtonElement>; readonly yesButtonRef: RefObject<HTMLButtonElement>;
modal: Modal; modal?: Modal;
state: ConfirmationModalState = { state: ConfirmationModalState = {
loading: false, loading: false,
}; };
@ -38,41 +47,6 @@ export default class ConfirmationModal extends Component<
this.modalDivRef = createRef(); this.modalDivRef = createRef();
this.yesButtonRef = 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() { render() {

View file

@ -59,24 +59,35 @@ export type ContentPostProps = {
type ContentActionDropdownProps = ContentCommentProps | ContentPostProps; type ContentActionDropdownProps = ContentCommentProps | ContentPostProps;
const dialogTypes = [ type DialogType =
"showBanDialog", | "BanDialog"
"showRemoveDialog", | "RemoveDialog"
"showPurgeDialog", | "PurgeDialog"
"showReportDialog", | "ReportDialog"
"showTransferCommunityDialog", | "TransferCommunityDialog"
"showAppointModDialog", | "AppointModDialog"
"showAppointAdminDialog", | "AppointAdminDialog"
"showViewVotesDialog", | "ViewVotesDialog";
] as const;
type DialogType = (typeof dialogTypes)[number]; type ActionTypeState = {
type ContentActionDropdownState = {
banType?: BanType; banType?: BanType;
purgeType?: PurgeType; 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 @tippyMixin
export default class ContentActionDropdown extends Component< export default class ContentActionDropdown extends Component<
@ -92,7 +103,15 @@ export default class ContentActionDropdown extends Component<
showReportDialog: false, showReportDialog: false,
showTransferCommunityDialog: false, showTransferCommunityDialog: false,
showViewVotesDialog: 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) { constructor(props: ContentActionDropdownProps, context: any) {
@ -113,10 +132,7 @@ export default class ContentActionDropdown extends Component<
this.toggleAppointAdminShow = this.toggleAppointAdminShow.bind(this); this.toggleAppointAdminShow = this.toggleAppointAdminShow.bind(this);
this.toggleViewVotesShow = this.toggleViewVotesShow.bind(this); this.toggleViewVotesShow = this.toggleViewVotesShow.bind(this);
this.wrapHandler = this.wrapHandler.bind(this); this.wrapHandler = this.wrapHandler.bind(this);
} this.handleDropdownToggleClick = this.handleDropdownToggleClick.bind(this);
componentDidMount() {
this.setState({ mounted: true });
} }
render() { render() {
@ -174,17 +190,22 @@ export default class ContentActionDropdown extends Component<
aria-expanded="false" aria-expanded="false"
aria-controls={dropdownId} aria-controls={dropdownId}
aria-label={I18NextService.i18n.t("more")} aria-label={I18NextService.i18n.t("more")}
onClick={this.handleDropdownToggleClick}
> >
<Icon icon="more-vertical" inline /> <Icon icon="more-vertical" inline />
</button> </button>
<ul className="dropdown-menu" id={dropdownId}> <ul className="dropdown-menu" id={dropdownId}>
{this.state.dropdownOpenedOnce && (
<>
{type === "post" && ( {type === "post" && (
<li> <li>
<ActionButton <ActionButton
icon={this.props.postView.hidden ? "eye" : "eye-slash"} icon={this.props.postView.hidden ? "eye" : "eye-slash"}
label={I18NextService.i18n.t( label={I18NextService.i18n.t(
this.props.postView.hidden ? "unhide_post" : "hide_post", this.props.postView.hidden
? "unhide_post"
: "hide_post",
)} )}
onClick={this.props.onHidePost} onClick={this.props.onHidePost}
/> />
@ -337,7 +358,9 @@ export default class ContentActionDropdown extends Component<
type === "post" ? "post" : "comment", type === "post" ? "post" : "comment",
)}` )}`
: I18NextService.i18n.t( : I18NextService.i18n.t(
type === "post" ? "remove_post" : "remove_comment", type === "post"
? "remove_post"
: "remove_comment",
) )
} }
icon={removed ? "restore" : "x"} icon={removed ? "restore" : "x"}
@ -362,10 +385,14 @@ export default class ContentActionDropdown extends Component<
? "unban_from_community" ? "unban_from_community"
: "ban_from_community", : "ban_from_community",
)} )}
icon={creator_banned_from_community ? "unban" : "ban"} icon={
creator_banned_from_community ? "unban" : "ban"
}
noLoading noLoading
iconClass={`text-${ iconClass={`text-${
creator_banned_from_community ? "success" : "danger" creator_banned_from_community
? "success"
: "danger"
}`} }`}
/> />
</li> </li>
@ -462,6 +489,8 @@ export default class ContentActionDropdown extends Component<
)} )}
</> </>
)} )}
</>
)}
</ul> </ul>
</div> </div>
{this.moderationDialogs} {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( toggleModDialogShow(
dialogType: DialogType, dialogType: DialogType,
stateOverride: Partial<ContentActionDropdownState> = {}, stateOverride: Partial<ActionTypeState> = {},
) { ) {
this.setState(prev => ({ const showKey: keyof ShowState = `show${dialogType}`;
...prev, const renderKey: keyof RenderState = `render${dialogType}`;
[dialogType]: !prev[dialogType], this.setState<keyof ShowState>({
...dialogTypes showBanDialog: false,
.filter(dt => dt !== dialogType) showRemoveDialog: false,
.reduce( showPurgeDialog: false,
(acc, dt) => ({ showReportDialog: false,
...acc, showTransferCommunityDialog: false,
[dt]: false, showAppointModDialog: false,
}), showAppointAdminDialog: false,
{}, showViewVotesDialog: false,
), [showKey]: !this.state[showKey],
[renderKey]: true, // for fade out just keep rendering after show becomes false
...stateOverride, ...stateOverride,
})); });
} }
hideAllDialogs() { hideAllDialogs() {
this.setState({ this.setState<keyof ShowState>({
showBanDialog: false, showBanDialog: false,
showPurgeDialog: false, showPurgeDialog: false,
showRemoveDialog: false, showRemoveDialog: false,
@ -503,52 +538,52 @@ export default class ContentActionDropdown extends Component<
} }
toggleReportDialogShow() { toggleReportDialogShow() {
this.toggleModDialogShow("showReportDialog"); this.toggleModDialogShow("ReportDialog");
} }
toggleRemoveShow() { toggleRemoveShow() {
this.toggleModDialogShow("showRemoveDialog"); this.toggleModDialogShow("RemoveDialog");
} }
toggleBanFromCommunityShow() { toggleBanFromCommunityShow() {
this.toggleModDialogShow("showBanDialog", { this.toggleModDialogShow("BanDialog", {
banType: BanType.Community, banType: BanType.Community,
}); });
} }
toggleBanFromSiteShow() { toggleBanFromSiteShow() {
this.toggleModDialogShow("showBanDialog", { this.toggleModDialogShow("BanDialog", {
banType: BanType.Site, banType: BanType.Site,
}); });
} }
togglePurgePersonShow() { togglePurgePersonShow() {
this.toggleModDialogShow("showPurgeDialog", { this.toggleModDialogShow("PurgeDialog", {
purgeType: PurgeType.Person, purgeType: PurgeType.Person,
}); });
} }
togglePurgeContentShow() { togglePurgeContentShow() {
this.toggleModDialogShow("showPurgeDialog", { this.toggleModDialogShow("PurgeDialog", {
purgeType: purgeType:
this.props.type === "post" ? PurgeType.Post : PurgeType.Comment, this.props.type === "post" ? PurgeType.Post : PurgeType.Comment,
}); });
} }
toggleTransferCommunityShow() { toggleTransferCommunityShow() {
this.toggleModDialogShow("showTransferCommunityDialog"); this.toggleModDialogShow("TransferCommunityDialog");
} }
toggleAppointModShow() { toggleAppointModShow() {
this.toggleModDialogShow("showAppointModDialog"); this.toggleModDialogShow("AppointModDialog");
} }
toggleAppointAdminShow() { toggleAppointAdminShow() {
this.toggleModDialogShow("showAppointAdminDialog"); this.toggleModDialogShow("AppointAdminDialog");
} }
toggleViewVotesShow() { toggleViewVotesShow() {
this.toggleModDialogShow("showViewVotesDialog"); this.toggleModDialogShow("ViewVotesDialog");
} }
get moderationDialogs() { get moderationDialogs() {
@ -563,7 +598,14 @@ export default class ContentActionDropdown extends Component<
showAppointModDialog, showAppointModDialog,
showAppointAdminDialog, showAppointAdminDialog,
showViewVotesDialog, showViewVotesDialog,
mounted, renderBanDialog,
renderPurgeDialog,
renderRemoveDialog,
renderReportDialog,
renderTransferCommunityDialog,
renderAppointModDialog,
renderAppointAdminDialog,
renderViewVotesDialog,
} = this.state; } = this.state;
const { const {
removed, 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 // Wait until componentDidMount runs (which only happens on the browser) to prevent sending over a gratuitous amount of markup
return ( return (
mounted && (
<> <>
{renderRemoveDialog && (
<ModActionFormModal <ModActionFormModal
onSubmit={this.wrapHandler(onRemove)} onSubmit={this.wrapHandler(onRemove)}
modActionType={ modActionType={
@ -600,6 +642,8 @@ export default class ContentActionDropdown extends Component<
onCancel={this.hideAllDialogs} onCancel={this.hideAllDialogs}
show={showRemoveDialog} show={showRemoveDialog}
/> />
)}
{renderBanDialog && (
<ModActionFormModal <ModActionFormModal
onSubmit={this.wrapHandler( onSubmit={this.wrapHandler(
banType === BanType.Community banType === BanType.Community
@ -621,6 +665,8 @@ export default class ContentActionDropdown extends Component<
community={community} community={community}
show={showBanDialog} show={showBanDialog}
/> />
)}
{renderReportDialog && (
<ModActionFormModal <ModActionFormModal
onSubmit={this.wrapHandler(onReport)} onSubmit={this.wrapHandler(onReport)}
modActionType={ modActionType={
@ -629,6 +675,8 @@ export default class ContentActionDropdown extends Component<
onCancel={this.hideAllDialogs} onCancel={this.hideAllDialogs}
show={showReportDialog} show={showReportDialog}
/> />
)}
{renderPurgeDialog && (
<ModActionFormModal <ModActionFormModal
onSubmit={this.wrapHandler( onSubmit={this.wrapHandler(
purgeType === PurgeType.Person ? onPurgeUser : onPurgeContent, purgeType === PurgeType.Person ? onPurgeUser : onPurgeContent,
@ -644,6 +692,8 @@ export default class ContentActionDropdown extends Component<
onCancel={this.hideAllDialogs} onCancel={this.hideAllDialogs}
show={showPurgeDialog} show={showPurgeDialog}
/> />
)}
{renderTransferCommunityDialog && (
<ConfirmationModal <ConfirmationModal
show={showTransferCommunityDialog} show={showTransferCommunityDialog}
message={I18NextService.i18n.t("transfer_community_are_you_sure", { message={I18NextService.i18n.t("transfer_community_are_you_sure", {
@ -654,6 +704,8 @@ export default class ContentActionDropdown extends Component<
onNo={this.hideAllDialogs} onNo={this.hideAllDialogs}
onYes={this.wrapHandler(onTransferCommunity)} onYes={this.wrapHandler(onTransferCommunity)}
/> />
)}
{renderAppointModDialog && (
<ConfirmationModal <ConfirmationModal
show={showAppointModDialog} show={showAppointModDialog}
message={I18NextService.i18n.t( message={I18NextService.i18n.t(
@ -671,6 +723,8 @@ export default class ContentActionDropdown extends Component<
onNo={this.hideAllDialogs} onNo={this.hideAllDialogs}
onYes={this.wrapHandler(onAppointCommunityMod)} onYes={this.wrapHandler(onAppointCommunityMod)}
/> />
)}
{renderAppointAdminDialog && (
<ConfirmationModal <ConfirmationModal
show={showAppointAdminDialog} show={showAppointAdminDialog}
message={I18NextService.i18n.t( message={I18NextService.i18n.t(
@ -688,14 +742,16 @@ export default class ContentActionDropdown extends Component<
onNo={this.hideAllDialogs} onNo={this.hideAllDialogs}
onYes={this.wrapHandler(onAppointAdmin)} onYes={this.wrapHandler(onAppointAdmin)}
/> />
)}
{renderViewVotesDialog && (
<ViewVotesModal <ViewVotesModal
type={type} type={type}
id={id} id={id}
show={showViewVotesDialog} show={showViewVotesDialog}
onCancel={this.hideAllDialogs} 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 { I18NextService } from "../../services/I18NextService";
import { PurgeWarning, Spinner } from "./icon"; import { PurgeWarning, Spinner } from "./icon";
import { getApubName, randomStr } from "@utils/helpers"; import { getApubName, randomStr } from "@utils/helpers";
@ -6,6 +12,7 @@ import type { Modal } from "bootstrap";
import classNames from "classnames"; import classNames from "classnames";
import { Community, Person } from "lemmy-js-client"; import { Community, Person } from "lemmy-js-client";
import { LoadingEllipses } from "./loading-ellipses"; import { LoadingEllipses } from "./loading-ellipses";
import { modalMixin } from "../mixins/modal-mixin";
export interface BanUpdateForm { export interface BanUpdateForm {
reason?: string; reason?: string;
@ -56,7 +63,7 @@ type ModActionFormModalProps = (
| ModActionFormModalPropsRest | ModActionFormModalPropsRest
| ModActionFormModalPropsPurgePerson | ModActionFormModalPropsPurgePerson
| ModActionFormModalPropsRemove | ModActionFormModalPropsRemove
) & { onCancel: () => void; show: boolean }; ) & { onCancel: () => void; show: boolean; children?: InfernoNode };
interface ModActionFormFormState { interface ModActionFormFormState {
loading: boolean; loading: boolean;
@ -109,13 +116,14 @@ async function handleSubmit(i: ModActionFormModal, event: any) {
}); });
} }
@modalMixin
export default class ModActionFormModal extends Component< export default class ModActionFormModal extends Component<
ModActionFormModalProps, ModActionFormModalProps,
ModActionFormFormState ModActionFormFormState
> { > {
private modalDivRef: RefObject<HTMLDivElement>; modalDivRef: RefObject<HTMLDivElement>;
private reasonRef: RefObject<HTMLInputElement>; private reasonRef: RefObject<HTMLInputElement>;
modal: Modal; modal?: Modal;
state: ModActionFormFormState = { state: ModActionFormFormState = {
loading: false, loading: false,
reason: "", reason: "",
@ -129,41 +137,6 @@ export default class ModActionFormModal extends Component<
if (this.isBanModal) { if (this.isBanModal) {
this.state.shouldRemoveData = false; 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() { render() {

View file

@ -1,5 +1,6 @@
import { import {
Component, Component,
InfernoNode,
MouseEventHandler, MouseEventHandler,
RefObject, RefObject,
createRef, createRef,
@ -8,14 +9,16 @@ import {
import { I18NextService } from "../../services"; import { I18NextService } from "../../services";
import { toast } from "../../toast"; import { toast } from "../../toast";
import type { Modal } from "bootstrap"; import type { Modal } from "bootstrap";
import { modalMixin } from "../mixins/modal-mixin";
interface TotpModalProps { interface TotpModalProps {
children?: InfernoNode;
/**Takes totp as param, returns whether submit was successful*/ /**Takes totp as param, returns whether submit was successful*/
onSubmit: (totp: string) => Promise<boolean>; onSubmit: (totp: string) => Promise<boolean>;
onClose: MouseEventHandler; onClose: MouseEventHandler;
type: "login" | "remove" | "generate"; type: "login" | "remove" | "generate";
secretUrl?: string; secretUrl?: string;
show?: boolean; show: boolean;
} }
interface TotpModalState { interface TotpModalState {
@ -68,13 +71,14 @@ function handlePaste(i: TotpModal, event: any) {
} }
} }
@modalMixin
export default class TotpModal extends Component< export default class TotpModal extends Component<
TotpModalProps, TotpModalProps,
TotpModalState TotpModalState
> { > {
readonly modalDivRef: RefObject<HTMLDivElement>; readonly modalDivRef: RefObject<HTMLDivElement>;
readonly inputRef: RefObject<HTMLInputElement>; readonly inputRef: RefObject<HTMLInputElement>;
modal: Modal; modal?: Modal;
state: TotpModalState = { state: TotpModalState = {
totp: "", totp: "",
pending: false, pending: false,
@ -85,52 +89,6 @@ export default class TotpModal extends Component<
this.modalDivRef = createRef(); this.modalDivRef = createRef();
this.inputRef = 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() { 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 { I18NextService } from "../../services";
import type { Modal } from "bootstrap"; import type { Modal } from "bootstrap";
import { Icon, Spinner } from "./icon"; import { Icon, Spinner } from "./icon";
@ -16,8 +22,10 @@ import {
} from "../../services/HttpService"; } from "../../services/HttpService";
import { fetchLimit } from "../../config"; import { fetchLimit } from "../../config";
import { PersonListing } from "../person/person-listing"; import { PersonListing } from "../person/person-listing";
import { modalMixin } from "../mixins/modal-mixin";
interface ViewVotesModalProps { interface ViewVotesModalProps {
children?: InfernoNode;
type: "comment" | "post"; type: "comment" | "post";
id: number; id: number;
show: boolean; show: boolean;
@ -57,13 +65,14 @@ function scoreToIcon(score: number) {
); );
} }
@modalMixin
export default class ViewVotesModal extends Component< export default class ViewVotesModal extends Component<
ViewVotesModalProps, ViewVotesModalProps,
ViewVotesModalState ViewVotesModalState
> { > {
readonly modalDivRef: RefObject<HTMLDivElement>; readonly modalDivRef: RefObject<HTMLDivElement>;
readonly yesButtonRef: RefObject<HTMLButtonElement>; readonly yesButtonRef: RefObject<HTMLButtonElement>;
modal: Modal; modal?: Modal;
state: ViewVotesModalState = { state: ViewVotesModalState = {
postLikesRes: EMPTY_REQUEST, postLikesRes: EMPTY_REQUEST,
commentLikesRes: EMPTY_REQUEST, commentLikesRes: EMPTY_REQUEST,
@ -76,42 +85,20 @@ export default class ViewVotesModal extends Component<
this.modalDivRef = createRef(); this.modalDivRef = createRef();
this.yesButtonRef = createRef(); this.yesButtonRef = createRef();
this.handleShow = this.handleShow.bind(this);
this.handleDismiss = this.handleDismiss.bind(this); this.handleDismiss = this.handleDismiss.bind(this);
this.handlePageChange = this.handlePageChange.bind(this); this.handlePageChange = this.handlePageChange.bind(this);
} }
async componentDidMount() { 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) { if (this.props.show) {
this.modal.show();
await this.refetch(); await this.refetch();
} }
} }
componentWillUnmount() { async componentWillReceiveProps({ show: nextShow }: ViewVotesModalProps) {
this.modalDivRef.current?.removeEventListener( if (nextShow !== this.props.show) {
"shown.bs.modal", if (nextShow) {
this.handleShow,
);
this.modal.dispose();
}
async componentDidUpdate({ show: prevShow }: ViewVotesModalProps) {
if (!!prevShow !== !!this.props.show) {
if (this.props.show) {
this.modal.show();
await this.refetch(); await this.refetch();
} else {
this.modal.hide();
} }
} }
} }
@ -191,7 +178,7 @@ export default class ViewVotesModal extends Component<
handleDismiss() { handleDismiss() {
this.props.onCancel(); this.props.onCancel();
this.modal.hide(); this.modal?.hide();
} }
async handlePageChange(page: number) { 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; let instance: TippyDelegateInstance<TippyProps> | undefined;
const tippySelector = "[data-tippy-content]"; const tippySelector = "[data-tippy-content]";
const shownInstances: Set<TippyInstance<TippyProps>> = new Set(); const shownInstances: Set<TippyInstance<TippyProps>> = new Set();
let instanceCounter = 0;
const tippyDelegateOptions: Partial<TippyProps> & { target: string } = { const tippyDelegateOptions: Partial<TippyProps> & { target: string } = {
delay: [500, 0], delay: [500, 0],
@ -21,6 +22,19 @@ const tippyDelegateOptions: Partial<TippyProps> & { target: string } = {
onHidden(i: TippyInstance<TippyProps>) { onHidden(i: TippyInstance<TippyProps>) {
shownInstances.delete(i); 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>) { export function setupTippy(root: RefObject<Element>) {
@ -29,24 +43,25 @@ export function setupTippy(root: RefObject<Element>) {
} }
} }
let requested = false;
export function cleanupTippy() { export function cleanupTippy() {
if (requested) { // Hide tooltips for elements that are no longer connected to the document.
return; shownInstances.forEach(i => {
if (!i.reference.isConnected) {
console.assert(!i.state.isDestroyed, "hide called on destroyed tippy");
i.hide();
} }
requested = true; });
queueMicrotask(() => {
requested = false; if (shownInstances.size || instanceCounter < 10) {
if (shownInstances.size) {
// Avoid randomly closing tooltips. // Avoid randomly closing tooltips.
return; return;
} }
instanceCounter = 0;
const current = instance?.reference ?? null;
// delegate from tippy.js creates tippy instances when needed, but only // delegate from tippy.js creates tippy instances when needed, but only
// destroys them when the delegate instance is destroyed. // destroys them when the delegate instance is destroyed.
const current = instance?.reference ?? null;
destroyTippy(); destroyTippy();
setupTippy({ current }); setupTippy({ current });
});
} }
export function destroyTippy() { export function destroyTippy() {