1 import { NoOptionI18nKeys } from "i18next";
2 import { Component, linkEvent } from "inferno";
3 import { T } from "inferno-i18next-dess";
4 import { Link } from "inferno-router";
5 import { RouteComponentProps } from "inferno-router/dist/Route";
8 AdminPurgeCommunityView,
11 CommunityModeratorView,
17 GetPersonDetailsResponse,
20 ModBanFromCommunityView,
25 ModRemoveCommunityView,
27 ModTransferCommunityView,
33 } from "lemmy-js-client";
34 import moment from "moment";
35 import { Subscription } from "rxjs";
36 import { i18n } from "../i18next";
37 import { InitialFetchRequest } from "../interfaces";
38 import { WebSocketService } from "../services";
60 import { HtmlTags } from "./common/html-tags";
61 import { Icon, Spinner } from "./common/icon";
62 import { MomentTime } from "./common/moment-time";
63 import { Paginator } from "./common/paginator";
64 import { SearchableSelect } from "./common/searchable-select";
65 import { CommunityLink } from "./community/community-link";
66 import { PersonListing } from "./person/person-listing";
68 type FilterType = "mod" | "user";
74 | ModRemoveCommentView
75 | ModRemoveCommunityView
76 | ModBanFromCommunityView
79 | ModTransferCommunityView
81 | AdminPurgePersonView
82 | AdminPurgeCommunityView
84 | AdminPurgeCommentView;
86 interface ModlogType {
88 type_: ModlogActionType;
94 const getModlogQueryParams = () =>
95 getQueryParams<ModlogProps>({
96 actionType: getActionFromString,
97 modId: getIdFromString,
98 userId: getIdFromString,
99 page: getPageFromString,
102 interface ModlogState {
103 res?: GetModlogResponse;
104 communityMods?: CommunityModeratorView[];
105 communityName?: string;
106 loadingModlog: boolean;
107 loadingModSearch: boolean;
108 loadingUserSearch: boolean;
109 modSearchOptions: Choice[];
110 userSearchOptions: Choice[];
113 interface ModlogProps {
115 userId?: number | null;
116 modId?: number | null;
117 actionType: ModlogActionType;
120 function getActionFromString(action?: string): ModlogActionType {
121 return action !== undefined ? (action as ModlogActionType) : "All";
124 const getModlogActionMapper =
126 actionType: ModlogActionType,
127 getAction: (view: View) => { id: number; when_: string }
129 (view: View & { moderator?: Person; admin?: Person }): ModlogType => {
130 const { id, when_ } = getAction(view);
137 moderator: view.moderator ?? view.admin,
141 function buildCombined({
149 admin_purged_comments,
150 admin_purged_communities,
151 admin_purged_persons,
154 banned_from_community,
155 transferred_to_community,
156 }: GetModlogResponse): ModlogType[] {
157 const combined = removed_posts
159 getModlogActionMapper(
161 ({ mod_remove_post }: ModRemovePostView) => mod_remove_post
166 getModlogActionMapper(
168 ({ mod_lock_post }: ModLockPostView) => mod_lock_post
174 getModlogActionMapper(
176 ({ mod_feature_post }: ModFeaturePostView) => mod_feature_post
181 removed_comments.map(
182 getModlogActionMapper(
184 ({ mod_remove_comment }: ModRemoveCommentView) => mod_remove_comment
189 removed_communities.map(
190 getModlogActionMapper(
191 "ModRemoveCommunity",
192 ({ mod_remove_community }: ModRemoveCommunityView) =>
198 banned_from_community.map(
199 getModlogActionMapper(
200 "ModBanFromCommunity",
201 ({ mod_ban_from_community }: ModBanFromCommunityView) =>
202 mod_ban_from_community
207 added_to_community.map(
208 getModlogActionMapper(
210 ({ mod_add_community }: ModAddCommunityView) => mod_add_community
215 transferred_to_community.map(
216 getModlogActionMapper(
217 "ModTransferCommunity",
218 ({ mod_transfer_community }: ModTransferCommunityView) =>
219 mod_transfer_community
225 getModlogActionMapper("ModAdd", ({ mod_add }: ModAddView) => mod_add)
230 getModlogActionMapper("ModBan", ({ mod_ban }: ModBanView) => mod_ban)
234 admin_purged_persons.map(
235 getModlogActionMapper(
237 ({ admin_purge_person }: AdminPurgePersonView) => admin_purge_person
242 admin_purged_communities.map(
243 getModlogActionMapper(
244 "AdminPurgeCommunity",
245 ({ admin_purge_community }: AdminPurgeCommunityView) =>
246 admin_purge_community
251 admin_purged_posts.map(
252 getModlogActionMapper(
254 ({ admin_purge_post }: AdminPurgePostView) => admin_purge_post
259 admin_purged_comments.map(
260 getModlogActionMapper(
262 ({ admin_purge_comment }: AdminPurgeCommentView) =>
269 combined.sort((a, b) => b.when_.localeCompare(a.when_));
274 function renderModlogType({ type_, view }: ModlogType) {
276 case "ModRemovePost": {
277 const mrpv = view as ModRemovePostView;
279 mod_remove_post: { reason, removed },
285 <span>{removed ? "Removed " : "Restored "}</span>
287 Post <Link to={`/post/${id}`}>{name}</Link>
291 <div>reason: {reason}</div>
298 case "ModLockPost": {
300 mod_lock_post: { locked },
302 } = view as ModLockPostView;
306 <span>{locked ? "Locked " : "Unlocked "}</span>
308 Post <Link to={`/post/${id}`}>{name}</Link>
314 case "ModFeaturePost": {
316 mod_feature_post: { featured, is_featured_community },
318 } = view as ModFeaturePostView;
322 <span>{featured ? "Featured " : "Unfeatured "}</span>
324 Post <Link to={`/post/${id}`}>{name}</Link>
326 <span>{is_featured_community ? " In Community" : " In Local"}</span>
330 case "ModRemoveComment": {
331 const mrc = view as ModRemoveCommentView;
333 mod_remove_comment: { reason, removed },
334 comment: { id, content },
340 <span>{removed ? "Removed " : "Restored "}</span>
342 Comment <Link to={`/comment/${id}`}>{content}</Link>
346 by <PersonListing person={commenter} />
350 <div>reason: {reason}</div>
357 case "ModRemoveCommunity": {
358 const mrco = view as ModRemoveCommunityView;
360 mod_remove_community: { reason, expires, removed },
366 <span>{removed ? "Removed " : "Restored "}</span>
368 Community <CommunityLink community={community} />
372 <div>reason: {reason}</div>
377 <div>expires: {moment.utc(expires).fromNow()}</div>
384 case "ModBanFromCommunity": {
385 const mbfc = view as ModBanFromCommunityView;
387 mod_ban_from_community: { reason, expires, banned },
394 <span>{banned ? "Banned " : "Unbanned "}</span>
396 <PersonListing person={banned_person} />
398 <span> from the community </span>
400 <CommunityLink community={community} />
404 <div>reason: {reason}</div>
409 <div>expires: {moment.utc(expires).fromNow()}</div>
416 case "ModAddCommunity": {
418 mod_add_community: { removed },
421 } = view as ModAddCommunityView;
425 <span>{removed ? "Removed " : "Appointed "}</span>
427 <PersonListing person={modded_person} />
429 <span> as a mod to the community </span>
431 <CommunityLink community={community} />
437 case "ModTransferCommunity": {
438 const { community, modded_person } = view as ModTransferCommunityView;
442 <span>Transferred</span>
444 <CommunityLink community={community} />
448 <PersonListing person={modded_person} />
456 mod_ban: { reason, expires, banned },
458 } = view as ModBanView;
462 <span>{banned ? "Banned " : "Unbanned "}</span>
464 <PersonListing person={banned_person} />
468 <div>reason: {reason}</div>
473 <div>expires: {moment.utc(expires).fromNow()}</div>
482 mod_add: { removed },
484 } = view as ModAddView;
488 <span>{removed ? "Removed " : "Appointed "}</span>
490 <PersonListing person={modded_person} />
492 <span> as an admin </span>
496 case "AdminPurgePerson": {
498 admin_purge_person: { reason },
499 } = view as AdminPurgePersonView;
503 <span>Purged a Person</span>
506 <div>reason: {reason}</div>
513 case "AdminPurgeCommunity": {
515 admin_purge_community: { reason },
516 } = view as AdminPurgeCommunityView;
520 <span>Purged a Community</span>
523 <div>reason: {reason}</div>
530 case "AdminPurgePost": {
532 admin_purge_post: { reason },
534 } = view as AdminPurgePostView;
538 <span>Purged a Post from from </span>
539 <CommunityLink community={community} />
542 <div>reason: {reason}</div>
549 case "AdminPurgeComment": {
551 admin_purge_comment: { reason },
553 } = view as AdminPurgeCommentView;
558 Purged a Comment from <Link to={`/post/${id}`}>{name}</Link>
562 <div>reason: {reason}</div>
582 filterType: FilterType;
583 onChange: (option: Choice) => void;
584 value?: number | null;
585 onSearch: (text: string) => void;
589 <div className="col-sm-6 form-group">
590 <label className="col-form-label" htmlFor={`filter-${filterType}`}>
591 {i18n.t(`filter_by_${filterType}` as NoOptionI18nKeys)}
594 id={`filter-${filterType}`}
598 label: i18n.t("all"),
609 async function createNewOptions({
615 oldOptions: Choice[];
618 const newOptions: Choice[] = [];
621 const selectedUser = oldOptions.find(
622 ({ value }) => value === id.toString()
626 newOptions.push(selectedUser);
630 if (text.length > 0) {
632 ...(await fetchUsers(text)).users
633 .slice(0, Number(fetchLimit))
634 .map<Choice>(personToChoice)
641 export class Modlog extends Component<
642 RouteComponentProps<{ communityId?: string }>,
645 private isoData = setIsoData(this.context);
646 private subscription?: Subscription;
648 state: ModlogState = {
650 loadingModSearch: false,
651 loadingUserSearch: false,
652 userSearchOptions: [],
653 modSearchOptions: [],
657 props: RouteComponentProps<{ communityId?: string }>,
660 super(props, context);
661 this.handlePageChange = this.handlePageChange.bind(this);
662 this.handleUserChange = this.handleUserChange.bind(this);
663 this.handleModChange = this.handleModChange.bind(this);
665 this.parseMessage = this.parseMessage.bind(this);
666 this.subscription = wsSubscribe(this.parseMessage);
668 // Only fetch the data if coming from another route
669 if (this.isoData.path === this.context.router.route.match.url) {
672 res: this.isoData.routeData[0] as GetModlogResponse,
675 const communityRes: GetCommunityResponse | undefined =
676 this.isoData.routeData[1];
678 // Getting the moderators
681 communityMods: communityRes?.moderators,
684 const filteredModRes: GetPersonDetailsResponse | undefined =
685 this.isoData.routeData[2];
686 if (filteredModRes) {
689 modSearchOptions: [personToChoice(filteredModRes.person_view)],
693 const filteredUserRes: GetPersonDetailsResponse | undefined =
694 this.isoData.routeData[3];
695 if (filteredUserRes) {
698 userSearchOptions: [personToChoice(filteredUserRes.person_view)],
702 this.state = { ...this.state, loadingModlog: false };
708 componentWillUnmount() {
710 this.subscription?.unsubscribe();
715 const res = this.state.res;
716 const combined = res ? buildCombined(res) : [];
723 <MomentTime published={i.when_} />
726 {this.amAdminOrMod && i.moderator ? (
727 <PersonListing person={i.moderator} />
729 <div>{this.modOrAdminText(i.moderator)}</div>
732 <td>{renderModlogType(i)}</td>
739 get amAdminOrMod(): boolean {
740 return amAdmin() || amMod(this.state.communityMods);
743 modOrAdminText(person?: Person): string {
745 this.isoData.site_res.admins.some(
746 ({ person: { id } }) => id === person.id
752 get documentTitle(): string {
753 return `Modlog - ${this.isoData.site_res.site_view.site.name}`;
765 const { actionType, page, modId, userId } = getModlogQueryParams();
768 <div className="container-lg">
770 title={this.documentTitle}
771 path={this.context.router.route.match.url}
776 className="alert alert-warning text-sm-start text-xs-center"
780 icon="alert-triangle"
782 classes="mr-sm-2 mx-auto d-sm-inline d-block"
784 <T i18nKey="modlog_content_warning" class="d-inline">
790 <Link className="text-body" to={`/c/${communityName}`}>
791 /c/{communityName}{" "}
794 <span>{i18n.t("modlog")}</span>
796 <div className="form-row">
799 onChange={linkEvent(this, this.handleFilterActionChange)}
800 className="custom-select col-sm-6"
803 <option disabled aria-hidden="true">
804 {i18n.t("filter_by_action")}
806 <option value={"All"}>{i18n.t("all")}</option>
807 <option value={"ModRemovePost"}>Removing Posts</option>
808 <option value={"ModLockPost"}>Locking Posts</option>
809 <option value={"ModFeaturePost"}>Featuring Posts</option>
810 <option value={"ModRemoveComment"}>Removing Comments</option>
811 <option value={"ModRemoveCommunity"}>Removing Communities</option>
812 <option value={"ModBanFromCommunity"}>
813 Banning From Communities
815 <option value={"ModAddCommunity"}>Adding Mod to Community</option>
816 <option value={"ModTransferCommunity"}>
817 Transferring Communities
819 <option value={"ModAdd"}>Adding Mod to Site</option>
820 <option value={"ModBan"}>Banning From Site</option>
823 <div className="form-row mb-2">
826 onChange={this.handleUserChange}
827 onSearch={this.handleSearchUsers}
829 options={userSearchOptions}
830 loading={loadingUserSearch}
832 {!this.isoData.site_res.site_view.local_site
833 .hide_modlog_mod_names && (
836 onChange={this.handleModChange}
837 onSearch={this.handleSearchMods}
839 options={modSearchOptions}
840 loading={loadingModSearch}
844 <div className="table-responsive">
850 <table id="modlog_table" className="table table-sm table-hover">
851 <thead className="pointer">
853 <th> {i18n.t("time")}</th>
854 <th>{i18n.t("mod")}</th>
855 <th>{i18n.t("action")}</th>
861 <Paginator page={page} onChange={this.handlePageChange} />
868 handleFilterActionChange(i: Modlog, event: any) {
870 actionType: event.target.value as ModlogActionType,
875 handlePageChange(page: bigint) {
876 this.updateUrl({ page });
879 handleUserChange(option: Choice) {
880 this.updateUrl({ userId: getIdFromString(option.value) ?? null, page: 1n });
883 handleModChange(option: Choice) {
884 this.updateUrl({ modId: getIdFromString(option.value) ?? null, page: 1n });
887 handleSearchUsers = debounce(async (text: string) => {
888 const { userId } = getModlogQueryParams();
889 const { userSearchOptions } = this.state;
890 this.setState({ loadingUserSearch: true });
892 const newOptions = await createNewOptions({
895 oldOptions: userSearchOptions,
899 userSearchOptions: newOptions,
900 loadingUserSearch: false,
904 handleSearchMods = debounce(async (text: string) => {
905 const { modId } = getModlogQueryParams();
906 const { modSearchOptions } = this.state;
907 this.setState({ loadingModSearch: true });
909 const newOptions = await createNewOptions({
912 oldOptions: modSearchOptions,
916 modSearchOptions: newOptions,
917 loadingModSearch: false,
921 updateUrl({ actionType, modId, page, userId }: Partial<ModlogProps>) {
924 actionType: urlActionType,
927 } = getModlogQueryParams();
929 const queryParams: QueryParams<ModlogProps> = {
930 page: (page ?? urlPage).toString(),
931 actionType: actionType ?? urlActionType,
932 modId: getUpdatedSearchId(modId, urlModId),
933 userId: getUpdatedSearchId(userId, urlUserId),
936 const communityId = this.props.match.params.communityId;
938 this.props.history.push(
939 `/modlog${communityId ? `/${communityId}` : ""}${getQueryString(
953 const auth = myAuth(false);
954 const { actionType, page, modId, userId } = getModlogQueryParams();
955 const { communityId: urlCommunityId } = this.props.match.params;
956 const communityId = getIdFromString(urlCommunityId);
958 const modlogForm: GetModlog = {
959 community_id: communityId,
963 other_person_id: userId ?? undefined,
964 mod_person_id: !this.isoData.site_res.site_view.local_site
965 .hide_modlog_mod_names
971 WebSocketService.Instance.send(wsClient.getModlog(modlogForm));
974 const communityForm: GetCommunity = {
979 WebSocketService.Instance.send(wsClient.getCommunity(communityForm));
983 static fetchInitialData({
986 query: { modId: urlModId, page, userId: urlUserId, actionType },
989 }: InitialFetchRequest<QueryParams<ModlogProps>>): Promise<any>[] {
990 const pathSplit = path.split("/");
991 const promises: Promise<any>[] = [];
992 const communityId = getIdFromString(pathSplit[2]);
993 const modId = !site.site_view.local_site.hide_modlog_mod_names
994 ? getIdFromString(urlModId)
996 const userId = getIdFromString(urlUserId);
998 const modlogForm: GetModlog = {
999 page: getPageFromString(page),
1001 community_id: communityId,
1002 type_: getActionFromString(actionType),
1003 mod_person_id: modId,
1004 other_person_id: userId,
1008 promises.push(client.getModlog(modlogForm));
1011 const communityForm: GetCommunity = {
1015 promises.push(client.getCommunity(communityForm));
1017 promises.push(Promise.resolve());
1021 const getPersonForm: GetPersonDetails = {
1026 promises.push(client.getPersonDetails(getPersonForm));
1028 promises.push(Promise.resolve());
1032 const getPersonForm: GetPersonDetails = {
1037 promises.push(client.getPersonDetails(getPersonForm));
1039 promises.push(Promise.resolve());
1045 parseMessage(msg: any) {
1046 const op = wsUserOp(msg);
1050 toast(i18n.t(msg.error), "danger");
1053 case UserOperation.GetModlog: {
1054 const res = wsJsonToRes<GetModlogResponse>(msg);
1055 window.scrollTo(0, 0);
1056 this.setState({ res, loadingModlog: false });
1061 case UserOperation.GetCommunity: {
1065 community: { name },
1067 } = wsJsonToRes<GetCommunityResponse>(msg);
1069 communityMods: moderators,
1070 communityName: name,