645 lines
22 KiB
TypeScript
645 lines
22 KiB
TypeScript
/*
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
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<Record<AuditLogActionType, IconComponent>> = {
|
|
[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, IconComponent> = {
|
|
[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<string>;
|
|
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<Array<GuildAuditLogEntry>>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [hasSuccessfulEmptyLoad, setHasSuccessfulEmptyLoad] = useState(false);
|
|
const [selectedUserId, setSelectedUserId] = useState('');
|
|
const [selectedAction, setSelectedAction] = useState('');
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [expandedEntryId, setExpandedEntryId] = useState<string | null>(null);
|
|
|
|
const members = GuildMemberStore.getMembers(guildId);
|
|
const userOptions = useMemo<Array<UserFilterOption>>(
|
|
() => [{value: '', label: t`All Users`}, ...buildUserOptions(members)],
|
|
[members],
|
|
);
|
|
|
|
const actionOptions = useMemo<Array<SelectOption<string>>>(
|
|
() => [
|
|
{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 (
|
|
<div className={styles.userSelectRowGlobal}>
|
|
<span className={styles.userSelectLabel}>{option.label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.userSelectRow}>
|
|
{isAuditLogUserOption(option) && (
|
|
<div className={styles.userSelectAvatarWrapper}>
|
|
<Avatar user={option.user} size={USER_FILTER_AVATAR_SIZE} guildId={guildId} />
|
|
</div>
|
|
)}
|
|
<span className={styles.userSelectLabel}>{option.label}</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const OptionComponent = observer((props: OptionProps<UserFilterOption, false>) => (
|
|
<reactSelectComponents.Option {...props}>{renderContent(props.data)}</reactSelectComponents.Option>
|
|
));
|
|
|
|
const SingleValueComponent = observer((props: SingleValueProps<UserFilterOption, false>) => (
|
|
<reactSelectComponents.SingleValue {...props}>{renderContent(props.data)}</reactSelectComponents.SingleValue>
|
|
));
|
|
|
|
return {Option: OptionComponent, SingleValue: SingleValueComponent};
|
|
}, [guildId]);
|
|
|
|
const actionSelectComponents = useMemo(() => {
|
|
const renderContent = (option: SelectOption<string>) => {
|
|
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 (
|
|
<div className={styles.actionSelectRow}>
|
|
<span className={clsx(styles.actionSelectIcon, actionToneClass)} aria-hidden>
|
|
<Icon size={18} weight="bold" />
|
|
</span>
|
|
<span className={styles.actionSelectLabel}>{option.label}</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const OptionComponent = observer((props: OptionProps<SelectOption<string>, false>) => (
|
|
<reactSelectComponents.Option {...props}>{renderContent(props.data)}</reactSelectComponents.Option>
|
|
));
|
|
|
|
const SingleValueComponent = observer((props: SingleValueProps<SelectOption<string>, false>) => (
|
|
<reactSelectComponents.SingleValue {...props}>{renderContent(props.data)}</reactSelectComponents.SingleValue>
|
|
));
|
|
|
|
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<string>();
|
|
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 (
|
|
<div className={styles.container}>
|
|
<div className={styles.headerTop}>
|
|
<h2 className={styles.pageTitle}>{t`Activity Log`}</h2>
|
|
<p className={styles.pageSubtitle}>{t`Track moderator actions across the community.`}</p>
|
|
</div>
|
|
|
|
<div className={styles.filterRow}>
|
|
<Select
|
|
value={selectedUserId}
|
|
onChange={(value) => setSelectedUserId(value)}
|
|
options={userOptions}
|
|
placeholder={t`All Users`}
|
|
components={userSelectComponents}
|
|
label={t`Filter by user`}
|
|
/>
|
|
<Select
|
|
value={selectedAction}
|
|
onChange={(value) => setSelectedAction(value)}
|
|
options={actionOptions}
|
|
placeholder={t`All Actions`}
|
|
label={t`Filter by action`}
|
|
components={actionSelectComponents}
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles.entries}>
|
|
{isLoading && entries.length === 0 && (
|
|
<div className={styles.spinnerRow}>
|
|
<Spinner size="large" />
|
|
</div>
|
|
)}
|
|
|
|
{entries.length > 0 && !shouldShowErrorState && (
|
|
<div className={styles.entryList}>
|
|
{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 (
|
|
<div className={styles.changeItem} key={`${entryId}-${change.key}-${changeIndex}`}>
|
|
<span className={clsx(styles.changeBullet, toneClass)} aria-hidden>
|
|
<ChangeIcon size={12} weight="bold" className={styles.changeBulletIcon} />
|
|
</span>
|
|
<span className={styles.changeText}>{rendered ?? renderFallbackChangeDetail(change)}</span>
|
|
</div>
|
|
);
|
|
})
|
|
.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 (
|
|
<div key={entryId} className={entryClasses}>
|
|
{isExpandable ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleExpanded(entryId)}
|
|
className={headerClasses}
|
|
aria-expanded={isExpandedView}
|
|
>
|
|
<span className={styles.icon} aria-hidden>
|
|
<ActionIcon size={20} weight="bold" className={styles.iconGlyph} />
|
|
</span>
|
|
|
|
<div className={styles.avatar}>
|
|
{actorUser ? (
|
|
<Avatar user={actorUser} size={32} guildId={guildId} />
|
|
) : (
|
|
<MockAvatar size={32} userTag={entry.user_id ?? 'Unknown user'} />
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.textBlock}>
|
|
<div className={styles.titleRow}>
|
|
<span className={styles.summary}>{summaryNode}</span>
|
|
</div>
|
|
<div className={styles.metaRow}>
|
|
<span className={styles.timestamp}>{formatTimestamp(entry.id)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<CaretDownIcon
|
|
size={20}
|
|
weight="bold"
|
|
className={clsx(styles.chevron, {[styles.chevronExpanded]: isExpandedView})}
|
|
/>
|
|
</button>
|
|
) : (
|
|
<div className={headerClasses}>
|
|
<span className={styles.icon} aria-hidden>
|
|
<ActionIcon size={20} weight="bold" className={styles.iconGlyph} />
|
|
</span>
|
|
|
|
<div className={styles.avatar}>
|
|
{actorUser ? (
|
|
<Avatar user={actorUser} size={32} guildId={guildId} />
|
|
) : (
|
|
<MockAvatar size={32} userTag={entry.user_id ?? 'Unknown user'} />
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.textBlock}>
|
|
<div className={styles.titleRow}>
|
|
<span className={styles.summary}>{summaryNode}</span>
|
|
</div>
|
|
<div className={styles.metaRow}>
|
|
<span className={styles.timestamp}>{formatTimestamp(entry.id)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isExpandedView && (
|
|
<div className={styles.details}>
|
|
{shouldShowReasonPreview && (
|
|
<div className={styles.reasonRow}>
|
|
<span className={styles.reasonLabel}>
|
|
<Trans>Reason</Trans>
|
|
</span>
|
|
<span className={styles.reasonValue}>{reasonText}</span>
|
|
</div>
|
|
)}
|
|
|
|
{(changeRows.length > 0 || optionEntries.length > 0) && (
|
|
<div className={styles.changeList}>
|
|
{changeRows}
|
|
{optionEntries.map(([key, value]) => (
|
|
<div className={styles.changeItem} key={key}>
|
|
{(() => {
|
|
const tone = getOptionTone(value);
|
|
const ChangeIcon = getChangeIcon(tone);
|
|
const toneClass = styles[`changeBullet_${tone}` as keyof typeof styles];
|
|
return (
|
|
<span className={clsx(styles.changeBullet, toneClass)} aria-hidden>
|
|
<ChangeIcon size={12} weight="bold" className={styles.changeBulletIcon} />
|
|
</span>
|
|
);
|
|
})()}
|
|
<span className={styles.changeText}>
|
|
{renderOptionDetailSentence(
|
|
key,
|
|
value,
|
|
guildId,
|
|
entry.action_type as AuditLogActionType,
|
|
translate,
|
|
)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{shouldShowEmptyState && (
|
|
<div className={styles.emptyState}>
|
|
<EmptySlate
|
|
Icon={ClipboardTextIcon}
|
|
title={t`No logs yet`}
|
|
description={t`When moderators begin moderating, you can moderate the moderation here.`}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && shouldShowErrorState && (
|
|
<div className={styles.errorState}>
|
|
<EmptySlate
|
|
Icon={WarningCircleIcon}
|
|
title={t`Unable to load activity logs`}
|
|
description={errorDescription}
|
|
/>
|
|
<div className={styles.statusActions}>
|
|
<Button variant="secondary" onClick={() => loadLogs({reset: true})}>
|
|
{t`Retry`}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{hasMore && entries.length > 0 && !shouldShowErrorState && (
|
|
<div className={styles.loadMore}>
|
|
<Button onClick={handleLoadMore} submitting={isLoading}>
|
|
{t`Load more`}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export default GuildAuditLogTab;
|