feat(mod-tools): reactive box + bug fixes in useModTools

ModToolsView
- Subscribe to useRoomUserListSnapshot so the selected user's
  "still in room" state is reactive — green dot when the user is
  in the current room, gray dot when they've left. Previously the
  selection was a static capture at click time.
- Add an inline X to clear the selected-user slot without having
  to click a different avatar.
- Report Tool button shows a count badge for OPEN tickets
  (IssueMessageData.STATE_OPEN) so a new ticket arriving while
  the panel is open is visible immediately. Caps display at 99+.
- Tooltip on the room-bound buttons explains why they're disabled
  ("Enter a room first") instead of showing a silent disabled state.
- Buttons grow their labels with `flex-grow text-start` so the
  trailing dot / badge / clear-X sits flush right.

useModTools
- Fix splice(index) → splice(index, 1) in close{Room,RoomChatlog,
  UserInfo,UserChatlog} — the omitted second argument was
  silently deleting EVERY subsequent open panel, not just the one
  being closed. Visible whenever a moderator had two or more panels
  of the same kind open.
- Fix toggleUserChatlog reading from openRoomChatlogs instead of
  openUserChatlogs — copy-paste typo made the toggle inconsistent
  with the underlying state.
This commit is contained in:
simoleo89
2026-05-19 22:12:19 +02:00
parent 888a6a3255
commit 5c3589c29e
2 changed files with 66 additions and 18 deletions
+61 -13
View File
@@ -1,8 +1,9 @@
import { AddLinkEventTracker, CreateLinkEvent, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomId, RoomObjectCategory, RoomObjectType } from '@nitrots/nitro-renderer';
import { FC, useEffect, useRef, useState } from 'react';
import { GetRoomSession, ISelectedUser } from '../../api';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { FaTimes } from 'react-icons/fa';
import { GetRoomSession, ISelectedUser, LocalizeText } from '../../api';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { useModTools, useNitroEvent, useObjectSelectedEvent } from '../../hooks';
import { useModTools, useNitroEvent, useObjectSelectedEvent, useRoomUserListSnapshot } from '../../hooks';
import { ModToolsChatlogView } from './views/room/ModToolsChatlogView';
import { ModToolsRoomView } from './views/room/ModToolsRoomView';
import { ModToolsTicketsView } from './views/tickets/ModToolsTicketsView';
@@ -15,8 +16,24 @@ export const ModToolsView: FC<{}> = props =>
const [ currentRoomId, setCurrentRoomId ] = useState<number>(-1);
const [ selectedUser, setSelectedUser ] = useState<ISelectedUser>(null);
const [ isTicketsVisible, setIsTicketsVisible ] = useState(false);
const { openRooms = [], openRoomChatlogs = [], openUserChatlogs = [], openUserInfos = [], openRoomInfo = null, closeRoomInfo = null, toggleRoomInfo = null, openRoomChatlog = null, closeRoomChatlog = null, toggleRoomChatlog = null, openUserInfo = null, closeUserInfo = null, toggleUserInfo = null, openUserChatlog = null, closeUserChatlog = null, toggleUserChatlog = null } = useModTools();
const { tickets = [], openRooms = [], openRoomChatlogs = [], openUserChatlogs = [], openUserInfos = [], openRoomInfo = null, closeRoomInfo = null, toggleRoomInfo = null, openRoomChatlog = null, closeRoomChatlog = null, toggleRoomChatlog = null, openUserInfo = null, closeUserInfo = null, toggleUserInfo = null, openUserChatlog = null, closeUserChatlog = null, toggleUserChatlog = null } = useModTools();
const elementRef = useRef<HTMLDivElement>(null);
// Reactive room roster — used to auto-clear the selected user if
// they leave the room while the panel is open, and to show an
// online dot on the selected-user button without going through
// userDataManager imperatively on every render.
const roomUserList = useRoomUserListSnapshot();
// Count of OPEN tickets the moderator hasn't picked yet — shown
// as a badge on the Report Tool button so a new ticket is visible
// immediately, without forcing the user to click through.
const openTicketsCount = useMemo(
() => tickets.filter(ticket => ticket && (ticket.state === 1)).length,
[ tickets ]
);
const isSelectedUserPresent = useMemo(
() => !!(selectedUser && roomUserList.some(user => user && (user.webID === selectedUser.userId))),
[ selectedUser, roomUserList ]
);
useNitroEvent<RoomEngineEvent>([
RoomEngineEvent.INITIALIZED,
@@ -120,28 +137,59 @@ export const ModToolsView: FC<{}> = props =>
const isRoomInfoOpen = currentRoomId > 0 && openRooms.includes(currentRoomId);
const isRoomChatlogOpen = currentRoomId > 0 && openRoomChatlogs.includes(currentRoomId);
const isUserInfoOpen = selectedUser && openUserInfos.includes(selectedUser.userId);
const noRoomHint = LocalizeText('mod.tools.no.room') || 'Enter a room first';
return (
<>
{ isVisible &&
<NitroCardView className="nitro-mod-tools min-w-[200px]" theme="primary-slim" uniqueKey="mod-tools" windowPosition={ DraggableWindowPosition.TOP_LEFT } >
<NitroCardView className="nitro-mod-tools min-w-[220px]" theme="primary-slim" uniqueKey="mod-tools" windowPosition={ DraggableWindowPosition.TOP_LEFT } >
<NitroCardHeaderView headerText={ 'Mod Tools' } onCloseClick={ event => setIsVisible(false) } />
<NitroCardContentView className="text-black" gap={ 2 }>
<Button active={ isRoomInfoOpen } disabled={ (currentRoomId <= 0) } gap={ 2 } justifyContent="start" onClick={ event => CreateLinkEvent(`mod-tools/toggle-room-info/${ currentRoomId }`) }>
<div className="nitro-icon icon-small-room shrink-0" /> Room Tool
<Button active={ isRoomInfoOpen } disabled={ (currentRoomId <= 0) } gap={ 2 } justifyContent="start" title={ (currentRoomId <= 0) ? noRoomHint : undefined } onClick={ event => CreateLinkEvent(`mod-tools/toggle-room-info/${ currentRoomId }`) }>
<div className="nitro-icon icon-small-room shrink-0" />
<span className="grow text-start">Room Tool</span>
</Button>
<Button active={ isRoomChatlogOpen } disabled={ (currentRoomId <= 0) } gap={ 2 } innerRef={ elementRef } justifyContent="start" onClick={ event => CreateLinkEvent(`mod-tools/toggle-room-chatlog/${ currentRoomId }`) }>
<div className="nitro-icon icon-chat-history shrink-0" /> Chatlog Tool
<Button active={ isRoomChatlogOpen } disabled={ (currentRoomId <= 0) } gap={ 2 } innerRef={ elementRef } justifyContent="start" title={ (currentRoomId <= 0) ? noRoomHint : undefined } onClick={ event => CreateLinkEvent(`mod-tools/toggle-room-chatlog/${ currentRoomId }`) }>
<div className="nitro-icon icon-chat-history shrink-0" />
<span className="grow text-start">Chatlog Tool</span>
</Button>
<Button active={ !!isUserInfoOpen } disabled={ !selectedUser } gap={ 2 } justifyContent="start" onClick={ () => CreateLinkEvent(`mod-tools/toggle-user-info/${ selectedUser.userId }`) }>
<Button active={ !!isUserInfoOpen } disabled={ !selectedUser } gap={ 2 } justifyContent="start" onClick={ () => selectedUser && CreateLinkEvent(`mod-tools/toggle-user-info/${ selectedUser.userId }`) }>
<div className="nitro-icon icon-user shrink-0" />
{ selectedUser
? <span className="truncate">{ selectedUser.username }</span>
: <span className="opacity-50 italic">Select a user</span>
? (
<>
<span className="truncate grow text-start">{ selectedUser.username }</span>
<span
aria-label={ isSelectedUserPresent ? 'In room' : 'Left room' }
className={ `inline-block w-2 h-2 rounded-full shrink-0 ${ isSelectedUserPresent ? 'bg-emerald-500' : 'bg-zinc-400' }` }
title={ isSelectedUserPresent ? 'Still in this room' : 'No longer in this room' }
/>
<span
className="inline-flex items-center justify-center w-4 h-4 rounded text-xs text-zinc-500 hover:text-rose-600 hover:bg-rose-100 shrink-0"
onClick={ event =>
{
event.stopPropagation();
setSelectedUser(null);
} }
role="button"
tabIndex={ 0 }
title="Clear selection">
<FaTimes />
</span>
</>
)
: <span className="opacity-50 italic grow text-start">Select a user</span>
}
</Button>
<Button active={ isTicketsVisible } gap={ 2 } justifyContent="start" onClick={ () => setIsTicketsVisible(prevValue => !prevValue) }>
<div className="nitro-icon icon-tickets shrink-0" /> Report Tool
<div className="nitro-icon icon-tickets shrink-0" />
<span className="grow text-start">Report Tool</span>
{ (openTicketsCount > 0) &&
<span
className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full bg-rose-500 text-white text-xs font-semibold shrink-0"
title={ `${ openTicketsCount } open ticket${ openTicketsCount === 1 ? '' : 's' }` }>
{ openTicketsCount > 99 ? '99+' : openTicketsCount }
</span> }
</Button>
</NitroCardContentView>
</NitroCardView> }
+5 -5
View File
@@ -30,7 +30,7 @@ const useModToolsState = () =>
const newValue = [ ...prevValue ];
const existingIndex = newValue.indexOf(roomId);
if(existingIndex >= 0) newValue.splice(existingIndex);
if(existingIndex >= 0) newValue.splice(existingIndex, 1);
return newValue;
});
@@ -56,7 +56,7 @@ const useModToolsState = () =>
const newValue = [ ...prevValue ];
const existingIndex = newValue.indexOf(roomId);
if(existingIndex >= 0) newValue.splice(existingIndex);
if(existingIndex >= 0) newValue.splice(existingIndex, 1);
return newValue;
});
@@ -82,7 +82,7 @@ const useModToolsState = () =>
const newValue = [ ...prevValue ];
const existingIndex = newValue.indexOf(userId);
if(existingIndex >= 0) newValue.splice(existingIndex);
if(existingIndex >= 0) newValue.splice(existingIndex, 1);
return newValue;
});
@@ -108,7 +108,7 @@ const useModToolsState = () =>
const newValue = [ ...prevValue ];
const existingIndex = newValue.indexOf(userId);
if(existingIndex >= 0) newValue.splice(existingIndex);
if(existingIndex >= 0) newValue.splice(existingIndex, 1);
return newValue;
});
@@ -116,7 +116,7 @@ const useModToolsState = () =>
const toggleUserChatlog = (userId: number) =>
{
if(openRoomChatlogs.indexOf(userId) >= 0) closeUserChatlog(userId);
if(openUserChatlogs.indexOf(userId) >= 0) closeUserChatlog(userId);
else openUserChatlog(userId);
};