From f437db2faec59a12d9192aacf49159d2c3b82c31 Mon Sep 17 00:00:00 2001 From: Lorenzune Date: Mon, 16 Mar 2026 15:12:42 +0100 Subject: [PATCH 01/14] feat(wired): update source-aware furni and signal UI --- .../views/actions/WiredActionBotMoveView.tsx | 22 +- .../actions/WiredActionBotTeleportView.tsx | 22 +- .../WiredActionCallAnotherStackView.tsx | 22 +- .../views/actions/WiredActionChaseView.tsx | 22 +- .../views/actions/WiredActionFleeView.tsx | 22 +- .../WiredActionMoveAndRotateFurniView.tsx | 22 +- .../actions/WiredActionMoveFurniToView.tsx | 22 +- .../actions/WiredActionMoveFurniView.tsx | 22 +- .../actions/WiredActionSendSignalView.tsx | 320 ++++++++++-------- .../WiredActionSetFurniStateToView.tsx | 22 +- .../views/actions/WiredActionTeleportView.tsx | 22 +- .../WiredActionToggleFurniStateView.tsx | 22 +- .../WiredConditionActorIsOnFurniView.tsx | 22 +- .../WiredConditionFurniHasAvatarOnView.tsx | 22 +- .../WiredConditionFurniHasFurniOnView.tsx | 22 +- .../WiredConditionFurniHasNotFurniOnView.tsx | 22 +- .../WiredConditionFurniIsOfTypeView.tsx | 22 +- ...WiredConditionFurniMatchesSnapshotView.tsx | 22 +- .../WiredSelectorFurniByTypeView.tsx | 59 +--- ...iredTriggerExecutePeriodicallyLongView.tsx | 2 +- .../WiredTriggerExecutePeriodicallyView.tsx | 2 +- .../WiredTriggerReceiveSignalView.tsx | 13 +- src/hooks/wired/useWired.ts | 85 ++++- 23 files changed, 339 insertions(+), 516 deletions(-) diff --git a/src/components/wired/views/actions/WiredActionBotMoveView.tsx b/src/components/wired/views/actions/WiredActionBotMoveView.tsx index 14a3d47..d07bc7b 100644 --- a/src/components/wired/views/actions/WiredActionBotMoveView.tsx +++ b/src/components/wired/views/actions/WiredActionBotMoveView.tsx @@ -1,5 +1,5 @@ import { FC, useEffect, useState } from 'react'; -import { LocalizeText, WiredFurniType, WiredSelectionVisualizer } from '../../../../api'; +import { LocalizeText, WiredFurniType } from '../../../../api'; import { Text } from '../../../../common'; import { useWired } from '../../../../hooks'; import { NitroInput } from '../../../../layout'; @@ -9,7 +9,7 @@ import { WiredSourcesSelector } from '../WiredSourcesSelector'; export const WiredActionBotMoveView: FC<{}> = props => { const [ botName, setBotName ] = useState(''); - const { trigger = null, furniIds = [], setFurniIds = null, setStringParam = null, setIntParams = null } = useWired(); + const { trigger = null, setStringParam = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { @@ -33,23 +33,9 @@ export const WiredActionBotMoveView: FC<{}> = props => else setFurniSource((trigger.selectedItems?.length ?? 0) > 0 ? 100 : 0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } + const onChangeFurniSource = (next: number) => setFurniSource(next); - setFurniSource(next); - }; - - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID; return ( = props => { const [ botName, setBotName ] = useState(''); - const { trigger = null, furniIds = [], setFurniIds = null, setStringParam = null, setIntParams = null } = useWired(); + const { trigger = null, setStringParam = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { @@ -33,23 +33,9 @@ export const WiredActionBotTeleportView: FC<{}> = props => else setFurniSource((trigger.selectedItems?.length ?? 0) > 0 ? 100 : 0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } + const onChangeFurniSource = (next: number) => setFurniSource(next); - setFurniSource(next); - }; - - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID; return ( = props => { - const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null } = useWired(); + const { trigger = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { if(trigger?.intData?.length >= 1) return trigger.intData[0]; @@ -21,25 +21,11 @@ export const WiredActionCallAnotherStackView: FC<{}> = props => else setFurniSource((trigger.selectedItems?.length ?? 0) > 0 ? 100 : 0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } - - setFurniSource(next); - }; + const onChangeFurniSource = (next: number) => setFurniSource(next); const save = () => setIntParams([ furniSource ]); - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_BY_TYPE_OR_FROM_CONTEXT - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_BY_TYPE_OR_FROM_CONTEXT; return ( = props => { - const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null } = useWired(); + const { trigger = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { if(trigger?.intData?.length >= 1) return trigger.intData[0]; @@ -21,25 +21,11 @@ export const WiredActionChaseView: FC<{}> = props => else setFurniSource((trigger.selectedItems?.length ?? 0) > 0 ? 100 : 0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } - - setFurniSource(next); - }; + const onChangeFurniSource = (next: number) => setFurniSource(next); const save = () => setIntParams([ furniSource ]); - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_BY_TYPE_OR_FROM_CONTEXT - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_BY_TYPE_OR_FROM_CONTEXT; return ( = props => { - const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null } = useWired(); + const { trigger = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { if(trigger?.intData?.length >= 1) return trigger.intData[0]; @@ -21,25 +21,11 @@ export const WiredActionFleeView: FC<{}> = props => else setFurniSource((trigger.selectedItems?.length ?? 0) > 0 ? 100 : 0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } - - setFurniSource(next); - }; + const onChangeFurniSource = (next: number) => setFurniSource(next); const save = () => setIntParams([ furniSource ]); - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_BY_TYPE_OR_FROM_CONTEXT - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_BY_TYPE_OR_FROM_CONTEXT; return ( = props => { const [ movement, setMovement ] = useState(-1); const [ rotation, setRotation ] = useState(-1); - const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null } = useWired(); + const { trigger = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { if(trigger?.intData?.length > 2) return trigger.intData[2]; @@ -56,23 +56,9 @@ export const WiredActionMoveAndRotateFurniView: FC<{}> = props => else setFurniSource((trigger.selectedItems?.length ?? 0) > 0 ? 100 : 0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } + const onChangeFurniSource = (next: number) => setFurniSource(next); - setFurniSource(next); - }; - - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_BY_TYPE_OR_FROM_CONTEXT - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_BY_TYPE_OR_FROM_CONTEXT; return ( = props => { const [ spacing, setSpacing ] = useState(-1); const [ movement, setMovement ] = useState(-1); - const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null } = useWired(); + const { trigger = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { if(trigger?.intData?.length > 2) return trigger.intData[2]; @@ -54,23 +54,9 @@ export const WiredActionMoveFurniToView: FC<{}> = props => else setFurniSource((trigger.selectedItems?.length ?? 0) > 0 ? 100 : 0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } + const onChangeFurniSource = (next: number) => setFurniSource(next); - setFurniSource(next); - }; - - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_OR_BY_TYPE - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_OR_BY_TYPE; return ( = props => { const [ movement, setMovement ] = useState(-1); const [ rotation, setRotation ] = useState(-1); - const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null } = useWired(); + const { trigger = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { if(trigger?.intData?.length > 2) return trigger.intData[2]; @@ -68,23 +68,9 @@ export const WiredActionMoveFurniView: FC<{}> = props => else setFurniSource((trigger.selectedItems?.length ?? 0) > 0 ? 100 : 0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } + const onChangeFurniSource = (next: number) => setFurniSource(next); - setFurniSource(next); - }; - - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_BY_TYPE_OR_FROM_CONTEXT - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_BY_TYPE_OR_FROM_CONTEXT; return ( +{ + if(!data || !data.length) return []; + + const ids = new Set(); + + for(const part of data.split(/[;,\t]/)) + { + const trimmed = part.trim(); + if(!trimmed.length) continue; + + const value = parseInt(trimmed, 10); + if(!isNaN(value) && value > 0) ids.add(value); + } + + return Array.from(ids); +}; + +const serializeForwardIds = (ids: number[]): string => +{ + if(!ids || !ids.length) return ''; + + return ids.filter(id => (id > 0)).join(FORWARD_ITEM_DELIMITER); +}; export const WiredActionSendSignalView: FC<{}> = () => { - const [ antennaSource, setAntennaSource ] = useState(ANTENNA_PICKED); - const [ furniForward, setFurniForward ] = useState(FORWARD_NONE); - const [ userForward, setUserForward ] = useState(FORWARD_NONE); + const [ furniSource, setFurniSource ] = useState(SOURCE_TRIGGER); + const [ userSource, setUserSource ] = useState(SOURCE_TRIGGER); const [ signalPerFurni, setSignalPerFurni ] = useState(false); const [ signalPerUser, setSignalPerUser ] = useState(false); - const [ showAdvanced, setShowAdvanced ] = useState(false); + const [ antennaIds, setAntennaIds ] = useState([]); + const [ forwardFurniIds, setForwardFurniIds ] = useState([]); + const [ selectionMode, setSelectionMode ] = useState('antenna'); - const { trigger = null, setIntParams } = useWired(); + const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null, setStringParam = null, setAllowedInteractionTypes = null } = useWired(); useEffect(() => { if(!trigger) return; const p = trigger.intData; - if(p.length >= 1) setAntennaSource(p[0]); - if(p.length >= 2) setFurniForward(p[1]); - if(p.length >= 3) setUserForward(p[2]); - if(p.length >= 4) setSignalPerFurni(p[3] === 1); - if(p.length >= 5) setSignalPerUser(p[4] === 1); + if(p.length > 1) setFurniSource(p[1]); + else setFurniSource(SOURCE_TRIGGER); - if(p.length >= 1 && (p[0] !== ANTENNA_PICKED || p[1] !== FORWARD_NONE || - p[2] !== FORWARD_NONE || (p.length >= 4 && p[3] === 1) || (p.length >= 5 && p[4] === 1))) - { - setShowAdvanced(true); - } + if(p.length > 2) setUserSource(p[2]); + else setUserSource(SOURCE_TRIGGER); + + setSignalPerFurni(p.length > 3 && p[3] === 1); + setSignalPerUser(p.length > 4 && p[4] === 1); + + setAntennaIds(trigger.selectedItems ?? []); + setForwardFurniIds(parseForwardIds(trigger.stringData)); + setSelectionMode('antenna'); }, [ trigger ]); + useEffect(() => + { + if(selectionMode === 'antenna') setAllowedInteractionTypes(ANTENNA_INTERACTION_TYPES); + else setAllowedInteractionTypes(null); + + return () => setAllowedInteractionTypes(null); + }, [ selectionMode, setAllowedInteractionTypes ]); + + useEffect(() => + { + if(selectionMode === 'antenna') setAntennaIds(furniIds); + else setForwardFurniIds(furniIds); + }, [ furniIds, selectionMode ]); + + const applySelection = useCallback((nextIds: number[]) => + { + if(!setFurniIds) return; + + setFurniIds(prev => + { + if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); + if(nextIds && nextIds.length) WiredSelectionVisualizer.applySelectionShaderToFurni(nextIds); + + return [ ...nextIds ]; + }); + }, [ setFurniIds ]); + + const switchSelection = useCallback((mode: SelectionMode) => + { + if(mode === selectionMode) return; + if(mode === 'furni' && furniSource !== SOURCE_SELECTED) return; + + const nextIds = (mode === 'antenna') ? antennaIds : forwardFurniIds; + applySelection(nextIds); + setSelectionMode(mode); + }, [ selectionMode, furniSource, antennaIds, forwardFurniIds, applySelection ]); + + const onChangeFurniSource = (next: number) => + { + if(forwardFurniIds.length) setForwardFurniIds([]); + + if(selectionMode === 'furni') + { + applySelection(antennaIds); + setSelectionMode('antenna'); + } + + setFurniSource(next); + }; + const save = useCallback(() => { + if(selectionMode === 'furni') + { + setSelectionMode('antenna'); + applySelection(antennaIds); + } + + const antennaSource = (antennaIds && antennaIds.length) ? antennaIds[0] : 0; + setIntParams([ antennaSource, - furniForward, - userForward, + furniSource, + userSource, signalPerFurni ? 1 : 0, signalPerUser ? 1 : 0, + 0, ]); - }, [ antennaSource, furniForward, userForward, signalPerFurni, signalPerUser, setIntParams ]); + + setStringParam(serializeForwardIds(forwardFurniIds)); + }, [ selectionMode, antennaIds, furniSource, userSource, signalPerFurni, signalPerUser, forwardFurniIds, setIntParams, setStringParam, applySelection, setSelectionMode ]); + + const selectionLimit = trigger?.maximumItemSelectionCount ?? 0; + const forwardSelectionEnabled = (furniSource === SOURCE_SELECTED); return ( -
- -
setShowAdvanced(!showAdvanced) }> - - { showAdvanced - ? LocalizeText('wiredfurni.params.hide_advanced') - : LocalizeText('wiredfurni.params.show_advanced') } - + cardStyle={ { width: '400px' } } + save={ save } + footer={ ( + + ) }> +
+
+ Antenne selezionate +
+ + { selectionLimit ? `${ antennaIds.length }/${ selectionLimit }` : antennaIds.length } +
+
+
+ Furni selezionati +
+ + { selectionLimit ? `${ forwardFurniIds.length }/${ selectionLimit }` : forwardFurniIds.length } +
- { showAdvanced && <> - - { /* --- Antennas --- */ } - { LocalizeText('wiredfurni.params.sources.furni.title.signal_antenna') } -
- setAntennaSource(antennaSource === ANTENNA_PICKED ? ANTENNA_TRIGGER : ANTENNA_PICKED) } /> - -
- - { /* --- Furni to forward --- */ } - { LocalizeText('wiredfurni.params.sources.furni.title.signal_forward') } -
-
- setFurniForward(e.target.checked ? FORWARD_TRIGGER : FORWARD_NONE) } /> - -
- { furniForward !== FORWARD_NONE && - - } -
- - { /* --- Users to forward --- */ } - { LocalizeText('wiredfurni.params.sources.users.title.signal_forward') } -
-
- setUserForward(e.target.checked ? FORWARD_TRIGGER : FORWARD_NONE) } /> - -
- { userForward !== FORWARD_NONE && - - } -
- - { LocalizeText('wiredfurni.params.signal.options') } -
- setSignalPerFurni(e.target.checked) } /> - -
-
- setSignalPerUser(e.target.checked) } /> - -
- } - + { LocalizeText('wiredfurni.params.signal.options') } +
+ setSignalPerFurni(e.target.checked) } /> + +
+
+ setSignalPerUser(e.target.checked) } /> + +
); diff --git a/src/components/wired/views/actions/WiredActionSetFurniStateToView.tsx b/src/components/wired/views/actions/WiredActionSetFurniStateToView.tsx index f7ee4c3..8f31754 100644 --- a/src/components/wired/views/actions/WiredActionSetFurniStateToView.tsx +++ b/src/components/wired/views/actions/WiredActionSetFurniStateToView.tsx @@ -1,5 +1,5 @@ import { FC, useEffect, useState } from 'react'; -import { LocalizeText, WiredFurniType, WiredSelectionVisualizer } from '../../../../api'; +import { LocalizeText, WiredFurniType } from '../../../../api'; import { Text } from '../../../../common'; import { useWired } from '../../../../hooks'; import { WiredActionBaseView } from './WiredActionBaseView'; @@ -10,7 +10,7 @@ export const WiredActionSetFurniStateToView: FC<{}> = props => const [ stateFlag, setStateFlag ] = useState(0); const [ directionFlag, setDirectionFlag ] = useState(0); const [ positionFlag, setPositionFlag ] = useState(0); - const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null } = useWired(); + const { trigger = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { if(trigger?.intData?.length > 3) return trigger.intData[3]; @@ -29,23 +29,9 @@ export const WiredActionSetFurniStateToView: FC<{}> = props => else setFurniSource((trigger.selectedItems?.length ?? 0) > 0 ? 100 : 0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } + const onChangeFurniSource = (next: number) => setFurniSource(next); - setFurniSource(next); - }; - - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID; return ( = props => { - const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null } = useWired(); + const { trigger = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { @@ -31,25 +31,11 @@ export const WiredActionTeleportView: FC<{}> = props => else setUserSource(0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } - - setFurniSource(next); - }; + const onChangeFurniSource = (next: number) => setFurniSource(next); const save = () => setIntParams([ furniSource, userSource ]); - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_BY_TYPE_OR_FROM_CONTEXT - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_BY_TYPE_OR_FROM_CONTEXT; return ( = props => { - const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null } = useWired(); + const { trigger = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { if(trigger?.intData?.length >= 1) return trigger.intData[0]; @@ -21,25 +21,11 @@ export const WiredActionToggleFurniStateView: FC<{}> = props => else setFurniSource((trigger.selectedItems?.length ?? 0) > 0 ? 100 : 0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } - - setFurniSource(next); - }; + const onChangeFurniSource = (next: number) => setFurniSource(next); const save = () => setIntParams([ furniSource ]); - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_BY_TYPE_OR_FROM_CONTEXT - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_BY_TYPE_OR_FROM_CONTEXT; return ( = props => { - const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null } = useWired(); + const { trigger = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { if(trigger?.intData?.length > 0) return trigger.intData[0]; @@ -29,25 +29,11 @@ export const WiredConditionActorIsOnFurniView: FC<{}> = props => else setUserSource(0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } - - setFurniSource(next); - }; + const onChangeFurniSource = (next: number) => setFurniSource(next); const save = () => setIntParams([ furniSource, userSource ]); - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID; return ( = props => { - const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null } = useWired(); + const { trigger = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { if(trigger?.intData?.length >= 1) return trigger.intData[0]; @@ -20,25 +20,11 @@ export const WiredConditionFurniHasAvatarOnView: FC<{}> = props => else setFurniSource((trigger.selectedItems?.length ?? 0) > 0 ? 100 : 0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } - - setFurniSource(next); - }; + const onChangeFurniSource = (next: number) => setFurniSource(next); const save = () => setIntParams([ furniSource ]); - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID; return ( = props => { const [ requireAll, setRequireAll ] = useState(-1); - const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null } = useWired(); + const { trigger = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { if(trigger?.intData?.length > 1) return trigger.intData[1]; @@ -24,23 +24,9 @@ export const WiredConditionFurniHasFurniOnView: FC<{}> = props => else setFurniSource((trigger.selectedItems?.length ?? 0) > 0 ? 100 : 0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } + const onChangeFurniSource = (next: number) => setFurniSource(next); - setFurniSource(next); - }; - - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID; return ( = props => { const [ requireAll, setRequireAll ] = useState(-1); - const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null } = useWired(); + const { trigger = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { if(trigger?.intData?.length > 1) return trigger.intData[1]; @@ -24,23 +24,9 @@ export const WiredConditionFurniHasNotFurniOnView: FC<{}> = props => else setFurniSource((trigger.selectedItems?.length ?? 0) > 0 ? 100 : 0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } + const onChangeFurniSource = (next: number) => setFurniSource(next); - setFurniSource(next); - }; - - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID; return ( = props => { - const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null } = useWired(); + const { trigger = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { if(trigger?.intData?.length >= 1) return trigger.intData[0]; @@ -20,25 +20,11 @@ export const WiredConditionFurniIsOfTypeView: FC<{}> = props => else setFurniSource((trigger.selectedItems?.length ?? 0) > 0 ? 100 : 0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } - - setFurniSource(next); - }; + const onChangeFurniSource = (next: number) => setFurniSource(next); const save = () => setIntParams([ furniSource ]); - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_OR_BY_TYPE - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID_OR_BY_TYPE; return ( = props => const [ stateFlag, setStateFlag ] = useState(0); const [ directionFlag, setDirectionFlag ] = useState(0); const [ positionFlag, setPositionFlag ] = useState(0); - const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null } = useWired(); + const { trigger = null, setIntParams = null } = useWired(); const [ furniSource, setFurniSource ] = useState(() => { if(trigger?.intData?.length > 3) return trigger.intData[3]; @@ -28,23 +28,9 @@ export const WiredConditionFurniMatchesSnapshotView: FC<{}> = props => else setFurniSource((trigger.selectedItems?.length ?? 0) > 0 ? 100 : 0); }, [ trigger ]); - const onChangeFurniSource = (next: number) => - { - if(furniIds.length && setFurniIds) - { - setFurniIds(prev => - { - if(prev && prev.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prev); - return []; - }); - } + const onChangeFurniSource = (next: number) => setFurniSource(next); - setFurniSource(next); - }; - - const requiresFurni = (furniSource === 100) - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID; return ( = () => { - const [ sourceType, setSourceType ] = useState(SOURCE_FURNI_PICKED); const [ matchState, setMatchState ] = useState(false); const [ filterExisting, setFilterExisting ] = useState(false); const [ invert, setInvert ] = useState(false); - const { trigger = null, furniIds = [], setIntParams, setSelectByType, setInvertSelection } = useWired(); + const { trigger = null, setIntParams, setSelectByType } = useWired(); useEffect(() => { if(!trigger) return; const p = trigger.intData; - if(p.length >= 1) setSourceType(p[0]); if(p.length >= 2) setMatchState(p[1] === 1); if(p.length >= 3) setFilterExisting(p[2] === 1); if(p.length >= 4) setInvert(p[3] === 1); @@ -37,38 +26,20 @@ export const WiredSelectorFurniByTypeView: FC<{}> = () => useEffect(() => { - setSelectByType(sourceType === SOURCE_FURNI_PICKED); - }, [ sourceType, setSelectByType ]); - - useEffect(() => - { - setInvertSelection(invert); - }, [ invert, setInvertSelection ]); + setSelectByType(true); + }, [ setSelectByType ]); const save = useCallback(() => { setIntParams([ - sourceType, + SOURCE_FURNI_PICKED, matchState ? 1 : 0, filterExisting ? 1 : 0, invert ? 1 : 0, ]); - }, [ sourceType, matchState, filterExisting, invert, setIntParams ]); + }, [ matchState, filterExisting, invert, setIntParams ]); - const sourceIndex = SOURCES.findIndex(s => s.value === sourceType); - - const prevSource = () => - setSourceType(SOURCES[(sourceIndex - 1 + SOURCES.length) % SOURCES.length].value); - - const nextSource = () => - setSourceType(SOURCES[(sourceIndex + 1) % SOURCES.length].value); - - const requiresFurni = sourceType === SOURCE_FURNI_PICKED - ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID - : WiredFurniType.STUFF_SELECTION_OPTION_NONE; - - const pickedCount = furniIds.length; - const pickedLimit = trigger?.maximumItemSelectionCount ?? 20; + const requiresFurni = WiredFurniType.STUFF_SELECTION_OPTION_BY_ID; return ( @@ -104,22 +75,6 @@ export const WiredSelectorFurniByTypeView: FC<{}> = () => onChange={ e => setInvert(e.target.checked) } /> { LocalizeText('wiredfurni.params.selector_option.1') } - -
- - { LocalizeText('wiredfurni.params.sources.furni.title') } - -
- -
- { LocalizeText(SOURCES[sourceIndex >= 0 ? sourceIndex : 0].label) } -
- -
); diff --git a/src/components/wired/views/triggers/WiredTriggerExecutePeriodicallyLongView.tsx b/src/components/wired/views/triggers/WiredTriggerExecutePeriodicallyLongView.tsx index 7b9a766..6cb4726 100644 --- a/src/components/wired/views/triggers/WiredTriggerExecutePeriodicallyLongView.tsx +++ b/src/components/wired/views/triggers/WiredTriggerExecutePeriodicallyLongView.tsx @@ -1,6 +1,6 @@ import { FC, useEffect, useState } from 'react'; import { FriendlyTime, LocalizeText, WiredFurniType } from '../../../../api'; -import { Text } from '../../../../common'; +import { Slider, Text } from '../../../../common'; import { useWired } from '../../../../hooks'; import { WiredTriggerBaseView } from './WiredTriggerBaseView'; diff --git a/src/components/wired/views/triggers/WiredTriggerExecutePeriodicallyView.tsx b/src/components/wired/views/triggers/WiredTriggerExecutePeriodicallyView.tsx index 7c3056a..ebed0ee 100644 --- a/src/components/wired/views/triggers/WiredTriggerExecutePeriodicallyView.tsx +++ b/src/components/wired/views/triggers/WiredTriggerExecutePeriodicallyView.tsx @@ -1,6 +1,6 @@ import { FC, useEffect, useState } from 'react'; import { GetWiredTimeLocale, LocalizeText, WiredFurniType } from '../../../../api'; -import { Text } from '../../../../common'; +import { Slider, Text } from '../../../../common'; import { useWired } from '../../../../hooks'; import { WiredTriggerBaseView } from './WiredTriggerBaseView'; diff --git a/src/components/wired/views/triggers/WiredTriggerReceiveSignalView.tsx b/src/components/wired/views/triggers/WiredTriggerReceiveSignalView.tsx index 036f81d..30bc9a6 100644 --- a/src/components/wired/views/triggers/WiredTriggerReceiveSignalView.tsx +++ b/src/components/wired/views/triggers/WiredTriggerReceiveSignalView.tsx @@ -4,12 +4,14 @@ import { Text } from '../../../../common'; import { useWired } from '../../../../hooks'; import { WiredTriggerBaseView } from './WiredTriggerBaseView'; +const ANTENNA_INTERACTION_TYPES = [ 'antenna' ]; + export const WiredTriggerReceiveSignalView: FC<{}> = () => { const [ senderCount, setSenderCount ] = useState(0); const [ maxSenders, setMaxSenders ] = useState(5); - const { trigger = null } = useWired(); + const { trigger = null, setAllowedInteractionTypes } = useWired(); useEffect(() => { @@ -20,8 +22,15 @@ export const WiredTriggerReceiveSignalView: FC<{}> = () => if(p.length >= 3) setMaxSenders(p[2]); }, [ trigger ]); + useEffect(() => + { + setAllowedInteractionTypes(ANTENNA_INTERACTION_TYPES); + + return () => setAllowedInteractionTypes(null); + }, [ setAllowedInteractionTypes ]); + return ( - +
{ LocalizeText('wiredfurni.params.signal.senders_connected') } { senderCount }/{ maxSenders } diff --git a/src/hooks/wired/useWired.ts b/src/hooks/wired/useWired.ts index b455766..b0c3f32 100644 --- a/src/hooks/wired/useWired.ts +++ b/src/hooks/wired/useWired.ts @@ -14,9 +14,9 @@ const useWiredState = () => const [ actionDelay, setActionDelay ] = useState(0); const [ allowsFurni, setAllowsFurni ] = useState(WiredFurniType.STUFF_SELECTION_OPTION_NONE); const [ selectByType, setSelectByType ] = useState(false); - const [ invertSelection, setInvertSelection ] = useState(false); const [ neighborhoodTiles, setNeighborhoodTiles ] = useState<{ x: number; y: number }[] | null>(null); const [ neighborhoodInvert, setNeighborhoodInvert ] = useState(false); + const [ allowedInteractionTypes, setAllowedInteractionTypes ] = useState(null); const { showConfirm = null, simpleAlert = null } = useNotification(); const saveWired = () => @@ -60,6 +60,30 @@ const useWiredState = () => if(objectId <= 0) return; + const getInteractionTypeName = (furniData: any): string => + { + if(!furniData) return null; + + const rawValue = (furniData as any).interactionType + ?? (furniData as any).interactionTypeName + ?? (furniData as any).interactionTypeId; + + if(rawValue === undefined || rawValue === null) return null; + if(typeof rawValue !== 'string') return null; + + return rawValue.toLowerCase(); + }; + + const isAllowedInteraction = (furniData: any): boolean => + { + if(!allowedInteractionTypes || !allowedInteractionTypes.length) return true; + + const interactionType = getInteractionTypeName(furniData); + if(!interactionType) return true; + + return allowedInteractionTypes.some(type => (type && type.toLowerCase() === interactionType)); + }; + if(selectByType && category === RoomObjectCategory.FLOOR) { const roomId = GetRoomSession().roomId; @@ -71,6 +95,21 @@ const useWiredState = () => const sourceFurniData = GetSessionDataManager().getFloorItemData(typeId); if(!sourceFurniData) return; + if(!isAllowedInteraction(sourceFurniData)) + { + setFurniIds(prevValue => + { + if(!prevValue.includes(objectId)) return prevValue; + + const remaining = prevValue.filter(id => id !== objectId); + + WiredSelectionVisualizer.hide(objectId); + + return remaining; + }); + + return; + } const matchFurniLine = sourceFurniData.furniLine; const matchName = sourceFurniData.name; @@ -84,6 +123,8 @@ const useWiredState = () => const fd = GetSessionDataManager().getFloorItemData(tId); if(!fd) return false; + if(!isAllowedInteraction(fd)) return false; + const furniLineMatch = matchFurniLine && matchFurniLine.length > 0 && fd.furniLine === matchFurniLine; return furniLineMatch || fd.name === matchName; }; @@ -102,10 +143,8 @@ const useWiredState = () => } // ── Select a new group ────────────────────────────────────── - if(prevValue && prevValue.length) WiredSelectionVisualizer.clearSelectionShaderFromFurni(prevValue); - const allFloorObjects = GetRoomEngine().getRoomObjects(roomId, RoomObjectCategory.FLOOR); - const newIds: number[] = []; + const newIds = [ ...prevValue ]; const limit = trigger.maximumItemSelectionCount; for(const obj of allFloorObjects) @@ -116,14 +155,16 @@ const useWiredState = () => const tId = obj.model.getValue(RoomObjectVariable.FURNITURE_TYPE_ID); const fd = GetSessionDataManager().getFloorItemData(tId); if(!fd) continue; + if(!isAllowedInteraction(fd)) continue; const furniLineMatch = matchFurniLine && matchFurniLine.length > 0 && fd.furniLine === matchFurniLine; const matches = furniLineMatch || fd.name === matchName; - if(invertSelection ? !matches : matches) newIds.push(obj.id); + if(matches && !newIds.includes(obj.id)) newIds.push(obj.id); } - WiredSelectionVisualizer.applySelectionShaderToFurni(newIds); + const addedIds = newIds.filter(id => !prevValue.includes(id)); + if(addedIds.length) WiredSelectionVisualizer.applySelectionShaderToFurni(addedIds); return newIds; }); @@ -131,6 +172,34 @@ const useWiredState = () => return; } + if(category === RoomObjectCategory.FLOOR && allowedInteractionTypes && allowedInteractionTypes.length) + { + const roomId = GetRoomSession().roomId; + const clickedObject = GetRoomEngine().getRoomObject(roomId, objectId, RoomObjectCategory.FLOOR); + + if(!clickedObject) return; + + const typeId = clickedObject.model.getValue(RoomObjectVariable.FURNITURE_TYPE_ID); + const sourceFurniData = GetSessionDataManager().getFloorItemData(typeId); + + if(!sourceFurniData) return; + if(!isAllowedInteraction(sourceFurniData)) + { + setFurniIds(prevValue => + { + if(!prevValue.includes(objectId)) return prevValue; + + const remaining = prevValue.filter(id => id !== objectId); + + WiredSelectionVisualizer.hide(objectId); + + return remaining; + }); + + return; + } + } + setFurniIds(prevValue => { const newFurniIds = [ ...prevValue ]; @@ -217,13 +286,13 @@ const useWiredState = () => }); setAllowsFurni(WiredFurniType.STUFF_SELECTION_OPTION_NONE); setSelectByType(false); - setInvertSelection(false); setNeighborhoodTiles(null); setNeighborhoodInvert(false); + setAllowedInteractionTypes(null); }; }, [ trigger ]); - return { trigger, setTrigger, intParams, setIntParams, stringParam, setStringParam, furniIds, setFurniIds, actionDelay, setActionDelay, setAllowsFurni, saveWired, selectObjectForWired, setSelectByType, setInvertSelection, setNeighborhoodTiles, setNeighborhoodInvert }; + return { trigger, setTrigger, intParams, setIntParams, stringParam, setStringParam, furniIds, setFurniIds, actionDelay, setActionDelay, setAllowsFurni, saveWired, selectObjectForWired, setSelectByType, setNeighborhoodTiles, setNeighborhoodInvert, setAllowedInteractionTypes }; }; export const useWired = () => useBetween(useWiredState); From 2d9d889da5ef65c09c2bfb0cafeaf84d8ae0d59f Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 16 Mar 2026 18:13:52 +0100 Subject: [PATCH 02/14] feat(badges): add drag & drop system for InfoStand and inventory - Drag & drop badges between active slots in InfoStand (own user only) - Mini badge picker on empty slot click with search - Swap/reorder badges between occupied slots - Hover animation (scale, glow) on badge slots - Race condition fix: localChangeRef prevents server response from overwriting local changes - Fixed-size array logic to prevent badge disappearing on room enter - Use avatarInfo badges as fallback when hook data not yet loaded --- .../views/badge/InventoryBadgeItemView.tsx | 16 +- .../views/badge/InventoryBadgeView.tsx | 141 +++++++++++++- .../infostand/InfoStandBadgeSlotView.tsx | 173 ++++++++++++++++++ .../infostand/InfoStandWidgetUserView.tsx | 63 ++++--- src/hooks/inventory/useInventoryBadges.ts | 108 ++++++++++- 5 files changed, 458 insertions(+), 43 deletions(-) create mode 100644 src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx diff --git a/src/components/inventory/views/badge/InventoryBadgeItemView.tsx b/src/components/inventory/views/badge/InventoryBadgeItemView.tsx index 4553621..4bf666b 100644 --- a/src/components/inventory/views/badge/InventoryBadgeItemView.tsx +++ b/src/components/inventory/views/badge/InventoryBadgeItemView.tsx @@ -11,8 +11,22 @@ export const InventoryBadgeItemView: FC const { isUnseen = null } = useInventoryUnseenTracker(); const unseen = isUnseen(UnseenItemCategory.BADGE, getBadgeId(badgeCode)); + const onDragStart = (event: React.DragEvent) => + { + event.dataTransfer.setData('badgeCode', badgeCode); + event.dataTransfer.setData('source', 'inventory'); + event.dataTransfer.effectAllowed = 'move'; + }; + return ( - toggleBadge(selectedBadgeCode) } onMouseDown={ event => setSelectedBadgeCode(badgeCode) } { ...rest }> + toggleBadge(selectedBadgeCode) } + onDragStart={ onDragStart } + onMouseDown={ event => setSelectedBadgeCode(badgeCode) } + { ...rest }> { children } diff --git a/src/components/inventory/views/badge/InventoryBadgeView.tsx b/src/components/inventory/views/badge/InventoryBadgeView.tsx index 8a60522..1bf6d13 100644 --- a/src/components/inventory/views/badge/InventoryBadgeView.tsx +++ b/src/components/inventory/views/badge/InventoryBadgeView.tsx @@ -1,5 +1,5 @@ import { DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer'; -import { FC, useEffect, useState } from 'react'; +import { FC, useCallback, useEffect, useState } from 'react'; import { FaTrashAlt } from 'react-icons/fa'; import { LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api'; import { LayoutBadgeImageView } from '../../../../common'; @@ -7,14 +7,74 @@ import { useInventoryBadges, useInventoryUnseenTracker, useNotification } from ' import { InfiniteGrid, NitroButton } from '../../../../layout'; import { InventoryBadgeItemView } from './InventoryBadgeItemView'; +const ActiveBadgeSlot: FC<{ + slotIndex: number; + badgeCode?: string; + onDropBadge: (badgeCode: string, slotIndex: number, sourceSlot?: number) => void; + onRemoveBadge: (badgeCode: string) => void; + onDragStartFromSlot: (event: React.DragEvent, badgeCode: string, slotIndex: number) => void; + onSelectBadge: (badgeCode: string) => void; + isSelected: boolean; +}> = ({ slotIndex, badgeCode, onDropBadge, onRemoveBadge, onDragStartFromSlot, onSelectBadge, isSelected }) => +{ + const [ isDragOver, setIsDragOver ] = useState(false); + + const onDragOver = useCallback((event: React.DragEvent) => + { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + setIsDragOver(true); + }, []); + + const onDragLeave = useCallback(() => setIsDragOver(false), []); + + const onDrop = useCallback((event: React.DragEvent) => + { + event.preventDefault(); + setIsDragOver(false); + + const droppedBadgeCode = event.dataTransfer.getData('badgeCode'); + const sourceSlotStr = event.dataTransfer.getData('activeSlot'); + const sourceSlot = sourceSlotStr ? parseInt(sourceSlotStr) : undefined; + + if(droppedBadgeCode) onDropBadge(droppedBadgeCode, slotIndex, sourceSlot); + }, [ slotIndex, onDropBadge ]); + + const onDragStart = useCallback((event: React.DragEvent) => + { + if(!badgeCode) return; + onDragStartFromSlot(event, badgeCode, slotIndex); + }, [ badgeCode, slotIndex, onDragStartFromSlot ]); + + return ( +
badgeCode && onSelectBadge(badgeCode) }> + { badgeCode + ? + : { slotIndex + 1 } } +
+ ); +}; + export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props => { const { filteredBadgeCodes = null } = props; const [ isVisible, setIsVisible ] = useState(false); - const { badgeCodes = [], activeBadgeCodes = [], selectedBadgeCode = null, isWearingBadge = null, canWearBadges = null, toggleBadge = null, getBadgeId = null, activate = null, deactivate = null } = useInventoryBadges(); + const { badgeCodes = [], activeBadgeCodes = [], selectedBadgeCode = null, isWearingBadge = null, canWearBadges = null, toggleBadge = null, getBadgeId = null, setBadgeAtSlot = null, removeBadge = null, reorderBadges = null, setSelectedBadgeCode = null, activate = null, deactivate = null } = useInventoryBadges(); const { isUnseen = null, removeUnseen = null } = useInventoryUnseenTracker(); const { showConfirm = null } = useNotification(); + const [ isDragOverInventory, setIsDragOverInventory ] = useState(false); + const maxSlots = 5; const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes); const attemptDeleteBadge = () => @@ -31,6 +91,58 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props = ); }; + const handleDropOnSlot = useCallback((badgeCode: string, slotIndex: number, sourceSlot?: number) => + { + if(sourceSlot !== undefined) + { + // Reorder within active badges + reorderBadges(sourceSlot, slotIndex); + } + else + { + // Drop from inventory to active slot + setBadgeAtSlot(badgeCode, slotIndex); + } + }, [ setBadgeAtSlot, reorderBadges ]); + + const handleDragStartFromSlot = useCallback((event: React.DragEvent, badgeCode: string, slotIndex: number) => + { + event.dataTransfer.setData('badgeCode', badgeCode); + event.dataTransfer.setData('activeSlot', slotIndex.toString()); + event.dataTransfer.setData('source', 'active'); + event.dataTransfer.effectAllowed = 'move'; + }, []); + + const handleRemoveBadge = useCallback((badgeCode: string) => + { + removeBadge(badgeCode); + }, [ removeBadge ]); + + // Handle drop on inventory area (remove from active) + const onInventoryDragOver = useCallback((event: React.DragEvent) => + { + const source = event.dataTransfer.types.includes('activeslot') ? 'active' : ''; + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + setIsDragOverInventory(true); + }, []); + + const onInventoryDragLeave = useCallback(() => setIsDragOverInventory(false), []); + + const onInventoryDrop = useCallback((event: React.DragEvent) => + { + event.preventDefault(); + setIsDragOverInventory(false); + + const badgeCode = event.dataTransfer.getData('badgeCode'); + const source = event.dataTransfer.getData('source'); + + if(source === 'active' && badgeCode) + { + removeBadge(badgeCode); + } + }, [ removeBadge ]); + useEffect(() => { if(!selectedBadgeCode || !isUnseen(UnseenItemCategory.BADGE, getBadgeId(selectedBadgeCode))) return; @@ -56,7 +168,11 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props = return (
-
+
columnCount={ 5 } estimateSize={ 50 } @@ -66,11 +182,20 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
{ LocalizeText('inventory.badges.activebadges') } - - columnCount={ 3 } - estimateSize={ 50 } - itemRender={ item => } - items={ activeBadgeCodes } /> +
+ { Array.from({ length: maxSlots }).map((_, index) => ( + + )) } +
{ !!selectedBadgeCode &&
diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx new file mode 100644 index 0000000..951df53 --- /dev/null +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx @@ -0,0 +1,173 @@ +import { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { FaPlus } from 'react-icons/fa'; +import { LayoutBadgeImageView } from '../../../../../common'; +import { useInventoryBadges } from '../../../../../hooks'; + +interface InfoStandBadgeSlotProps +{ + slotIndex: number; + badgeCode?: string; + isOwnUser: boolean; +} + +const BadgeMiniPicker: FC<{ + onSelect: (badgeCode: string) => void; + onClose: () => void; + activeBadgeCodes: string[]; +}> = ({ onSelect, onClose, activeBadgeCodes }) => +{ + const { badgeCodes = [], requestBadges = null } = useInventoryBadges(); + const ref = useRef(null); + const [ search, setSearch ] = useState(''); + + useEffect(() => + { + if(badgeCodes.length === 0) requestBadges(); + }, []); + + const availableBadges = badgeCodes.filter(code => !activeBadgeCodes.includes(code)); + const filtered = search.length > 0 + ? availableBadges.filter(code => code.toLowerCase().includes(search.toLowerCase())) + : availableBadges; + + useEffect(() => + { + const handleClickOutside = (event: MouseEvent) => + { + if(ref.current && !ref.current.contains(event.target as Node)) onClose(); + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [ onClose ]); + + return ( +
e.stopPropagation() }> + setSearch(e.target.value) } + /> + { badgeCodes.length === 0 + ? Caricamento... + : ( +
+ { filtered.slice(0, 40).map(code => ( +
onSelect(code) }> + +
+ )) } + { filtered.length === 0 && ( + Nessun badge + ) } +
+ ) } +
+ ); +}; + +export const InfoStandBadgeSlotView: FC = ({ slotIndex, badgeCode: badgeCodeFromProps, isOwnUser }) => +{ + const { activeBadgeCodes = [], setBadgeAtSlot = null, swapBadges = null } = useInventoryBadges(); + const [ isDragOver, setIsDragOver ] = useState(false); + const [ showPicker, setShowPicker ] = useState(false); + + // For own user: use hook data if loaded, otherwise fall back to props (avatarInfo) + // For other users: always use props + const hookBadge = activeBadgeCodes.length > 0 ? (activeBadgeCodes[slotIndex] ?? null) : null; + const badgeCode = isOwnUser ? (hookBadge ?? badgeCodeFromProps ?? null) : (badgeCodeFromProps ?? null); + + const onDragStart = useCallback((event: React.DragEvent) => + { + if(!badgeCode || !isOwnUser) return; + event.dataTransfer.setData('badgeCode', badgeCode); + event.dataTransfer.setData('infostandSlot', slotIndex.toString()); + event.dataTransfer.effectAllowed = 'move'; + }, [ badgeCode, slotIndex, isOwnUser ]); + + const onDragOver = useCallback((event: React.DragEvent) => + { + if(!isOwnUser) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + setIsDragOver(true); + }, [ isOwnUser ]); + + const onDragLeave = useCallback(() => setIsDragOver(false), []); + + const onDrop = useCallback((event: React.DragEvent) => + { + event.preventDefault(); + setIsDragOver(false); + if(!isOwnUser) return; + + const droppedBadgeCode = event.dataTransfer.getData('badgeCode'); + const sourceSlotStr = event.dataTransfer.getData('infostandSlot'); + + if(!droppedBadgeCode) return; + + if(sourceSlotStr !== '') + { + // Dragged from another infostand slot -> always swap (works with empty slots too) + const sourceSlot = parseInt(sourceSlotStr); + + if(sourceSlot !== slotIndex) swapBadges(sourceSlot, slotIndex); + } + else + { + // Dragged from inventory or external -> place at this slot + setBadgeAtSlot(droppedBadgeCode, slotIndex); + } + }, [ isOwnUser, slotIndex, swapBadges, setBadgeAtSlot ]); + + const handleSlotClick = useCallback(() => + { + if(!isOwnUser || badgeCode) return; + + setShowPicker(true); + }, [ isOwnUser, badgeCode ]); + + const handlePickerSelect = useCallback((code: string) => + { + setBadgeAtSlot(code, slotIndex); + setShowPicker(false); + }, [ setBadgeAtSlot, slotIndex ]); + + return ( +
+
+ { badgeCode + ? + : isOwnUser && } +
+ { showPicker && ( + setShowPicker(false) } + onSelect={ handlePickerSelect } + /> + ) } +
+ ); +}; diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx index a53e35c..1791979 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx @@ -4,6 +4,7 @@ import { FaPencilAlt, FaTimes } from 'react-icons/fa'; import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api'; import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserProfileIconView } from '../../../../../common'; import { useMessageEvent, useNitroEvent, useRoom } from '../../../../../hooks'; +import { InfoStandBadgeSlotView } from './InfoStandBadgeSlotView'; import { InfoStandWidgetUserRelationshipsView } from './InfoStandWidgetUserRelationshipsView'; import { InfoStandWidgetUserTagsView } from './InfoStandWidgetUserTagsView'; import { BackgroundsView } from '../../../../backgrounds/BackgroundsView'; @@ -158,31 +159,43 @@ export const InfoStandWidgetUserView: FC = props = /> )} -
-
- {avatarInfo.badges[0] && } -
- 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}> - {avatarInfo.groupId > 0 && - } - -
- -
- {avatarInfo.badges[1] && } -
-
- {avatarInfo.badges[2] && } -
-
- -
- {avatarInfo.badges[3] && } -
-
- {avatarInfo.badges[4] && } -
-
+ { GetConfigurationValue('user.badges.group.slot.enabled', true) + ? ( + <> +
+ + 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}> + {avatarInfo.groupId > 0 && + } + +
+ + + + + + + + + + ) + : ( + <> + + + + + + + + + + + + + + ) + }

diff --git a/src/hooks/inventory/useInventoryBadges.ts b/src/hooks/inventory/useInventoryBadges.ts index aebe155..39e0667 100644 --- a/src/hooks/inventory/useInventoryBadges.ts +++ b/src/hooks/inventory/useInventoryBadges.ts @@ -1,5 +1,5 @@ import { BadgeReceivedEvent, BadgesEvent, RequestBadgesComposer, SetActivatedBadgesComposer } from '@nitrots/nitro-renderer'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useBetween } from 'use-between'; import { GetConfigurationValue, SendMessageComposer, UnseenItemCategory } from '../../api'; import { useMessageEvent } from '../events'; @@ -17,9 +17,18 @@ const useInventoryBadgesState = () => const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker(); const maxBadgeCount = GetConfigurationValue('user.badges.max.slots', 5); + const localChangeRef = useRef(false); const isWearingBadge = (badgeCode: string) => (activeBadgeCodes.indexOf(badgeCode) >= 0); const canWearBadges = () => (activeBadgeCodes.length < maxBadgeCount); + const sendActiveBadges = (badges: string[]) => + { + localChangeRef.current = true; + const composer = new SetActivatedBadgesComposer(); + for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(badges[i] ?? ''); + SendMessageComposer(composer); + }; + const toggleBadge = (badgeCode: string) => { setActiveBadgeCodes(prevValue => @@ -30,7 +39,7 @@ const useInventoryBadgesState = () => if(index === -1) { - if(!canWearBadges()) return prevValue; + if(newValue.length >= maxBadgeCount) return prevValue; newValue.push(badgeCode); } @@ -39,11 +48,7 @@ const useInventoryBadgesState = () => newValue.splice(index, 1); } - const composer = new SetActivatedBadgesComposer(); - - for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(newValue[i] ?? ''); - - SendMessageComposer(composer); + sendActiveBadges(newValue); return newValue; }); @@ -77,7 +82,16 @@ const useInventoryBadgesState = () => return newValue; }); - setActiveBadgeCodes(parser.getActiveBadgeCodes()); + // Skip overwriting activeBadgeCodes if we recently made a local change + if(localChangeRef.current) + { + localChangeRef.current = false; + } + else + { + setActiveBadgeCodes(parser.getActiveBadgeCodes()); + } + setBadgeCodes(allBadgeCodes); }); @@ -141,7 +155,83 @@ const useInventoryBadgesState = () => setNeedsUpdate(false); }, [ isVisible, needsUpdate ]); - return { badgeCodes, activeBadgeCodes, selectedBadgeCode, setSelectedBadgeCode, isWearingBadge, canWearBadges, toggleBadge, getBadgeId, activate, deactivate }; + const setBadgeAtSlot = (badgeCode: string, slotIndex: number) => + { + setActiveBadgeCodes(prevValue => + { + // Build a fixed-size array of maxBadgeCount slots + const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null); + + // Remove badge if already in another slot + const existingIndex = slots.indexOf(badgeCode); + if(existingIndex >= 0) slots[existingIndex] = null; + + // Place badge at target slot + slots[slotIndex] = badgeCode; + + // Compact: remove nulls, keep order + const result = slots.filter(Boolean) as string[]; + + sendActiveBadges(result); + return result; + }); + }; + + const removeBadge = (badgeCode: string) => + { + setActiveBadgeCodes(prevValue => + { + const result = prevValue.filter(code => code !== badgeCode); + + sendActiveBadges(result); + return result; + }); + }; + + const reorderBadges = (fromIndex: number, toIndex: number) => + { + setActiveBadgeCodes(prevValue => + { + if(fromIndex === toIndex) return prevValue; + if(fromIndex >= prevValue.length) return prevValue; + + const newValue = [ ...prevValue ]; + const [ moved ] = newValue.splice(fromIndex, 1); + newValue.splice(toIndex, 0, moved); + + sendActiveBadges(newValue); + return newValue; + }); + }; + + const swapBadges = (fromIndex: number, toIndex: number) => + { + setActiveBadgeCodes(prevValue => + { + if(fromIndex === toIndex) return prevValue; + + // Build fixed-size array so swap works even with empty slots + const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null); + + // Swap the two slots + const temp = slots[fromIndex]; + slots[fromIndex] = slots[toIndex]; + slots[toIndex] = temp; + + // Compact: remove nulls, keep order + const result = slots.filter(Boolean) as string[]; + + sendActiveBadges(result); + return result; + }); + }; + + const requestBadges = () => + { + SendMessageComposer(new RequestBadgesComposer()); + }; + + return { badgeCodes, activeBadgeCodes, selectedBadgeCode, setSelectedBadgeCode, isWearingBadge, canWearBadges, toggleBadge, getBadgeId, setBadgeAtSlot, removeBadge, reorderBadges, swapBadges, requestBadges, activate, deactivate }; }; export const useInventoryBadges = () => useBetween(useInventoryBadgesState); From 7cee5bd066df7dd8d526f6b3c721fa45f78160cb Mon Sep 17 00:00:00 2001 From: duckietm Date: Tue, 17 Mar 2026 11:15:57 +0100 Subject: [PATCH 03/14] =?UTF-8?q?=F0=9F=86=99=20Small=20fix=20for=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../avatar-info/infostand/InfoStandBadgeSlotView.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx index 951df53..6a504d2 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx @@ -1,5 +1,6 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'; import { FaPlus } from 'react-icons/fa'; +import { LocalizeText } from '../../../../../api'; import { LayoutBadgeImageView } from '../../../../../common'; import { useInventoryBadges } from '../../../../../hooks'; @@ -49,13 +50,13 @@ const BadgeMiniPicker: FC<{ setSearch(e.target.value) } /> { badgeCodes.length === 0 - ? Caricamento... + ? { LocalizeText('generic.loading') } : (
{ filtered.slice(0, 40).map(code => ( @@ -67,7 +68,7 @@ const BadgeMiniPicker: FC<{
)) } { filtered.length === 0 && ( - Nessun badge + { LocalizeText('generic.no_results_found') } ) }
) } @@ -81,8 +82,6 @@ export const InfoStandBadgeSlotView: FC = ({ slotIndex, const [ isDragOver, setIsDragOver ] = useState(false); const [ showPicker, setShowPicker ] = useState(false); - // For own user: use hook data if loaded, otherwise fall back to props (avatarInfo) - // For other users: always use props const hookBadge = activeBadgeCodes.length > 0 ? (activeBadgeCodes[slotIndex] ?? null) : null; const badgeCode = isOwnUser ? (hookBadge ?? badgeCodeFromProps ?? null) : (badgeCodeFromProps ?? null); @@ -117,14 +116,12 @@ export const InfoStandBadgeSlotView: FC = ({ slotIndex, if(sourceSlotStr !== '') { - // Dragged from another infostand slot -> always swap (works with empty slots too) const sourceSlot = parseInt(sourceSlotStr); if(sourceSlot !== slotIndex) swapBadges(sourceSlot, slotIndex); } else { - // Dragged from inventory or external -> place at this slot setBadgeAtSlot(droppedBadgeCode, slotIndex); } }, [ isOwnUser, slotIndex, swapBadges, setBadgeAtSlot ]); From 321dff2f6d4ad97e950b127c7c440d332b2f3844 Mon Sep 17 00:00:00 2001 From: duckietm Date: Tue, 17 Mar 2026 11:46:02 +0100 Subject: [PATCH 04/14] =?UTF-8?q?=F0=9F=86=99=20Now=20always=20generate=20?= =?UTF-8?q?a=20generic=20index.css=20when=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vite.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vite.config.mjs b/vite.config.mjs index 3267645..11b36a2 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -16,7 +16,7 @@ export default defineConfig({ chunkSizeWarningLimit: 200000, rollupOptions: { output: { - assetFileNames: 'src/assets/[name].[ext]', + assetFileNames: 'src/assets/[name]-[hash].[ext]', manualChunks: id => { if(id.includes('node_modules')) From f213e891226557c5ccbb96f5f26ab900d8ba8a8a Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 16 Mar 2026 23:10:27 +0100 Subject: [PATCH 05/14] Improve mod tools UI layout and usability - Fix icon alignment using flexbox instead of absolute positioning - Add active state indicators on buttons when sub-panels are open - Add min-width constraints to prevent cramped layouts - Improve user button with placeholder text and truncated username - Improve room info panel with better spacing, clickable owner, colored owner status - Improve chatlog with scrollable container, alternating row colors, compact headers - Clean up room info header and room name display --- src/components/mod-tools/ModToolsView.tsx | 28 +++++++----- .../mod-tools/views/chatlog/ChatlogView.tsx | 19 ++++---- .../views/room/ModToolsChatlogView.tsx | 6 +-- .../mod-tools/views/room/ModToolsRoomView.tsx | 45 ++++++++++--------- 4 files changed, 54 insertions(+), 44 deletions(-) diff --git a/src/components/mod-tools/ModToolsView.tsx b/src/components/mod-tools/ModToolsView.tsx index 2d7c5c0..d42381d 100644 --- a/src/components/mod-tools/ModToolsView.tsx +++ b/src/components/mod-tools/ModToolsView.tsx @@ -117,23 +117,31 @@ export const ModToolsView: FC<{}> = props => return () => RemoveLinkEventTracker(linkTracker); }, [ openRoomInfo, closeRoomInfo, toggleRoomInfo, openRoomChatlog, closeRoomChatlog, toggleRoomChatlog, openUserInfo, closeUserInfo, toggleUserInfo, openUserChatlog, closeUserChatlog, toggleUserChatlog ]); + const isRoomInfoOpen = currentRoomId > 0 && openRooms.includes(currentRoomId); + const isRoomChatlogOpen = currentRoomId > 0 && openRoomChatlogs.includes(currentRoomId); + const isUserInfoOpen = selectedUser && openUserInfos.includes(selectedUser.userId); + return ( <> { isVisible && - + setIsVisible(false) } /> - - - - - } diff --git a/src/components/mod-tools/views/chatlog/ChatlogView.tsx b/src/components/mod-tools/views/chatlog/ChatlogView.tsx index 3aa8650..63e5201 100644 --- a/src/components/mod-tools/views/chatlog/ChatlogView.tsx +++ b/src/components/mod-tools/views/chatlog/ChatlogView.tsx @@ -46,14 +46,11 @@ export const ChatlogView: FC = props => const RoomInfo = (props: { roomId: number, roomName: string }) => { return ( - -
- Room name: - { props.roomName } -
-
- - + + { props.roomName } +
+ +
); @@ -63,7 +60,7 @@ export const ChatlogView: FC = props => <> - +
Time
User
Message
@@ -77,8 +74,8 @@ export const ChatlogView: FC = props => { row.isRoomInfo && } { !row.isRoomInfo && - - { row.timestamp } + + { row.timestamp } CreateLinkEvent(`mod-tools/open-user-info/${ row.habboId }`) }>{ row.username } { row.message } } diff --git a/src/components/mod-tools/views/room/ModToolsChatlogView.tsx b/src/components/mod-tools/views/room/ModToolsChatlogView.tsx index 394633d..c42320d 100644 --- a/src/components/mod-tools/views/room/ModToolsChatlogView.tsx +++ b/src/components/mod-tools/views/room/ModToolsChatlogView.tsx @@ -33,9 +33,9 @@ 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 5793ea7..37d9fc5 100644 --- a/src/components/mod-tools/views/room/ModToolsRoomView.tsx +++ b/src/components/mod-tools/views/room/ModToolsRoomView.tsx @@ -69,47 +69,52 @@ export const ModToolsRoomView: FC = props => }, [ roomId, infoRequested, setInfoRequested ]); return ( - - onCloseClick() } /> - + + onCloseClick() } /> + + { name && +
+ { name } +
+ }
- -
- Room Owner: - { ownerName } + +
+ Owner: + CreateLinkEvent(`mod-tools/open-user-info/${ ownerId }`) }>{ ownerName }
-
- Users in room: +
+ Users in room: { usersInRoom }
-
- Owner in room: - { ownerInRoom ? 'Yes' : 'No' } +
+ Owner here: + { ownerInRoom ? 'Yes' : 'No' }
-
+
-
+
setKickUsers(event.target.checked) } /> Kick everyone out
-
+
setLockRoom(event.target.checked) } /> Enable the doorbell
-
+
setChangeRoomName(event.target.checked) } /> Change room name
- -
- - + +
+ +
From a0d10caa79408936460c7edd99c0a0ffcca7051f Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 17 Mar 2026 13:42:36 +0100 Subject: [PATCH 06/14] feat(room-settings): add underpass walk-under-furniture checkbox Add allowUnderpass toggle to room settings UI (Info tab), allowing room owners to enable/disable walking under elevated furniture per room. Changes: - IRoomData: add allowUnderpass boolean field - NavigatorRoomSettingsView: map allowUnderpass from server data, handle changes, and send via SaveRoomSettingsComposer - NavigatorRoomSettingsBasicTabView: add checkbox below "Disabilita blocco caselle" Requires server-side PR: duckietm/Arcturus-Morningstar-Extended#12 Note: nitro-renderer changes (RoomSettingsData, RoomSettingsDataParser, SaveRoomSettingsComposer) must be applied separately in node_modules. Co-Authored-By: medievalshell --- src/api/navigator/IRoomData.ts | 1 + .../room-settings/NavigatorRoomSettingsBasicTabView.tsx | 5 +++++ .../views/room-settings/NavigatorRoomSettingsView.tsx | 7 ++++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/api/navigator/IRoomData.ts b/src/api/navigator/IRoomData.ts index 9146314..65f3c5d 100644 --- a/src/api/navigator/IRoomData.ts +++ b/src/api/navigator/IRoomData.ts @@ -11,6 +11,7 @@ export interface IRoomData tags: string[]; tradeState: number; allowWalkthrough: boolean; + allowUnderpass: boolean; lockState: number; password: string; allowPets: boolean; diff --git a/src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx b/src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx index 340d66b..58de23a 100644 --- a/src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx +++ b/src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx @@ -162,6 +162,11 @@ export const NavigatorRoomSettingsBasicTabView: FC handleChange('allow_walkthrough', event.target.checked) } /> { LocalizeText('navigator.roomsettings.allow_walk_through') } + + + handleChange('allow_underpass', event.target.checked) } /> + { LocalizeText('navigator.roomsettings.allow_underpass') } + { LocalizeText('navigator.roomsettings.delete') } diff --git a/src/components/navigator/views/room-settings/NavigatorRoomSettingsView.tsx b/src/components/navigator/views/room-settings/NavigatorRoomSettingsView.tsx index 7b9ecec..29b7f85 100644 --- a/src/components/navigator/views/room-settings/NavigatorRoomSettingsView.tsx +++ b/src/components/navigator/views/room-settings/NavigatorRoomSettingsView.tsx @@ -39,6 +39,7 @@ export const NavigatorRoomSettingsView: FC<{}> = props => tags: data.tags, tradeState: data.tradeMode, allowWalkthrough: data.allowWalkThrough, + allowUnderpass: data.allowUnderpass, lockState: data.doorMode, password: null, allowPets: data.allowPets, @@ -98,6 +99,9 @@ export const NavigatorRoomSettingsView: FC<{}> = props => case 'allow_walkthrough': newValue.allowWalkthrough = Boolean(value); break; + case 'allow_underpass': + newValue.allowUnderpass = Boolean(value); + break; case 'allow_pets': newValue.allowPets = Boolean(value); break; @@ -171,7 +175,8 @@ export const NavigatorRoomSettingsView: FC<{}> = props => newValue.chatSettings.weight, newValue.chatSettings.speed, newValue.chatSettings.distance, - newValue.chatSettings.protection + newValue.chatSettings.protection, + newValue.allowUnderpass )); return newValue; From 2973c18ead51371eb4f699e9c0d3c1f0f0583f74 Mon Sep 17 00:00:00 2001 From: duckietm Date: Tue, 17 Mar 2026 17:21:16 +0100 Subject: [PATCH 07/14] =?UTF-8?q?=F0=9F=86=99=20Small=20Camera=20Layout=20?= =?UTF-8?q?fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/camera/views/CameraWidgetCaptureView.tsx | 2 +- src/components/camera/views/editor/CameraWidgetEditorView.tsx | 2 +- src/css/index.css | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/camera/views/CameraWidgetCaptureView.tsx b/src/components/camera/views/CameraWidgetCaptureView.tsx index 4249d49..49e6d71 100644 --- a/src/components/camera/views/CameraWidgetCaptureView.tsx +++ b/src/components/camera/views/CameraWidgetCaptureView.tsx @@ -60,7 +60,7 @@ export const CameraWidgetCaptureView: FC = props = return ( - { selectedPicture && } + { selectedPicture && }
diff --git a/src/components/camera/views/editor/CameraWidgetEditorView.tsx b/src/components/camera/views/editor/CameraWidgetEditorView.tsx index d31ac35..7777164 100644 --- a/src/components/camera/views/editor/CameraWidgetEditorView.tsx +++ b/src/components/camera/views/editor/CameraWidgetEditorView.tsx @@ -155,7 +155,7 @@ export const CameraWidgetEditorView: FC = props => }, [ picture, selectedEffects ]); return ( - + processAction('close') } /> { TABS.map(tab => ( diff --git a/src/css/index.css b/src/css/index.css index 36d3b21..f71008a 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -18,6 +18,10 @@ body { scrollbar-width: thin; } +.image-rendering-pixelated { + image-rendering: pixelated; +} + *, *:focus, *:hover { From 9a6638219d2f110074001ca26c7a0a355ca2a616 Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 18 Mar 2026 09:17:04 +0100 Subject: [PATCH 08/14] =?UTF-8?q?=F0=9F=86=99=20Camera=20Fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/editor/CameraWidgetEditorView.tsx | 61 +++++++++++-------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/src/components/camera/views/editor/CameraWidgetEditorView.tsx b/src/components/camera/views/editor/CameraWidgetEditorView.tsx index 7777164..2197961 100644 --- a/src/components/camera/views/editor/CameraWidgetEditorView.tsx +++ b/src/components/camera/views/editor/CameraWidgetEditorView.tsx @@ -1,8 +1,9 @@ -import { GetRoomCameraWidgetManager, IRoomCameraWidgetEffect, IRoomCameraWidgetSelectedEffect, NitroLogger, RoomCameraWidgetSelectedEffect } from '@nitrots/nitro-renderer'; +import { GetRoomCameraWidgetManager, IRoomCameraWidgetEffect, IRoomCameraWidgetSelectedEffect, NitroLogger, RoomCameraWidgetSelectedEffect, TextureUtils } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Texture } from 'pixi.js'; import { FaSave, FaSearchMinus, FaSearchPlus, FaTrash } from 'react-icons/fa'; import { CameraEditorTabs, CameraPicture, CameraPictureThumbnail, LocalizeText } from '../../../../api'; -import { Button, Column, Flex, Grid, LayoutImage, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Slider, Text } from '../../../../common'; +import { Button, Column, Flex, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Slider, Text } from '../../../../common'; import { CameraWidgetEffectListView } from './effect-list'; export interface CameraWidgetEditorViewProps { @@ -23,10 +24,18 @@ export const CameraWidgetEditorView: FC = props => const [ selectedEffects, setSelectedEffects ] = useState([]); const [ effectsThumbnails, setEffectsThumbnails ] = useState([]); const [ isZoomed, setIsZoomed ] = useState(false); - const [ currentPictureUrl, setCurrentPictureUrl ] = useState(''); + const [ currentPictureUrl, setCurrentPictureUrl ] = useState(picture?.imageUrl ?? ''); + const [ stableTexture, setStableTexture ] = useState(null); const debounceTimerRef = useRef>(null); const requestIdRef = useRef(0); + useEffect(() => + { + const img = new Image(); + img.onload = () => setStableTexture(Texture.from(img)); + img.src = picture.imageUrl; + }, [ picture ]); + const getColorMatrixEffects = useMemo(() => { return availableEffects.filter(effect => effect.colorMatrix); }, [ availableEffects ]); @@ -108,12 +117,14 @@ export const CameraWidgetEditorView: FC = props => setSelectedEffects([]); return; case 'download': { - (async () => { - const image = new Image(); - image.src = currentPictureUrl; - const newWindow = window.open(''); - newWindow.document.write(image.outerHTML); - })(); + if(!currentPictureUrl || !currentPictureUrl.startsWith('data:image/')) return; + + const link = document.createElement('a'); + link.href = currentPictureUrl; + link.download = 'camera_photo.png'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); return; } case 'zoom': @@ -123,25 +134,29 @@ export const CameraWidgetEditorView: FC = props => }, [ availableEffects, selectedEffectName, currentPictureUrl, getSelectedEffectIndex, onCancel, onCheckout, onClose ]); useEffect(() => { + if(!stableTexture) return; + const processThumbnails = async () => { const renderedEffects = await Promise.all( availableEffects.map(effect => - GetRoomCameraWidgetManager().applyEffects(picture.texture, [ new RoomCameraWidgetSelectedEffect(effect, 1) ], false) + GetRoomCameraWidgetManager().applyEffects(stableTexture, [ new RoomCameraWidgetSelectedEffect(effect, 1) ], false) ) ); setEffectsThumbnails(renderedEffects.map((image, index) => new CameraPictureThumbnail(availableEffects[index].name, image.src))); }; processThumbnails(); - }, [ picture, availableEffects ]); + }, [ stableTexture, availableEffects ]); useEffect(() => { + if(!stableTexture) return; + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); debounceTimerRef.current = setTimeout(() => { const id = ++requestIdRef.current; GetRoomCameraWidgetManager() - .applyEffects(picture.texture, selectedEffects, false) + .applyEffects(stableTexture, selectedEffects, false) .then(imageElement => { if (id !== requestIdRef.current) return; setCurrentPictureUrl(imageElement.src); @@ -152,10 +167,10 @@ export const CameraWidgetEditorView: FC = props => return () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); }; - }, [ picture, selectedEffects ]); + }, [ stableTexture, selectedEffects ]); return ( - + processAction('close') } /> { TABS.map(tab => ( @@ -177,16 +192,14 @@ export const CameraWidgetEditorView: FC = props => - +
+ { currentPictureUrl && } +
{ selectedEffectName && ( { LocalizeText('camera.effect.name.' + selectedEffectName) } From ae7fe4c19c3be9f6bf1a6bd2cef37d6c72defd7d Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 18 Mar 2026 09:42:15 +0100 Subject: [PATCH 09/14] =?UTF-8?q?=F0=9F=86=99=20Small=20update=20to=20Came?= =?UTF-8?q?ra=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../camera/views/editor/CameraWidgetEditorView.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/camera/views/editor/CameraWidgetEditorView.tsx b/src/components/camera/views/editor/CameraWidgetEditorView.tsx index 2197961..8e7ac59 100644 --- a/src/components/camera/views/editor/CameraWidgetEditorView.tsx +++ b/src/components/camera/views/editor/CameraWidgetEditorView.tsx @@ -1,6 +1,5 @@ -import { GetRoomCameraWidgetManager, IRoomCameraWidgetEffect, IRoomCameraWidgetSelectedEffect, NitroLogger, RoomCameraWidgetSelectedEffect, TextureUtils } from '@nitrots/nitro-renderer'; +import { GetRoomCameraWidgetManager, IRoomCameraWidgetEffect, IRoomCameraWidgetSelectedEffect, NitroLogger, NitroTexture, RoomCameraWidgetSelectedEffect } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Texture } from 'pixi.js'; import { FaSave, FaSearchMinus, FaSearchPlus, FaTrash } from 'react-icons/fa'; import { CameraEditorTabs, CameraPicture, CameraPictureThumbnail, LocalizeText } from '../../../../api'; import { Button, Column, Flex, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Slider, Text } from '../../../../common'; @@ -25,14 +24,14 @@ export const CameraWidgetEditorView: FC = props => const [ effectsThumbnails, setEffectsThumbnails ] = useState([]); const [ isZoomed, setIsZoomed ] = useState(false); const [ currentPictureUrl, setCurrentPictureUrl ] = useState(picture?.imageUrl ?? ''); - const [ stableTexture, setStableTexture ] = useState(null); + const [ stableTexture, setStableTexture ] = useState(null); const debounceTimerRef = useRef>(null); const requestIdRef = useRef(0); useEffect(() => { const img = new Image(); - img.onload = () => setStableTexture(Texture.from(img)); + img.onload = () => setStableTexture(NitroTexture.from(img)); img.src = picture.imageUrl; }, [ picture ]); From d1080fafbfd52e558da896f18456e1c5db64a17e Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 18 Mar 2026 10:23:01 +0100 Subject: [PATCH 10/14] :up: Fix the save / delete button in the camera editor --- .../views/editor/CameraWidgetEditorView.tsx | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/components/camera/views/editor/CameraWidgetEditorView.tsx b/src/components/camera/views/editor/CameraWidgetEditorView.tsx index 8e7ac59..b05da0e 100644 --- a/src/components/camera/views/editor/CameraWidgetEditorView.tsx +++ b/src/components/camera/views/editor/CameraWidgetEditorView.tsx @@ -112,18 +112,27 @@ export const CameraWidgetEditorView: FC = props => return; } case 'clear_effects': - setSelectedEffectName(null); - setSelectedEffects([]); + onCancel(); return; case 'download': { - if(!currentPictureUrl || !currentPictureUrl.startsWith('data:image/')) return; + if(!currentPictureUrl) return; + + const parts = currentPictureUrl.split(','); + const mime = parts[0].match(/:(.*?);/)?.[1] || 'image/png'; + const binary = atob(parts[1]); + const bytes = new Uint8Array(binary.length); + for(let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + const blob = new Blob([ bytes ], { type: mime }); + const blobUrl = URL.createObjectURL(blob); + + const w = window.open('', '_blank'); + if(w) + { + w.document.title = 'camera_photo.png'; + w.document.body.style.margin = '0'; + w.document.body.innerHTML = ``; + } - const link = document.createElement('a'); - link.href = currentPictureUrl; - link.download = 'camera_photo.png'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); return; } case 'zoom': From c703029c34f29e5111ccdfd9604f630638ec56cb Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 18 Mar 2026 13:25:43 +0100 Subject: [PATCH 11/14] =?UTF-8?q?=F0=9F=86=99=20Fix=20whit=20background=20?= =?UTF-8?q?while=20loading=20rooms=20etc.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/css/index.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/css/index.css b/src/css/index.css index f71008a..806e0e5 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -13,6 +13,7 @@ body { width: 100%; height: 100%; overflow: hidden; + background-color: #000; -webkit-user-select: none; user-select: none; scrollbar-width: thin; From 50a0e3911a11886bd977c1d0588ec8a3035c360a Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 18 Mar 2026 13:53:14 +0100 Subject: [PATCH 12/14] =?UTF-8?q?=F0=9F=86=99=20Fix=20screen=20offset=20be?= =?UTF-8?q?ing=20stale=20after=20resize?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MainView.tsx | 1 + src/components/hotel-view/HotelView.tsx | 45 +++++++++++++++---------- src/components/room/RoomView.tsx | 3 +- src/hooks/rooms/useRoom.ts | 19 +++++++---- 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 3fef0cc..817bf2a 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -85,6 +85,7 @@ export const MainView: FC<{}> = props => { landingViewVisible && diff --git a/src/components/hotel-view/HotelView.tsx b/src/components/hotel-view/HotelView.tsx index efd3774..e0a2605 100644 --- a/src/components/hotel-view/HotelView.tsx +++ b/src/components/hotel-view/HotelView.tsx @@ -83,27 +83,36 @@ export const HotelView: FC<{}> = props => if(!container) return; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight - 55; - - const lobbyEl = container.querySelector('.nitro-hotel-view-lobby'); - - if(lobbyEl) + const centerView = () => { - const containerRect = container.getBoundingClientRect(); - const lobbyRect = lobbyEl.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight - 55; - const lobbyCenterX = (lobbyRect.left - containerRect.left) + container.scrollLeft + lobbyRect.width / 2; - const lobbyCenterY = (lobbyRect.top - containerRect.top) + container.scrollTop + lobbyRect.height / 2; + const lobbyEl = container.querySelector('.nitro-hotel-view-lobby'); - container.scrollLeft = Math.max(0, lobbyCenterX - viewportWidth / 2); - container.scrollTop = Math.max(0, lobbyCenterY - viewportHeight / 2); - } - else - { - container.scrollLeft = Math.max(0, (2600 - viewportWidth) / 2); - container.scrollTop = Math.max(0, (1425 - viewportHeight) / 2); - } + if(lobbyEl) + { + const containerRect = container.getBoundingClientRect(); + const lobbyRect = lobbyEl.getBoundingClientRect(); + + const lobbyCenterX = (lobbyRect.left - containerRect.left) + container.scrollLeft + lobbyRect.width / 2; + const lobbyCenterY = (lobbyRect.top - containerRect.top) + container.scrollTop + lobbyRect.height / 2; + + container.scrollLeft = Math.max(0, lobbyCenterX - viewportWidth / 2); + container.scrollTop = Math.max(0, lobbyCenterY - viewportHeight / 2); + } + else + { + container.scrollLeft = Math.max(0, (2600 - viewportWidth) / 2); + container.scrollTop = Math.max(0, (1425 - viewportHeight) / 2); + } + }; + + centerView(); + + window.addEventListener('resize', centerView); + + return () => window.removeEventListener('resize', centerView); }, []); const handleMouseDown = (e: React.MouseEvent) => diff --git a/src/components/room/RoomView.tsx b/src/components/room/RoomView.tsx index afec584..e2a8764 100644 --- a/src/components/room/RoomView.tsx +++ b/src/components/room/RoomView.tsx @@ -43,10 +43,11 @@ export const RoomView: FC<{}> = (props) => { -
+
{ roomSession instanceof RoomSession && <> diff --git a/src/hooks/rooms/useRoom.ts b/src/hooks/rooms/useRoom.ts index 743ce87..157ab03 100644 --- a/src/hooks/rooms/useRoom.ts +++ b/src/hooks/rooms/useRoom.ts @@ -1,7 +1,7 @@ import { ColorConverter, GetRenderer, GetRoomEngine, GetStage, IRoomSession, NitroAdjustmentFilter, NitroSprite, NitroTexture, RoomBackgroundColorEvent, RoomEngineEvent, RoomEngineObjectEvent, RoomGeometry, RoomId, RoomObjectCategory, RoomObjectHSLColorEnabledEvent, RoomObjectOperationType, RoomSessionEvent, RoomVariableEnum, Vector3d } from '@nitrots/nitro-renderer'; import { useEffect, useState } from 'react'; import { useBetween } from 'use-between'; -import { CanManipulateFurniture, DispatchUiEvent, GetRoomSession, InitializeRoomInstanceRenderingCanvas, IsFurnitureSelectionDisabled, ProcessRoomObjectOperation, RoomWidgetUpdateBackgroundColorPreviewEvent, RoomWidgetUpdateRoomObjectEvent, SetActiveRoomId, StartRoomSession } from '../../api'; +import { CanManipulateFurniture, DispatchUiEvent, GetRoomSession, IsFurnitureSelectionDisabled, ProcessRoomObjectOperation, RoomWidgetUpdateBackgroundColorPreviewEvent, RoomWidgetUpdateRoomObjectEvent, SetActiveRoomId, StartRoomSession } from '../../api'; import { useNitroEvent, useUiEvent } from '../events'; const useRoomState = () => @@ -253,15 +253,20 @@ const useRoomState = () => const resize = (event: UIEvent) => { - const width = Math.floor(window.innerWidth); - const height = Math.floor(window.innerHeight); + const newWidth = Math.floor(window.innerWidth); + const newHeight = Math.floor(window.innerHeight); - renderer.resize(width, height, window.devicePixelRatio); + const offsetX = canvas.screenOffsetX - (newWidth - canvas.width) / 2; + const offsetY = canvas.screenOffsetY - (newHeight - canvas.height) / 2; - background.width = width; - background.height = height; + renderer.resize(newWidth, newHeight, window.devicePixelRatio); - InitializeRoomInstanceRenderingCanvas(width, height, 1); + background.width = newWidth; + background.height = newHeight; + + canvas.initialize(newWidth, newHeight); + canvas.screenOffsetX = ~~offsetX; + canvas.screenOffsetY = ~~offsetY; }; window.addEventListener('resize', resize); From 6d768c92b1255e9b4be76b8866cdab029bbfd21a Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 18 Mar 2026 14:25:45 +0100 Subject: [PATCH 13/14] =?UTF-8?q?=F0=9F=86=99=20Allow=20windows=20to=20be?= =?UTF-8?q?=20dragged=20outside=20the=20view=20for=2080%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/draggable-window/DraggableWindow.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/common/draggable-window/DraggableWindow.tsx b/src/common/draggable-window/DraggableWindow.tsx index 15c7fd0..7400a5f 100644 --- a/src/common/draggable-window/DraggableWindow.tsx +++ b/src/common/draggable-window/DraggableWindow.tsx @@ -8,6 +8,7 @@ const CURRENT_WINDOWS: HTMLElement[] = []; const POS_MEMORY: Map = new Map(); const BOUNDS_THRESHOLD_TOP: number = 0; const BOUNDS_THRESHOLD_LEFT: number = 0; +const DRAG_OUTSIDE_PERCENT: number = 0.80; export interface DraggableWindowProps { uniqueKey?: Key; @@ -80,8 +81,11 @@ export const DraggableWindow: FC = props => { const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - const clampedX = Math.max(BOUNDS_THRESHOLD_LEFT, Math.min(newX, viewportWidth - windowWidth)); - const clampedY = Math.max(BOUNDS_THRESHOLD_TOP, Math.min(newY, viewportHeight - windowHeight)); + const maxOutX = windowWidth * DRAG_OUTSIDE_PERCENT; + const maxOutY = windowHeight * DRAG_OUTSIDE_PERCENT; + + const clampedX = Math.max(-maxOutX, Math.min(newX, viewportWidth - windowWidth + maxOutX)); + const clampedY = Math.max(-maxOutY, Math.min(newY, viewportHeight - windowHeight + maxOutY)); return { x: clampedX, y: clampedY }; }, []); From bffaccf6a35c4cc25ba8595ee16a0c68e8a7c778 Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 18 Mar 2026 16:52:32 +0100 Subject: [PATCH 14/14] =?UTF-8?q?=F0=9F=86=99=20Security=20Fix=20-=20Will?= =?UTF-8?q?=20not=20go=20into=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/layout/LayoutBadgeImageView.tsx | 37 ++++++++++++++----- src/components/MainView.tsx | 2 +- .../catalog/views/gift/CatalogGiftView.tsx | 8 ++++ .../page/layout/CatalogLayoutRoomAdsView.tsx | 6 +++ .../page/layout/CatalogLayoutVipBuyView.tsx | 8 +++- .../CatalogLayoutMarketplaceOwnItemsView.tsx | 16 +++++++- ...atalogLayoutMarketplacePublicItemsView.tsx | 8 +++- .../marketplace/MarketplacePostOfferView.tsx | 9 ++++- .../vip-gifts/CatalogLayoutVipGiftsView.tsx | 8 ++++ .../widgets/CatalogPurchaseWidgetView.tsx | 9 ++++- .../views/targeted-offer/OfferWindowView.tsx | 8 ++++ .../groups/views/GroupCreatorView.tsx | 7 +++- .../groups/views/GroupMembersView.tsx | 18 ++++++++- .../views/tickets/ModToolsMyIssuesTabView.tsx | 15 +++++++- .../tickets/ModToolsOpenIssuesTabView.tsx | 15 +++++++- .../views/user/ModToolsUserModActionView.tsx | 11 +++++- .../views/NavigatorRoomCreatorView.tsx | 18 ++++++++- .../NavigatorRoomSettingsRightsTabView.tsx | 19 ++++++++-- 18 files changed, 194 insertions(+), 28 deletions(-) diff --git a/src/common/layout/LayoutBadgeImageView.tsx b/src/common/layout/LayoutBadgeImageView.tsx index f1639c4..75a6533 100644 --- a/src/common/layout/LayoutBadgeImageView.tsx +++ b/src/common/layout/LayoutBadgeImageView.tsx @@ -67,11 +67,20 @@ export const LayoutBadgeImageView: FC = props => { if(event.badgeId !== badgeCode) return; - const element = await TextureUtils.generateImage(new NitroSprite(event.image)); - - console.log ('boe'); + if(isGroup) + { + const element = await TextureUtils.generateImage(new NitroSprite(event.image)); - element.onload = () => setImageElement(element); + element.onload = () => setImageElement(element); + } + else + { + const badgeUrl = GetConfigurationValue('badge.asset.url').replace('%badgename%', badgeCode.toString()); + const img = new Image(); + + img.onload = () => setImageElement(img); + img.src = badgeUrl; + } didSetBadge = true; @@ -84,13 +93,23 @@ export const LayoutBadgeImageView: FC = props => if(texture && !didSetBadge) { - (async () => + if(isGroup) { - const element = await TextureUtils.generateImage(new NitroSprite(texture)); - + (async () => + { + const element = await TextureUtils.generateImage(new NitroSprite(texture)); - element.onload = () => setImageElement(element); - })(); + element.onload = () => setImageElement(element); + })(); + } + else + { + const badgeUrl = GetConfigurationValue('badge.asset.url').replace('%badgename%', badgeCode.toString()); + const img = new Image(); + + img.onload = () => setImageElement(img); + img.src = badgeUrl; + } } return () => GetEventDispatcher().removeEventListener(BadgeImageReadyEvent.IMAGE_READY, onBadgeImageReadyEvent); diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 817bf2a..4b3217f 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -85,7 +85,7 @@ export const MainView: FC<{}> = props => { landingViewVisible && diff --git a/src/components/catalog/views/gift/CatalogGiftView.tsx b/src/components/catalog/views/gift/CatalogGiftView.tsx index c027fba..8104afb 100644 --- a/src/components/catalog/views/gift/CatalogGiftView.tsx +++ b/src/components/catalog/views/gift/CatalogGiftView.tsx @@ -7,6 +7,8 @@ import { CatalogEvent, CatalogInitGiftEvent, CatalogPurchasedEvent } from '../.. import { useCatalog, useFriends, useMessageEvent, useUiEvent } from '../../../../hooks'; import { classNames } from '../../../../layout'; +let isBuyingGift = false; + export const CatalogGiftView: FC<{}> = props => { const [ isVisible, setIsVisible ] = useState(false); @@ -32,6 +34,7 @@ export const CatalogGiftView: FC<{}> = props => const onClose = useCallback(() => { + isBuyingGift = false; setIsVisible(false); setPageId(0); setOfferId(0); @@ -122,6 +125,10 @@ export const CatalogGiftView: FC<{}> = props => return; } + if(isBuyingGift) return; + + isBuyingGift = true; + SendMessageComposer(new PurchaseFromCatalogAsGiftComposer(pageId, offerId, extraData, receiverName, message, colourId, selectedBoxIndex, selectedRibbonIndex, showMyFace)); return; } @@ -136,6 +143,7 @@ export const CatalogGiftView: FC<{}> = props => switch(event.type) { case CatalogPurchasedEvent.PURCHASE_SUCCESS: + isBuyingGift = false; onClose(); return; case CatalogEvent.INIT_GIFT: diff --git a/src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx index 4a62f88..1722db8 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx @@ -6,6 +6,8 @@ import { useCatalog, useMessageEvent, useNavigator, useRoomPromote } from '../.. import { NitroInput } from '../../../../../layout'; import { CatalogLayoutProps } from './CatalogLayout.types'; +let isPurchasingAd = false; + export const CatalogLayoutRoomAdsView: FC = props => { const { page = null } = props; @@ -45,6 +47,10 @@ export const CatalogLayoutRoomAdsView: FC = props => const purchaseAd = () => { + if(isPurchasingAd) return; + + isPurchasingAd = true; + const pageId = page.pageId; const offerId = page.offers.length >= 1 ? page.offers[0].offerId : -1; const flatId = roomId; diff --git a/src/components/catalog/views/page/layout/CatalogLayoutVipBuyView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutVipBuyView.tsx index 897fd58..1cb5283 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutVipBuyView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutVipBuyView.tsx @@ -1,5 +1,5 @@ import { ClubOfferData, GetClubOffersMessageComposer, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer'; -import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CatalogPurchaseState, LocalizeText, SendMessageComposer } from '../../../../../api'; import { AutoGrid, Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, LayoutLoadingSpinnerView, Text } from '../../../../../common'; import { CatalogEvent, CatalogPurchaseFailureEvent, CatalogPurchasedEvent } from '../../../../../events'; @@ -13,15 +13,18 @@ export const CatalogLayoutVipBuyView: FC = props => const { currentPage = null, catalogOptions = null } = useCatalog(); const { purse = null, getCurrencyAmount = null } = usePurse(); const { clubOffers = null } = catalogOptions; + const isPurchasingRef = useRef(false); const onCatalogEvent = useCallback((event: CatalogEvent) => { switch(event.type) { case CatalogPurchasedEvent.PURCHASE_SUCCESS: + isPurchasingRef.current = false; setPurchaseState(CatalogPurchaseState.NONE); return; case CatalogPurchaseFailureEvent.PURCHASE_FAILED: + isPurchasingRef.current = false; setPurchaseState(CatalogPurchaseState.FAILED); return; } @@ -83,8 +86,9 @@ export const CatalogLayoutVipBuyView: FC = props => const purchaseSubscription = useCallback(() => { - if(!pendingOffer) return; + if(!pendingOffer || isPurchasingRef.current) return; + isPurchasingRef.current = true; setPurchaseState(CatalogPurchaseState.PURCHASE); SendMessageComposer(new PurchaseFromCatalogComposer(currentPage.pageId, pendingOffer.offerId, null, 1)); }, [ pendingOffer, currentPage ]); diff --git a/src/components/catalog/views/page/layout/marketplace/CatalogLayoutMarketplaceOwnItemsView.tsx b/src/components/catalog/views/page/layout/marketplace/CatalogLayoutMarketplaceOwnItemsView.tsx index 3e1f847..4acd055 100644 --- a/src/components/catalog/views/page/layout/marketplace/CatalogLayoutMarketplaceOwnItemsView.tsx +++ b/src/components/catalog/views/page/layout/marketplace/CatalogLayoutMarketplaceOwnItemsView.tsx @@ -1,5 +1,5 @@ import { CancelMarketplaceOfferMessageComposer, GetMarketplaceOwnOffersMessageComposer, MarketplaceCancelOfferResultEvent, MarketplaceOwnOffersEvent, RedeemMarketplaceOfferCreditsMessageComposer } from '@nitrots/nitro-renderer'; -import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { LocalizeText, MarketplaceOfferData, MarketPlaceOfferState, NotificationAlertType, SendMessageComposer } from '../../../../../../api'; import { Button, Column, Text } from '../../../../../../common'; import { useMessageEvent, useNotification } from '../../../../../../hooks'; @@ -11,6 +11,8 @@ export const CatalogLayoutMarketplaceOwnItemsView: FC = prop const [ creditsWaiting, setCreditsWaiting ] = useState(0); const [ offers, setOffers ] = useState([]); const { simpleAlert = null } = useNotification(); + const isRedeemingRef = useRef(false); + const pendingCancelsRef = useRef>(new Set()); useMessageEvent(MarketplaceOwnOffersEvent, event => { @@ -54,6 +56,10 @@ export const CatalogLayoutMarketplaceOwnItemsView: FC = prop const redeemSoldOffers = useCallback(() => { + if(isRedeemingRef.current) return; + + isRedeemingRef.current = true; + setOffers(prevValue => { const idsToDelete = soldOffers.map(value => value.offerId); @@ -62,11 +68,19 @@ export const CatalogLayoutMarketplaceOwnItemsView: FC = prop }); SendMessageComposer(new RedeemMarketplaceOfferCreditsMessageComposer()); + + setTimeout(() => isRedeemingRef.current = false, 3000); }, [ soldOffers ]); const takeItemBack = (offerData: MarketplaceOfferData) => { + if(pendingCancelsRef.current.has(offerData.offerId)) return; + + pendingCancelsRef.current.add(offerData.offerId); + SendMessageComposer(new CancelMarketplaceOfferMessageComposer(offerData.offerId)); + + setTimeout(() => pendingCancelsRef.current.delete(offerData.offerId), 2000); }; useEffect(() => diff --git a/src/components/catalog/views/page/layout/marketplace/CatalogLayoutMarketplacePublicItemsView.tsx b/src/components/catalog/views/page/layout/marketplace/CatalogLayoutMarketplacePublicItemsView.tsx index b95d948..459f2ce 100644 --- a/src/components/catalog/views/page/layout/marketplace/CatalogLayoutMarketplacePublicItemsView.tsx +++ b/src/components/catalog/views/page/layout/marketplace/CatalogLayoutMarketplacePublicItemsView.tsx @@ -1,5 +1,5 @@ import { BuyMarketplaceOfferMessageComposer, GetMarketplaceOffersMessageComposer, MarketplaceBuyOfferResultEvent, MarketPlaceOffersEvent } from '@nitrots/nitro-renderer'; -import { FC, useCallback, useMemo, useState } from 'react'; +import { FC, useCallback, useMemo, useRef, useState } from 'react'; import { IMarketplaceSearchOptions, LocalizeText, MarketplaceOfferData, MarketplaceSearchType, NotificationAlertType, SendMessageComposer } from '../../../../../../api'; import { Button, Column, Text } from '../../../../../../common'; import { useMessageEvent, useNotification, usePurse } from '../../../../../../hooks'; @@ -23,6 +23,7 @@ export const CatalogLayoutMarketplacePublicItemsView: FC({ minPrice: -1, maxPrice: -1, query: '', type: 3 }); const { getCurrencyAmount = null } = usePurse(); const { simpleAlert = null, showConfirm = null } = useNotification(); + const isBuyingRef = useRef(false); const requestOffers = useCallback((options: IMarketplaceSearchOptions) => { @@ -56,6 +57,9 @@ export const CatalogLayoutMarketplacePublicItemsView: FC { + if(isBuyingRef.current) return; + + isBuyingRef.current = true; SendMessageComposer(new BuyMarketplaceOfferMessageComposer(offerId)); }, null, null, null, LocalizeText('catalog.marketplace.confirm_title')); @@ -83,6 +87,8 @@ export const CatalogLayoutMarketplacePublicItemsView: FC = props => { const [ item, setItem ] = useState(null); @@ -65,10 +67,15 @@ export const MarketplacePostOfferView: FC<{}> = props => const postItem = () => { - if(!item || (askingPrice < marketplaceConfiguration.minimumPrice)) return; + if(!item || (askingPrice < marketplaceConfiguration.minimumPrice) || isPostingMarketplaceOffer) return; showConfirm(LocalizeText('inventory.marketplace.confirm_offer.info', [ 'furniname', 'price' ], [ getFurniTitle, askingPrice.toString() ]), () => { + if(isPostingMarketplaceOffer) return; + + isPostingMarketplaceOffer = true; + setTimeout(() => isPostingMarketplaceOffer = false, 5000); + SendMessageComposer(new MakeOfferMessageComposer(askingPrice, item.isWallItem ? 2 : 1, item.id)); setItem(null); }, diff --git a/src/components/catalog/views/page/layout/vip-gifts/CatalogLayoutVipGiftsView.tsx b/src/components/catalog/views/page/layout/vip-gifts/CatalogLayoutVipGiftsView.tsx index 02daa03..f627250 100644 --- a/src/components/catalog/views/page/layout/vip-gifts/CatalogLayoutVipGiftsView.tsx +++ b/src/components/catalog/views/page/layout/vip-gifts/CatalogLayoutVipGiftsView.tsx @@ -6,6 +6,8 @@ import { useCatalog, useNotification, usePurse } from '../../../../../../hooks'; import { CatalogLayoutProps } from '../CatalogLayout.types'; import { VipGiftItem } from './VipGiftItemView'; +let isSelectingGift = false; + export const CatalogLayoutVipGiftsView: FC = props => { const { purse = null } = usePurse(); @@ -30,6 +32,10 @@ export const CatalogLayoutVipGiftsView: FC = props => { showConfirm(LocalizeText('catalog.club_gift.confirm'), () => { + if(isSelectingGift) return; + + isSelectingGift = true; + SendMessageComposer(new SelectClubGiftComposer(localizationId)); setCatalogOptions(prevValue => @@ -38,6 +44,8 @@ export const CatalogLayoutVipGiftsView: FC = props => return { ...prevValue }; }); + + setTimeout(() => isSelectingGift = false, 5000); }, null); }, [ setCatalogOptions, showConfirm ]); diff --git a/src/components/catalog/views/page/widgets/CatalogPurchaseWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogPurchaseWidgetView.tsx index 25cf610..7f2837d 100644 --- a/src/components/catalog/views/page/widgets/CatalogPurchaseWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogPurchaseWidgetView.tsx @@ -11,6 +11,8 @@ interface CatalogPurchaseWidgetViewProps purchaseCallback?: () => void; } +let isPurchasingCatalogItem = false; + export const CatalogPurchaseWidgetView: FC = props => { const { noGiftOption = false, purchaseCallback = null } = props; @@ -25,15 +27,19 @@ export const CatalogPurchaseWidgetView: FC = pro switch(event.type) { case CatalogPurchasedEvent.PURCHASE_SUCCESS: + isPurchasingCatalogItem = false; setPurchaseState(CatalogPurchaseState.NONE); return; case CatalogPurchaseFailureEvent.PURCHASE_FAILED: + isPurchasingCatalogItem = false; setPurchaseState(CatalogPurchaseState.FAILED); return; case CatalogPurchaseNotAllowedEvent.NOT_ALLOWED: + isPurchasingCatalogItem = false; setPurchaseState(CatalogPurchaseState.FAILED); return; case CatalogPurchaseSoldOutEvent.SOLD_OUT: + isPurchasingCatalogItem = false; setPurchaseState(CatalogPurchaseState.SOLD_OUT); return; } @@ -62,7 +68,7 @@ export const CatalogPurchaseWidgetView: FC = pro const purchase = (isGift: boolean = false) => { - if(!currentOffer) return; + if(!currentOffer || isPurchasingCatalogItem) return; if(GetClubMemberLevel() < currentOffer.clubLevel) { @@ -78,6 +84,7 @@ export const CatalogPurchaseWidgetView: FC = pro return; } + isPurchasingCatalogItem = true; setPurchaseState(CatalogPurchaseState.PURCHASE); if(purchaseCallback) diff --git a/src/components/catalog/views/targeted-offer/OfferWindowView.tsx b/src/components/catalog/views/targeted-offer/OfferWindowView.tsx index e3052ed..0d00732 100644 --- a/src/components/catalog/views/targeted-offer/OfferWindowView.tsx +++ b/src/components/catalog/views/targeted-offer/OfferWindowView.tsx @@ -4,6 +4,8 @@ import { FriendlyTime, GetConfigurationValue, LocalizeText, SendMessageComposer import { Button, Column, Flex, LayoutCurrencyIcon, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; import { usePurse } from '../../../../hooks'; +let isBuyingOffer = false; + export const OfferWindowView = (props: { offer: TargetedOfferData, setOpen: Dispatch> }) => { const { offer = null, setOpen = null } = props; @@ -37,8 +39,14 @@ export const OfferWindowView = (props: { offer: TargetedOfferData, setOpen: Disp const buyOffer = () => { + if(isBuyingOffer) return; + + isBuyingOffer = true; + SendMessageComposer(new PurchaseTargetedOfferComposer(offer.id, amount)); SendMessageComposer(new GetTargetedOfferComposer()); + + setTimeout(() => isBuyingOffer = false, 5000); }; if(!offer) return; diff --git a/src/components/groups/views/GroupCreatorView.tsx b/src/components/groups/views/GroupCreatorView.tsx index 5bdc086..d0d24d1 100644 --- a/src/components/groups/views/GroupCreatorView.tsx +++ b/src/components/groups/views/GroupCreatorView.tsx @@ -15,6 +15,8 @@ interface GroupCreatorViewProps const TABS: number[] = [ 1, 2, 3, 4 ]; +let isBuyingGroup = false; + export const GroupCreatorView: FC = props => { const { onClose = null } = props; @@ -34,7 +36,10 @@ export const GroupCreatorView: FC = props => const buyGroup = () => { - if(!groupData) return; + if(!groupData || isBuyingGroup) return; + + isBuyingGroup = true; + setTimeout(() => isBuyingGroup = false, 5000); const badge = []; diff --git a/src/components/groups/views/GroupMembersView.tsx b/src/components/groups/views/GroupMembersView.tsx index 198c9ca..5adc6db 100644 --- a/src/components/groups/views/GroupMembersView.tsx +++ b/src/components/groups/views/GroupMembersView.tsx @@ -1,5 +1,5 @@ import { AddLinkEventTracker, GetSessionDataManager, GroupAdminGiveComposer, GroupAdminTakeComposer, GroupConfirmMemberRemoveEvent, GroupConfirmRemoveMemberComposer, GroupMemberParser, GroupMembersComposer, GroupMembersEvent, GroupMembershipAcceptComposer, GroupMembershipDeclineComposer, GroupMembersParser, GroupRank, GroupRemoveMemberComposer, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; -import { FC, useCallback, useEffect, useState } from 'react'; +import { FC, useCallback, useEffect, useRef, useState } from 'react'; import { FaChevronLeft, FaChevronRight } from 'react-icons/fa'; import { GetUserProfile, LocalizeText, SendMessageComposer } from '../../../api'; import { Button, Column, Flex, Grid, LayoutAvatarImageView, LayoutBadgeImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../common'; @@ -16,6 +16,7 @@ export const GroupMembersView: FC<{}> = props => const [ searchQuery, setSearchQuery ] = useState(''); const [ removingMemberName, setRemovingMemberName ] = useState(null); const { showConfirm = null } = useNotification(); + const pendingActionsRef = useRef>(new Set()); const getRankDescription = (member: GroupMemberParser) => { @@ -42,6 +43,11 @@ export const GroupMembersView: FC<{}> = props => { if(!membersData.admin || (member.rank === GroupRank.OWNER)) return; + const key = `admin_${member.id}`; + if(pendingActionsRef.current.has(key)) return; + pendingActionsRef.current.add(key); + setTimeout(() => pendingActionsRef.current.delete(key), 2000); + if(member.rank !== GroupRank.ADMIN) SendMessageComposer(new GroupAdminGiveComposer(membersData.groupId, member.id)); else SendMessageComposer(new GroupAdminTakeComposer(membersData.groupId, member.id)); @@ -52,6 +58,11 @@ export const GroupMembersView: FC<{}> = props => { if(!membersData.admin || (member.rank !== GroupRank.REQUESTED)) return; + const key = `accept_${member.id}`; + if(pendingActionsRef.current.has(key)) return; + pendingActionsRef.current.add(key); + setTimeout(() => pendingActionsRef.current.delete(key), 2000); + SendMessageComposer(new GroupMembershipAcceptComposer(membersData.groupId, member.id)); refreshMembers(); @@ -61,6 +72,11 @@ export const GroupMembersView: FC<{}> = props => { if(!membersData.admin) return; + const key = `remove_${member.id}`; + if(pendingActionsRef.current.has(key)) return; + pendingActionsRef.current.add(key); + setTimeout(() => pendingActionsRef.current.delete(key), 2000); + if(member.rank === GroupRank.REQUESTED) { SendMessageComposer(new GroupMembershipDeclineComposer(membersData.groupId, member.id)); diff --git a/src/components/mod-tools/views/tickets/ModToolsMyIssuesTabView.tsx b/src/components/mod-tools/views/tickets/ModToolsMyIssuesTabView.tsx index a8de00e..9aaa441 100644 --- a/src/components/mod-tools/views/tickets/ModToolsMyIssuesTabView.tsx +++ b/src/components/mod-tools/views/tickets/ModToolsMyIssuesTabView.tsx @@ -1,5 +1,5 @@ import { IssueMessageData, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer'; -import { FC } from 'react'; +import { FC, useRef } from 'react'; import { SendMessageComposer } from '../../../../api'; import { Button, Column, Grid } from '../../../../common'; @@ -12,6 +12,17 @@ interface ModToolsMyIssuesTabViewProps export const ModToolsMyIssuesTabView: FC = props => { const { myIssues = null, handleIssue = null } = props; + const pendingReleasesRef = useRef>(new Set()); + + const releaseIssue = (issueId: number) => + { + if(pendingReleasesRef.current.has(issueId)) return; + + pendingReleasesRef.current.add(issueId); + SendMessageComposer(new ReleaseIssuesMessageComposer([ issueId ])); + + setTimeout(() => pendingReleasesRef.current.delete(issueId), 2000); + }; return ( @@ -36,7 +47,7 @@ export const ModToolsMyIssuesTabView: FC = props =
- +
); diff --git a/src/components/mod-tools/views/tickets/ModToolsOpenIssuesTabView.tsx b/src/components/mod-tools/views/tickets/ModToolsOpenIssuesTabView.tsx index 17e4901..387580b 100644 --- a/src/components/mod-tools/views/tickets/ModToolsOpenIssuesTabView.tsx +++ b/src/components/mod-tools/views/tickets/ModToolsOpenIssuesTabView.tsx @@ -1,5 +1,5 @@ import { IssueMessageData, PickIssuesMessageComposer } from '@nitrots/nitro-renderer'; -import { FC } from 'react'; +import { FC, useRef } from 'react'; import { SendMessageComposer } from '../../../../api'; import { Button, Column, Grid } from '../../../../common'; @@ -11,6 +11,17 @@ interface ModToolsOpenIssuesTabViewProps export const ModToolsOpenIssuesTabView: FC = props => { const { openIssues = null } = props; + const pendingPicksRef = useRef>(new Set()); + + const pickIssue = (issueId: number) => + { + if(pendingPicksRef.current.has(issueId)) return; + + pendingPicksRef.current.add(issueId); + SendMessageComposer(new PickIssuesMessageComposer([ issueId ], false, 0, 'pick issue button')); + + setTimeout(() => pendingPicksRef.current.delete(issueId), 2000); + }; return ( @@ -31,7 +42,7 @@ export const ModToolsOpenIssuesTabView: FC = pro
{ issue.reportedUserName }
{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }
- +
); diff --git a/src/components/mod-tools/views/user/ModToolsUserModActionView.tsx b/src/components/mod-tools/views/user/ModToolsUserModActionView.tsx index 2dcdd3e..1bea10a 100644 --- a/src/components/mod-tools/views/user/ModToolsUserModActionView.tsx +++ b/src/components/mod-tools/views/user/ModToolsUserModActionView.tsx @@ -1,5 +1,5 @@ import { CallForHelpTopicData, DefaultSanctionMessageComposer, ModAlertMessageComposer, ModBanMessageComposer, ModKickMessageComposer, ModMessageMessageComposer, ModMuteMessageComposer, ModTradingLockMessageComposer } from '@nitrots/nitro-renderer'; -import { FC, useMemo, useState } from 'react'; +import { FC, useMemo, useRef, useState } from 'react'; import { ISelectedUser, LocalizeText, ModActionDefinition, NotificationAlertType, SendMessageComposer } from '../../../../api'; import { Button, DraggableWindowPosition, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; import { useModTools, useNotification } from '../../../../hooks'; @@ -33,6 +33,7 @@ export const ModToolsUserModActionView: FC = pro const [ message, setMessage ] = useState(''); const { cfhCategories = null, settings = null } = useModTools(); const { simpleAlert = null } = useNotification(); + const isSendingRef = useRef(false); const topics = useMemo(() => { @@ -53,6 +54,8 @@ export const ModToolsUserModActionView: FC = pro const sendDefaultSanction = () => { + if(isSendingRef.current) return; + let errorMessage: string = null; const category = topics[selectedTopic]; @@ -63,6 +66,8 @@ export const ModToolsUserModActionView: FC = pro const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message; + isSendingRef.current = true; + SendMessageComposer(new DefaultSanctionMessageComposer(user.userId, selectedTopic, messageOrDefault)); onCloseClick(); @@ -70,6 +75,8 @@ export const ModToolsUserModActionView: FC = pro const sendSanction = () => { + if(isSendingRef.current) return; + let errorMessage: string = null; const category = topics[selectedTopic]; @@ -145,6 +152,8 @@ export const ModToolsUserModActionView: FC = pro } } + isSendingRef.current = true; + onCloseClick(); }; diff --git a/src/components/navigator/views/NavigatorRoomCreatorView.tsx b/src/components/navigator/views/NavigatorRoomCreatorView.tsx index 0b4adf4..9307a4b 100644 --- a/src/components/navigator/views/NavigatorRoomCreatorView.tsx +++ b/src/components/navigator/views/NavigatorRoomCreatorView.tsx @@ -6,6 +6,9 @@ import { Button, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, Text } from '.. import { useNavigator } from '../../../hooks'; import { NitroInput } from '../../../layout'; +let isCreatingRoom = false; +let createRoomTimeout: ReturnType = null; + export const NavigatorRoomCreatorView: FC<{}> = props => { const [ maxVisitorsList, setMaxVisitorsList ] = useState(null); @@ -16,6 +19,7 @@ export const NavigatorRoomCreatorView: FC<{}> = props => const [ tradesSetting, setTradesSetting ] = useState(0); const [ roomModels, setRoomModels ] = useState([]); const [ selectedModelName, setSelectedModelName ] = useState(''); + const [ isCreating, setIsCreating ] = useState(isCreatingRoom); const { categories = null } = useNavigator(); const hcDisabled = GetConfigurationValue('hc.disabled', false); @@ -31,7 +35,19 @@ export const NavigatorRoomCreatorView: FC<{}> = props => const createRoom = () => { + if(isCreatingRoom) return; + + isCreatingRoom = true; + setIsCreating(true); + SendMessageComposer(new CreateFlatMessageComposer(name, description, 'model_' + selectedModelName, Number(category), Number(visitorsCount), tradesSetting)); + + if(createRoomTimeout) clearTimeout(createRoomTimeout); + createRoomTimeout = setTimeout(() => + { + isCreatingRoom = false; + setIsCreating(false); + }, 5000); }; useEffect(() => @@ -117,7 +133,7 @@ export const NavigatorRoomCreatorView: FC<{}> = props => }
- +
); }; diff --git a/src/components/navigator/views/room-settings/NavigatorRoomSettingsRightsTabView.tsx b/src/components/navigator/views/room-settings/NavigatorRoomSettingsRightsTabView.tsx index 3f77676..6f166ae 100644 --- a/src/components/navigator/views/room-settings/NavigatorRoomSettingsRightsTabView.tsx +++ b/src/components/navigator/views/room-settings/NavigatorRoomSettingsRightsTabView.tsx @@ -1,5 +1,5 @@ import { FlatControllerAddedEvent, FlatControllerRemovedEvent, FlatControllersEvent, RemoveAllRightsMessageComposer, RoomGiveRightsComposer, RoomTakeRightsComposer, RoomUsersWithRightsComposer } from '@nitrots/nitro-renderer'; -import { FC, useEffect, useState } from 'react'; +import { FC, useEffect, useRef, useState } from 'react'; import { IRoomData, LocalizeText, SendMessageComposer } from '../../../../api'; import { Button, Column, Flex, Grid, Text, UserProfileIconView } from '../../../../common'; import { useFriends, useMessageEvent } from '../../../../hooks'; @@ -18,6 +18,17 @@ export const NavigatorRoomSettingsRightsTabView: FC>(new Map()); const { onlineFriends = [], offlineFriends = [] } = useFriends(); + const pendingActionsRef = useRef>(new Set()); + + const guardedSend = (key: string, composer: any) => + { + if(pendingActionsRef.current.has(key)) return; + + pendingActionsRef.current.add(key); + SendMessageComposer(composer); + + setTimeout(() => pendingActionsRef.current.delete(key), 2000); + }; const allFriendsRaw = [ ...onlineFriends, ...offlineFriends ]; @@ -115,7 +126,7 @@ export const NavigatorRoomSettingsRightsTabView: FC SendMessageComposer(new RoomTakeRightsComposer(id)) }> + onClick={ () => guardedSend(`take_${id}`, new RoomTakeRightsComposer(id)) }> { name } @@ -127,7 +138,7 @@ export const NavigatorRoomSettingsRightsTabView: FC roomData && SendMessageComposer(new RemoveAllRightsMessageComposer(roomData.roomId)) }> + onClick={ () => roomData && guardedSend('removeAll', new RemoveAllRightsMessageComposer(roomData.roomId)) }> { LocalizeText('navigator.flatctrls.clear') } @@ -154,7 +165,7 @@ export const NavigatorRoomSettingsRightsTabView: FC SendMessageComposer(new RoomGiveRightsComposer(friend.id)) }> + onClick={ () => guardedSend(`give_${friend.id}`, new RoomGiveRightsComposer(friend.id)) }> { friend.name }