Moderation/content action overhaul (#2258)

* Fix remove post dialog

* Consolidate mod action logic

* Make mod action form less janky

* Move content action dropdown to its own component

* Make reusable component for content action buttons

* Finish up mod dropdown

* Introduce new content dropdown component to post listing

* Fix cancel moderation button bug

* Add icons, tweak UI

* Handle delete/undelete icons

* The thing

* Fix some of the banning related bugs

* Fix mod form ban bugs

* Fix some more bugs

* Make comments use dropdown menu

* Use mod action form with comments

* Make confirmation modal

* Make all the mod action dialogs modals

* Tweak modal

* Fix bug with mod form submit

* Tweak modal more

* More modal tweaking and some feedback toasts

* Use icon pairs for on/off

* Make modals auto focus input

* Implement PR suggestions

* Make UI use async functions where needed

* Make loading state for context action modals

* Hide context actions that users should not be able to do

* Add loading state to confirmation modals

* Use updated translations

* PR feedback

* Add forgotten trnslations

* Fix scrolling bug

---------

Co-authored-by: SleeplessOne <insomnia-void@protonmail.com>
This commit is contained in:
SleeplessOne1917 2023-12-06 23:17:02 +00:00 committed by GitHub
parent 39f86d421e
commit 8594a30a53
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 2381 additions and 2324 deletions

@ -1 +1 @@
Subproject commit 45d478e44fb12f0748640cf0416724e42e37d9a6
Subproject commit b3343aef72e5a7e5df34cf328b910ed798027270

View file

@ -90,7 +90,6 @@
}
.icon {
display: inline-grid;
display: inline-flex;
width: 1em;
height: 1em;

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 67 KiB

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@ import {
BanPerson,
BlockPerson,
CommentId,
CommentResponse,
CommunityModeratorView,
CreateComment,
CreateCommentLike,
@ -28,6 +29,7 @@ import {
} from "lemmy-js-client";
import { CommentNodeI, CommentViewType } from "../../interfaces";
import { CommentNode } from "./comment-node";
import { RequestState } from "../../services/HttpService";
interface CommentNodesProps {
nodes: CommentNodeI[];
@ -49,25 +51,29 @@ interface CommentNodesProps {
isChild?: boolean;
depth?: number;
finished: Map<CommentId, boolean | undefined>;
onSaveComment(form: SaveComment): void;
onSaveComment(form: SaveComment): Promise<void>;
onCommentReplyRead(form: MarkCommentReplyAsRead): void;
onPersonMentionRead(form: MarkPersonMentionAsRead): void;
onCreateComment(form: EditComment | CreateComment): void;
onEditComment(form: EditComment | CreateComment): void;
onCommentVote(form: CreateCommentLike): void;
onBlockPerson(form: BlockPerson): void;
onDeleteComment(form: DeleteComment): void;
onRemoveComment(form: RemoveComment): void;
onDistinguishComment(form: DistinguishComment): void;
onAddModToCommunity(form: AddModToCommunity): void;
onAddAdmin(form: AddAdmin): void;
onBanPersonFromCommunity(form: BanFromCommunity): void;
onBanPerson(form: BanPerson): void;
onTransferCommunity(form: TransferCommunity): void;
onCreateComment(
form: EditComment | CreateComment,
): Promise<RequestState<CommentResponse>>;
onEditComment(
form: EditComment | CreateComment,
): Promise<RequestState<CommentResponse>>;
onCommentVote(form: CreateCommentLike): Promise<void>;
onBlockPerson(form: BlockPerson): Promise<void>;
onDeleteComment(form: DeleteComment): Promise<void>;
onRemoveComment(form: RemoveComment): Promise<void>;
onDistinguishComment(form: DistinguishComment): Promise<void>;
onAddModToCommunity(form: AddModToCommunity): Promise<void>;
onAddAdmin(form: AddAdmin): Promise<void>;
onBanPersonFromCommunity(form: BanFromCommunity): Promise<void>;
onBanPerson(form: BanPerson): Promise<void>;
onTransferCommunity(form: TransferCommunity): Promise<void>;
onFetchChildren?(form: GetComments): void;
onCommentReport(form: CreateCommentReport): void;
onPurgePerson(form: PurgePerson): void;
onPurgeComment(form: PurgeComment): void;
onCommentReport(form: CreateCommentReport): Promise<void>;
onPurgePerson(form: PurgePerson): Promise<void>;
onPurgeComment(form: PurgeComment): Promise<void>;
}
export class CommentNodes extends Component<CommentNodesProps, any> {

View file

@ -84,23 +84,23 @@ export class CommentReport extends Component<
hideImages
// All of these are unused, since its viewonly
finished={new Map()}
onSaveComment={() => {}}
onBlockPerson={() => {}}
onDeleteComment={() => {}}
onRemoveComment={() => {}}
onCommentVote={() => {}}
onCommentReport={() => {}}
onDistinguishComment={() => {}}
onAddModToCommunity={() => {}}
onAddAdmin={() => {}}
onTransferCommunity={() => {}}
onPurgeComment={() => {}}
onPurgePerson={() => {}}
onSaveComment={async () => {}}
onBlockPerson={async () => {}}
onDeleteComment={async () => {}}
onRemoveComment={async () => {}}
onCommentVote={async () => {}}
onCommentReport={async () => {}}
onDistinguishComment={async () => {}}
onAddModToCommunity={async () => {}}
onAddAdmin={async () => {}}
onTransferCommunity={async () => {}}
onPurgeComment={async () => {}}
onPurgePerson={async () => {}}
onCommentReplyRead={() => {}}
onPersonMentionRead={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onCreateComment={() => Promise.resolve(EMPTY_REQUEST)}
onBanPersonFromCommunity={async () => {}}
onBanPerson={async () => {}}
onCreateComment={async () => Promise.resolve(EMPTY_REQUEST)}
onEditComment={() => Promise.resolve(EMPTY_REQUEST)}
/>
<div>

View file

@ -0,0 +1,140 @@
import { Component, RefObject, createRef, linkEvent } from "inferno";
import { I18NextService } from "../../services";
import type { Modal } from "bootstrap";
import { Spinner } from "./icon";
import { LoadingEllipses } from "./loading-ellipses";
interface ConfirmationModalProps {
onYes: () => Promise<void>;
onNo: () => void;
message: string;
loadingMessage: string;
show: boolean;
}
interface ConfirmationModalState {
loading: boolean;
}
async function handleYes(i: ConfirmationModal) {
i.setState({ loading: true });
await i.props.onYes();
i.setState({ loading: false });
}
export default class ConfirmationModal extends Component<
ConfirmationModalProps,
ConfirmationModalState
> {
readonly modalDivRef: RefObject<HTMLDivElement>;
readonly yesButtonRef: RefObject<HTMLButtonElement>;
modal: Modal;
state: ConfirmationModalState = {
loading: false,
};
constructor(props: ConfirmationModalProps, context: any) {
super(props, context);
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() {
const { message, onNo, loadingMessage } = this.props;
const { loading } = this.state;
return (
<div
className="modal fade"
id="confirmModal"
tabIndex={-1}
aria-hidden
aria-labelledby="#confirmationModalTitle"
data-bs-backdrop="static"
ref={this.modalDivRef}
>
<div className="modal-dialog modal-fullscreen-sm-down">
<div className="modal-content">
<header className="modal-header">
<h3 className="modal-title" id="confirmationModalTitle">
{I18NextService.i18n.t("confirmation_required")}
</h3>
</header>
<div className="modal-body text-center align-middle text-body">
{loading ? (
<>
<Spinner large />
<div>
{loadingMessage}
<LoadingEllipses />
</div>
</>
) : (
message
)}
</div>
<footer className="modal-footer">
<button
type="button"
className="btn btn-success"
onClick={linkEvent(this, handleYes)}
ref={this.yesButtonRef}
disabled={loading}
>
{I18NextService.i18n.t("yes")}
</button>
<button
type="button"
className="btn btn-danger"
onClick={onNo}
disabled={loading}
>
{I18NextService.i18n.t("no")}
</button>
</footer>
</div>
</div>
</div>
);
}
handleShow() {
this.yesButtonRef.current?.focus();
}
}

View file

@ -0,0 +1,79 @@
import { Component, linkEvent } from "inferno";
import { Icon, Spinner } from "../icon";
import classNames from "classnames";
interface ActionButtonPropsBase {
label: string;
icon: string;
iconClass?: string;
inline?: boolean;
noLoading?: boolean;
}
interface ActionButtonPropsLoading extends ActionButtonPropsBase {
onClick: () => Promise<void>;
noLoading?: false;
}
interface ActionButtonPropsNoLoading extends ActionButtonPropsBase {
onClick: () => void;
noLoading: true;
}
type ActionButtonProps = ActionButtonPropsLoading | ActionButtonPropsNoLoading;
interface ActionButtonState {
loading: boolean;
}
async function handleClick(i: ActionButton) {
if (!i.props.noLoading) {
i.setState({ loading: true });
}
await i.props.onClick();
i.setState({ loading: false });
}
export default class ActionButton extends Component<
ActionButtonProps,
ActionButtonState
> {
state: ActionButtonState = {
loading: false,
};
constructor(props: ActionButtonProps, context: any) {
super(props, context);
}
render() {
const { label, icon, iconClass, inline } = this.props;
return (
<button
className={classNames(
"btn btn-link btn-sm",
inline
? "btn-animate text-muted py-0"
: "d-flex align-items-center rounded-0 dropdown-item",
)}
onClick={linkEvent(this, handleClick)}
aria-label={label}
data-tippy-content={inline ? label : undefined}
disabled={this.state.loading}
>
{this.state.loading ? (
<Spinner />
) : (
<Icon classes={classNames("me-2", iconClass)} icon={icon} inline />
)}
{!inline && label}
</button>
);
}
}
ActionButton.defaultProps = {
inline: false,
noLoading: false,
};

View file

@ -0,0 +1,8 @@
import { InfernoNode } from "inferno";
import ContentActionDropdown, {
ContentCommentProps,
} from "./content-action-dropdown";
export default (
props: Omit<ContentCommentProps, "type" | "postView">,
): InfernoNode => <ContentActionDropdown type="comment" {...props} />;

View file

@ -0,0 +1,766 @@
import { Component } from "inferno";
import { I18NextService, UserService } from "../../../services";
import { Icon } from "../icon";
import { CrossPostParams } from "@utils/types";
import CrossPostButton from "./cross-post-button";
import {
CommentView,
CommunityModeratorView,
PersonView,
PostView,
} from "lemmy-js-client";
import {
amAdmin,
amCommunityCreator,
amMod,
canAdmin,
canMod,
isBanned,
} from "@utils/roles";
import ActionButton from "./action-button";
import classNames from "classnames";
import { Link } from "inferno-router";
import ConfirmationModal from "../confirmation-modal";
import ModActionFormModal, { BanUpdateForm } from "../mod-action-form-modal";
import { BanType, PurgeType } from "../../../interfaces";
import { getApubName, hostname } from "@utils/helpers";
interface ContentActionDropdownPropsBase {
onSave: () => Promise<void>;
onEdit: () => void;
onDelete: () => Promise<void>;
onReport: (reason: string) => Promise<void>;
onBlock: () => Promise<void>;
onRemove: (reason: string) => Promise<void>;
onBanFromCommunity: (form: BanUpdateForm) => Promise<void>;
onAppointCommunityMod: () => Promise<void>;
onTransferCommunity: () => Promise<void>;
onBanFromSite: (form: BanUpdateForm) => Promise<void>;
onPurgeContent: (reason: string) => Promise<void>;
onPurgeUser: (reason: string) => Promise<void>;
onAppointAdmin: () => Promise<void>;
moderators?: CommunityModeratorView[];
admins?: PersonView[];
}
export type ContentCommentProps = {
type: "comment";
commentView: CommentView;
onReply: () => void;
onDistinguish: () => Promise<void>;
} & ContentActionDropdownPropsBase;
export type ContentPostProps = {
type: "post";
postView: PostView;
crossPostParams: CrossPostParams;
onLock: () => Promise<void>;
onFeatureLocal: () => Promise<void>;
onFeatureCommunity: () => Promise<void>;
} & ContentActionDropdownPropsBase;
type ContentActionDropdownProps = ContentCommentProps | ContentPostProps;
const dialogTypes = [
"showBanDialog",
"showRemoveDialog",
"showPurgeDialog",
"showReportDialog",
"showTransferCommunityDialog",
"showAppointModDialog",
"showAppointAdminDialog",
] as const;
type DialogType = (typeof dialogTypes)[number];
type ContentActionDropdownState = {
banType?: BanType;
purgeType?: PurgeType;
mounted: boolean;
} & { [key in DialogType]: boolean };
export default class ContentActionDropdown extends Component<
ContentActionDropdownProps,
ContentActionDropdownState
> {
state: ContentActionDropdownState = {
showAppointAdminDialog: false,
showAppointModDialog: false,
showBanDialog: false,
showPurgeDialog: false,
showRemoveDialog: false,
showReportDialog: false,
showTransferCommunityDialog: false,
mounted: false,
};
constructor(props: ContentActionDropdownProps, context: any) {
super(props, context);
this.toggleModDialogShow = this.toggleModDialogShow.bind(this);
this.hideAllDialogs = this.hideAllDialogs.bind(this);
this.toggleReportDialogShow = this.toggleReportDialogShow.bind(this);
this.toggleRemoveShow = this.toggleRemoveShow.bind(this);
this.toggleBanFromCommunityShow =
this.toggleBanFromCommunityShow.bind(this);
this.toggleBanFromSiteShow = this.toggleBanFromSiteShow.bind(this);
this.togglePurgePersonShow = this.togglePurgePersonShow.bind(this);
this.togglePurgeContentShow = this.togglePurgeContentShow.bind(this);
this.toggleTransferCommunityShow =
this.toggleTransferCommunityShow.bind(this);
this.toggleAppointModShow = this.toggleAppointModShow.bind(this);
this.toggleAppointAdminShow = this.toggleAppointAdminShow.bind(this);
this.wrapHandler = this.wrapHandler.bind(this);
}
componentDidMount() {
this.setState({ mounted: true });
}
render() {
// Possible enhancement: Priority+ pattern instead of just hard coding which get hidden behind the show more button.
const { onSave, type, onDelete, onBlock, onEdit, moderators } = this.props;
const {
id,
saved,
deleted,
locked,
removed,
creator_banned_from_community,
creator,
community,
creator_is_admin,
creator_is_moderator,
} = this.contentInfo;
const dropdownId =
type === "post"
? `post-actions-dropdown-${id}`
: `comment-actions-dropdown-${id}`;
const creatorBannedFromLocal = isBanned(creator);
const showToggleAdmin = !creatorBannedFromLocal && creator.local;
const canAppointCommunityMod =
(amMod(community.id) || (amAdmin() && community.local)) &&
!creator_banned_from_community;
return (
<>
{type === "comment" && (
<ActionButton
onClick={this.props.onReply}
icon="reply1"
inline
label={I18NextService.i18n.t("reply")}
noLoading
/>
)}
<ActionButton
onClick={onSave}
inline
icon="star"
label={I18NextService.i18n.t(saved ? "unsave" : "save")}
iconClass={classNames({ "text-warning": saved })}
/>
{type === "post" && (
<CrossPostButton {...this.props.crossPostParams!} />
)}
<div className="dropdown">
<button
className="btn btn-sm btn-link btn-animate text-muted py-0 dropdown-toggle"
data-tippy-content={I18NextService.i18n.t("more")}
data-bs-toggle="dropdown"
aria-expanded="false"
aria-controls={dropdownId}
aria-label={I18NextService.i18n.t("more")}
>
<Icon icon="more-vertical" inline />
</button>
<ul className="dropdown-menu" id={dropdownId}>
{this.amCreator ? (
<>
<li>
<ActionButton
icon="edit"
label={I18NextService.i18n.t("edit")}
noLoading
onClick={onEdit}
/>
</li>
<li>
<ActionButton
onClick={onDelete}
icon={deleted ? "undo-trash" : "trash"}
label={I18NextService.i18n.t(
deleted ? "undelete" : "delete",
)}
iconClass={`text-${deleted ? "success" : "danger"}`}
/>
</li>
</>
) : (
<>
{type === "comment" && (
<li>
<Link
className="btn btn-link d-flex align-items-center rounded-0 dropdown-item"
to={`/create_private_message/${creator.id}`}
title={I18NextService.i18n.t("message")}
aria-label={I18NextService.i18n.t("message")}
data-tippy-content={I18NextService.i18n.t("message")}
>
<Icon icon="mail" inline classes="me-2" />
{I18NextService.i18n.t("message")}
</Link>
</li>
)}
<li>
<ActionButton
icon="flag"
label={I18NextService.i18n.t("create_report")}
onClick={this.toggleReportDialogShow}
noLoading
/>
</li>
<li>
<ActionButton
icon="slash"
label={I18NextService.i18n.t("block_user")}
onClick={onBlock}
/>
</li>
</>
)}
{(amMod(community.id) || amAdmin()) && (
<>
<li>
<hr className="dropdown-divider" />
</li>
{type === "post" && (
<>
<li>
<ActionButton
onClick={this.props.onLock}
label={I18NextService.i18n.t(
locked ? "unlock" : "lock",
)}
icon={locked ? "unlock" : "lock"}
/>
</li>
<li>
<ActionButton
onClick={this.props.onFeatureCommunity}
label={I18NextService.i18n.t(
this.props.postView.post.featured_community
? "unfeature_from_community"
: "feature_in_community",
)}
icon={
this.props.postView.post.featured_community
? "pin-off"
: "pin"
}
/>
</li>
{amAdmin() && (
<li>
<ActionButton
onClick={this.props.onFeatureLocal}
label={I18NextService.i18n.t(
this.props.postView.post.featured_local
? "unfeature_from_local"
: "feature_in_local",
)}
icon={
this.props.postView.post.featured_local
? "pin-off"
: "pin"
}
/>
</li>
)}
</>
)}
</>
)}
{type === "comment" &&
this.amCreator &&
(this.canModOnSelf || this.canAdminOnSelf) && (
<li>
<ActionButton
onClick={this.props.onDistinguish}
icon={
this.props.commentView.comment.distinguished
? "shield-off"
: "shield"
}
label={I18NextService.i18n.t(
this.props.commentView.comment.distinguished
? "undistinguish"
: "distinguish",
)}
/>
</li>
)}
{(this.canMod || this.canAdmin) && (
<li>
<ActionButton
label={
removed
? `${I18NextService.i18n.t(
"restore",
)} ${I18NextService.i18n.t(
type === "post" ? "post" : "comment",
)}`
: I18NextService.i18n.t(
type === "post" ? "remove_post" : "remove_comment",
)
}
icon={removed ? "restore" : "x"}
noLoading
onClick={this.toggleRemoveShow}
iconClass={`text-${removed ? "success" : "danger"}`}
/>
</li>
)}
{this.canMod &&
(!creator_is_moderator || canAppointCommunityMod) && (
<>
<li>
<hr className="dropdown-divider" />
</li>
{!creator_is_moderator && (
<li>
<ActionButton
onClick={this.toggleBanFromCommunityShow}
label={I18NextService.i18n.t(
creator_banned_from_community
? "unban_from_community"
: "ban_from_community",
)}
icon={creator_banned_from_community ? "unban" : "ban"}
noLoading
iconClass={`text-${
creator_banned_from_community ? "success" : "danger"
}`}
/>
</li>
)}
{canAppointCommunityMod && (
<li>
<ActionButton
onClick={this.toggleAppointModShow}
label={I18NextService.i18n.t(
`${
creator_is_moderator ? "remove" : "appoint"
}_as_mod`,
)}
icon={creator_is_moderator ? "demote" : "promote"}
iconClass={`text-${
creator_is_moderator ? "danger" : "success"
}`}
noLoading
/>
</li>
)}
</>
)}
{(amCommunityCreator(this.id, moderators) || this.canAdmin) &&
creator_is_moderator && (
<li>
<ActionButton
label={I18NextService.i18n.t("transfer_community")}
onClick={this.toggleTransferCommunityShow}
icon="transfer"
noLoading
/>
</li>
)}
{this.canAdmin && (showToggleAdmin || !creator_is_admin) && (
<>
<li>
<hr className="dropdown-divider" />
</li>
{!creator_is_admin && (
<>
<li>
<ActionButton
label={I18NextService.i18n.t(
creatorBannedFromLocal
? "unban_from_site"
: "ban_from_site",
)}
onClick={this.toggleBanFromSiteShow}
icon={creatorBannedFromLocal ? "unban" : "ban"}
iconClass={`text-${
creatorBannedFromLocal ? "success" : "danger"
}`}
noLoading
/>
</li>
<li>
<ActionButton
label={I18NextService.i18n.t("purge_user")}
onClick={this.togglePurgePersonShow}
icon="purge"
noLoading
iconClass="text-danger"
/>
</li>
<li>
<ActionButton
label={I18NextService.i18n.t(
`purge_${type === "post" ? "post" : "comment"}`,
)}
onClick={this.togglePurgeContentShow}
icon="purge"
noLoading
iconClass="text-danger"
/>
</li>
</>
)}
{showToggleAdmin && (
<li>
<ActionButton
label={I18NextService.i18n.t(
`${creator_is_admin ? "remove" : "appoint"}_as_admin`,
)}
onClick={this.toggleAppointAdminShow}
icon={creator_is_admin ? "demote" : "promote"}
iconClass={`text-${
creator_is_admin ? "danger" : "success"
}`}
noLoading
/>
</li>
)}
</>
)}
</ul>
</div>
{this.moderationDialogs}
</>
);
}
toggleModDialogShow(
dialogType: DialogType,
stateOverride: Partial<ContentActionDropdownState> = {},
) {
this.setState(prev => ({
...prev,
[dialogType]: !prev[dialogType],
...dialogTypes
.filter(dt => dt !== dialogType)
.reduce(
(acc, dt) => ({
...acc,
[dt]: false,
}),
{},
),
...stateOverride,
}));
}
hideAllDialogs() {
this.setState({
showBanDialog: false,
showPurgeDialog: false,
showRemoveDialog: false,
showReportDialog: false,
showAppointAdminDialog: false,
showAppointModDialog: false,
showTransferCommunityDialog: false,
});
}
toggleReportDialogShow() {
this.toggleModDialogShow("showReportDialog");
}
toggleRemoveShow() {
this.toggleModDialogShow("showRemoveDialog");
}
toggleBanFromCommunityShow() {
this.toggleModDialogShow("showBanDialog", {
banType: BanType.Community,
});
}
toggleBanFromSiteShow() {
this.toggleModDialogShow("showBanDialog", {
banType: BanType.Site,
});
}
togglePurgePersonShow() {
this.toggleModDialogShow("showPurgeDialog", {
purgeType: PurgeType.Person,
});
}
togglePurgeContentShow() {
this.toggleModDialogShow("showPurgeDialog", {
purgeType:
this.props.type === "post" ? PurgeType.Post : PurgeType.Comment,
});
}
toggleTransferCommunityShow() {
this.toggleModDialogShow("showTransferCommunityDialog");
}
toggleAppointModShow() {
this.toggleModDialogShow("showAppointModDialog");
}
toggleAppointAdminShow() {
this.toggleModDialogShow("showAppointAdminDialog");
}
get moderationDialogs() {
const {
showBanDialog,
showPurgeDialog,
showRemoveDialog,
showReportDialog,
banType,
purgeType,
showTransferCommunityDialog,
showAppointModDialog,
showAppointAdminDialog,
mounted,
} = this.state;
const {
removed,
creator,
creator_banned_from_community,
community,
creator_is_admin,
creator_is_moderator,
} = this.contentInfo;
const {
onReport,
onRemove,
onBanFromCommunity,
onBanFromSite,
onPurgeContent,
onPurgeUser,
onTransferCommunity,
onAppointCommunityMod,
onAppointAdmin,
type,
} = this.props;
// Wait until componentDidMount runs (which only happens on the browser) to prevent sending over a gratuitous amount of markup
return (
mounted && (
<>
<ModActionFormModal
onSubmit={this.wrapHandler(onRemove)}
modActionType={
type === "comment" ? "remove-comment" : "remove-post"
}
isRemoved={removed}
onCancel={this.hideAllDialogs}
show={showRemoveDialog}
/>
<ModActionFormModal
onSubmit={this.wrapHandler(
banType === BanType.Community
? onBanFromCommunity
: onBanFromSite,
)}
modActionType={
banType === BanType.Community ? "community-ban" : "site-ban"
}
creator={creator}
onCancel={this.hideAllDialogs}
isBanned={
banType === BanType.Community
? creator_banned_from_community
: banType === BanType.Site
? creator.banned
: false
}
community={community}
show={showBanDialog}
/>
<ModActionFormModal
onSubmit={this.wrapHandler(onReport)}
modActionType={
type === "comment" ? "report-comment" : "report-post"
}
onCancel={this.hideAllDialogs}
show={showReportDialog}
/>
<ModActionFormModal
onSubmit={this.wrapHandler(
purgeType === PurgeType.Person ? onPurgeUser : onPurgeContent,
)}
modActionType={
purgeType === PurgeType.Post
? "purge-post"
: purgeType === PurgeType.Comment
? "purge-comment"
: "purge-person"
}
creator={creator}
onCancel={this.hideAllDialogs}
show={showPurgeDialog}
/>
<ConfirmationModal
show={showTransferCommunityDialog}
message={I18NextService.i18n.t("transfer_community_are_you_sure", {
user: getApubName(creator),
community: getApubName(community),
})}
loadingMessage={I18NextService.i18n.t("transferring_community")}
onNo={this.hideAllDialogs}
onYes={this.wrapHandler(onTransferCommunity)}
/>
<ConfirmationModal
show={showAppointModDialog}
message={I18NextService.i18n.t(
creator_is_moderator
? "remove_as_mod_are_you_sure"
: "appoint_as_mod_are_you_sure",
{
user: getApubName(creator),
community: getApubName(creator),
},
)}
loadingMessage={I18NextService.i18n.t(
creator_is_moderator ? "removing_mod" : "appointing_mod",
)}
onNo={this.hideAllDialogs}
onYes={this.wrapHandler(onAppointCommunityMod)}
/>
<ConfirmationModal
show={showAppointAdminDialog}
message={I18NextService.i18n.t(
creator_is_admin
? "removing_as_admin_are_you_sure"
: "appoint_as_admin_are_you_sure",
{
user: getApubName(creator),
instance: hostname(creator.actor_id),
},
)}
loadingMessage={I18NextService.i18n.t(
creator_is_admin ? "removing_admin" : "appointing_admin",
)}
onNo={this.hideAllDialogs}
onYes={this.wrapHandler(onAppointAdmin)}
/>
</>
)
);
}
get contentInfo() {
if (this.props.type === "post") {
const {
post: { id, deleted, locked, removed },
saved,
creator,
creator_banned_from_community,
community,
creator_is_admin,
creator_is_moderator,
} = this.props.postView;
return {
id,
saved,
deleted,
creator,
locked,
removed,
creator_banned_from_community,
community,
creator_is_admin,
creator_is_moderator,
};
} else {
const {
comment: { id, deleted, removed },
saved,
creator,
creator_banned_from_community,
community,
creator_is_admin,
creator_is_moderator,
} = this.props.commentView;
return {
id,
saved,
deleted,
creator,
removed,
creator_banned_from_community,
community,
creator_is_admin,
creator_is_moderator,
};
}
}
get amCreator() {
const { creator } = this.contentInfo;
return (
creator.id === UserService.Instance.myUserInfo?.local_user_view.person.id
);
}
get canMod() {
const { creator } = this.contentInfo;
return canMod(creator.id, this.props.moderators, this.props.admins);
}
get canAdmin() {
const { creator } = this.contentInfo;
return canAdmin(creator.id, this.props.admins);
}
get canModOnSelf() {
const { creator } = this.contentInfo;
return canMod(
creator.id,
this.props.moderators,
this.props.admins,
UserService.Instance.myUserInfo,
true,
);
}
get canAdminOnSelf() {
const { creator } = this.contentInfo;
return canAdmin(
creator.id,
this.props.admins,
UserService.Instance.myUserInfo,
true,
);
}
get id() {
return this.props.type === "post"
? this.props.postView.creator.id
: this.props.commentView.creator.id;
}
wrapHandler(handler: (arg?: any) => Promise<void>) {
return async (arg?: any) => {
await handler(arg);
this.hideAllDialogs();
};
}
}

View file

@ -0,0 +1,22 @@
import { Link } from "inferno-router";
import { I18NextService } from "../../../services";
import { Icon } from "../icon";
import { CrossPostParams } from "@utils/types";
import { InfernoNode } from "inferno";
export default function CrossPostButton(props: CrossPostParams): InfernoNode {
return (
<Link
className="btn btn-sm btn-link btn-animate text-muted py-0"
to={{
pathname: "/create_post",
state: props,
}}
title={I18NextService.i18n.t("cross_post")}
data-tippy-content={I18NextService.i18n.t("cross_post")}
aria-label={I18NextService.i18n.t("cross_post")}
>
<Icon icon="copy" inline />
</Link>
);
}

View file

@ -0,0 +1,8 @@
import { InfernoNode } from "inferno";
import ContentActionDropdown, {
ContentPostProps,
} from "./content-action-dropdown";
export default (
props: Omit<ContentPostProps, "type" | "commentView">,
): InfernoNode => <ContentActionDropdown type="post" {...props} />;

View file

@ -0,0 +1,464 @@
import { Component, RefObject, createRef, linkEvent } from "inferno";
import { I18NextService } from "../../services/I18NextService";
import { PurgeWarning, Spinner } from "./icon";
import { getApubName, randomStr } from "@utils/helpers";
import type { Modal } from "bootstrap";
import classNames from "classnames";
import { Community, Person } from "lemmy-js-client";
import { LoadingEllipses } from "./loading-ellipses";
export interface BanUpdateForm {
reason?: string;
shouldRemove?: boolean;
daysUntilExpires?: number;
}
interface ModActionFormModalPropsSiteBan {
modActionType: "site-ban";
onSubmit: (form: BanUpdateForm) => Promise<void>;
creator: Person;
isBanned: boolean;
}
interface ModActionFormModalPropsCommunityBan {
modActionType: "community-ban";
onSubmit: (form: BanUpdateForm) => Promise<void>;
creator: Person;
community: Community;
isBanned: boolean;
}
interface ModActionFormModalPropsPurgePerson {
modActionType: "purge-person";
onSubmit: (reason: string) => Promise<void>;
creator: Person;
}
interface ModActionFormModalPropsRemove {
modActionType: "remove-post" | "remove-comment";
onSubmit: (reason: string) => Promise<void>;
isRemoved: boolean;
}
interface ModActionFormModalPropsRest {
modActionType:
| "report-post"
| "report-comment"
| "report-message"
| "purge-post"
| "purge-comment";
onSubmit: (reason: string) => Promise<void>;
}
type ModActionFormModalProps = (
| ModActionFormModalPropsSiteBan
| ModActionFormModalPropsCommunityBan
| ModActionFormModalPropsRest
| ModActionFormModalPropsPurgePerson
| ModActionFormModalPropsRemove
) & { onCancel: () => void; show: boolean };
interface ModActionFormFormState {
loading: boolean;
reason: string;
daysUntilExpire?: number;
shouldRemoveData?: boolean;
shouldPermaBan?: boolean;
}
function handleReasonChange(i: ModActionFormModal, event: any) {
i.setState({ reason: event.target.value });
}
function handleExpiryChange(i: ModActionFormModal, event: any) {
i.setState({ daysUntilExpire: parseInt(event.target.value, 10) });
}
function handleToggleRemove(i: ModActionFormModal) {
i.setState(prev => ({
...prev,
shouldRemoveData: !prev.shouldRemoveData,
}));
}
function handleTogglePermaBan(i: ModActionFormModal) {
i.setState(prev => ({
...prev,
shouldPermaBan: !prev.shouldPermaBan,
daysUntilExpire: undefined,
}));
}
async function handleSubmit(i: ModActionFormModal, event: any) {
event.preventDefault();
i.setState({ loading: true });
if (i.isBanModal) {
await i.props.onSubmit({
reason: i.state.reason,
daysUntilExpires: i.state.daysUntilExpire!,
shouldRemove: i.state.shouldRemoveData!,
} as BanUpdateForm & string); // Need to & string to handle type weirdness
} else {
await i.props.onSubmit(i.state.reason);
}
i.setState({
loading: false,
reason: "",
});
}
export default class ModActionFormModal extends Component<
ModActionFormModalProps,
ModActionFormFormState
> {
private modalDivRef: RefObject<HTMLDivElement>;
private reasonRef: RefObject<HTMLInputElement>;
modal: Modal;
state: ModActionFormFormState = {
loading: false,
reason: "",
};
constructor(props: ModActionFormModalProps, context: any) {
super(props, context);
this.modalDivRef = createRef();
this.reasonRef = createRef();
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() {
const {
loading,
reason,
daysUntilExpire,
shouldRemoveData,
shouldPermaBan,
} = this.state;
const reasonId = `mod-form-reason-${randomStr()}`;
const expiresId = `mod-form-expires-${randomStr()}`;
const { modActionType, onCancel } = this.props;
const formId = `mod-action-form-${randomStr()}`;
const isBanned =
(modActionType === "site-ban" || modActionType === "community-ban") &&
this.props.isBanned;
const showExpiresField = this.isBanModal && !(isBanned || shouldPermaBan);
return (
<div
className={classNames("modal fade", {
"modal-lg": this.isBanModal,
})}
data-bs-backdrop="static"
id="moderationModal"
tabIndex={-1}
aria-hidden
aria-labelledby="#moderationModalTitle"
ref={this.modalDivRef}
>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<header className="modal-header">
<h3 className="modal-title" id="moderationModalTitle">
{this.headerText}
</h3>
</header>
<div
className={classNames("modal-body text-body", {
"text-center": loading,
})}
>
{loading ? (
<>
<Spinner large />
<div>
{this.loadingText}
<LoadingEllipses />
</div>
</>
) : (
<form
onSubmit={linkEvent(this, handleSubmit)}
className="p-3 w-100 container"
id={formId}
>
<div className="row mb-3">
<div
className={classNames("col-12", {
"col-lg-6 col-xl-7": showExpiresField,
})}
>
{modActionType.includes("purge") && <PurgeWarning />}
<label className="visually-hidden" htmlFor={reasonId}>
{I18NextService.i18n.t("reason")}
</label>
<input
type="text"
id={reasonId}
className="form-control my-2 my-lg-0"
placeholder={I18NextService.i18n.t("reason")}
required
value={reason}
onInput={linkEvent(this, handleReasonChange)}
ref={this.reasonRef}
/>
</div>
{showExpiresField && (
<div className="col-12 col-lg-6 col-xl-5">
<label className="visually-hidden" htmlFor={expiresId}>
{I18NextService.i18n.t("expires")}
</label>
<input
type="number"
id={expiresId}
className="form-control my-2 my-lg-0"
placeholder={I18NextService.i18n.t(
"days_until_expiration",
)}
min={1}
value={daysUntilExpire}
onInput={linkEvent(this, handleExpiryChange)}
required
/>
</div>
)}
</div>
<div className="row">
{this.isBanModal && !isBanned && (
<div className="mb-2 col-12 col-lg-6 col-xxl-7">
<div className="form-check m2-3">
<label
className="form-check-label me-3 user-select-none"
title={I18NextService.i18n.t("remove_content_more")}
>
<input
className="form-check-input user-select-none"
type="checkbox"
checked={shouldRemoveData}
onChange={linkEvent(this, handleToggleRemove)}
/>
{I18NextService.i18n.t("remove_content")}
</label>
</div>
<div className="form-check mt-2">
<label
className="form-check-label"
title={I18NextService.i18n.t("remove_content_more")}
>
<input
className="form-check-input"
type="checkbox"
onChange={linkEvent(this, handleTogglePermaBan)}
checked={shouldPermaBan}
/>
{I18NextService.i18n.t("permanently_ban")}
</label>
</div>
</div>
)}
</div>
</form>
)}
</div>
<footer className="modal-footer">
<button
type="submit"
className="btn btn-secondary me-3"
form={formId}
disabled={loading}
>
{this.buttonText}
</button>
<button
type="button"
className="btn btn-light"
onClick={onCancel}
disabled={loading}
>
{I18NextService.i18n.t("cancel")}
</button>
</footer>
</div>
</div>
</div>
);
}
handleShow() {
this.reasonRef.current?.focus();
}
get isBanModal() {
return (
this.props.modActionType === "site-ban" ||
this.props.modActionType === "community-ban"
);
}
get headerText() {
switch (this.props.modActionType) {
case "site-ban": {
return I18NextService.i18n.t(
this.props.isBanned ? "unban_with_name" : "ban_with_name",
{
user: getApubName(this.props.creator),
},
);
}
case "community-ban": {
return I18NextService.i18n.t(
this.props.isBanned
? "unban_from_community_with_name"
: "ban_from_community_with_name",
{
user: getApubName(this.props.creator),
community: getApubName(this.props.community),
},
);
}
case "purge-post": {
return I18NextService.i18n.t("purge_post");
}
case "purge-comment": {
return I18NextService.i18n.t("purge_comment");
}
case "purge-person": {
return I18NextService.i18n.t("purge_user_with_name", {
user: getApubName(this.props.creator),
});
}
case "remove-post": {
return I18NextService.i18n.t(
this.props.isRemoved ? "restore_post" : "remove_post",
);
}
case "remove-comment": {
return I18NextService.i18n.t(
this.props.isRemoved ? "restore_comment" : "remove_comment",
);
}
case "report-post": {
return I18NextService.i18n.t("report_post");
}
case "report-comment": {
return I18NextService.i18n.t("report_comment");
}
case "report-message": {
return I18NextService.i18n.t("report_message");
}
}
}
get buttonText() {
switch (this.props.modActionType) {
case "site-ban":
case "community-ban": {
return I18NextService.i18n.t(this.props.isBanned ? "unban" : "ban");
}
case "purge-post":
case "purge-comment":
case "purge-person": {
return I18NextService.i18n.t("purge");
}
case "remove-post":
case "remove-comment": {
return I18NextService.i18n.t(
this.props.isRemoved ? "restore" : "remove",
);
}
case "report-post":
case "report-comment":
case "report-message": {
return I18NextService.i18n.t("create_report");
}
}
}
get loadingText() {
let translation: string;
switch (this.props.modActionType) {
case "site-ban":
case "community-ban": {
translation = this.props.isBanned ? "unbanning" : "banning";
break;
}
case "purge-post":
case "purge-comment":
case "purge-person": {
translation = "purging";
break;
}
case "remove-post":
case "remove-comment": {
translation = this.props.isRemoved ? "restoring" : "removing";
break;
}
case "report-post":
case "report-comment":
case "report-message": {
translation = "creating_report";
break;
}
}
return I18NextService.i18n.t(translation);
}
}

View file

@ -1,69 +0,0 @@
import { Component, linkEvent } from "inferno";
import { I18NextService } from "../../services/I18NextService";
import { Spinner } from "./icon";
import { randomStr } from "@utils/helpers";
interface ReportFormProps {
onSubmit: (reason: string) => void;
}
interface ReportFormState {
loading: boolean;
reason: string;
}
function handleReportReasonChange(i: ReportForm, event: any) {
i.setState({ reason: event.target.value });
}
function handleReportSubmit(i: ReportForm, event: any) {
event.preventDefault();
i.setState({ loading: true });
i.props.onSubmit(i.state.reason);
i.setState({
loading: false,
reason: "",
});
}
export default class ReportForm extends Component<
ReportFormProps,
ReportFormState
> {
state: ReportFormState = {
loading: false,
reason: "",
};
constructor(props, context) {
super(props, context);
}
render() {
const { loading, reason } = this.state;
const id = `report-form-${randomStr()}`;
return (
<form
className="form-inline"
onSubmit={linkEvent(this, handleReportSubmit)}
>
<label className="visually-hidden" htmlFor={id}>
{I18NextService.i18n.t("reason")}
</label>
<input
type="text"
id={id}
className="form-control me-2"
placeholder={I18NextService.i18n.t("reason")}
required
value={reason}
onInput={linkEvent(this, handleReportReasonChange)}
/>
<button type="submit" className="btn btn-secondary">
{loading ? <Spinner /> : I18NextService.i18n.t("create_report")}
</button>
</form>
);
}
}

View file

@ -172,7 +172,7 @@ class RemoteFetchModal extends Component<
aria-hidden
aria-labelledby="#remoteFetchModalTitle"
>
<div className="modal-dialog modal-fullscreen-sm-down">
<div className="modal-dialog modal-dialog-centered modal-fullscreen-sm-down">
<div className="modal-content">
<header className="modal-header">
<h3 className="modal-title" id="remoteFetchModalTitle">

View file

@ -143,7 +143,7 @@ export default class TotpModal extends Component<
data-bs-backdrop="static"
ref={this.modalDivRef}
>
<div className="modal-dialog modal-fullscreen-sm-down">
<div className="modal-dialog modal-dialog-centered modal-fullscreen-sm-down">
<div className="modal-content">
<header className="modal-header">
<h3 className="modal-title" id="totpModalTitle">

View file

@ -447,7 +447,7 @@ export class Community extends Component<
onAddAdmin={this.handleAddAdmin}
onTransferCommunity={this.handleTransferCommunity}
onFeaturePost={this.handleFeaturePost}
onMarkPostAsRead={() => {}}
onMarkPostAsRead={async () => {}}
/>
);
}
@ -758,11 +758,13 @@ export class Community extends Component<
async handlePostEdit(form: EditPost) {
const res = await HttpService.client.editPost(form);
this.findAndUpdatePost(res);
return res;
}
async handlePostVote(form: CreatePostLike) {
const voteRes = await HttpService.client.likePost(form);
this.findAndUpdatePost(voteRes);
return voteRes;
}
async handleCommentReport(form: CreateCommentReport) {

View file

@ -716,7 +716,7 @@ export class Home extends Component<any, HomeState> {
onAddAdmin={this.handleAddAdmin}
onTransferCommunity={this.handleTransferCommunity}
onFeaturePost={this.handleFeaturePost}
onMarkPostAsRead={() => {}}
onMarkPostAsRead={async () => {}}
/>
);
}
@ -970,11 +970,13 @@ export class Home extends Component<any, HomeState> {
async handlePostEdit(form: EditPost) {
const res = await HttpService.client.editPost(form);
this.findAndUpdatePost(res);
return res;
}
async handlePostVote(form: CreatePostLike) {
const voteRes = await HttpService.client.likePost(form);
this.findAndUpdatePost(voteRes);
return voteRes;
}
async handleCommentReport(form: CreateCommentReport) {

View file

@ -7,6 +7,7 @@ import {
BanPerson,
BlockPerson,
CommentId,
CommentResponse,
CommentView,
CreateComment,
CreateCommentLike,
@ -27,6 +28,7 @@ import {
MarkPersonMentionAsRead,
MarkPostAsRead,
PersonView,
PostResponse,
PostView,
PurgeComment,
PurgePerson,
@ -43,6 +45,7 @@ import { setupTippy } from "../../tippy";
import { CommentNodes } from "../comment/comment-nodes";
import { Paginator } from "../common/paginator";
import { PostListing } from "../post/post-listing";
import { RequestState } from "../../services/HttpService";
interface PersonDetailsProps {
personRes: GetPersonDetailsResponse;
@ -57,34 +60,34 @@ interface PersonDetailsProps {
enableNsfw: boolean;
view: PersonDetailsView;
onPageChange(page: number): number | any;
onSaveComment(form: SaveComment): void;
onSaveComment(form: SaveComment): Promise<void>;
onCommentReplyRead(form: MarkCommentReplyAsRead): void;
onPersonMentionRead(form: MarkPersonMentionAsRead): void;
onCreateComment(form: CreateComment): void;
onEditComment(form: EditComment): void;
onCommentVote(form: CreateCommentLike): void;
onBlockPerson(form: BlockPerson): void;
onDeleteComment(form: DeleteComment): void;
onRemoveComment(form: RemoveComment): void;
onDistinguishComment(form: DistinguishComment): void;
onAddModToCommunity(form: AddModToCommunity): void;
onAddAdmin(form: AddAdmin): void;
onBanPersonFromCommunity(form: BanFromCommunity): void;
onBanPerson(form: BanPerson): void;
onTransferCommunity(form: TransferCommunity): void;
onCreateComment(form: CreateComment): Promise<RequestState<CommentResponse>>;
onEditComment(form: EditComment): Promise<RequestState<CommentResponse>>;
onCommentVote(form: CreateCommentLike): Promise<void>;
onBlockPerson(form: BlockPerson): Promise<void>;
onDeleteComment(form: DeleteComment): Promise<void>;
onRemoveComment(form: RemoveComment): Promise<void>;
onDistinguishComment(form: DistinguishComment): Promise<void>;
onAddModToCommunity(form: AddModToCommunity): Promise<void>;
onAddAdmin(form: AddAdmin): Promise<void>;
onBanPersonFromCommunity(form: BanFromCommunity): Promise<void>;
onBanPerson(form: BanPerson): Promise<void>;
onTransferCommunity(form: TransferCommunity): Promise<void>;
onFetchChildren?(form: GetComments): void;
onCommentReport(form: CreateCommentReport): void;
onPurgePerson(form: PurgePerson): void;
onPurgeComment(form: PurgeComment): void;
onPostEdit(form: EditPost): void;
onPostVote(form: CreatePostLike): void;
onPostReport(form: CreatePostReport): void;
onLockPost(form: LockPost): void;
onDeletePost(form: DeletePost): void;
onRemovePost(form: RemovePost): void;
onSavePost(form: SavePost): void;
onFeaturePost(form: FeaturePost): void;
onPurgePost(form: PurgePost): void;
onCommentReport(form: CreateCommentReport): Promise<void>;
onPurgePerson(form: PurgePerson): Promise<void>;
onPurgeComment(form: PurgeComment): Promise<void>;
onPostEdit(form: EditPost): Promise<RequestState<PostResponse>>;
onPostVote(form: CreatePostLike): Promise<RequestState<PostResponse>>;
onPostReport(form: CreatePostReport): Promise<void>;
onLockPost(form: LockPost): Promise<void>;
onDeletePost(form: DeletePost): Promise<void>;
onRemovePost(form: RemovePost): Promise<void>;
onSavePost(form: SavePost): Promise<void>;
onFeaturePost(form: FeaturePost): Promise<void>;
onPurgePost(form: PurgePost): Promise<void>;
onMarkPostAsRead(form: MarkPostAsRead): void;
}

View file

@ -897,11 +897,13 @@ export class Profile extends Component<
async handlePostVote(form: CreatePostLike) {
const voteRes = await HttpService.client.likePost(form);
this.findAndUpdatePost(voteRes);
return voteRes;
}
async handlePostEdit(form: EditPost) {
const res = await HttpService.client.editPost(form);
this.findAndUpdatePost(res);
return res;
}
async handleCommentReport(form: CreateCommentReport) {

View file

@ -444,23 +444,23 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
siteLanguages={this.props.siteLanguages}
viewOnly
// All of these are unused, since its view only
onPostEdit={() => {}}
onPostVote={() => {}}
onPostReport={() => {}}
onBlockPerson={() => {}}
onLockPost={() => {}}
onDeletePost={() => {}}
onRemovePost={() => {}}
onSavePost={() => {}}
onFeaturePost={() => {}}
onPurgePerson={() => {}}
onPurgePost={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onAddModToCommunity={() => {}}
onAddAdmin={() => {}}
onTransferCommunity={() => {}}
onMarkPostAsRead={() => {}}
onPostEdit={async () => EMPTY_REQUEST}
onPostVote={async () => EMPTY_REQUEST}
onPostReport={async () => {}}
onBlockPerson={async () => {}}
onLockPost={async () => {}}
onDeletePost={async () => {}}
onRemovePost={async () => {}}
onSavePost={async () => {}}
onFeaturePost={async () => {}}
onPurgePerson={async () => {}}
onPurgePost={async () => {}}
onBanPersonFromCommunity={async () => {}}
onBanPerson={async () => {}}
onAddModToCommunity={async () => {}}
onAddAdmin={async () => {}}
onTransferCommunity={async () => {}}
onMarkPostAsRead={async () => {}}
/>
</>
)}
@ -615,23 +615,23 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
siteLanguages={this.props.siteLanguages}
viewOnly
// All of these are unused, since its view only
onPostEdit={() => {}}
onPostVote={() => {}}
onPostReport={() => {}}
onBlockPerson={() => {}}
onLockPost={() => {}}
onDeletePost={() => {}}
onRemovePost={() => {}}
onSavePost={() => {}}
onFeaturePost={() => {}}
onPurgePerson={() => {}}
onPurgePost={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onAddModToCommunity={() => {}}
onAddAdmin={() => {}}
onTransferCommunity={() => {}}
onMarkPostAsRead={() => {}}
onPostEdit={async () => EMPTY_REQUEST}
onPostVote={async () => EMPTY_REQUEST}
onPostReport={async () => {}}
onBlockPerson={async () => {}}
onLockPost={async () => {}}
onDeletePost={async () => {}}
onRemovePost={async () => {}}
onSavePost={async () => {}}
onFeaturePost={async () => {}}
onPurgePerson={async () => {}}
onPurgePost={async () => {}}
onBanPersonFromCommunity={async () => {}}
onBanPerson={async () => {}}
onAddModToCommunity={async () => {}}
onAddAdmin={async () => {}}
onTransferCommunity={async () => {}}
onMarkPostAsRead={async () => {}}
/>
</>
)

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,7 @@ import {
Language,
LockPost,
MarkPostAsRead,
PostResponse,
PostView,
PurgePerson,
PurgePost,
@ -24,6 +25,7 @@ import {
} from "lemmy-js-client";
import { I18NextService } from "../../services";
import { PostListing } from "./post-listing";
import { RequestState } from "../../services/HttpService";
interface PostListingsProps {
posts: PostView[];
@ -34,23 +36,23 @@ interface PostListingsProps {
enableDownvotes?: boolean;
enableNsfw?: boolean;
viewOnly?: boolean;
onPostEdit(form: EditPost): void;
onPostVote(form: CreatePostLike): void;
onPostReport(form: CreatePostReport): void;
onBlockPerson(form: BlockPerson): void;
onLockPost(form: LockPost): void;
onDeletePost(form: DeletePost): void;
onRemovePost(form: RemovePost): void;
onSavePost(form: SavePost): void;
onFeaturePost(form: FeaturePost): void;
onPurgePerson(form: PurgePerson): void;
onPurgePost(form: PurgePost): void;
onBanPersonFromCommunity(form: BanFromCommunity): void;
onBanPerson(form: BanPerson): void;
onAddModToCommunity(form: AddModToCommunity): void;
onAddAdmin(form: AddAdmin): void;
onTransferCommunity(form: TransferCommunity): void;
onMarkPostAsRead(form: MarkPostAsRead): void;
onPostEdit(form: EditPost): Promise<RequestState<PostResponse>>;
onPostVote(form: CreatePostLike): Promise<RequestState<PostResponse>>;
onPostReport(form: CreatePostReport): Promise<void>;
onBlockPerson(form: BlockPerson): Promise<void>;
onLockPost(form: LockPost): Promise<void>;
onDeletePost(form: DeletePost): Promise<void>;
onRemovePost(form: RemovePost): Promise<void>;
onSavePost(form: SavePost): Promise<void>;
onFeaturePost(form: FeaturePost): Promise<void>;
onPurgePerson(form: PurgePerson): Promise<void>;
onPurgePost(form: PurgePost): Promise<void>;
onBanPersonFromCommunity(form: BanFromCommunity): Promise<void>;
onBanPerson(form: BanPerson): Promise<void>;
onAddModToCommunity(form: AddModToCommunity): Promise<void>;
onAddAdmin(form: AddAdmin): Promise<void>;
onTransferCommunity(form: TransferCommunity): Promise<void>;
onMarkPostAsRead(form: MarkPostAsRead): Promise<void>;
}
export class PostListings extends Component<PostListingsProps, any> {

View file

@ -5,6 +5,7 @@ import { I18NextService } from "../../services";
import { Icon, Spinner } from "../common/icon";
import { PersonListing } from "../person/person-listing";
import { PostListing } from "./post-listing";
import { EMPTY_REQUEST } from "../../services/HttpService";
interface PostReportProps {
report: PostReportView;
@ -72,23 +73,23 @@ export class PostReport extends Component<PostReportProps, PostReportState> {
siteLanguages={[]}
hideImage
// All of these are unused, since its view only
onPostEdit={() => {}}
onPostVote={() => {}}
onPostReport={() => {}}
onBlockPerson={() => {}}
onLockPost={() => {}}
onDeletePost={() => {}}
onRemovePost={() => {}}
onSavePost={() => {}}
onFeaturePost={() => {}}
onPurgePerson={() => {}}
onPurgePost={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onAddModToCommunity={() => {}}
onAddAdmin={() => {}}
onTransferCommunity={() => {}}
onMarkPostAsRead={() => {}}
onPostEdit={async () => EMPTY_REQUEST}
onPostVote={async () => EMPTY_REQUEST}
onPostReport={async () => {}}
onBlockPerson={async () => {}}
onLockPost={async () => {}}
onDeletePost={async () => {}}
onRemovePost={async () => {}}
onSavePost={async () => {}}
onFeaturePost={async () => {}}
onPurgePerson={async () => {}}
onPurgePost={async () => {}}
onBanPersonFromCommunity={async () => {}}
onBanPerson={async () => {}}
onAddModToCommunity={async () => {}}
onAddAdmin={async () => {}}
onTransferCommunity={async () => {}}
onMarkPostAsRead={async () => {}}
/>
<div>
{I18NextService.i18n.t("reporter")}:{" "}

View file

@ -18,7 +18,7 @@ import {
restoreScrollPosition,
saveScrollPosition,
} from "@utils/browser";
import { debounce, randomStr } from "@utils/helpers";
import { debounce, getApubName, randomStr } from "@utils/helpers";
import { isImage } from "@utils/media";
import { RouteDataResponse } from "@utils/types";
import autosize from "autosize";
@ -752,6 +752,11 @@ export class Post extends Component<any, PostState> {
async handleAddModToCommunity(form: AddModToCommunity) {
const addModRes = await HttpService.client.addModToCommunity(form);
this.updateModerators(addModRes);
if (addModRes.state === "success") {
toast(
I18NextService.i18n.t(form.added ? "appointed_mod" : "removed_mod"),
);
}
}
async handleFollow(form: FollowCommunity) {
@ -837,21 +842,45 @@ export class Post extends Component<any, PostState> {
async handleDeleteComment(form: DeleteComment) {
const deleteCommentRes = await HttpService.client.deleteComment(form);
this.findAndUpdateComment(deleteCommentRes);
if (deleteCommentRes.state === "success") {
toast(
I18NextService.i18n.t(
form.deleted ? "deleted_comment" : "undeleted_comment",
),
);
}
}
async handleDeletePost(form: DeletePost) {
const deleteRes = await HttpService.client.deletePost(form);
this.updatePost(deleteRes);
if (deleteRes.state === "success") {
toast(
I18NextService.i18n.t(form.deleted ? "deleted_post" : "undeleted_post"),
);
}
}
async handleRemovePost(form: RemovePost) {
const removeRes = await HttpService.client.removePost(form);
this.updatePost(removeRes);
if (removeRes.state === "success") {
toast(
I18NextService.i18n.t(form.removed ? "removed_post" : "restored_post"),
);
}
}
async handleRemoveComment(form: RemoveComment) {
const removeCommentRes = await HttpService.client.removeComment(form);
this.findAndUpdateComment(removeCommentRes);
if (removeCommentRes.state === "success") {
toast(
I18NextService.i18n.t(
form.removed ? "removed_comment" : "restored_comment",
),
);
}
}
async handleSaveComment(form: SaveComment) {
@ -867,6 +896,13 @@ export class Post extends Component<any, PostState> {
async handleFeaturePost(form: FeaturePost) {
const featureRes = await HttpService.client.featurePost(form);
this.updatePost(featureRes);
if (featureRes.state === "success") {
toast(
I18NextService.i18n.t(
form.featured ? "featured_post" : "unfeatured_post",
),
);
}
}
async handleCommentVote(form: CreateCommentLike) {
@ -877,11 +913,13 @@ export class Post extends Component<any, PostState> {
async handlePostVote(form: CreatePostLike) {
const voteRes = await HttpService.client.likePost(form);
this.updatePost(voteRes);
return voteRes;
}
async handlePostEdit(form: EditPost) {
const res = await HttpService.client.editPost(form);
this.updatePost(res);
return res;
}
async handleCommentReport(form: CreateCommentReport) {
@ -901,11 +939,25 @@ export class Post extends Component<any, PostState> {
async handleLockPost(form: LockPost) {
const lockRes = await HttpService.client.lockPost(form);
this.updatePost(lockRes);
if (lockRes.state === "success") {
toast(
I18NextService.i18n.t(form.locked ? "locked_post" : "unlocked_post"),
);
}
}
async handleDistinguishComment(form: DistinguishComment) {
const distinguishRes = await HttpService.client.distinguishComment(form);
this.findAndUpdateComment(distinguishRes);
if (distinguishRes.state === "success") {
toast(
I18NextService.i18n.t(
form.distinguished
? "distinguished_comment"
: "undistinguished_comment",
),
);
}
}
async handleAddAdmin(form: AddAdmin) {
@ -913,6 +965,9 @@ export class Post extends Component<any, PostState> {
if (addAdminRes.state === "success") {
this.setState(s => ((s.siteRes.admins = addAdminRes.data.admins), s));
toast(
I18NextService.i18n.t(form.added ? "appointed_admin" : "removed_admin"),
);
}
}
@ -920,6 +975,9 @@ export class Post extends Component<any, PostState> {
const transferCommunityRes =
await HttpService.client.transferCommunity(form);
this.updateCommunityFull(transferCommunityRes);
if (transferCommunityRes.state === "success") {
toast(I18NextService.i18n.t("transferred_community"));
}
}
async handleFetchChildren(form: GetComments) {
@ -944,12 +1002,33 @@ export class Post extends Component<any, PostState> {
async handleBanFromCommunity(form: BanFromCommunity) {
const banRes = await HttpService.client.banFromCommunity(form);
this.updateBan(banRes);
this.updateBanFromCommunity(banRes);
if (banRes.state === "success" && this.state.postRes.state === "success") {
toast(
I18NextService.i18n.t(
form.ban ? "banned_from_community" : "unbanned_from_community",
{
user: getApubName(this.state.postRes.data.post_view.creator),
community: getApubName(this.state.postRes.data.post_view.community),
},
),
);
}
}
async handleBanPerson(form: BanPerson) {
const banRes = await HttpService.client.banPerson(form);
this.updateBan(banRes);
if (banRes.state === "success" && this.state.postRes.state === "success") {
toast(
I18NextService.i18n.t(
form.ban ? "banned_from_site" : "unbanned_from_site",
{
user: getApubName(this.state.postRes.data.post_view.creator),
},
),
);
}
}
updateBanFromCommunity(banRes: RequestState<BanFromCommunityResponse>) {

View file

@ -14,7 +14,7 @@ import { Icon, Spinner } from "../common/icon";
import { MomentTime } from "../common/moment-time";
import { PersonListing } from "../person/person-listing";
import { PrivateMessageForm } from "./private-message-form";
import ReportForm from "../common/report-form";
import ModActionFormModal from "../common/mod-action-form-modal";
interface PrivateMessageState {
showReply: boolean;
@ -53,6 +53,7 @@ export class PrivateMessage extends Component<
super(props, context);
this.handleReplyCancel = this.handleReplyCancel.bind(this);
this.handleReportSubmit = this.handleReportSubmit.bind(this);
this.hideReportDialog = this.hideReportDialog.bind(this);
}
get mine(): boolean {
@ -247,9 +248,12 @@ export class PrivateMessage extends Component<
</div>
)}
</div>
{this.state.showReportDialog && (
<ReportForm onSubmit={this.handleReportSubmit} />
)}
<ModActionFormModal
onSubmit={this.handleReportSubmit}
modActionType="report-message"
onCancel={this.hideReportDialog}
show={this.state.showReportDialog}
/>
{this.state.showReply && (
<div className="row">
<div className="col-sm-6">
@ -327,17 +331,21 @@ export class PrivateMessage extends Component<
}
handleShowReportDialog(i: PrivateMessage) {
i.setState({ showReportDialog: !i.state.showReportDialog });
i.setState({ showReportDialog: true });
}
handleReportSubmit(reason: string) {
hideReportDialog() {
this.setState({
showReportDialog: false,
});
}
async handleReportSubmit(reason: string) {
this.props.onReport({
private_message_id: this.props.private_message_view.private_message.id,
reason,
});
this.setState({
showReportDialog: false,
});
this.hideReportDialog();
}
}

View file

@ -704,23 +704,23 @@ export class Search extends Component<any, SearchState> {
siteLanguages={this.state.siteRes.discussion_languages}
viewOnly
// All of these are unused, since its view only
onPostEdit={() => {}}
onPostVote={() => {}}
onPostReport={() => {}}
onBlockPerson={() => {}}
onLockPost={() => {}}
onDeletePost={() => {}}
onRemovePost={() => {}}
onSavePost={() => {}}
onFeaturePost={() => {}}
onPurgePerson={() => {}}
onPurgePost={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onAddModToCommunity={() => {}}
onAddAdmin={() => {}}
onTransferCommunity={() => {}}
onMarkPostAsRead={() => {}}
onPostEdit={async () => EMPTY_REQUEST}
onPostVote={async () => EMPTY_REQUEST}
onPostReport={async () => {}}
onBlockPerson={async () => {}}
onLockPost={async () => {}}
onDeletePost={async () => {}}
onRemovePost={async () => {}}
onSavePost={async () => {}}
onFeaturePost={async () => {}}
onPurgePerson={async () => {}}
onPurgePost={async () => {}}
onBanPersonFromCommunity={async () => {}}
onBanPerson={async () => {}}
onAddModToCommunity={async () => {}}
onAddAdmin={async () => {}}
onTransferCommunity={async () => {}}
onMarkPostAsRead={async () => {}}
/>
)}
{i.type_ === "comments" && (
@ -742,24 +742,24 @@ export class Search extends Component<any, SearchState> {
siteLanguages={this.state.siteRes.discussion_languages}
// All of these are unused, since its viewonly
finished={new Map()}
onSaveComment={() => {}}
onBlockPerson={() => {}}
onDeleteComment={() => {}}
onRemoveComment={() => {}}
onCommentVote={() => {}}
onCommentReport={() => {}}
onDistinguishComment={() => {}}
onAddModToCommunity={() => {}}
onAddAdmin={() => {}}
onTransferCommunity={() => {}}
onPurgeComment={() => {}}
onPurgePerson={() => {}}
onSaveComment={async () => {}}
onBlockPerson={async () => {}}
onDeleteComment={async () => {}}
onRemoveComment={async () => {}}
onCommentVote={async () => {}}
onCommentReport={async () => {}}
onDistinguishComment={async () => {}}
onAddModToCommunity={async () => {}}
onAddAdmin={async () => {}}
onTransferCommunity={async () => {}}
onPurgeComment={async () => {}}
onPurgePerson={async () => {}}
onCommentReplyRead={() => {}}
onPersonMentionRead={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onCreateComment={() => Promise.resolve(EMPTY_REQUEST)}
onEditComment={() => Promise.resolve(EMPTY_REQUEST)}
onBanPersonFromCommunity={async () => {}}
onBanPerson={async () => {}}
onCreateComment={async () => EMPTY_REQUEST}
onEditComment={async () => EMPTY_REQUEST}
/>
)}
{i.type_ === "communities" && (
@ -803,24 +803,24 @@ export class Search extends Component<any, SearchState> {
siteLanguages={siteRes.discussion_languages}
// All of these are unused, since its viewonly
finished={new Map()}
onSaveComment={() => {}}
onBlockPerson={() => {}}
onDeleteComment={() => {}}
onRemoveComment={() => {}}
onCommentVote={() => {}}
onCommentReport={() => {}}
onDistinguishComment={() => {}}
onAddModToCommunity={() => {}}
onAddAdmin={() => {}}
onTransferCommunity={() => {}}
onPurgeComment={() => {}}
onPurgePerson={() => {}}
onSaveComment={async () => {}}
onBlockPerson={async () => {}}
onDeleteComment={async () => {}}
onRemoveComment={async () => {}}
onCommentVote={async () => {}}
onCommentReport={async () => {}}
onDistinguishComment={async () => {}}
onAddModToCommunity={async () => {}}
onAddAdmin={async () => {}}
onTransferCommunity={async () => {}}
onPurgeComment={async () => {}}
onPurgePerson={async () => {}}
onCommentReplyRead={() => {}}
onPersonMentionRead={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onCreateComment={() => Promise.resolve(EMPTY_REQUEST)}
onEditComment={() => Promise.resolve(EMPTY_REQUEST)}
onBanPersonFromCommunity={async () => {}}
onBanPerson={async () => {}}
onCreateComment={async () => EMPTY_REQUEST}
onEditComment={async () => EMPTY_REQUEST}
/>
);
}
@ -855,22 +855,22 @@ export class Search extends Component<any, SearchState> {
siteLanguages={siteRes.discussion_languages}
viewOnly
// All of these are unused, since its view only
onPostEdit={() => {}}
onPostVote={() => {}}
onPostReport={() => {}}
onBlockPerson={() => {}}
onLockPost={() => {}}
onDeletePost={() => {}}
onRemovePost={() => {}}
onSavePost={() => {}}
onFeaturePost={() => {}}
onPurgePerson={() => {}}
onPurgePost={() => {}}
onBanPersonFromCommunity={() => {}}
onBanPerson={() => {}}
onAddModToCommunity={() => {}}
onAddAdmin={() => {}}
onTransferCommunity={() => {}}
onPostEdit={async () => EMPTY_REQUEST}
onPostVote={async () => EMPTY_REQUEST}
onPostReport={async () => {}}
onBlockPerson={async () => {}}
onLockPost={async () => {}}
onDeletePost={async () => {}}
onRemovePost={async () => {}}
onSavePost={async () => {}}
onFeaturePost={async () => {}}
onPurgePerson={async () => {}}
onPurgePost={async () => {}}
onBanPersonFromCommunity={async () => {}}
onBanPerson={async () => {}}
onAddModToCommunity={async () => {}}
onAddAdmin={async () => {}}
onTransferCommunity={async () => {}}
onMarkPostAsRead={() => {}}
/>
</div>

View file

@ -1,13 +1,32 @@
import { WithComment } from "@utils/types";
export default function editWith<D extends WithComment, L extends WithComment>(
{ comment, counts, saved, my_vote }: D,
{
comment,
counts,
saved,
my_vote,
creator_banned_from_community,
creator_blocked,
creator_is_admin,
creator_is_moderator,
}: D,
list: L[],
) {
return [
...list.map(c =>
c.comment.id === comment.id
? { ...c, comment, counts, saved, my_vote }
? {
...c,
comment,
counts,
saved,
my_vote,
creator_banned_from_community,
creator_blocked,
creator_is_admin,
creator_is_moderator,
}
: c,
),
];

View file

@ -0,0 +1,11 @@
import hostname from "./hostname";
export default function getApubName({
name,
actor_id,
}: {
name: string;
actor_id: string;
}) {
return `${name}@${hostname(actor_id)}`;
}

View file

@ -23,6 +23,7 @@ import validInstanceTLD from "./valid-instance-tld";
import validTitle from "./valid-title";
import validURL from "./valid-url";
import dedupByProperty from "./dedup-by-property";
import getApubName from "./apub-name";
export {
capitalizeFirstLetter,
@ -50,4 +51,5 @@ export {
validTitle,
validURL,
dedupByProperty,
getApubName,
};

View file

@ -6,6 +6,6 @@ export default function amMod(
myUserInfo = UserService.Instance.myUserInfo,
): boolean {
return myUserInfo
? myUserInfo.moderates.map(cmv => cmv.community.id).includes(communityId)
? myUserInfo.moderates.some(cmv => cmv.community.id === communityId)
: false;
}

View file

@ -0,0 +1,5 @@
export default interface CrossPostParams {
name: string;
url?: string;
body?: string;
}

View file

@ -6,6 +6,7 @@ import { QueryParams } from "./query-params";
import { RouteDataResponse } from "./route-data-response";
import { ThemeColor } from "./theme-color";
import WithComment from "./with-comment";
import CrossPostParams from "./cross-post-params";
export {
Choice,
@ -16,4 +17,5 @@ export {
RouteDataResponse,
ThemeColor,
WithComment,
CrossPostParams,
};

View file

@ -5,4 +5,8 @@ export default interface WithComment {
counts: CommentAggregates;
my_vote?: number;
saved: boolean;
creator_is_moderator: boolean;
creator_is_admin: boolean;
creator_blocked: boolean;
creator_banned_from_community: boolean;
}