Split useChatInputWidget into state + actions (flat hooks layout)

Continues the proposal #4 split pattern (doorbell, poll, furni-chooser,
user-chooser, friend-request) for the chat-input widget. Splits the
334-line useChatInputWidget along the natural seam:

- useChatInputState — selectedUsername / floodBlocked / floodBlockedSeconds
  / isTyping / isIdle state plus the three event listeners
  (FLOOD_EVENT, ObjectSelected, ObjectDeselected) and the three lifecycle
  effects (flood-countdown, idle-auto-clear, typing-indicator sync).
- useChatInputActions — sendChat(text, chatType, recipientName, styleId).
  Carries the slash-command handler (":shake", ":rotate", ":zoom",
  ":screenshot", ":pickall", etc.) and the chat-vs-shout-vs-whisper
  dispatch path, with the optional outgoing-translation hook.
- useChatInputWidget — deprecated shim that composes both into the
  historical { selectedUsername, floodBlocked, floodBlockedSeconds,
  setIsTyping, setIsIdle, sendChat } shape so ChatInputView keeps
  working unchanged.

Bonus while in here:
- Guarded all roomSession reads in actions with optional chaining
  (the hook can be called during the brief no-room window between
  enter and leave).
- Dropped the useless 'if(isIdle)' inside the idle effect body — the
  early return guard above it already covers that branch.
