Merge branch 'main' into content-warning

This commit is contained in:
SleeplessOne1917 2024-04-13 14:54:24 -04:00
commit 3cae1b5a23
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,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}
/>
)}
</>
)
);
}

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) {
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() {