From a4c9dd87db98082cba6f2d760bfde83479582eb0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 11 May 2026 22:00:23 +0200 Subject: [PATCH] Split useChatInputWidget into state + actions (flat hooks layout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../rooms/widgets/useChatInputActions.ts | 260 +++++++++++++ src/hooks/rooms/widgets/useChatInputState.ts | 113 ++++++ src/hooks/rooms/widgets/useChatInputWidget.ts | 341 +----------------- 3 files changed, 385 insertions(+), 329 deletions(-) create mode 100644 src/hooks/rooms/widgets/useChatInputActions.ts create mode 100644 src/hooks/rooms/widgets/useChatInputState.ts diff --git a/src/hooks/rooms/widgets/useChatInputActions.ts b/src/hooks/rooms/widgets/useChatInputActions.ts new file mode 100644 index 0000000..3233c6d --- /dev/null +++ b/src/hooks/rooms/widgets/useChatInputActions.ts @@ -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 }; +}; diff --git a/src/hooks/rooms/widgets/useChatInputState.ts b/src/hooks/rooms/widgets/useChatInputState.ts new file mode 100644 index 0000000..ab9c668 --- /dev/null +++ b/src/hooks/rooms/widgets/useChatInputState.ts @@ -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(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.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.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 }; +}; diff --git a/src/hooks/rooms/widgets/useChatInputWidget.ts b/src/hooks/rooms/widgets/useChatInputWidget.ts index 5f8d156..c4d1f84 100644 --- a/src/hooks/rooms/widgets/useChatInputWidget.ts +++ b/src/hooks/rooms/widgets/useChatInputWidget.ts @@ -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(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.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.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 = 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;