mirror of
https://github.com/LemmyNet/lemmy-ui.git
synced 2024-11-25 05:41:13 +00:00
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:
parent
6e33395572
commit
fdeb9244db
7 changed files with 522 additions and 474 deletions
|
@ -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() {
|
||||
|
|
|
@ -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,17 +190,22 @@ 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}>
|
||||
{this.state.dropdownOpenedOnce && (
|
||||
<>
|
||||
{type === "post" && (
|
||||
<li>
|
||||
<ActionButton
|
||||
icon={this.props.postView.hidden ? "eye" : "eye-slash"}
|
||||
label={I18NextService.i18n.t(
|
||||
this.props.postView.hidden ? "unhide_post" : "hide_post",
|
||||
this.props.postView.hidden
|
||||
? "unhide_post"
|
||||
: "hide_post",
|
||||
)}
|
||||
onClick={this.props.onHidePost}
|
||||
/>
|
||||
|
@ -337,7 +358,9 @@ export default class ContentActionDropdown extends Component<
|
|||
type === "post" ? "post" : "comment",
|
||||
)}`
|
||||
: I18NextService.i18n.t(
|
||||
type === "post" ? "remove_post" : "remove_comment",
|
||||
type === "post"
|
||||
? "remove_post"
|
||||
: "remove_comment",
|
||||
)
|
||||
}
|
||||
icon={removed ? "restore" : "x"}
|
||||
|
@ -362,10 +385,14 @@ export default class ContentActionDropdown extends Component<
|
|||
? "unban_from_community"
|
||||
: "ban_from_community",
|
||||
)}
|
||||
icon={creator_banned_from_community ? "unban" : "ban"}
|
||||
icon={
|
||||
creator_banned_from_community ? "unban" : "ban"
|
||||
}
|
||||
noLoading
|
||||
iconClass={`text-${
|
||||
creator_banned_from_community ? "success" : "danger"
|
||||
creator_banned_from_community
|
||||
? "success"
|
||||
: "danger"
|
||||
}`}
|
||||
/>
|
||||
</li>
|
||||
|
@ -462,6 +489,8 @@ export default class ContentActionDropdown extends Component<
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
81
src/shared/components/mixins/modal-mixin.ts
Normal file
81
src/shared/components/mixins/modal-mixin.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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) {
|
||||
return;
|
||||
// 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();
|
||||
}
|
||||
requested = true;
|
||||
queueMicrotask(() => {
|
||||
requested = false;
|
||||
if (shownInstances.size) {
|
||||
});
|
||||
|
||||
if (shownInstances.size || instanceCounter < 10) {
|
||||
// Avoid randomly closing tooltips.
|
||||
return;
|
||||
}
|
||||
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.
|
||||
const current = instance?.reference ?? null;
|
||||
destroyTippy();
|
||||
setupTippy({ current });
|
||||
});
|
||||
}
|
||||
|
||||
export function destroyTippy() {
|
||||
|
|
Loading…
Reference in a new issue