/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {Trans, useLingui} from '@lingui/react/macro'; import type {IconWeight} from '@phosphor-icons/react'; import { BuildingsIcon, CaretDownIcon, ClipboardTextIcon, FunnelSimpleIcon, GearIcon, HashIcon, LinkIcon, MinusIcon, PencilSimpleIcon, PlugIcon, PlusIcon, SmileyIcon, StampIcon, TagIcon, UserGearIcon, WarningCircleIcon, } from '@phosphor-icons/react'; import clsx from 'clsx'; import {observer} from 'mobx-react-lite'; import type React from 'react'; import type {ReactElement} from 'react'; import {useCallback, useEffect, useMemo, useState} from 'react'; import {type OptionProps, components as reactSelectComponents, type SingleValueProps} from 'react-select'; import type {GuildAuditLogEntry} from '~/actions/GuildActionCreators'; import * as GuildActionCreators from '~/actions/GuildActionCreators'; import {Select, type SelectOption} from '~/components/form/Select'; import {EmptySlate} from '~/components/modals/shared/EmptySlate'; import {Avatar} from '~/components/uikit/Avatar'; import {Button} from '~/components/uikit/Button/Button'; import {MockAvatar} from '~/components/uikit/MockAvatar'; import {Spinner} from '~/components/uikit/Spinner'; import {AuditLogActionType} from '~/constants/AuditLogActionType'; import {AUDIT_LOG_ACTIONS, getTranslatedAuditLogActions} from '~/constants/AuditLogConstants'; import {Logger} from '~/lib/Logger'; import GuildMemberStore from '~/stores/GuildMemberStore'; import UserStore from '~/stores/UserStore'; import { getActionKind, getTargetType, looksLikeSnowflake, normalizeChanges, resolveIdToName, safeScalarString, shouldSuppressDetailsForAction, toChangeShape, } from '~/utils/modals/guildTabs/GuildAuditLogTabUtils'; import {AuditLogTargetType, DEFAULT_FOR_STRINGS_KEY, LOG_PAGE_SIZE} from './GuildAuditLogTab.constants'; import styles from './GuildAuditLogTab.module.css'; import {getRendererTableForTarget} from './GuildAuditLogTab.renderers'; import type {AuditLogUserOption} from './GuildAuditLogTab.utils'; import { buildUserOptions, formatTimestamp, renderEntrySummary, renderFallbackChangeDetail, renderOptionDetailSentence, resolveChannelLabel, resolveTargetLabel, shouldNotRenderChangeDetail, shouldShowFallbackChangeDetail, } from './GuildAuditLogTab.utils'; type IconComponent = React.ComponentType<{size?: number | string; weight?: IconWeight; className?: string}>; type ChangeTone = 'add' | 'remove' | 'update'; const logger = new Logger('GuildAuditLogTab'); const actionIconMap: Partial> = { [AuditLogActionType.GUILD_UPDATE]: GearIcon, [AuditLogActionType.CHANNEL_CREATE]: HashIcon, [AuditLogActionType.CHANNEL_UPDATE]: HashIcon, [AuditLogActionType.CHANNEL_DELETE]: HashIcon, [AuditLogActionType.CHANNEL_OVERWRITE_CREATE]: HashIcon, [AuditLogActionType.CHANNEL_OVERWRITE_UPDATE]: HashIcon, [AuditLogActionType.CHANNEL_OVERWRITE_DELETE]: HashIcon, [AuditLogActionType.MEMBER_KICK]: UserGearIcon, [AuditLogActionType.MEMBER_PRUNE]: UserGearIcon, [AuditLogActionType.MEMBER_BAN_ADD]: UserGearIcon, [AuditLogActionType.MEMBER_BAN_REMOVE]: UserGearIcon, [AuditLogActionType.MEMBER_UPDATE]: UserGearIcon, [AuditLogActionType.MEMBER_ROLE_UPDATE]: UserGearIcon, [AuditLogActionType.MEMBER_MOVE]: UserGearIcon, [AuditLogActionType.MEMBER_DISCONNECT]: UserGearIcon, [AuditLogActionType.BOT_ADD]: UserGearIcon, [AuditLogActionType.ROLE_CREATE]: TagIcon, [AuditLogActionType.ROLE_UPDATE]: TagIcon, [AuditLogActionType.ROLE_DELETE]: TagIcon, [AuditLogActionType.INVITE_CREATE]: LinkIcon, [AuditLogActionType.INVITE_UPDATE]: LinkIcon, [AuditLogActionType.INVITE_DELETE]: LinkIcon, [AuditLogActionType.WEBHOOK_CREATE]: PlugIcon, [AuditLogActionType.WEBHOOK_UPDATE]: PlugIcon, [AuditLogActionType.WEBHOOK_DELETE]: PlugIcon, [AuditLogActionType.EMOJI_CREATE]: SmileyIcon, [AuditLogActionType.EMOJI_UPDATE]: SmileyIcon, [AuditLogActionType.EMOJI_DELETE]: SmileyIcon, [AuditLogActionType.STICKER_CREATE]: StampIcon, [AuditLogActionType.STICKER_UPDATE]: StampIcon, [AuditLogActionType.STICKER_DELETE]: StampIcon, [AuditLogActionType.MESSAGE_DELETE]: PencilSimpleIcon, [AuditLogActionType.MESSAGE_BULK_DELETE]: PencilSimpleIcon, [AuditLogActionType.MESSAGE_PIN]: PencilSimpleIcon, [AuditLogActionType.MESSAGE_UNPIN]: PencilSimpleIcon, }; const targetIconMap: Record = { [AuditLogTargetType.ALL]: BuildingsIcon, [AuditLogTargetType.GUILD]: GearIcon, [AuditLogTargetType.CHANNEL]: HashIcon, [AuditLogTargetType.USER]: UserGearIcon, [AuditLogTargetType.ROLE]: TagIcon, [AuditLogTargetType.INVITE]: LinkIcon, [AuditLogTargetType.WEBHOOK]: PlugIcon, [AuditLogTargetType.EMOJI]: SmileyIcon, [AuditLogTargetType.STICKER]: StampIcon, [AuditLogTargetType.MESSAGE]: PencilSimpleIcon, }; const getActionIcon = (actionType: AuditLogActionType): IconComponent => { const targetType = getTargetType(actionType); return targetIconMap[targetType as AuditLogTargetType] ?? actionIconMap[actionType] ?? BuildingsIcon; }; const getActionOptionIcon = (value: string): IconComponent => { if (!value) return FunnelSimpleIcon; const action = AUDIT_LOG_ACTIONS.find((item) => item.value.toString() === value); if (!action) return FunnelSimpleIcon; const actionType = Number(value) as AuditLogActionType; const targetType = getTargetType(actionType); return targetIconMap[targetType as AuditLogTargetType] ?? getActionIcon(actionType); }; const USER_FILTER_AVATAR_SIZE = 28; const getChangeTone = (change: {key: string; oldValue: unknown; newValue: unknown}): ChangeTone => { if (change.key === '$remove') return 'remove'; if (typeof change.newValue === 'boolean' && typeof change.oldValue === 'boolean') { return change.newValue ? 'add' : 'remove'; } if (change.oldValue != null && change.newValue == null) return 'remove'; return 'add'; }; const getOptionTone = (value: unknown): ChangeTone => { if (typeof value === 'boolean') return value ? 'add' : 'remove'; return 'add'; }; const getChangeIcon = (tone: ChangeTone): IconComponent => { switch (tone) { case 'remove': return MinusIcon; default: return PlusIcon; } }; type UserFilterOption = AuditLogUserOption | SelectOption; const isAuditLogUserOption = (option: UserFilterOption): option is AuditLogUserOption => 'user' in option && option.user != null; const GuildAuditLogTab: React.FC<{guildId: string}> = observer(({guildId}) => { const {t, i18n} = useLingui(); const translate = t ?? i18n._; const [entries, setEntries] = useState>([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [hasSuccessfulEmptyLoad, setHasSuccessfulEmptyLoad] = useState(false); const [selectedUserId, setSelectedUserId] = useState(''); const [selectedAction, setSelectedAction] = useState(''); const [hasMore, setHasMore] = useState(true); const [expandedEntryId, setExpandedEntryId] = useState(null); const members = GuildMemberStore.getMembers(guildId); const userOptions = useMemo>( () => [{value: '', label: t`All Users`}, ...buildUserOptions(members)], [members], ); const actionOptions = useMemo>>( () => [ {value: '', label: t`All Actions`}, ...getTranslatedAuditLogActions(i18n).map((action) => ({ value: action.value.toString(), label: action.label, })), ], [], ); const userSelectComponents = useMemo(() => { const renderContent = (option: UserFilterOption) => { if (!option.value) { return (
{option.label}
); } return (
{isAuditLogUserOption(option) && (
)} {option.label}
); }; const OptionComponent = observer((props: OptionProps) => ( {renderContent(props.data)} )); const SingleValueComponent = observer((props: SingleValueProps) => ( {renderContent(props.data)} )); return {Option: OptionComponent, SingleValue: SingleValueComponent}; }, [guildId]); const actionSelectComponents = useMemo(() => { const renderContent = (option: SelectOption) => { const Icon = getActionOptionIcon(option.value); const actionKind = option.value ? getActionKind(Number(option.value) as AuditLogActionType) : null; const actionToneClass = actionKind != null ? styles[`actionSelectIcon_${actionKind}` as keyof typeof styles] : styles.actionSelectIconNeutral; return (
{option.label}
); }; const OptionComponent = observer((props: OptionProps, false>) => ( {renderContent(props.data)} )); const SingleValueComponent = observer((props: SingleValueProps, false>) => ( {renderContent(props.data)} )); return {Option: OptionComponent, SingleValue: SingleValueComponent}; }, []); const loadLogs = useCallback( async ({reset = false, before}: {reset?: boolean; before?: string | null} = {}) => { setIsLoading(true); setError(null); try { const actionType = selectedAction ? Number(selectedAction) : undefined; const response = await GuildActionCreators.fetchGuildAuditLogs(guildId, { limit: LOG_PAGE_SIZE, beforeLogId: reset ? undefined : (before ?? undefined), userId: selectedUserId || undefined, actionType: actionType ?? undefined, }); const fetchedEntries = response.audit_log_entries; UserStore.cacheUsers(response.users); setEntries((current) => { const updatedEntries = reset ? fetchedEntries : [...current, ...fetchedEntries]; setHasSuccessfulEmptyLoad(reset && updatedEntries.length === 0); return updatedEntries; }); setHasMore(fetchedEntries.length === LOG_PAGE_SIZE); if (reset) setExpandedEntryId(null); } catch (err) { logger.error('Failed to load audit logs', err); setError(t`Something went wrong while loading the audit log.`); setHasSuccessfulEmptyLoad(false); } finally { setIsLoading(false); } }, [guildId, selectedAction, selectedUserId], ); useEffect(() => { loadLogs({reset: true}); }, [loadLogs]); useEffect(() => { if (entries.length === 0) { return; } const userIds = new Set(); for (const entry of entries) { if (entry.user_id) { userIds.add(entry.user_id); } if (entry.target_id) { userIds.add(entry.target_id); } } if (userIds.size > 0) { GuildMemberStore.ensureMembersLoaded(guildId, Array.from(userIds)).catch((error) => { console.error('[GuildAuditLogTab] Failed to ensure members:', error); }); } }, [guildId, entries]); const shouldShowErrorState = Boolean(error); const errorDescription = error ?? t`Something went wrong while loading the audit log.`; const shouldShowEmptyState = !shouldShowErrorState && !isLoading && entries.length === 0 && hasSuccessfulEmptyLoad; const handleLoadMore = useCallback(() => { if (!hasMore || entries.length === 0) return; loadLogs({before: entries[entries.length - 1].id}); }, [entries, hasMore, loadLogs]); const toggleExpanded = (id: string) => setExpandedEntryId((current) => (current === id ? null : id)); return (

{t`Activity Log`}

{t`Track moderator actions across the community.`}

setSelectedAction(value)} options={actionOptions} placeholder={t`All Actions`} label={t`Filter by action`} components={actionSelectComponents} />
{isLoading && entries.length === 0 && (
)} {entries.length > 0 && !shouldShowErrorState && (
{entries.map((entry) => { const entryId = entry.id; const targetType = getTargetType(entry.action_type as AuditLogActionType); const actionKind = getActionKind(entry.action_type as AuditLogActionType); const targetClassKey = `target_${targetType}` as keyof typeof styles; const actionClassKey = `type_${actionKind}` as keyof typeof styles; const entryClasses = clsx(styles.auditLog, styles[targetClassKey], styles[actionClassKey]); const ActionIcon = getActionIcon(entry.action_type as AuditLogActionType); const actorUser = entry.user_id ? (UserStore.getUser(entry.user_id) ?? null) : null; const targetUser = entry.target_id ? (UserStore.getUser(entry.target_id) ?? null) : null; const targetLabel = resolveTargetLabel(entry, translate); const channelLabel = resolveChannelLabel(entry, guildId, translate); const summaryNode = renderEntrySummary({ entry, actorUser, targetUser, targetLabel, channelLabel, guildId, t: translate, }); const suppressDetails = shouldSuppressDetailsForAction(entry.action_type as AuditLogActionType); const changeShapes = suppressDetails ? [] : normalizeChanges(entry.changes) .map(toChangeShape) .filter((change) => change.key && !shouldNotRenderChangeDetail(targetType, change.key)); const rendererTable = getRendererTableForTarget(targetType); const actionType = entry.action_type as AuditLogActionType; const renderedChangeKeys = new Set( changeShapes .filter((change) => rendererTable[change.key]?.(change, {entry, guildId, t: translate}) != null) .map((change) => change.key), ); const optionEntries = entry.options ? Object.entries(entry.options).filter(([key, value]) => { if (key === DEFAULT_FOR_STRINGS_KEY) return false; if (renderedChangeKeys.has(key)) return false; if (actionType === AuditLogActionType.MESSAGE_BULK_DELETE) { if ( key === 'count' || key === 'message_count' || key === 'delete_count' || key === 'channel_id' ) { return false; } } if ( key !== 'channel_id' && key !== 'message_id' && key !== 'inviter_id' && (key === 'id' || key.endsWith('_id')) ) return false; const scalar = safeScalarString(value, translate); if (scalar && looksLikeSnowflake(scalar)) { return resolveIdToName(scalar, guildId) != null; } return true; }) : []; const reasonText = typeof entry.reason === 'string' && entry.reason.trim() ? entry.reason.trim() : t`No reason was provided.`; const changeRows = changeShapes .map((change, changeIndex) => { const renderer = rendererTable[change.key]; const rendered = renderer?.(change, {entry, guildId, t: translate}); if (!rendered && !shouldShowFallbackChangeDetail(change)) { return null; } const tone = getChangeTone(change); const ChangeIcon = getChangeIcon(tone); const toneClass = styles[`changeBullet_${tone}` as keyof typeof styles]; return (
{rendered ?? renderFallbackChangeDetail(change)}
); }) .filter((row): row is ReactElement => row !== null); const shouldShowReasonPreview = typeof entry.reason === 'string' && entry.reason.trim().length > 0; const isExpandable = shouldShowReasonPreview || changeRows.length > 0 || optionEntries.length > 0; const isExpandedView = isExpandable && expandedEntryId === entryId; const headerClasses = clsx(styles.header, { [styles.headerClickable]: isExpandable, [styles.headerStatic]: !isExpandable, [styles.headerExpanded]: isExpandedView, [styles.headerDefault]: !isExpandedView, }); return (
{isExpandable ? ( ) : (
{actorUser ? ( ) : ( )}
{summaryNode}
{formatTimestamp(entry.id)}
)} {isExpandedView && (
{shouldShowReasonPreview && (
Reason {reasonText}
)} {(changeRows.length > 0 || optionEntries.length > 0) && (
{changeRows} {optionEntries.map(([key, value]) => (
{(() => { const tone = getOptionTone(value); const ChangeIcon = getChangeIcon(tone); const toneClass = styles[`changeBullet_${tone}` as keyof typeof styles]; return ( ); })()} {renderOptionDetailSentence( key, value, guildId, entry.action_type as AuditLogActionType, translate, )}
))}
)}
)}
); })}
)} {shouldShowEmptyState && (
)} {!isLoading && shouldShowErrorState && (
)} {hasMore && entries.length > 0 && !shouldShowErrorState && (
)}
); }); export default GuildAuditLogTab;