From f4d17ece16b113e0df0ac8422713448b8f2002b0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 20 May 2026 20:30:51 +0200 Subject: [PATCH 01/21] fix(draggable-window): pass null to useRef for TS6 Upstream V3.5.0 introduced a useRef() call with no initial value. TS6 (and the tsgo preview compiler) now require an explicit initial value for useRef typed against a DOM element. Pass null to match the React 19 RefObject shape. --- src/common/draggable-window/DraggableWindow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/draggable-window/DraggableWindow.tsx b/src/common/draggable-window/DraggableWindow.tsx index e3b8695..20a17e9 100644 --- a/src/common/draggable-window/DraggableWindow.tsx +++ b/src/common/draggable-window/DraggableWindow.tsx @@ -30,7 +30,7 @@ export const DraggableWindow: FC = props => const [isDragging, setIsDragging] = useState(false); const [isPositioned, setIsPositioned] = useState(false); const [dragHandler, setDragHandler] = useState(null); - const elementRef = useRef(); + const elementRef = useRef(null); const bringToTop = useCallback(() => { let zIndex = 400; for (const existingWindow of CURRENT_WINDOWS) From 49dfb43c2a64657b9312cdb76e8c490646533958 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 20 May 2026 20:30:52 +0200 Subject: [PATCH 02/21] test(catalog): include getNodesByOfferId in useCatalogActions contract Upstream Buy/Search fix added getNodesByOfferId to the useCatalogActions filter but didn't refresh the actions-shape contract test in useCatalog.filters.test.tsx. Add the key so the test reflects the current public surface. --- src/hooks/catalog/useCatalog.filters.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/catalog/useCatalog.filters.test.tsx b/src/hooks/catalog/useCatalog.filters.test.tsx index 59b6446..4c29653 100644 --- a/src/hooks/catalog/useCatalog.filters.test.tsx +++ b/src/hooks/catalog/useCatalog.filters.test.tsx @@ -142,6 +142,7 @@ describe('useCatalog filter contract', () => 'getBuilderFurniPlaceableStatus', 'getNodeById', 'getNodeByName', + 'getNodesByOfferId', 'openCatalogByType', 'openPageById', 'openPageByName', From ef313adcfaf07f204d76b7ca45e61f4abc26a1ae Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 20 May 2026 20:30:41 +0200 Subject: [PATCH 03/21] feat(mod-tools): reactive ModToolsUserView (online dot + refresh on sanction) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModToolsUserView used a one-shot ModeratorUserInfoData snapshot taken at panel-open time. Two consequences: - The online/offline icon (rendered next to userName) was frozen on the value at open. If the target user joined/left while the panel stayed open, the icon kept lying. - After the moderator applied a sanction via ModToolsUserModActionView the user info window stayed open with stale cfhCount / banCount / cautionCount / lastSanctionTime; you had to close and reopen to see the bump. Fix shape mirrors the ModToolsView selected-user dot from yesterday: - Read useRoomUserListSnapshot in the component (outside any useBetween scope — useSyncExternalStore constraint). If the target user is in the current room they're online; fall back to userInfo.online otherwise. Tooltip surfaces which path produced the value. - Subscribe to ModeratorActionResultMessageEvent (parser carries userId + success). On a successful action targeting THIS userId, re-send GetModeratorUserInfoMessageComposer so the table re-fetches. --- .../mod-tools/views/user/ModToolsUserView.tsx | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/src/components/mod-tools/views/user/ModToolsUserView.tsx b/src/components/mod-tools/views/user/ModToolsUserView.tsx index 6f65700..8a2620d 100644 --- a/src/components/mod-tools/views/user/ModToolsUserView.tsx +++ b/src/components/mod-tools/views/user/ModToolsUserView.tsx @@ -1,8 +1,8 @@ -import { CreateLinkEvent, GetModeratorUserInfoMessageComposer, ModeratorUserInfoData, ModeratorUserInfoEvent } from '@nitrots/nitro-renderer'; +import { CreateLinkEvent, GetModeratorUserInfoMessageComposer, ModeratorActionResultMessageEvent, ModeratorUserInfoData, ModeratorUserInfoEvent } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useState } from 'react'; import { FriendlyTime, LocalizeText, SendMessageComposer } from '../../../../api'; import { Button, Column, DraggableWindowPosition, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; -import { useMessageEvent } from '../../../../hooks'; +import { useMessageEvent, useRoomUserListSnapshot } from '../../../../hooks'; import { ModToolsUserModActionView } from './ModToolsUserModActionView'; import { ModToolsUserRoomVisitsView } from './ModToolsUserRoomVisitsView'; import { ModToolsUserSendMessageView } from './ModToolsUserSendMessageView'; @@ -20,6 +20,15 @@ export const ModToolsUserView: FC = props => 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 userProperties = useMemo(() => { @@ -95,6 +104,19 @@ export const ModToolsUserView: FC = props => 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, event => + { + const parser = event.getParser(); + + if(!parser || !parser.success || parser.userId !== userId) return; + + SendMessageComposer(new GetModeratorUserInfoMessageComposer(userId)); + }); + useEffect(() => { SendMessageComposer(new GetModeratorUserInfoMessageComposer(userId)); @@ -120,7 +142,7 @@ export const ModToolsUserView: FC = props => { property.value } { property.showOnline && - } + } ); From 7ade3986104363421f50e811e06f5885929f42f8 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 20 May 2026 20:39:55 +0200 Subject: [PATCH 04/21] refactor(mod-tools): redesign ModToolsUserView template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flat striped table with a structured layout that surfaces the moderation signal at a glance: Identity header Username + ID + classification, presence pill (In room / Online / Offline) with colour coding (emerald / sky / zinc) and a matching dot, plus a manual refresh button. The pill source-of-truth is useRoomUserListSnapshot for the "in room" case (reactive) falling back to userInfo.online — tooltip discloses which path produced the value. Stat strip Four counter cards in a single row — CFH, Cautions, Bans, Trade locks — tinted warn (amber) or danger (rose) when value > 0, neutral (zinc) when zero. Big tabular-nums numbers so the moderator sees a problem account immediately without parsing rows. Sectioned body Account / Activity / Sanctions / Trading as labelled dl groups (grid-cols-[auto_1fr]) replacing the 14-row striped table. Missing values render as a dim em-dash instead of an empty cell. Action bar 2×2 button grid with react-icons/fa glyphs (FaCommentDots, FaEnvelope, FaDoorOpen, FaGavel). Mod Action keeps variant="danger" so the destructive action stands out from the three info actions (variant="secondary"). No behaviour changes — the same composer / event listeners / sub-views are wired up; this is a presentation rewrite. Card grows to min-w-[420px] max-w-[480px] to fit the new layout without horizontal scroll on mod laptops. --- .../mod-tools/views/user/ModToolsUserView.tsx | 229 ++++++++++-------- 1 file changed, 126 insertions(+), 103 deletions(-) diff --git a/src/components/mod-tools/views/user/ModToolsUserView.tsx b/src/components/mod-tools/views/user/ModToolsUserView.tsx index 8a2620d..8b03395 100644 --- a/src/components/mod-tools/views/user/ModToolsUserView.tsx +++ b/src/components/mod-tools/views/user/ModToolsUserView.tsx @@ -1,7 +1,8 @@ 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, Column, DraggableWindowPosition, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; +import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; import { useMessageEvent, useRoomUserListSnapshot } from '../../../../hooks'; import { ModToolsUserModActionView } from './ModToolsUserModActionView'; import { ModToolsUserRoomVisitsView } from './ModToolsUserRoomVisitsView'; @@ -13,6 +14,52 @@ interface ModToolsUserViewProps onCloseClick: () => void; } +interface StatCardProps +{ + icon: React.ReactNode; + label: string; + value: number | string; + tone?: 'neutral' | 'warn' | 'danger'; +} + +const StatCard: FC = ({ 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 ( +
+
+ { icon } + { label } +
+
{ value }
+
+ ); +}; + +const Section: FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => ( +
+
{ title }
+
+ { children } +
+
+); + +const Field: FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => ( + <> +
{ label }
+
{ value || }
+ +); + export const ModToolsUserView: FC = props => { const { onCloseClick = null, userId = null } = props; @@ -29,71 +76,19 @@ export const ModToolsUserView: FC = props => [ roomUserList, userId ] ); const isOnline = isPresentInCurrentRoom || !!(userInfo && userInfo.online); + const presenceLabel = isPresentInCurrentRoom ? 'In room' : (isOnline ? 'Online' : 'Offline'); + 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 userProperties = useMemo(() => - { - if(!userInfo) return null; - - return [ - { - localeKey: 'modtools.userinfo.userName', - value: userInfo.userName, - showOnline: true - }, - { - localeKey: 'modtools.userinfo.cfhCount', - value: userInfo.cfhCount.toString() - }, - { - localeKey: 'modtools.userinfo.abusiveCfhCount', - value: userInfo.abusiveCfhCount.toString() - }, - { - localeKey: 'modtools.userinfo.cautionCount', - value: userInfo.cautionCount.toString() - }, - { - localeKey: 'modtools.userinfo.banCount', - value: userInfo.banCount.toString() - }, - { - localeKey: 'modtools.userinfo.lastSanctionTime', - value: userInfo.lastSanctionTime - }, - { - localeKey: 'modtools.userinfo.tradingLockCount', - value: userInfo.tradingLockCount.toString() - }, - { - localeKey: 'modtools.userinfo.tradingExpiryDate', - value: userInfo.tradingExpiryDate - }, - { - localeKey: 'modtools.userinfo.minutesSinceLastLogin', - value: FriendlyTime.format(userInfo.minutesSinceLastLogin * 60, '.ago', 2) - }, - { - localeKey: 'modtools.userinfo.lastPurchaseDate', - value: userInfo.lastPurchaseDate - }, - { - localeKey: 'modtools.userinfo.primaryEmailAddress', - value: userInfo.primaryEmailAddress - }, - { - localeKey: 'modtools.userinfo.identityRelatedBanCount', - value: userInfo.identityRelatedBanCount.toString() - }, - { - localeKey: 'modtools.userinfo.registrationAgeInMinutes', - value: FriendlyTime.format(userInfo.registrationAgeInMinutes * 60, '.ago', 2) - }, - { - localeKey: 'modtools.userinfo.userClassification', - value: userInfo.userClassification - } - ]; - }, [ userInfo ]); + const refresh = () => SendMessageComposer(new GetModeratorUserInfoMessageComposer(userId)); useMessageEvent(ModeratorUserInfoEvent, event => { @@ -114,7 +109,7 @@ export const ModToolsUserView: FC = props => if(!parser || !parser.success || parser.userId !== userId) return; - SendMessageComposer(new GetModeratorUserInfoMessageComposer(userId)); + refresh(); }); useEffect(() => @@ -126,45 +121,73 @@ export const ModToolsUserView: FC = props => return ( <> - + onCloseClick() } /> - - - - - - { userProperties.map( (property, index) => - { + + {/* Identity header: name + presence pill + manual refresh */} +
+
+ { userInfo.userName } + ID #{ userInfo.userId }{ userInfo.userClassification ? ` · ${ userInfo.userClassification }` : '' } +
+ + + { presenceLabel } + + +
- return ( -
- - - - ); - }) } - -
{ LocalizeText(property.localeKey) } - { property.value } - { property.showOnline && - } -
-
- - - - - - -
+ {/* Moderation stat strip */} +
+ } label="CFH" tone="warn" value={ userInfo.cfhCount } /> + } label="Cautions" tone="warn" value={ userInfo.cautionCount } /> + } label="Bans" tone="danger" value={ userInfo.banCount } /> + } label="Trade locks" tone="danger" value={ userInfo.tradingLockCount } /> +
+ + {/* Body sections */} +
+
+ + + +
+
+ + +
+
+ + + +
+
+ +
+
+ + {/* Action bar */} +
+ + + + +
{ sendMessageVisible && From d3552a0948ec5166a6906b8a33a2613e00164403 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 20 May 2026 20:48:24 +0200 Subject: [PATCH 05/21] refactor(mod-tools): redesign all related windows with shared visual language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the visual language introduced in ModToolsUserView yesterday to every other ModTools window. The design tokens used consistently: emerald — present in current room / positive state sky — online / informational / current selection zinc — neutral / disabled amber — warn-level (CFH, alerts, cautions) rose — danger (bans, releases, abusive) Files redesigned: ModToolsRoomView Identity header with FaDoorOpen, room name + ID, owner-present pill (emerald/zinc), manual refresh button. Stat strip: user count (sky) + clickable owner name (zinc) opening user info. Quick actions (Visit / Chatlog) in a 2-col grid. Moderate panel collapsed into an amber-tinted card with the 3 toggles + textarea + two CTAs (Send Caution=danger, Send Alert=warning). CTAs disabled until a message is typed AND the room info has loaded. ModToolsUserModActionView Numbered 3-step form (CFH topic → sanction → optional message). Live preview row showing the chosen topic + sanction as tone-coded pills (amber/sky/rose/orange/fuchsia/zinc by action type). Primary CTA = Default Sanction, success CTA = Apply Sanction, both disabled until the required selections are made. ModToolsUserSendMessageView Recipient header with FaEnvelope and the username, autofocused textarea, char counter, single full-width Send button gated on non-empty message. ModToolsUserRoomVisitsView Header strip with entry count badge, three-column grid (time / room name / visit button), monospace timestamps, hover row highlight, empty state with FaDoorOpen icon. ModToolsUserChatlogView / ModToolsChatlogView / CfhChatlogView Loading state with spinner instead of returning null. Cards grow to min-w-[460px] max-w-[520px] max-h-[500px] for usable chatlog area. ChatlogView Replace Bootstrap-ish striped table with a CSS grid (60px / 120px / 1fr). Room-info separator rendered as a sky card with Visit/Tools pill buttons. Per-row hover + even-row tint; highlighted rows (hasHighlighting) get an amber wash. Username is a button opening user info via existing link event. Empty state with FaCommentDots. ModToolsTicketsView Tabs get icons (FaListUl / FaUserCheck / FaCheckSquare) and inline count badges (amber/sky/zinc) so the moderator sees the queue size at a glance. ticket bucket filtering memoized off the tickets array. ModToolsOpenIssuesTabView / MyIssuesTabView / PickedIssuesTabView Same CSS grid table style. Category renders as a tone-coded pill (Open=amber, Mine=sky, All picked=zinc). Action buttons get icons (FaHandPointer Pick, FaTools Handle, FaSignOutAlt Release). Empty state with FaInbox. ModToolsIssueInfoView Card header with category + topic pills. Details rendered as a dl grid instead of a striped table. Caller / Reported names as inline link buttons with external-link icon. Chatlog toggle is full-width secondary. Resolution buttons in a 3-col grid with intent colours (success=Resolved, dark=Useless, danger=Abusive) + a separate Release-to-queue button on its own row so it isn't confused with the resolutions. No behaviour changes — all composers, message events, parent state hookups, and sanction validation paths are unchanged. This is purely a presentation pass. typecheck + vitest 214/214 + lint:hooks all clean. --- .../mod-tools/views/chatlog/ChatlogView.tsx | 96 ++++++----- .../views/room/ModToolsChatlogView.tsx | 7 +- .../mod-tools/views/room/ModToolsRoomView.tsx | 126 +++++++++----- .../views/tickets/CfhChatlogView.tsx | 16 +- .../views/tickets/ModToolsIssueInfoView.tsx | 125 ++++++++------ .../views/tickets/ModToolsMyIssuesTabView.tsx | 72 ++++---- .../tickets/ModToolsOpenIssuesTabView.tsx | 62 ++++--- .../tickets/ModToolsPickedIssuesTabView.tsx | 54 +++--- .../views/tickets/ModToolsTicketsView.tsx | 72 +++++--- .../views/user/ModToolsUserChatlogView.tsx | 15 +- .../views/user/ModToolsUserModActionView.tsx | 161 +++++++++++------- .../views/user/ModToolsUserRoomVisitsView.tsx | 65 ++++--- .../user/ModToolsUserSendMessageView.tsx | 47 ++++- 13 files changed, 580 insertions(+), 338 deletions(-) diff --git a/src/components/mod-tools/views/chatlog/ChatlogView.tsx b/src/components/mod-tools/views/chatlog/ChatlogView.tsx index 63e5201..118d9d8 100644 --- a/src/components/mod-tools/views/chatlog/ChatlogView.tsx +++ b/src/components/mod-tools/views/chatlog/ChatlogView.tsx @@ -1,7 +1,8 @@ import { ChatRecordData, CreateLinkEvent } from '@nitrots/nitro-renderer'; import { FC, useMemo } from 'react'; +import { FaCommentDots, FaDoorOpen, FaSignInAlt, FaTools } from 'react-icons/fa'; import { TryVisitRoom } from '../../../../api'; -import { Button, Column, Flex, Grid, InfiniteScroll, Text } from '../../../../common'; +import { Column, InfiniteScroll } from '../../../../common'; import { useModTools } from '../../../../hooks'; import { ChatlogRecord } from './ChatlogRecord'; @@ -43,46 +44,61 @@ export const ChatlogView: FC = props => return results; }, [ records ]); - const RoomInfo = (props: { roomId: number, roomName: string }) => - { - return ( - - { props.roomName } -
- - -
-
- ); - }; + const totalMessages = useMemo( + () => allRecords.filter(r => !r.isRoomInfo).length, + [ allRecords ] + ); + + const RoomInfo = (props: { roomId: number, roomName: string }) => ( +
+ +
{ props.roomName }
+
+ + +
+
+ ); + + const isEmpty = !records || records.length === 0 || totalMessages === 0; return ( - <> - - - -
Time
-
User
-
Message
-
-
- { (records && (records.length > 0)) && - - { - return ( - <> - { row.isRoomInfo && - } - { !row.isRoomInfo && - - { row.timestamp } - CreateLinkEvent(`mod-tools/open-user-info/${ row.habboId }`) }>{ row.username } - { row.message } - } - - ); - } } rows={ allRecords } /> } -
- + + {/* Column headers */} +
+
Time
+
User
+
Message
+
+ { isEmpty + ?
+ + No messages +
+ : + { + if(row.isRoomInfo) return ; + + return ( +
+ { row.timestamp } + + { row.message } +
+ ); + } } rows={ allRecords } /> } +
); }; diff --git a/src/components/mod-tools/views/room/ModToolsChatlogView.tsx b/src/components/mod-tools/views/room/ModToolsChatlogView.tsx index 65061be..f668301 100644 --- a/src/components/mod-tools/views/room/ModToolsChatlogView.tsx +++ b/src/components/mod-tools/views/room/ModToolsChatlogView.tsx @@ -26,11 +26,10 @@ export const ModToolsChatlogView: FC = props => if(!roomChatlog) return null; return ( - + - - { roomChatlog && - } + + ); diff --git a/src/components/mod-tools/views/room/ModToolsRoomView.tsx b/src/components/mod-tools/views/room/ModToolsRoomView.tsx index 37d9fc5..b178141 100644 --- a/src/components/mod-tools/views/room/ModToolsRoomView.tsx +++ b/src/components/mod-tools/views/room/ModToolsRoomView.tsx @@ -1,7 +1,8 @@ import { CreateLinkEvent, GetModeratorRoomInfoMessageComposer, ModerateRoomMessageComposer, ModeratorActionMessageComposer, ModeratorRoomInfoEvent } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; +import { FaBullhorn, FaCommentDots, FaDoorOpen, FaExclamationTriangle, FaSignInAlt, FaSync, FaUserShield, FaUsers } from 'react-icons/fa'; import { SendMessageComposer, TryVisitRoom } from '../../../../api'; -import { Button, Column, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; +import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; import { useMessageEvent } from '../../../../hooks'; interface ModToolsRoomViewProps @@ -25,7 +26,9 @@ export const ModToolsRoomView: FC = props => const [ changeRoomName, setChangeRoomName ] = useState(false); const [ message, setMessage ] = useState(''); - const handleClick = (action: string, value?: string) => + const refresh = () => SendMessageComposer(new GetModeratorRoomInfoMessageComposer(roomId)); + + const handleClick = (action: string) => { if(!action) return; @@ -66,55 +69,102 @@ export const ModToolsRoomView: FC = props => SendMessageComposer(new GetModeratorRoomInfoMessageComposer(roomId)); setInfoRequested(true); - }, [ roomId, infoRequested, setInfoRequested ]); + }, [ roomId, infoRequested ]); + + const isLoaded = loadedRoomId !== null; + const hasMessage = message.trim().length > 0; + const ownerPillClass = ownerInRoom + ? 'bg-emerald-100 text-emerald-700 border-emerald-200' + : 'bg-zinc-100 text-zinc-600 border-zinc-200'; + const ownerDotClass = ownerInRoom ? 'bg-emerald-500' : 'bg-zinc-400'; return ( - - onCloseClick() } /> + + onCloseClick() } /> - { name && -
- { name } + {/* Identity header */} +
+ +
+ { name || 'Loading…' } + Room #{ roomId }
- } -
- -
- Owner: - CreateLinkEvent(`mod-tools/open-user-info/${ ownerId }`) }>{ ownerName } + + + { ownerInRoom ? 'Owner here' : 'Owner away' } + + +
+ + {/* Stat strip */} +
+
+
+ Users
-
- Users in room: - { usersInRoom } +
{ usersInRoom }
+
+
+
+ Owner
-
- Owner here: - { ownerInRoom ? 'Yes' : 'No' } +
ownerId && CreateLinkEvent(`mod-tools/open-user-info/${ ownerId }`) } + title={ ownerName ? `Open ${ ownerName }'s info` : '' }> + { ownerName || '—' }
- -
- -
- -
+ + {/* Quick actions */} +
+ + +
+ + {/* Moderate panel */} +
+
+ Moderate room +
+
-
+ Kick everyone out + +
-
+ Enable the doorbell + + + -
- -
diff --git a/src/components/mod-tools/views/tickets/CfhChatlogView.tsx b/src/components/mod-tools/views/tickets/CfhChatlogView.tsx index 33cc52d..d691c0f 100644 --- a/src/components/mod-tools/views/tickets/CfhChatlogView.tsx +++ b/src/components/mod-tools/views/tickets/CfhChatlogView.tsx @@ -1,7 +1,8 @@ import { CfhChatlogData, CfhChatlogEvent, GetCfhChatlogMessageComposer } from '@nitrots/nitro-renderer'; import { FC } from 'react'; +import { FaSpinner } from 'react-icons/fa'; import { useNitroQuery } from '../../../../api/nitro-query'; -import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; +import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; import { ChatlogView } from '../chatlog/ChatlogView'; interface CfhChatlogViewProps @@ -24,10 +25,15 @@ export const CfhChatlogView: FC = props => }); return ( - - - - { chatlogData && } + + + + { chatlogData + ? + :
+ + Loading chatlog… +
}
); diff --git a/src/components/mod-tools/views/tickets/ModToolsIssueInfoView.tsx b/src/components/mod-tools/views/tickets/ModToolsIssueInfoView.tsx index 7444a73..f5fb954 100644 --- a/src/components/mod-tools/views/tickets/ModToolsIssueInfoView.tsx +++ b/src/components/mod-tools/views/tickets/ModToolsIssueInfoView.tsx @@ -1,7 +1,8 @@ import { CloseIssuesMessageComposer, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useState } from 'react'; +import { FaBan, FaCheck, FaCommentDots, FaExternalLinkAlt, FaSignOutAlt, FaTrashAlt } from 'react-icons/fa'; import { GetIssueCategoryName, LocalizeText, SendMessageComposer } from '../../../../api'; -import { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; +import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; import { useModTools } from '../../../../hooks'; import { CfhChatlogView } from './CfhChatlogView'; @@ -11,76 +12,102 @@ interface IssueInfoViewProps onIssueInfoClosed(issueId: number): void; } +const Field: FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( + <> +
{ label }
+
{ children || }
+ +); + export const ModToolsIssueInfoView: FC = props => { const { issueId = null, onIssueInfoClosed = null } = props; - const [ cfhChatlogOpen, setcfhChatlogOpen ] = useState(false); + const [ cfhChatlogOpen, setCfhChatlogOpen ] = useState(false); const { tickets = [], openUserInfo = null } = useModTools(); const ticket = tickets.find(issue => (issue.issueId === issueId)); - const releaseIssue = (issueId: number) => + const releaseIssue = () => { SendMessageComposer(new ReleaseIssuesMessageComposer([ issueId ])); - onIssueInfoClosed(issueId); }; const closeIssue = (resolutionType: number) => { SendMessageComposer(new CloseIssuesMessageComposer([ issueId ], resolutionType)); - onIssueInfoClosed(issueId); }; + if(!ticket) return null; + return ( <> - - onIssueInfoClosed(issueId) } /> - - Issue Information - - - - - - - - - - - - - - - - - - - - - - - - - -
Source{ GetIssueCategoryName(ticket.categoryId) }
Category{ LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) }
Description{ ticket.message }
Caller - openUserInfo(ticket.reporterUserId) }>{ ticket.reporterUserName } -
Reported User - openUserInfo(ticket.reportedUserId) }>{ ticket.reportedUserName } -
-
- - - - - - - -
+ + onIssueInfoClosed(issueId) } /> + + {/* Issue header */} +
+ +
+
Issue #{ issueId }
+
{ GetIssueCategoryName(ticket.categoryId) }
+
+ + { LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) } + +
+ + {/* Details */} +
+
Details
+
+ { GetIssueCategoryName(ticket.categoryId) } + { LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) } + { ticket.message } + + + + + + +
+
+ + {/* Tools */} + + + {/* Resolution buttons */} +
+
Resolve as
+
+ + + +
+ +
{ cfhChatlogOpen && - setcfhChatlogOpen(false) }/> } + setCfhChatlogOpen(false) } /> } ); }; diff --git a/src/components/mod-tools/views/tickets/ModToolsMyIssuesTabView.tsx b/src/components/mod-tools/views/tickets/ModToolsMyIssuesTabView.tsx index 9aaa441..3aa9025 100644 --- a/src/components/mod-tools/views/tickets/ModToolsMyIssuesTabView.tsx +++ b/src/components/mod-tools/views/tickets/ModToolsMyIssuesTabView.tsx @@ -1,7 +1,7 @@ import { IssueMessageData, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useRef } from 'react'; -import { SendMessageComposer } from '../../../../api'; -import { Button, Column, Grid } from '../../../../common'; +import { FaClock, FaInbox, FaSignOutAlt, FaTools, FaUser } from 'react-icons/fa'; +import { GetIssueCategoryName, SendMessageComposer } from '../../../../api'; interface ModToolsMyIssuesTabViewProps { @@ -24,35 +24,45 @@ export const ModToolsMyIssuesTabView: FC = props = setTimeout(() => pendingReleasesRef.current.delete(issueId), 2000); }; + const isEmpty = !myIssues || myIssues.length === 0; + return ( - - - -
Type
-
Room/Player
-
Opened
-
-
-
-
- - { myIssues && (myIssues.length > 0) && myIssues.map(issue => - { - return ( - -
{ issue.categoryId }
-
{ issue.reportedUserName }
-
{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }
-
- -
-
- -
-
- ); - }) } -
-
+
+
+
Type
+
Reported
+
Opened
+
+
+
+ { isEmpty + ?
+ + No issues picked by you +
+ :
+ { myIssues.map(issue => ( +
+ + { GetIssueCategoryName(issue.categoryId) } + + { issue.reportedUserName } + + { new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() } + + + +
+ )) } +
} +
); }; diff --git a/src/components/mod-tools/views/tickets/ModToolsOpenIssuesTabView.tsx b/src/components/mod-tools/views/tickets/ModToolsOpenIssuesTabView.tsx index 387580b..5fb4596 100644 --- a/src/components/mod-tools/views/tickets/ModToolsOpenIssuesTabView.tsx +++ b/src/components/mod-tools/views/tickets/ModToolsOpenIssuesTabView.tsx @@ -1,7 +1,7 @@ import { IssueMessageData, PickIssuesMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useRef } from 'react'; -import { SendMessageComposer } from '../../../../api'; -import { Button, Column, Grid } from '../../../../common'; +import { FaClock, FaHandPointer, FaInbox, FaUser } from 'react-icons/fa'; +import { GetIssueCategoryName, SendMessageComposer } from '../../../../api'; interface ModToolsOpenIssuesTabViewProps { @@ -23,31 +23,39 @@ export const ModToolsOpenIssuesTabView: FC = pro setTimeout(() => pendingPicksRef.current.delete(issueId), 2000); }; + const isEmpty = !openIssues || openIssues.length === 0; + return ( - - - -
Type
-
Room/Player
-
Opened
-
-
-
- - { openIssues && (openIssues.length > 0) && openIssues.map(issue => - { - return ( - -
{ issue.categoryId }
-
{ issue.reportedUserName }
-
{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }
-
- -
-
- ); - }) } -
-
+
+
+
Type
+
Reported
+
Opened
+
+
+ { isEmpty + ?
+ + No open issues +
+ :
+ { openIssues.map(issue => ( +
+ + { GetIssueCategoryName(issue.categoryId) } + + { issue.reportedUserName } + + { new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() } + + +
+ )) } +
} +
); }; diff --git a/src/components/mod-tools/views/tickets/ModToolsPickedIssuesTabView.tsx b/src/components/mod-tools/views/tickets/ModToolsPickedIssuesTabView.tsx index ca6003e..8c22b10 100644 --- a/src/components/mod-tools/views/tickets/ModToolsPickedIssuesTabView.tsx +++ b/src/components/mod-tools/views/tickets/ModToolsPickedIssuesTabView.tsx @@ -1,6 +1,7 @@ import { IssueMessageData } from '@nitrots/nitro-renderer'; import { FC } from 'react'; -import { Column, Grid } from '../../../../common'; +import { FaClock, FaInbox, FaUser, FaUserShield } from 'react-icons/fa'; +import { GetIssueCategoryName } from '../../../../api'; interface ModToolsPickedIssuesTabViewProps { @@ -10,30 +11,35 @@ interface ModToolsPickedIssuesTabViewProps export const ModToolsPickedIssuesTabView: FC = props => { const { pickedIssues = null } = props; + const isEmpty = !pickedIssues || pickedIssues.length === 0; return ( - - - -
Type
-
Room/Player
-
Opened
-
Picker
-
-
- - { pickedIssues && (pickedIssues.length > 0) && pickedIssues.map(issue => - { - return ( - -
{ issue.categoryId }
-
{ issue.reportedUserName }
-
{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }
-
{ issue.pickerUserName }
-
- ); - }) } -
-
+
+
+
Type
+
Reported
+
Opened
+
Picker
+
+ { isEmpty + ?
+ + No picked issues +
+ :
+ { pickedIssues.map(issue => ( +
+ + { GetIssueCategoryName(issue.categoryId) } + + { issue.reportedUserName } + + { new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() } + + { issue.pickerUserName } +
+ )) } +
} +
); }; diff --git a/src/components/mod-tools/views/tickets/ModToolsTicketsView.tsx b/src/components/mod-tools/views/tickets/ModToolsTicketsView.tsx index ab7ac35..5bb9c45 100644 --- a/src/components/mod-tools/views/tickets/ModToolsTicketsView.tsx +++ b/src/components/mod-tools/views/tickets/ModToolsTicketsView.tsx @@ -1,5 +1,6 @@ import { GetSessionDataManager, IssueMessageData } from '@nitrots/nitro-renderer'; -import { FC, useState } from 'react'; +import { FC, useMemo, useState } from 'react'; +import { FaCheckSquare, FaListUl, FaUserCheck } from 'react-icons/fa'; import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../../../common'; import { useModTools } from '../../../../hooks'; import { ModToolsIssueInfoView } from './ModToolsIssueInfoView'; @@ -12,11 +13,30 @@ interface ModToolsTicketsViewProps onCloseClick: () => void; } -const TABS: string[] = [ - 'Open Issues', - 'My Issues', - 'Picked Issues' -]; +interface TabBadgeProps +{ + label: string; + count: number; + icon: React.ReactNode; + tone: 'amber' | 'sky' | 'zinc'; +} + +const TONE_MAP: Record = { + amber: 'bg-amber-500 text-white', + sky: 'bg-sky-500 text-white', + zinc: 'bg-zinc-400 text-white' +}; + +const TabLabel: FC = ({ label, count, icon, tone }) => ( + + { icon } + { label } + { count > 0 && + + { count > 99 ? '99+' : count } + } + +); export const ModToolsTicketsView: FC = props => { @@ -25,9 +45,15 @@ export const ModToolsTicketsView: FC = props => const [ issueInfoWindows, setIssueInfoWindows ] = useState([]); const { tickets = [] } = useModTools(); - const openIssues = tickets.filter(issue => issue.state === IssueMessageData.STATE_OPEN); - const myIssues = tickets.filter(issue => (issue.state === IssueMessageData.STATE_PICKED) && (issue.pickerUserId === GetSessionDataManager().userId)); - const pickedIssues = tickets.filter(issue => issue.state === IssueMessageData.STATE_PICKED); + const { openIssues, myIssues, pickedIssues } = useMemo(() => + { + const ownId = GetSessionDataManager()?.userId; + return { + openIssues: tickets.filter(issue => issue.state === IssueMessageData.STATE_OPEN), + myIssues: tickets.filter(issue => (issue.state === IssueMessageData.STATE_PICKED) && (issue.pickerUserId === ownId)), + pickedIssues: tickets.filter(issue => issue.state === IssueMessageData.STATE_PICKED) + }; + }, [ tickets ]); const closeIssue = (issueId: number) => { @@ -56,32 +82,34 @@ export const ModToolsTicketsView: FC = props => }); }; - const CurrentTabComponent = () => + const renderTab = () => { switch(currentTab) { - case 0: return ; - case 1: return ; - case 2: return ; + case 0: return ; + case 1: return ; + case 2: return ; } - return null; }; return ( <> - + - { TABS.map((tab, index) => - { - return ( setCurrentTab(index) }> - { tab } - ); - }) } + setCurrentTab(0) }> + } tone="amber" /> + + setCurrentTab(1) }> + } tone="sky" /> + + setCurrentTab(2) }> + } tone="zinc" /> + - + { renderTab() } { issueInfoWindows && (issueInfoWindows.length > 0) && issueInfoWindows.map(issueId => ) } diff --git a/src/components/mod-tools/views/user/ModToolsUserChatlogView.tsx b/src/components/mod-tools/views/user/ModToolsUserChatlogView.tsx index acae308..a18107e 100644 --- a/src/components/mod-tools/views/user/ModToolsUserChatlogView.tsx +++ b/src/components/mod-tools/views/user/ModToolsUserChatlogView.tsx @@ -1,5 +1,6 @@ import { ChatRecordData, GetUserChatlogMessageComposer, UserChatlogEvent } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; +import { FaSpinner } from 'react-icons/fa'; import { SendMessageComposer } from '../../../../api'; import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; import { useMessageEvent } from '../../../../hooks'; @@ -33,11 +34,15 @@ export const ModToolsUserChatlogView: FC = props = }, [ userId ]); return ( - - - - { userChatlog && - } + + + + { userChatlog + ? + :
+ + Loading chatlog… +
}
); diff --git a/src/components/mod-tools/views/user/ModToolsUserModActionView.tsx b/src/components/mod-tools/views/user/ModToolsUserModActionView.tsx index 1bea10a..97b969f 100644 --- a/src/components/mod-tools/views/user/ModToolsUserModActionView.tsx +++ b/src/components/mod-tools/views/user/ModToolsUserModActionView.tsx @@ -1,7 +1,8 @@ import { CallForHelpTopicData, DefaultSanctionMessageComposer, ModAlertMessageComposer, ModBanMessageComposer, ModKickMessageComposer, ModMessageMessageComposer, ModMuteMessageComposer, ModTradingLockMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useMemo, useRef, useState } from 'react'; +import { FaBan, FaBolt, FaEnvelope, FaExclamationTriangle, FaGavel, FaUserSlash, FaVolumeMute } from 'react-icons/fa'; import { ISelectedUser, LocalizeText, ModActionDefinition, NotificationAlertType, SendMessageComposer } from '../../../../api'; -import { Button, DraggableWindowPosition, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; +import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; import { useModTools, useNotification } from '../../../../hooks'; interface ModToolsUserModActionViewProps @@ -25,6 +26,24 @@ const MOD_ACTION_DEFINITIONS = [ new ModActionDefinition(105, 'Message', ModActionDefinition.MESSAGE, 0, 0), ]; +const ACTION_ICONS: Record = { + [ModActionDefinition.ALERT]: , + [ModActionDefinition.MUTE]: , + [ModActionDefinition.BAN]: , + [ModActionDefinition.KICK]: , + [ModActionDefinition.TRADE_LOCK]: , + [ModActionDefinition.MESSAGE]: , +}; + +const ACTION_TONE: Record = { + [ModActionDefinition.ALERT]: 'bg-amber-100 text-amber-800 border-amber-200', + [ModActionDefinition.MUTE]: 'bg-sky-100 text-sky-800 border-sky-200', + [ModActionDefinition.BAN]: 'bg-rose-100 text-rose-800 border-rose-200', + [ModActionDefinition.KICK]: 'bg-orange-100 text-orange-800 border-orange-200', + [ModActionDefinition.TRADE_LOCK]: 'bg-fuchsia-100 text-fuchsia-800 border-fuchsia-200', + [ModActionDefinition.MESSAGE]: 'bg-zinc-100 text-zinc-800 border-zinc-200', +}; + export const ModToolsUserModActionView: FC = props => { const { user = null, onCloseClick = null } = props; @@ -50,26 +69,20 @@ export const ModToolsUserModActionView: FC = pro return values; }, [ cfhCategories ]); - const sendAlert = (message: string) => simpleAlert(message, NotificationAlertType.DEFAULT, null, null, 'Error'); + const sendAlert = (m: string) => simpleAlert(m, NotificationAlertType.DEFAULT, null, null, 'Error'); const sendDefaultSanction = () => { if(isSendingRef.current) return; - let errorMessage: string = null; - const category = topics[selectedTopic]; - if(selectedTopic === -1) errorMessage = 'You must select a CFH topic'; - - if(errorMessage) return sendAlert(errorMessage); + if(selectedTopic === -1) return sendAlert('You must select a CFH topic'); const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message; isSendingRef.current = true; - SendMessageComposer(new DefaultSanctionMessageComposer(user.userId, selectedTopic, messageOrDefault)); - onCloseClick(); }; @@ -78,7 +91,6 @@ export const ModToolsUserModActionView: FC = pro if(isSendingRef.current) return; let errorMessage: string = null; - const category = topics[selectedTopic]; const sanction = MOD_ACTION_DEFINITIONS[selectedAction]; @@ -87,25 +99,14 @@ export const ModToolsUserModActionView: FC = pro else if(!category) errorMessage = 'You must select a CFH topic'; else if(!sanction) errorMessage = 'You must select a sanction'; - if(errorMessage) - { - sendAlert(errorMessage); - - return; - } + if(errorMessage) return sendAlert(errorMessage); const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message; switch(sanction.actionType) { case ModActionDefinition.ALERT: { - if(!settings.alertPermission) - { - sendAlert('You have insufficient permissions'); - - return; - } - + if(!settings.alertPermission) return sendAlert('You have insufficient permissions'); SendMessageComposer(new ModAlertMessageComposer(user.userId, messageOrDefault, category.id)); break; } @@ -113,72 +114,106 @@ export const ModToolsUserModActionView: FC = pro SendMessageComposer(new ModMuteMessageComposer(user.userId, messageOrDefault, category.id)); break; case ModActionDefinition.BAN: { - if(!settings.banPermission) - { - sendAlert('You have insufficient permissions'); - - return; - } - + if(!settings.banPermission) return sendAlert('You have insufficient permissions'); SendMessageComposer(new ModBanMessageComposer(user.userId, messageOrDefault, category.id, selectedAction, (sanction.actionId === 106))); break; } case ModActionDefinition.KICK: { - if(!settings.kickPermission) - { - sendAlert('You have insufficient permissions'); - return; - } - + if(!settings.kickPermission) return sendAlert('You have insufficient permissions'); SendMessageComposer(new ModKickMessageComposer(user.userId, messageOrDefault, category.id)); break; } case ModActionDefinition.TRADE_LOCK: { const numSeconds = (sanction.actionLengthHours * 60); - SendMessageComposer(new ModTradingLockMessageComposer(user.userId, messageOrDefault, numSeconds, category.id)); break; } case ModActionDefinition.MESSAGE: { - if(message.trim().length === 0) - { - sendAlert('Please write a message to user'); - - return; - } - + if(message.trim().length === 0) return sendAlert('Please write a message to user'); SendMessageComposer(new ModMessageMessageComposer(user.userId, message, category.id)); break; } } isSendingRef.current = true; - onCloseClick(); }; if(!user) return null; + const selectedSanction = selectedAction >= 0 ? MOD_ACTION_DEFINITIONS[selectedAction] : null; + const selectedTopicName = selectedTopic >= 0 && topics[selectedTopic] ? LocalizeText('help.cfh.topic.' + topics[selectedTopic].id) : null; + const sanctionTone = selectedSanction ? ACTION_TONE[selectedSanction.actionType] : ''; + const sanctionIcon = selectedSanction ? ACTION_ICONS[selectedSanction.actionType] : null; + const canSubmit = (selectedTopic !== -1); + return ( - - onCloseClick() } /> - - - -
- Optional message type, overrides default - - + + {/* Recipient header */} +
+ +
+
Message to
+
+ + { user.username } +
+
+
+ + {/* Body */} +
+ +