This commit is contained in:
simoleo89
2026-05-11 22:00:23 +02:00
parent 68de96cac1
commit a4c9dd87db
3 changed files with 385 additions and 329 deletions
@@ -0,0 +1,260 @@
import { AvatarExpressionEnum, CreateLinkEvent, GetEventDispatcher, GetRoomEngine, GetSessionDataManager, GetTicker, HabboClubLevelEnum, RoomControllerLevel, RoomRotatingEffect, RoomSettingsComposer, RoomShakingEffect, RoomZoomEvent, TextureUtils } from '@nitrots/nitro-renderer';
import { useCallback } from 'react';
import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, SendMessageComposer } from '../../../api';
import { useNotification } from '../../notification';
import { useTranslation } from '../../translation';
import { useRoom } from '../useRoom';
/**
* Pure imperative dispatch for the chat-input widget. Exposes
* `sendChat(text, chatType, recipientName?, styleId?)` which:
*
* 1. Intercepts in-room slash commands (`:shake`, `:rotate`, `:zoom`,
* `:screenshot`, `:pickall`, ...) and turns them into the matching
* renderer/composer call — these never reach the server as chat
* payload.
* 2. Falls back to the regular default/shout/whisper composer path,
* optionally piping the text through the translation pipeline if
* outgoing translation is enabled.
*
* No state lives in this hook — the typing/flood/idle state belongs
* to useChatInputState.
*/
export const useChatInputActions = () =>
{
const { showNitroAlert = null, showConfirm = null } = useNotification();
const { settings, translateOutgoing, enqueueOutgoingTranslation } = useTranslation();
const { roomSession = null } = useRoom();
const sendChat = useCallback((text: string, chatType: number, recipientName: string = '', styleId: number = 0) =>
{
if(text === '') return null;
const parts = text.split(' ');
if(parts.length > 0)
{
const firstPart = parts[0];
let secondPart = '';
if(parts.length > 1) secondPart = parts[1];
if((firstPart.charAt(0) === ':') && (secondPart === 'x'))
{
const selectedAvatarId = GetRoomEngine().selectedAvatarId;
if(selectedAvatarId > -1)
{
const userData = roomSession?.userDataManager?.getUserDataByIndex(selectedAvatarId);
if(userData)
{
secondPart = userData.name;
text = text.replace(' x', (' ' + userData.name));
}
}
}
switch(firstPart.toLowerCase())
{
case ':shake':
RoomShakingEffect.init(2500, 5000);
RoomShakingEffect.turnVisualizationOn();
return null;
case ':rotate':
RoomRotatingEffect.init(2500, 5000);
RoomRotatingEffect.turnVisualizationOn();
return null;
case ':d':
case ';d':
if(GetClubMemberLevel() === HabboClubLevelEnum.VIP)
{
roomSession?.sendExpressionMessage(AvatarExpressionEnum.LAUGH.ordinal);
}
break;
case 'o/':
case '_o/':
roomSession?.sendExpressionMessage(AvatarExpressionEnum.WAVE.ordinal);
return null;
case ':kiss':
if(GetClubMemberLevel() === HabboClubLevelEnum.VIP)
{
roomSession?.sendExpressionMessage(AvatarExpressionEnum.BLOW.ordinal);
return null;
}
break;
case ':jump':
if(GetClubMemberLevel() === HabboClubLevelEnum.VIP)
{
roomSession?.sendExpressionMessage(AvatarExpressionEnum.JUMP.ordinal);
return null;
}
break;
case ':idle':
roomSession?.sendExpressionMessage(AvatarExpressionEnum.IDLE.ordinal);
return null;
case '_b':
roomSession?.sendExpressionMessage(AvatarExpressionEnum.RESPECT.ordinal);
return null;
case ':sign':
roomSession?.sendSignMessage(parseInt(secondPart));
return null;
case ':iddqd':
case ':flip':
if(roomSession) GetEventDispatcher().dispatchEvent(new RoomZoomEvent(roomSession.roomId, -1, true));
return null;
case ':zoom':
if(roomSession) GetEventDispatcher().dispatchEvent(new RoomZoomEvent(roomSession.roomId, parseInt(secondPart)));
return null;
case ':screenshot':
if(!roomSession) return null;
{
const texture = GetRoomEngine().createTextureFromRoom(roomSession.roomId, 1);
(async () =>
{
try
{
const imageUrl = await TextureUtils.generateImageUrl(texture);
if(!imageUrl) return;
const link = document.createElement('a');
link.href = imageUrl;
link.download = `room_${ roomSession.roomId }_${ Date.now() }.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
catch(e)
{
console.warn('[Screenshot] Failed:', e);
}
})();
}
return null;
case ':pickall':
if(roomSession?.isRoomOwner || GetSessionDataManager().isModerator)
{
showConfirm(LocalizeText('room.confirm.pick_all'), () =>
{
GetSessionDataManager().sendSpecialCommandMessage(':pickall');
},
null, null, null, LocalizeText('generic.alert.title'));
}
return null;
case ':ejectall':
if(roomSession?.isRoomOwner || GetSessionDataManager().isModerator || (roomSession?.controllerLevel ?? 0) >= RoomControllerLevel.GUEST)
{
showConfirm(LocalizeText('room.confirm.eject_all'), () =>
{
GetSessionDataManager().sendSpecialCommandMessage(':ejectall');
},
null, null, null, LocalizeText('generic.alert.title'));
}
return null;
case ':furni':
CreateLinkEvent('furni-chooser/');
return null;
case ':chooser':
CreateLinkEvent('user-chooser/');
return null;
case ':floor':
case ':bcfloor':
if((roomSession?.controllerLevel ?? 0) >= RoomControllerLevel.ROOM_OWNER) CreateLinkEvent('floor-editor/show');
return null;
case ':togglefps': {
if(GetTicker().maxFPS > 0) GetTicker().maxFPS = 0;
else GetTicker().maxFPS = GetConfigurationValue('system.animation.fps');
return null;
}
case ':client':
case ':nitro':
case ':billsonnn':
showNitroAlert();
return null;
case ':settings':
if(roomSession && (roomSession.isRoomOwner || GetSessionDataManager().isModerator))
{
SendMessageComposer(new RoomSettingsComposer(roomSession.roomId));
}
return null;
case ':customize':
CreateLinkEvent('customize/show');
return null;
}
}
if(!roomSession) return null;
const preserveTrailingSpaces = (message: string) =>
{
if(message.startsWith(':')) return message;
return message.replace(/ +$/g, match => ' '.repeat(match.length));
};
const dispatchChatMessage = (message: string) =>
{
const preservedMessage = preserveTrailingSpaces(message);
switch(chatType)
{
case ChatMessageTypeEnum.CHAT_DEFAULT:
roomSession.sendChatMessage(preservedMessage, styleId);
return;
case ChatMessageTypeEnum.CHAT_SHOUT:
roomSession.sendShoutMessage(preservedMessage, styleId);
return;
case ChatMessageTypeEnum.CHAT_WHISPER:
roomSession.sendWhisperMessage(recipientName, preservedMessage, styleId);
return;
}
};
const trimmedText = text.trimStart();
const shouldTranslateOutgoing = settings.enabled && !!trimmedText.length && (trimmedText.charAt(0) !== ':');
if(!shouldTranslateOutgoing)
{
dispatchChatMessage(text);
return null;
}
void (async () =>
{
const translation = await translateOutgoing(text);
if(translation)
{
enqueueOutgoingTranslation(translation);
dispatchChatMessage(translation.translatedText);
return;
}
dispatchChatMessage(text);
})();
return null;
}, [ roomSession, settings, translateOutgoing, enqueueOutgoingTranslation, showConfirm, showNitroAlert ]);
return { sendChat };
};
@@ -0,0 +1,113 @@
import { RoomEngineObjectEvent, RoomObjectCategory, RoomSessionChatEvent } from '@nitrots/nitro-renderer';
import { useEffect, useState } from 'react';
import { useNitroEvent } from '../../events';
import { useObjectSelectedEvent } from '../engine';
import { useRoom } from '../useRoom';
/**
* State + event subscriptions for the chat-input widget. Pure
* imperative dispatch (sendChat) lives in useChatInputActions.
*
* - selectedUsername → tracks the last avatar the user clicked,
* used by `/whisper` shortcuts.
* - floodBlocked / → flood-throttle banner state, driven by the
* floodBlockedSeconds renderer's FLOOD_EVENT plus a 1s tick.
* - isTyping / → typing indicator + 10s idle auto-clear, with
* isIdle an internal `typingStartedSent` ref so the
* outgoing sendChatTypingMessage only fires on
* state edges (start / stop), not every render.
*/
export const useChatInputState = () =>
{
const [ selectedUsername, setSelectedUsername ] = useState('');
const [ isTyping, setIsTyping ] = useState<boolean>(false);
const [ typingStartedSent, setTypingStartedSent ] = useState(false);
const [ isIdle, setIsIdle ] = useState(false);
const [ floodBlocked, setFloodBlocked ] = useState(false);
const [ floodBlockedSeconds, setFloodBlockedSeconds ] = useState(0);
const { roomSession = null } = useRoom();
useNitroEvent<RoomSessionChatEvent>(RoomSessionChatEvent.FLOOD_EVENT, event =>
{
setFloodBlocked(true);
setFloodBlockedSeconds(parseFloat(event.message));
});
useObjectSelectedEvent(event =>
{
if(event.category !== RoomObjectCategory.UNIT) return;
const userData = roomSession?.userDataManager?.getUserDataByIndex(event.id);
if(!userData) return;
setSelectedUsername(userData.name);
});
useNitroEvent<RoomEngineObjectEvent>(RoomEngineObjectEvent.DESELECTED, () => setSelectedUsername(''));
useEffect(() =>
{
if(!floodBlocked) return;
let seconds = 0;
const interval = setInterval(() =>
{
setFloodBlockedSeconds(prevValue =>
{
seconds = ((prevValue || 0) - 1);
return seconds;
});
if(seconds < 0)
{
clearInterval(interval);
setFloodBlocked(false);
}
}, 1000);
return () => clearInterval(interval);
}, [ floodBlocked ]);
useEffect(() =>
{
if(!isIdle) return;
const timeout = setTimeout(() =>
{
setIsIdle(false);
setIsTyping(false);
}, 10000);
return () => clearTimeout(timeout);
}, [ isIdle ]);
useEffect(() =>
{
if(!roomSession) return;
if(isTyping)
{
if(!typingStartedSent)
{
setTypingStartedSent(true);
roomSession.sendChatTypingMessage(isTyping);
}
}
else
{
if(typingStartedSent)
{
setTypingStartedSent(false);
roomSession.sendChatTypingMessage(isTyping);
}
}
}, [ roomSession, isTyping, typingStartedSent ]);
return { selectedUsername, floodBlocked, floodBlockedSeconds, setIsTyping, setIsIdle };
};
+12 -329
View File
@@ -1,334 +1,17 @@
import { AvatarExpressionEnum, CreateLinkEvent, GetEventDispatcher, GetRoomEngine, GetSessionDataManager, GetTicker, HabboClubLevelEnum, RoomControllerLevel, RoomEngineObjectEvent, RoomObjectCategory, RoomRotatingEffect, RoomSessionChatEvent, RoomSettingsComposer, RoomShakingEffect, RoomZoomEvent, TextureUtils } from '@nitrots/nitro-renderer';
import { useEffect, useState } from 'react';
import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, SendMessageComposer } from '../../../api';
import { useNitroEvent } from '../../events';
import { useNotification } from '../../notification';
import { useTranslation } from '../../translation';
import { useObjectSelectedEvent } from '../engine';
import { useRoom } from '../useRoom';
import { useChatInputActions } from './useChatInputActions';
import { useChatInputState } from './useChatInputState';
const useChatInputWidgetState = () =>
/**
* @deprecated Use `useChatInputState` and `useChatInputActions`
* directly. This shim preserves the
* `{ selectedUsername, floodBlocked, floodBlockedSeconds, setIsTyping,
* setIsIdle, sendChat }` shape so the single consumer (`ChatInputView`)
* keeps working unchanged.
*/
export const useChatInputWidget = () =>
{
const [ selectedUsername, setSelectedUsername ] = useState('');
const [ isTyping, setIsTyping ] = useState<boolean>(false);
const [ typingStartedSent, setTypingStartedSent ] = useState(false);
const [ isIdle, setIsIdle ] = useState(false);
const [ floodBlocked, setFloodBlocked ] = useState(false);
const [ floodBlockedSeconds, setFloodBlockedSeconds ] = useState(0);
const { showNitroAlert = null, showConfirm = null } = useNotification();
const { settings, translateOutgoing, enqueueOutgoingTranslation } = useTranslation();
const { roomSession = null } = useRoom();
const sendChat = (text: string, chatType: number, recipientName: string = '', styleId: number = 0) =>
{
if(text === '') return null;
const parts = text.split(' ');
if(parts.length > 0)
{
const firstPart = parts[0];
let secondPart = '';
if(parts.length > 1) secondPart = parts[1];
if((firstPart.charAt(0) === ':') && (secondPart === 'x'))
{
const selectedAvatarId = GetRoomEngine().selectedAvatarId;
if(selectedAvatarId > -1)
{
const userData = roomSession.userDataManager.getUserDataByIndex(selectedAvatarId);
if(userData)
{
secondPart = userData.name;
text = text.replace(' x', (' ' + userData.name));
}
}
}
switch(firstPart.toLowerCase())
{
case ':shake':
RoomShakingEffect.init(2500, 5000);
RoomShakingEffect.turnVisualizationOn();
return null;
case ':rotate':
RoomRotatingEffect.init(2500, 5000);
RoomRotatingEffect.turnVisualizationOn();
return null;
case ':d':
case ';d':
if(GetClubMemberLevel() === HabboClubLevelEnum.VIP)
{
roomSession.sendExpressionMessage(AvatarExpressionEnum.LAUGH.ordinal);
}
break;
case 'o/':
case '_o/':
roomSession.sendExpressionMessage(AvatarExpressionEnum.WAVE.ordinal);
return null;
case ':kiss':
if(GetClubMemberLevel() === HabboClubLevelEnum.VIP)
{
roomSession.sendExpressionMessage(AvatarExpressionEnum.BLOW.ordinal);
return null;
}
break;
case ':jump':
if(GetClubMemberLevel() === HabboClubLevelEnum.VIP)
{
roomSession.sendExpressionMessage(AvatarExpressionEnum.JUMP.ordinal);
return null;
}
break;
case ':idle':
roomSession.sendExpressionMessage(AvatarExpressionEnum.IDLE.ordinal);
return null;
case '_b':
roomSession.sendExpressionMessage(AvatarExpressionEnum.RESPECT.ordinal);
return null;
case ':sign':
roomSession.sendSignMessage(parseInt(secondPart));
return null;
case ':iddqd':
case ':flip':
GetEventDispatcher().dispatchEvent(new RoomZoomEvent(roomSession.roomId, -1, true));
return null;
case ':zoom':
GetEventDispatcher().dispatchEvent(new RoomZoomEvent(roomSession.roomId, parseInt(secondPart)));
return null;
case ':screenshot':
const texture = GetRoomEngine().createTextureFromRoom(roomSession.roomId, 1);
(async () =>
{
try
{
const imageUrl = await TextureUtils.generateImageUrl(texture);
if (!imageUrl) return;
const link = document.createElement('a');
link.href = imageUrl;
link.download = `room_${ roomSession.roomId }_${ Date.now() }.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
catch (e)
{
console.warn('[Screenshot] Failed:', e);
}
})();
return null;
case ':pickall':
if(roomSession.isRoomOwner || GetSessionDataManager().isModerator)
{
showConfirm(LocalizeText('room.confirm.pick_all'), () =>
{
GetSessionDataManager().sendSpecialCommandMessage(':pickall');
},
null, null, null, LocalizeText('generic.alert.title'));
}
return null;
case ':ejectall':
if(roomSession.isRoomOwner || GetSessionDataManager().isModerator || roomSession.controllerLevel >= RoomControllerLevel.GUEST)
{
showConfirm(LocalizeText('room.confirm.eject_all'), () =>
{
GetSessionDataManager().sendSpecialCommandMessage(':ejectall');
},
null, null, null, LocalizeText('generic.alert.title'));
}
return null;
case ':furni':
CreateLinkEvent('furni-chooser/');
return null;
case ':chooser':
CreateLinkEvent('user-chooser/');
return null;
case ':floor':
case ':bcfloor':
if(roomSession.controllerLevel >= RoomControllerLevel.ROOM_OWNER) CreateLinkEvent('floor-editor/show');
return null;
case ':togglefps': {
if(GetTicker().maxFPS > 0) GetTicker().maxFPS = 0;
else GetTicker().maxFPS = GetConfigurationValue('system.animation.fps');
return null;
}
case ':client':
case ':nitro':
case ':billsonnn':
showNitroAlert();
return null;
case ':settings':
if(roomSession.isRoomOwner || GetSessionDataManager().isModerator)
{
SendMessageComposer(new RoomSettingsComposer(roomSession.roomId));
}
return null;
case ':customize':
CreateLinkEvent('customize/show');
return null;
}
}
const preserveTrailingSpaces = (message: string) =>
{
if(message.startsWith(':')) return message;
return message.replace(/ +$/g, match => '\u00A0'.repeat(match.length));
};
const dispatchChatMessage = (message: string) =>
{
const preservedMessage = preserveTrailingSpaces(message);
switch(chatType)
{
case ChatMessageTypeEnum.CHAT_DEFAULT:
roomSession.sendChatMessage(preservedMessage, styleId);
return;
case ChatMessageTypeEnum.CHAT_SHOUT:
roomSession.sendShoutMessage(preservedMessage, styleId);
return;
case ChatMessageTypeEnum.CHAT_WHISPER:
roomSession.sendWhisperMessage(recipientName, preservedMessage, styleId);
return;
}
};
const trimmedText = text.trimStart();
const shouldTranslateOutgoing = settings.enabled && !!trimmedText.length && (trimmedText.charAt(0) !== ':');
if(!shouldTranslateOutgoing)
{
dispatchChatMessage(text);
return null;
}
void (async () =>
{
const translation = await translateOutgoing(text);
if(translation)
{
enqueueOutgoingTranslation(translation);
dispatchChatMessage(translation.translatedText);
return;
}
dispatchChatMessage(text);
})();
return null;
};
useNitroEvent<RoomSessionChatEvent>(RoomSessionChatEvent.FLOOD_EVENT, event =>
{
setFloodBlocked(true);
setFloodBlockedSeconds(parseFloat(event.message));
});
useObjectSelectedEvent(event =>
{
if(event.category !== RoomObjectCategory.UNIT) return;
const userData = roomSession.userDataManager.getUserDataByIndex(event.id);
if(!userData) return;
setSelectedUsername(userData.name);
});
useNitroEvent<RoomEngineObjectEvent>(RoomEngineObjectEvent.DESELECTED, event => setSelectedUsername(''));
useEffect(() =>
{
if(!floodBlocked) return;
let seconds = 0;
const interval = setInterval(() =>
{
setFloodBlockedSeconds(prevValue =>
{
seconds = ((prevValue || 0) - 1);
return seconds;
});
if(seconds < 0)
{
clearInterval(interval);
setFloodBlocked(false);
}
}, 1000);
return () => clearInterval(interval);
}, [ floodBlocked ]);
useEffect(() =>
{
if(!isIdle) return;
let timeout: ReturnType<typeof setTimeout> = null;
if(isIdle)
{
timeout = setTimeout(() =>
{
setIsIdle(false);
setIsTyping(false);
}, 10000);
}
return () => clearTimeout(timeout);
}, [ isIdle ]);
useEffect(() =>
{
if(isTyping)
{
if(!typingStartedSent)
{
setTypingStartedSent(true);
roomSession.sendChatTypingMessage(isTyping);
}
}
else
{
if(typingStartedSent)
{
setTypingStartedSent(false);
roomSession.sendChatTypingMessage(isTyping);
}
}
}, [ roomSession, isTyping, typingStartedSent ]);
const { selectedUsername, floodBlocked, floodBlockedSeconds, setIsTyping, setIsIdle } = useChatInputState();
const { sendChat } = useChatInputActions();
return { selectedUsername, floodBlocked, floodBlockedSeconds, setIsTyping, setIsIdle, sendChat };
};
export const useChatInputWidget = useChatInputWidgetState;