Files
Nitro-V3/src/components/mod-tools/views/user/ModToolsUserView.tsx
T
simoleo89 46daa96100 fix(mod-tools): empty-value placeholder no longer renders as music note
User reported empty fields (Email, Last Purchase, Lock Expires, Banned
Accs, Abusive CFHs) showing what looks like a music-note glyph next to
the label. They aren't censored — they're genuinely empty (a rank-7
Administrator account has none of that data populated). The em-dash
"—" (U+2014) used as the placeholder doesn't have a glyph in the
Habbo pixel font (Volter / Volter-Goldfish), so the engine falls
through to a placeholder glyph that on some font stacks looks like a
music note.

Two-part fix in ModToolsUserView (Field), ModToolsIssueInfoView
(Field) and ModToolsRoomView (owner fallback):

1. Replace the U+2014 em-dash with a plain ASCII `-`. Hyphen-minus is
   safely in Volter, so the placeholder renders correctly across the
   whole client.

2. The `value || placeholder` guard is now `(value || value === 0)`.
   Stat fields whose value is the literal number 0 — a clean account
   with cfhCount=0, banCount=0, cautionCount=0 — were rendering the
   placeholder because 0 is falsy. Treat 0 as a real value.

Also dropped the `italic` class on the placeholder span — the
hyphen does the job on its own and italic on a single-character
glyph in a pixel font was making it look like a tilted line.
2026-05-20 21:41:52 +02:00

207 lines
12 KiB
TypeScript

