import { NoOptionI18nKeys } from "i18next"; import { Component, linkEvent } from "inferno"; import { T } from "inferno-i18next-dess"; import { Link } from "inferno-router"; import { RouteComponentProps } from "inferno-router/dist/Route"; import { AdminPurgeCommentView, AdminPurgeCommunityView, AdminPurgePersonView, AdminPurgePostView, CommunityModeratorView, GetCommunity, GetCommunityResponse, GetModlog, GetModlogResponse, GetPersonDetails, GetPersonDetailsResponse, ModAddCommunityView, ModAddView, ModBanFromCommunityView, ModBanView, ModFeaturePostView, ModLockPostView, ModRemoveCommentView, ModRemoveCommunityView, ModRemovePostView, ModTransferCommunityView, ModlogActionType, Person, UserOperation, wsJsonToRes, wsUserOp, } from "lemmy-js-client"; import moment from "moment"; import { Subscription } from "rxjs"; import { i18n } from "../i18next"; import { InitialFetchRequest } from "../interfaces"; import { WebSocketService } from "../services"; import { Choice, QueryParams, WithPromiseKeys, amAdmin, amMod, debounce, fetchLimit, fetchUsers, getIdFromString, getPageFromString, getQueryParams, getQueryString, getUpdatedSearchId, isBrowser, myAuth, personToChoice, setIsoData, toast, wsClient, wsSubscribe, } from "../utils"; import { HtmlTags } from "./common/html-tags"; import { Icon, Spinner } from "./common/icon"; import { MomentTime } from "./common/moment-time"; import { Paginator } from "./common/paginator"; import { SearchableSelect } from "./common/searchable-select"; import { CommunityLink } from "./community/community-link"; import { PersonListing } from "./person/person-listing"; type FilterType = "mod" | "user"; type View = | ModRemovePostView | ModLockPostView | ModFeaturePostView | ModRemoveCommentView | ModRemoveCommunityView | ModBanFromCommunityView | ModBanView | ModAddCommunityView | ModTransferCommunityView | ModAddView | AdminPurgePersonView | AdminPurgeCommunityView | AdminPurgePostView | AdminPurgeCommentView; interface ModlogData { modlogResponse: GetModlogResponse; communityResponse?: GetCommunityResponse; modUserResponse?: GetPersonDetailsResponse; userResponse?: GetPersonDetailsResponse; } interface ModlogType { id: number; type_: ModlogActionType; moderator?: Person; view: View; when_: string; } const getModlogQueryParams = () => getQueryParams({ actionType: getActionFromString, modId: getIdFromString, userId: getIdFromString, page: getPageFromString, }); interface ModlogState { res?: GetModlogResponse; communityMods?: CommunityModeratorView[]; communityName?: string; loadingModlog: boolean; loadingModSearch: boolean; loadingUserSearch: boolean; modSearchOptions: Choice[]; userSearchOptions: Choice[]; } interface ModlogProps { page: number; userId?: number | null; modId?: number | null; actionType: ModlogActionType; } function getActionFromString(action?: string): ModlogActionType { return action !== undefined ? (action as ModlogActionType) : "All"; } const getModlogActionMapper = ( actionType: ModlogActionType, getAction: (view: View) => { id: number; when_: string } ) => (view: View & { moderator?: Person; admin?: Person }): ModlogType => { const { id, when_ } = getAction(view); return { id, type_: actionType, view, when_, moderator: view.moderator ?? view.admin, }; }; function buildCombined({ removed_comments, locked_posts, featured_posts, removed_communities, removed_posts, added, added_to_community, admin_purged_comments, admin_purged_communities, admin_purged_persons, admin_purged_posts, banned, banned_from_community, transferred_to_community, }: GetModlogResponse): ModlogType[] { const combined = removed_posts .map( getModlogActionMapper( "ModRemovePost", ({ mod_remove_post }: ModRemovePostView) => mod_remove_post ) ) .concat( locked_posts.map( getModlogActionMapper( "ModLockPost", ({ mod_lock_post }: ModLockPostView) => mod_lock_post ) ) ) .concat( featured_posts.map( getModlogActionMapper( "ModFeaturePost", ({ mod_feature_post }: ModFeaturePostView) => mod_feature_post ) ) ) .concat( removed_comments.map( getModlogActionMapper( "ModRemoveComment", ({ mod_remove_comment }: ModRemoveCommentView) => mod_remove_comment ) ) ) .concat( removed_communities.map( getModlogActionMapper( "ModRemoveCommunity", ({ mod_remove_community }: ModRemoveCommunityView) => mod_remove_community ) ) ) .concat( banned_from_community.map( getModlogActionMapper( "ModBanFromCommunity", ({ mod_ban_from_community }: ModBanFromCommunityView) => mod_ban_from_community ) ) ) .concat( added_to_community.map( getModlogActionMapper( "ModAddCommunity", ({ mod_add_community }: ModAddCommunityView) => mod_add_community ) ) ) .concat( transferred_to_community.map( getModlogActionMapper( "ModTransferCommunity", ({ mod_transfer_community }: ModTransferCommunityView) => mod_transfer_community ) ) ) .concat( added.map( getModlogActionMapper("ModAdd", ({ mod_add }: ModAddView) => mod_add) ) ) .concat( banned.map( getModlogActionMapper("ModBan", ({ mod_ban }: ModBanView) => mod_ban) ) ) .concat( admin_purged_persons.map( getModlogActionMapper( "AdminPurgePerson", ({ admin_purge_person }: AdminPurgePersonView) => admin_purge_person ) ) ) .concat( admin_purged_communities.map( getModlogActionMapper( "AdminPurgeCommunity", ({ admin_purge_community }: AdminPurgeCommunityView) => admin_purge_community ) ) ) .concat( admin_purged_posts.map( getModlogActionMapper( "AdminPurgePost", ({ admin_purge_post }: AdminPurgePostView) => admin_purge_post ) ) ) .concat( admin_purged_comments.map( getModlogActionMapper( "AdminPurgeComment", ({ admin_purge_comment }: AdminPurgeCommentView) => admin_purge_comment ) ) ); // Sort them by time combined.sort((a, b) => b.when_.localeCompare(a.when_)); return combined; } function renderModlogType({ type_, view }: ModlogType) { switch (type_) { case "ModRemovePost": { const mrpv = view as ModRemovePostView; const { mod_remove_post: { reason, removed }, post: { name, id }, } = mrpv; return ( <> {removed ? "Removed " : "Restored "} Post {name} {reason && (
reason: {reason}
)} ); } case "ModLockPost": { const { mod_lock_post: { locked }, post: { id, name }, } = view as ModLockPostView; return ( <> {locked ? "Locked " : "Unlocked "} Post {name} ); } case "ModFeaturePost": { const { mod_feature_post: { featured, is_featured_community }, post: { id, name }, } = view as ModFeaturePostView; return ( <> {featured ? "Featured " : "Unfeatured "} Post {name} {is_featured_community ? " In Community" : " In Local"} ); } case "ModRemoveComment": { const mrc = view as ModRemoveCommentView; const { mod_remove_comment: { reason, removed }, comment: { id, content }, commenter, } = mrc; return ( <> {removed ? "Removed " : "Restored "} Comment {content} {" "} by {reason && (
reason: {reason}
)} ); } case "ModRemoveCommunity": { const mrco = view as ModRemoveCommunityView; const { mod_remove_community: { reason, expires, removed }, community, } = mrco; return ( <> {removed ? "Removed " : "Restored "} Community {reason && (
reason: {reason}
)} {expires && (
expires: {moment.utc(expires).fromNow()}
)} ); } case "ModBanFromCommunity": { const mbfc = view as ModBanFromCommunityView; const { mod_ban_from_community: { reason, expires, banned }, banned_person, community, } = mbfc; return ( <> {banned ? "Banned " : "Unbanned "} from the community {reason && (
reason: {reason}
)} {expires && (
expires: {moment.utc(expires).fromNow()}
)} ); } case "ModAddCommunity": { const { mod_add_community: { removed }, modded_person, community, } = view as ModAddCommunityView; return ( <> {removed ? "Removed " : "Appointed "} as a mod to the community ); } case "ModTransferCommunity": { const { community, modded_person } = view as ModTransferCommunityView; return ( <> Transferred to ); } case "ModBan": { const { mod_ban: { reason, expires, banned }, banned_person, } = view as ModBanView; return ( <> {banned ? "Banned " : "Unbanned "} {reason && (
reason: {reason}
)} {expires && (
expires: {moment.utc(expires).fromNow()}
)} ); } case "ModAdd": { const { mod_add: { removed }, modded_person, } = view as ModAddView; return ( <> {removed ? "Removed " : "Appointed "} as an admin ); } case "AdminPurgePerson": { const { admin_purge_person: { reason }, } = view as AdminPurgePersonView; return ( <> Purged a Person {reason && (
reason: {reason}
)} ); } case "AdminPurgeCommunity": { const { admin_purge_community: { reason }, } = view as AdminPurgeCommunityView; return ( <> Purged a Community {reason && (
reason: {reason}
)} ); } case "AdminPurgePost": { const { admin_purge_post: { reason }, community, } = view as AdminPurgePostView; return ( <> Purged a Post from from {reason && (
reason: {reason}
)} ); } case "AdminPurgeComment": { const { admin_purge_comment: { reason }, post: { id, name }, } = view as AdminPurgeCommentView; return ( <> Purged a Comment from {name} {reason && (
reason: {reason}
)} ); } default: return <>; } } const Filter = ({ filterType, onChange, value, onSearch, options, loading, }: { filterType: FilterType; onChange: (option: Choice) => void; value?: number | null; onSearch: (text: string) => void; options: Choice[]; loading: boolean; }) => (
); async function createNewOptions({ id, oldOptions, text, }: { id?: number | null; oldOptions: Choice[]; text: string; }) { const newOptions: Choice[] = []; if (id) { const selectedUser = oldOptions.find( ({ value }) => value === id.toString() ); if (selectedUser) { newOptions.push(selectedUser); } } if (text.length > 0) { newOptions.push( ...(await fetchUsers(text)).users .slice(0, Number(fetchLimit)) .map(personToChoice) ); } return newOptions; } export class Modlog extends Component< RouteComponentProps<{ communityId?: string }>, ModlogState > { private isoData = setIsoData(this.context); private subscription?: Subscription; state: ModlogState = { loadingModlog: true, loadingModSearch: false, loadingUserSearch: false, userSearchOptions: [], modSearchOptions: [], }; constructor( props: RouteComponentProps<{ communityId?: string }>, context: any ) { super(props, context); this.handlePageChange = this.handlePageChange.bind(this); this.handleUserChange = this.handleUserChange.bind(this); this.handleModChange = this.handleModChange.bind(this); this.parseMessage = this.parseMessage.bind(this); this.subscription = wsSubscribe(this.parseMessage); // Only fetch the data if coming from another route if (this.isoData.path === this.context.router.route.match.url) { const { modlogResponse, communityResponse, modUserResponse, userResponse, } = this.isoData.routeData; this.state = { ...this.state, res: modlogResponse, }; // Getting the moderators this.state = { ...this.state, communityMods: communityResponse?.moderators, }; if (modUserResponse) { this.state = { ...this.state, modSearchOptions: [personToChoice(modUserResponse.person_view)], }; } if (userResponse) { this.state = { ...this.state, userSearchOptions: [personToChoice(userResponse.person_view)], }; } this.state = { ...this.state, loadingModlog: false }; } else { this.refetch(); } } componentWillUnmount() { if (isBrowser()) { this.subscription?.unsubscribe(); } } get combined() { const res = this.state.res; const combined = res ? buildCombined(res) : []; return ( {combined.map(i => ( {this.amAdminOrMod && i.moderator ? ( ) : (
{this.modOrAdminText(i.moderator)}
)} {renderModlogType(i)} ))} ); } get amAdminOrMod(): boolean { return amAdmin() || amMod(this.state.communityMods); } modOrAdminText(person?: Person): string { return person && this.isoData.site_res.admins.some( ({ person: { id } }) => id === person.id ) ? i18n.t("admin") : i18n.t("mod"); } get documentTitle(): string { return `Modlog - ${this.isoData.site_res.site_view.site.name}`; } render() { const { communityName, loadingModlog, loadingModSearch, loadingUserSearch, userSearchOptions, modSearchOptions, } = this.state; const { actionType, page, modId, userId } = getModlogQueryParams(); return (
###
{communityName && ( /c/{communityName}{" "} )} {i18n.t("modlog")}
{!this.isoData.site_res.site_view.local_site .hide_modlog_mod_names && ( )}
{loadingModlog ? (
) : ( {this.combined}
{i18n.t("time")} {i18n.t("mod")} {i18n.t("action")}
)}
); } handleFilterActionChange(i: Modlog, event: any) { i.updateUrl({ actionType: event.target.value as ModlogActionType, page: 1, }); } handlePageChange(page: number) { this.updateUrl({ page }); } handleUserChange(option: Choice) { this.updateUrl({ userId: getIdFromString(option.value) ?? null, page: 1 }); } handleModChange(option: Choice) { this.updateUrl({ modId: getIdFromString(option.value) ?? null, page: 1 }); } handleSearchUsers = debounce(async (text: string) => { const { userId } = getModlogQueryParams(); const { userSearchOptions } = this.state; this.setState({ loadingUserSearch: true }); const newOptions = await createNewOptions({ id: userId, text, oldOptions: userSearchOptions, }); this.setState({ userSearchOptions: newOptions, loadingUserSearch: false, }); }); handleSearchMods = debounce(async (text: string) => { const { modId } = getModlogQueryParams(); const { modSearchOptions } = this.state; this.setState({ loadingModSearch: true }); const newOptions = await createNewOptions({ id: modId, text, oldOptions: modSearchOptions, }); this.setState({ modSearchOptions: newOptions, loadingModSearch: false, }); }); updateUrl({ actionType, modId, page, userId }: Partial) { const { page: urlPage, actionType: urlActionType, modId: urlModId, userId: urlUserId, } = getModlogQueryParams(); const queryParams: QueryParams = { page: (page ?? urlPage).toString(), actionType: actionType ?? urlActionType, modId: getUpdatedSearchId(modId, urlModId), userId: getUpdatedSearchId(userId, urlUserId), }; const communityId = this.props.match.params.communityId; this.props.history.push( `/modlog${communityId ? `/${communityId}` : ""}${getQueryString( queryParams )}` ); this.setState({ loadingModlog: true, res: undefined, }); this.refetch(); } refetch() { const auth = myAuth(false); const { actionType, page, modId, userId } = getModlogQueryParams(); const { communityId: urlCommunityId } = this.props.match.params; const communityId = getIdFromString(urlCommunityId); const modlogForm: GetModlog = { community_id: communityId, page, limit: fetchLimit, type_: actionType, other_person_id: userId ?? undefined, mod_person_id: !this.isoData.site_res.site_view.local_site .hide_modlog_mod_names ? modId ?? undefined : undefined, auth, }; WebSocketService.Instance.send(wsClient.getModlog(modlogForm)); if (communityId) { const communityForm: GetCommunity = { id: communityId, auth, }; WebSocketService.Instance.send(wsClient.getCommunity(communityForm)); } } static fetchInitialData({ client, path, query: { modId: urlModId, page, userId: urlUserId, actionType }, auth, site, }: InitialFetchRequest< QueryParams >): WithPromiseKeys { const pathSplit = path.split("/"); const communityId = getIdFromString(pathSplit[2]); const modId = !site.site_view.local_site.hide_modlog_mod_names ? getIdFromString(urlModId) : undefined; const userId = getIdFromString(urlUserId); const modlogForm: GetModlog = { page: getPageFromString(page), limit: fetchLimit, community_id: communityId, type_: getActionFromString(actionType), mod_person_id: modId, other_person_id: userId, auth, }; let communityResponse: Promise | undefined = undefined; if (communityId) { const communityForm: GetCommunity = { id: communityId, auth, }; communityResponse = client.getCommunity(communityForm); } let modUserResponse: Promise | undefined = undefined; if (modId) { const getPersonForm: GetPersonDetails = { person_id: modId, auth, }; modUserResponse = client.getPersonDetails(getPersonForm); } let userResponse: Promise | undefined = undefined; if (userId) { const getPersonForm: GetPersonDetails = { person_id: userId, auth, }; userResponse = client.getPersonDetails(getPersonForm); } return { modlogResponse: client.getModlog(modlogForm), communityResponse, modUserResponse, userResponse, }; } parseMessage(msg: any) { const op = wsUserOp(msg); console.log(msg); if (msg.error) { toast(i18n.t(msg.error), "danger"); } else { switch (op) { case UserOperation.GetModlog: { const res = wsJsonToRes(msg); window.scrollTo(0, 0); this.setState({ res, loadingModlog: false }); break; } case UserOperation.GetCommunity: { const { moderators, community_view: { community: { name }, }, } = wsJsonToRes(msg); this.setState({ communityMods: moderators, communityName: name, }); break; } } } } }