diff --git a/src/shared/components/common/confirmation-modal.tsx b/src/shared/components/common/confirmation-modal.tsx index d369863bb..538484973 100644 --- a/src/shared/components/common/confirmation-modal.tsx +++ b/src/shared/components/common/confirmation-modal.tsx @@ -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; 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; readonly yesButtonRef: RefObject; - 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() { diff --git a/src/shared/components/common/content-actions/content-action-dropdown.tsx b/src/shared/components/common/content-actions/content-action-dropdown.tsx index 1fb0e47f0..f0560687b 100644 --- a/src/shared/components/common/content-actions/content-action-dropdown.tsx +++ b/src/shared/components/common/content-actions/content-action-dropdown.tsx @@ -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 = (typeof dialogTypes)[number]; - -type ContentActionDropdownState = { +type DialogType = + | "BanDialog" + | "RemoveDialog" + | "PurgeDialog" + | "ReportDialog" + | "TransferCommunityDialog" + | "AppointModDialog" + | "AppointAdminDialog" + | "ViewVotesDialog"; + +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,292 +190,305 @@ export default class ContentActionDropdown extends Component< aria-expanded="false" aria-controls={dropdownId} aria-label={I18NextService.i18n.t("more")} + onClick={this.handleDropdownToggleClick} >
    - {type === "post" && ( -
  • - -
  • - )} - {this.amCreator ? ( + {this.state.dropdownOpenedOnce && ( <> -
  • - -
  • -
  • - -
  • - - ) : ( - <> - {type === "comment" && ( + {type === "post" && (
  • - - - {I18NextService.i18n.t("message")} - +
  • )} -
  • - -
  • -
  • - -
  • - - )} - {amAdmin() && ( -
  • - -
  • - )} - - {(amMod(community.id) || amAdmin()) && ( - <> -
  • -
    -
  • - {type === "post" && ( + {this.amCreator ? ( <>
  • - {amAdmin() && ( + + ) : ( + <> + {type === "comment" && (
  • - + + + {I18NextService.i18n.t("message")} +
  • )} - - )} - - )} - {type === "comment" && - this.amCreator && - (this.canModOnSelf || this.canAdminOnSelf) && ( -
  • - -
  • - )} - {(this.canMod || this.canAdmin) && ( -
  • - -
  • - )} - {this.canMod && - (!creator_is_moderator || canAppointCommunityMod) && ( - <> -
  • -
    -
  • - {!creator_is_moderator && (
  • - )} - {canAppointCommunityMod && (
  • - )} - - )} - {(amCommunityCreator(this.id, moderators) || this.canAdmin) && - creator_is_moderator && ( -
  • - -
  • - )} - - {this.canAdmin && (showToggleAdmin || !creator_is_admin) && ( - <> -
  • -
    -
  • - {!creator_is_admin && ( + + )} + {amAdmin() && ( +
  • + +
  • + )} + + {(amMod(community.id) || amAdmin()) && ( <> +
  • +
    +
  • + {type === "post" && ( + <> +
  • + +
  • +
  • + +
  • + {amAdmin() && ( +
  • + +
  • + )} + + )} + + )} + {type === "comment" && + this.amCreator && + (this.canModOnSelf || this.canAdminOnSelf) && (
  • + )} + {(this.canMod || this.canAdmin) && ( +
  • + +
  • + )} + {this.canMod && + (!creator_is_moderator || canAppointCommunityMod) && ( + <> +
  • +
    +
  • + {!creator_is_moderator && ( +
  • + +
  • + )} + {canAppointCommunityMod && ( +
  • + +
  • + )} + + )} + {(amCommunityCreator(this.id, moderators) || this.canAdmin) && + creator_is_moderator && (
  • + )} + + {this.canAdmin && (showToggleAdmin || !creator_is_admin) && ( + <>
  • - +
  • + {!creator_is_admin && ( + <> +
  • + +
  • +
  • + +
  • +
  • + +
  • + + )} + {showToggleAdmin && ( +
  • + +
  • + )} )} - {showToggleAdmin && ( -
  • - -
  • - )} )}
@@ -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 = {}, + stateOverride: Partial = {}, ) { - 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({ + 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({ 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 && ( + )} + {renderBanDialog && ( + )} + {renderReportDialog && ( + )} + {renderPurgeDialog && ( + )} + {renderTransferCommunityDialog && ( + )} + {renderAppointModDialog && ( + )} + {renderAppointAdminDialog && ( + )} + {renderViewVotesDialog && ( - - ) + )} + ); } diff --git a/src/shared/components/common/mod-action-form-modal.tsx b/src/shared/components/common/mod-action-form-modal.tsx index 24c8ecb55..36a41d281 100644 --- a/src/shared/components/common/mod-action-form-modal.tsx +++ b/src/shared/components/common/mod-action-form-modal.tsx @@ -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; + modalDivRef: RefObject; private reasonRef: RefObject; - 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() { diff --git a/src/shared/components/common/totp-modal.tsx b/src/shared/components/common/totp-modal.tsx index 8a9eabc3e..20310a049 100644 --- a/src/shared/components/common/totp-modal.tsx +++ b/src/shared/components/common/totp-modal.tsx @@ -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; 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; readonly inputRef: RefObject; - 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(); + } } diff --git a/src/shared/components/common/view-votes-modal.tsx b/src/shared/components/common/view-votes-modal.tsx index c94c0214e..5ac9f801b 100644 --- a/src/shared/components/common/view-votes-modal.tsx +++ b/src/shared/components/common/view-votes-modal.tsx @@ -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; readonly yesButtonRef: RefObject; - 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) { diff --git a/src/shared/components/mixins/modal-mixin.ts b/src/shared/components/mixins/modal-mixin.ts new file mode 100644 index 000000000..5108f772a --- /dev/null +++ b/src/shared/components/mixins/modal-mixin.ts @@ -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 & { + readonly modalDivRef: RefObject; + handleShow?(): void; + handleHide?(): void; + }, +>(base: Base, _context?: ClassDecoratorContext) { + 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); + } + }; +} diff --git a/src/shared/tippy.ts b/src/shared/tippy.ts index b890b1a10..b66aff0d9 100644 --- a/src/shared/tippy.ts +++ b/src/shared/tippy.ts @@ -9,6 +9,7 @@ import { let instance: TippyDelegateInstance | undefined; const tippySelector = "[data-tippy-content]"; const shownInstances: Set> = new Set(); +let instanceCounter = 0; const tippyDelegateOptions: Partial & { target: string } = { delay: [500, 0], @@ -21,6 +22,19 @@ const tippyDelegateOptions: Partial & { target: string } = { onHidden(i: TippyInstance) { shownInstances.delete(i); }, + onCreate() { + instanceCounter++; + }, + onDestroy(i: TippyInstance) { + // 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) { @@ -29,24 +43,25 @@ export function setupTippy(root: RefObject) { } } -let requested = false; export function cleanupTippy() { - if (requested) { - return; - } - requested = true; - queueMicrotask(() => { - requested = false; - if (shownInstances.size) { - // Avoid randomly closing tooltips. - 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(); } - // 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 }); }); + + 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. + destroyTippy(); + setupTippy({ current }); } export function destroyTippy() {