import { CreateLinkEvent, GetModeratorUserInfoMessageComposer, ModeratorActionResultMessageEvent, ModeratorUserInfoData, ModeratorUserInfoEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { FaBan, FaCommentDots, FaDoorOpen, FaEnvelope, FaExchangeAlt, FaExclamationTriangle, FaGavel, FaSync } from 'react-icons/fa';
import { FriendlyTime, LocalizeText, SendMessageComposer } from '../../../../api';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { useMessageEvent, useRoomUserListSnapshot } from '../../../../hooks';
import { ModToolsUserModActionView } from './ModToolsUserModActionView';
import { ModToolsUserRoomVisitsView } from './ModToolsUserRoomVisitsView';
import { ModToolsUserSendMessageView } from './ModToolsUserSendMessageView';
interface ModToolsUserViewProps
{
userId: number;
onCloseClick: () => void;
}
interface StatCardProps
{
icon: React.ReactNode;
label: string;
value: number | string;
tone?: 'neutral' | 'warn' | 'danger';
}
const StatCard: FC<StatCardProps> = ({ icon, label, value, tone = 'neutral' }) =>
{
const numericValue = typeof value === 'number' ? value : parseInt(value as string, 10);
const isElevated = !Number.isNaN(numericValue) && numericValue > 0;
const toneClasses = (() =>
{
if(tone === 'danger' && isElevated) return 'bg-rose-50 border-rose-200 text-rose-700';
if(tone === 'warn' && isElevated) return 'bg-amber-50 border-amber-200 text-amber-700';
return 'bg-zinc-50 border-zinc-200 text-zinc-700';
})();
return (
<div className={ `flex flex-col items-center justify-center px-2 py-1.5 rounded border ${ toneClasses } grow min-w-0` }>
<div className="flex items-center gap-1.5 text-[.7rem] uppercase tracking-wide opacity-70">
<span className="shrink-0">{ icon }</span>
<span className="truncate">{ label }</span>
</div>
<div className="text-lg font-semibold tabular-nums leading-tight">{ value }</div>
</div>
);
};
const Section: FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
<div className="flex flex-col gap-1">
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 mb-0.5">{ title }</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[.8rem] m-0">
{ children }
</dl>
</div>
);
const Field: FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<>
<dt className="opacity-60 whitespace-nowrap">{ label }</dt>
<dd className="m-0 break-words font-medium">{ (value || value === 0) ? value : <span className="opacity-40">-</span> }</dd>
</>
);
export const ModToolsUserView: FC<ModToolsUserViewProps> = props =>
{
const { onCloseClick = null, userId = null } = props;
const [ userInfo, setUserInfo ] = useState<ModeratorUserInfoData>(null);
const [ sendMessageVisible, setSendMessageVisible ] = useState(false);
const [ modActionVisible, setModActionVisible ] = useState(false);
const [ roomVisitsVisible, setRoomVisitsVisible ] = useState(false);
// Reactive presence: if the target user is currently in the room
// we're observing, they're online — irrespective of what the
// one-shot ModeratorUserInfoData.online said when the panel opened.
const roomUserList = useRoomUserListSnapshot();
const isPresentInCurrentRoom = useMemo(
() => roomUserList.some(user => user && (user.webID === userId)),
[ roomUserList, userId ]
);
const isOnline = isPresentInCurrentRoom || !!(userInfo && userInfo.online);
const presenceLabel = isPresentInCurrentRoom
? LocalizeText('modtools.userinfo.presence.in_room')
: (isOnline ? LocalizeText('modtools.userinfo.presence.online') : LocalizeText('modtools.userinfo.presence.offline'));
const presenceTitle = isPresentInCurrentRoom
? LocalizeText('modtools.userinfo.presence.in_room.title')
: (isOnline ? LocalizeText('modtools.userinfo.presence.online.title') : LocalizeText('modtools.userinfo.presence.offline.title'));
const presencePillClass = isPresentInCurrentRoom
? 'bg-emerald-100 text-emerald-700 border-emerald-200'
: isOnline
? 'bg-sky-100 text-sky-700 border-sky-200'
: 'bg-zinc-100 text-zinc-600 border-zinc-200';
const presenceDotClass = isPresentInCurrentRoom
? 'bg-emerald-500'
: isOnline
? 'bg-sky-500'
: 'bg-zinc-400';
const refresh = () => SendMessageComposer(new GetModeratorUserInfoMessageComposer(userId));
useMessageEvent<ModeratorUserInfoEvent>(ModeratorUserInfoEvent, event =>
{
const parser = event.getParser();
if(!parser || parser.data.userId !== userId) return;
setUserInfo(parser.data);
});
// Refresh counters (cfhCount / banCount / cautionCount /
// lastSanctionTime) after the moderator applies a sanction on THIS
// user — otherwise the table stays frozen on the values at panel
// open. Parser carries userId so we can filter precisely.
useMessageEvent<ModeratorActionResultMessageEvent>(ModeratorActionResultMessageEvent, event =>
{
const parser = event.getParser();
if(!parser || !parser.success || parser.userId !== userId) return;
refresh();
});
useEffect(() =>
{
SendMessageComposer(new GetModeratorUserInfoMessageComposer(userId));
}, [ userId ]);
if(!userInfo) return null;
return (
<>
<NitroCardView className="nitro-mod-tools-user min-w-[420px] max-w-[480px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ LocalizeText('modtools.userinfo.title', [ 'username' ], [ userInfo.userName ]) } onCloseClick={ () => onCloseClick() } />
<NitroCardContentView className="text-black" gap={ 2 }>
{/* Identity header: name + presence pill + manual refresh */}
<div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100">
<div className="flex flex-col grow min-w-0">
<Text bold className="truncate text-base leading-tight">{ userInfo.userName }</Text>
<Text className="opacity-60 text-xs truncate">ID #{ userInfo.userId }{ userInfo.userClassification ? ` · ${ userInfo.userClassification }` : '' }</Text>
</div>
<span
className={ `inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border ${ presencePillClass }` }
title={ presenceTitle }>
<span className={ `inline-block w-2 h-2 rounded-full ${ presenceDotClass }` } />
{ presenceLabel }
</span>
<button
className="inline-flex items-center justify-center w-7 h-7 rounded text-zinc-500 hover:text-sky-700 hover:bg-sky-100 transition-colors shrink-0"
onClick={ refresh }
title={ LocalizeText('modtools.userinfo.refresh') }>
<FaSync size={ 12 } />
</button>
</div>
{/* Moderation stat strip */}
<div className="flex gap-1.5">
<StatCard icon={ <FaExclamationTriangle size={ 10 } /> } label={ LocalizeText('modtools.userinfo.stat.cfh') } tone="warn" value={ userInfo.cfhCount } />
<StatCard icon={ <FaGavel size={ 10 } /> } label={ LocalizeText('modtools.userinfo.stat.cautions') } tone="warn" value={ userInfo.cautionCount } />
<StatCard icon={ <FaBan size={ 10 } /> } label={ LocalizeText('modtools.userinfo.stat.bans') } tone="danger" value={ userInfo.banCount } />
<StatCard icon={ <FaExchangeAlt size={ 10 } /> } label={ LocalizeText('modtools.userinfo.stat.trade.locks') } tone="danger" value={ userInfo.tradingLockCount } />
</div>
{/* Body sections */}
<div className="flex flex-col gap-2 max-h-[300px] overflow-auto pr-1">
<Section title={ LocalizeText('modtools.userinfo.section.account') }>
<Field label={ LocalizeText('modtools.userinfo.primaryEmailAddress') } value={ userInfo.primaryEmailAddress } />
<Field label={ LocalizeText('modtools.userinfo.registrationAgeInMinutes') } value={ FriendlyTime.format(userInfo.registrationAgeInMinutes * 60, '.ago', 2) } />
<Field label={ LocalizeText('modtools.userinfo.userClassification') } value={ userInfo.userClassification } />
</Section>
<Section title={ LocalizeText('modtools.userinfo.section.activity') }>
<Field label={ LocalizeText('modtools.userinfo.minutesSinceLastLogin') } value={ FriendlyTime.format(userInfo.minutesSinceLastLogin * 60, '.ago', 2) } />
<Field label={ LocalizeText('modtools.userinfo.lastPurchaseDate') } value={ userInfo.lastPurchaseDate } />
</Section>
<Section title={ LocalizeText('modtools.userinfo.section.sanctions') }>
<Field label={ LocalizeText('modtools.userinfo.abusiveCfhCount') } value={ userInfo.abusiveCfhCount } />
<Field label={ LocalizeText('modtools.userinfo.lastSanctionTime') } value={ userInfo.lastSanctionTime } />
<Field label={ LocalizeText('modtools.userinfo.identityRelatedBanCount') } value={ userInfo.identityRelatedBanCount } />
</Section>
<Section title={ LocalizeText('modtools.userinfo.section.trading') }>
<Field label={ LocalizeText('modtools.userinfo.tradingExpiryDate') } value={ userInfo.tradingExpiryDate } />
</Section>
</div>
{/* Action bar */}
<div className="grid grid-cols-2 gap-1.5 pt-1 border-t border-zinc-200">
<Button gap={ 1 } variant="secondary" onClick={ () => CreateLinkEvent(`mod-tools/open-user-chatlog/${ userId }`) }>
<FaCommentDots size={ 12 } /> { LocalizeText('modtools.userinfo.button.room.chat') }
</Button>
<Button gap={ 1 } variant="secondary" onClick={ () => setSendMessageVisible(prev => !prev) }>
<FaEnvelope size={ 12 } /> { LocalizeText('modtools.userinfo.button.send.message') }
</Button>
<Button gap={ 1 } variant="secondary" onClick={ () => setRoomVisitsVisible(prev => !prev) }>
<FaDoorOpen size={ 12 } /> { LocalizeText('modtools.userinfo.button.room.visits') }
</Button>
<Button gap={ 1 } variant="danger" onClick={ () => setModActionVisible(prev => !prev) }>
<FaGavel size={ 12 } /> { LocalizeText('modtools.userinfo.button.mod.action') }
</Button>
</div>
</NitroCardContentView>
</NitroCardView>
{ sendMessageVisible &&
<ModToolsUserSendMessageView user={ { userId: userId, username: userInfo.userName } } onCloseClick={ () => setSendMessageVisible(false) } /> }
{ modActionVisible &&
<ModToolsUserModActionView user={ { userId: userId, username: userInfo.userName } } onCloseClick={ () => setModActionVisible(false) } /> }
{ roomVisitsVisible &&
<ModToolsUserRoomVisitsView userId={ userId } onCloseClick={ () => setRoomVisitsVisible(false) } /> }
</>
);
};