mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
46daa96100
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.
207 lines
12 KiB
TypeScript
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) } /> }
|
|
</>
|
|
);
|
|
};
|