nitro-v36-with-renderer-v36
/
src /
components /
room /
widgets /
room-tools /
RoomToolsWidgetView.tsx
RoomToolsWidgetView.tsx 19.2 Ko
Télécharger le fichier
import { AddLinkEventTracker, CreateLinkEvent, GetGuestRoomResultEvent, ILinkEventTracker, GetRoomEngine, RateFlatMessageComposer, RemoveLinkEventTracker, RoomEngineEvent, RoomGeometry } from '@octane/renderer';
import { AnimatePresence, motion } from 'framer-motion';
import { FC, useEffect, useEffectEvent, useRef, useState } from 'react';
import { GetConfigurationValue, LocalizeText, SendMessageComposer, SetLocalStorage, TryVisitRoom } from '../../../../api';
import { Text } from '../../../../common';
import { localizeWithFallback } from '../../../../api/utils/localizeWithFallback';
import { useAchievements, useMessageEvent, useNavigatorData, useOctaneEvent, useRoom } from '../../../../hooks';
import { classNames } from '../../../../layout';
import { getRegisteredPlugins, IOctanePlugin, subscribePlugins } from '../../../plugins/OctanePluginApi';
import { applyRoomZoom } from './roomZoom.helpers';
import { BaseButton } from '../../../../common/BaseButton';
import { Hint } from '../../../../common/Tooltip';
import { NativeTextTree } from '../../../../common/native-text/NativeTextTree';
interface RoomHistoryEntry {
roomId: number;
roomName: string;
}
interface RoomEnterInfo {
roomId: number;
roomName: string;
ownerLine: string;
tags: string[];
}
const ROOM_INFO_TAG_MAX = 16;
const ROOM_INFO_TAG_COUNT = 2;
// Habbo shortens tags on the entry card to 16 characters.
const trimRoomTag = (tag: string) => (tag.length > ROOM_INFO_TAG_MAX ? `${tag.slice(0, ROOM_INFO_TAG_MAX)}...` : tag);
const ROOM_HISTORY_KEY = 'nitro.room.history';
const ROOM_HISTORY_MAX = 10;
const ROOM_NAME_MAX = 80;
const ROOM_ZOOM_SCALES = [0.5, 1, 2, 4, 8, 16];
const readRoomHistory = (): RoomHistoryEntry[] => {
try {
const raw = window.localStorage.getItem(ROOM_HISTORY_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.filter((entry) => entry && Number.isInteger(entry.roomId) && entry.roomId > 0 && typeof entry.roomName === 'string')
.slice(-ROOM_HISTORY_MAX)
.map((entry) => ({ roomId: entry.roomId, roomName: entry.roomName.slice(0, ROOM_NAME_MAX) }));
} catch {
return [];
}
};
const getNearestZoomScale = (scale: number) => {
if (Number.isNaN(scale) || scale <= 0) return 1;
return ROOM_ZOOM_SCALES.reduce((nearest, current) => (Math.abs(current - scale) < Math.abs(nearest - scale) ? current : nearest), ROOM_ZOOM_SCALES[0]);
};
const getNextZoomScale = (scale: number, direction: number) => {
const currentScale = getNearestZoomScale(scale);
if (direction > 0) {
return ROOM_ZOOM_SCALES.find((zoomScale) => zoomScale > currentScale + 0.001) ?? currentScale;
}
if (direction < 0) {
return [...ROOM_ZOOM_SCALES].reverse().find((zoomScale) => zoomScale < currentScale - 0.001) ?? currentScale;
}
return currentScale;
};
const getZoomText = (scale: number) => (Math.round(Math.log(getNearestZoomScale(scale)) / Math.LN2) + 1).toString();
export const RoomToolsWidgetView: FC<{}> = (props) => {
const [zoomScale, setZoomScale] = useState<number>(1);
const [hasLikedRoom, setHasLikedRoom] = useState<boolean>(false);
const [isToolsOpen, setIsToolsOpen] = useState<boolean>(true);
const [isOpenHistory, setIsOpenHistory] = useState<boolean>(false);
const [roomHistory, setRoomHistory] = useState<RoomHistoryEntry[]>([]);
const [plugins, setPlugins] = useState<IOctanePlugin[]>([]);
const [roomEnterInfo, setRoomEnterInfo] = useState<RoomEnterInfo>(null);
const [isRoomInfoHovered, setIsRoomInfoHovered] = useState(false);
const roomInfoTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
const { navigatorData } = useNavigatorData();
const { hasWiredAchievements } = useAchievements();
const { roomSession = null } = useRoom();
useEffect(() => {
setPlugins(getRegisteredPlugins());
return subscribePlugins(() => setPlugins(getRegisteredPlugins()));
}, []);
const getLogicalZoomScale = () => {
if (!roomSession) return 1;
const displayScale = GetRoomEngine().getRoomInstanceRenderingCanvasScale(roomSession.roomId, 1);
const geometry = GetRoomEngine().getRoomInstanceGeometry(roomSession.roomId, 1);
const geometryRatio = geometry ? geometry.scale / RoomGeometry.SCALE_ZOOMED_IN : 1;
return getNearestZoomScale(displayScale * geometryRatio);
};
const applyZoomScale = (logicalScale: number) => {
if (!roomSession) return;
applyRoomZoom(roomSession.roomId, logicalScale);
setZoomScale(logicalScale);
};
const updateZoomScale = () => {
if (!roomSession) return;
setZoomScale(getLogicalZoomScale());
};
const zoomRoom = (direction: number) => {
if (!roomSession || direction === 0) return;
if (!GetConfigurationValue('room.zoom.enabled', true)) {
const geometry = GetRoomEngine().getRoomInstanceGeometry(roomSession.roomId, 1);
if (!geometry) return;
if (direction > 0) geometry.performZoomIn();
else geometry.performZoomOut();
setZoomScale(direction > 0 ? 2 : 1);
return;
}
const currentScale = getLogicalZoomScale();
const nextScale = getNextZoomScale(currentScale, direction);
if (Math.abs(nextScale - currentScale) <= 0.001) return;
applyZoomScale(nextScale);
};
const handleToolClick = (action: string, value?: string) => {
if (!roomSession) return;
switch (action) {
case 'achievements':
CreateLinkEvent('questengine/achievements/wired_games');
return;
case 'settings':
CreateLinkEvent('navigator/toggle-room-info');
return;
case 'zoom_in':
zoomRoom(1);
return;
case 'zoom_out':
zoomRoom(-1);
return;
case 'chat_history':
CreateLinkEvent('chat-history/toggle');
return;
case 'like_room':
if (hasLikedRoom) return;
SendMessageComposer(new RateFlatMessageComposer(1));
setHasLikedRoom(true);
return;
case 'toggle_room_link':
CreateLinkEvent('navigator/toggle-room-link');
return;
case 'room_history':
if (roomHistory.length > 0) setIsOpenHistory((prev) => !prev);
return;
case 'room_history_back':
const prevIndex = roomHistory.findIndex((room) => room.roomId === navigatorData.currentRoomId) - 1;
if (prevIndex >= 0) TryVisitRoom(roomHistory[prevIndex].roomId);
return;
case 'room_history_next':
const nextIndex = roomHistory.findIndex((room) => room.roomId === navigatorData.currentRoomId) + 1;
if (nextIndex < roomHistory.length) TryVisitRoom(roomHistory[nextIndex].roomId);
return;
}
};
const currentRoomHistoryIndex = navigatorData ? roomHistory.findIndex((room) => room.roomId === navigatorData.currentRoomId) : -1;
const hasHistory = roomHistory.length > 0;
const canGoBack = currentRoomHistoryIndex > 0;
const canGoNext = currentRoomHistoryIndex !== -1 && currentRoomHistoryIndex < roomHistory.length - 1;
const onChangeRoomHistory = (roomId: number, roomName: string) => {
if (!Number.isInteger(roomId) || roomId <= 0) return;
let newStorage = readRoomHistory();
if (newStorage.some((room) => room.roomId === roomId)) return;
if (newStorage.length >= ROOM_HISTORY_MAX) newStorage.shift();
newStorage = [...newStorage, { roomId, roomName: (roomName || '').slice(0, ROOM_NAME_MAX) }];
setRoomHistory(newStorage);
SetLocalStorage(ROOM_HISTORY_KEY, newStorage);
};
useMessageEvent<GetGuestRoomResultEvent>(GetGuestRoomResultEvent, (event) => {
const parser = event.getParser();
if (!parser.roomEnter || parser.data.roomId !== roomSession.roomId) return;
onChangeRoomHistory(parser.data.roomId, parser.data.roomName);
if (!GetConfigurationValue<boolean>('room.enter.info.enabled', true)) return;
const data = parser.data;
setRoomEnterInfo({
roomId: data.roomId,
roomName: data.roomName,
ownerLine: data.showOwner
? `${localizeWithFallback('room.tool.room.owner.prefix', 'by')} ${data.ownerName}`
: localizeWithFallback('room.tool.public.room', 'Public room'),
tags: (data.tags ?? []).filter((tag) => !!tag).slice(0, ROOM_INFO_TAG_COUNT)
});
});
// The entry card hides itself after a while; hovering keeps it open.
useEffect(() => {
if (!roomEnterInfo || isRoomInfoHovered) return;
const delay = GetConfigurationValue<number>('room.enter.info.collapse.delay', 5000);
roomInfoTimerRef.current = setTimeout(() => setRoomEnterInfo(null), Math.max(1000, delay));
return () => clearTimeout(roomInfoTimerRef.current);
}, [roomEnterInfo, isRoomInfoHovered]);
const searchRoomTag = (tag: string) => {
setRoomEnterInfo(null);
CreateLinkEvent(`navigator/tag/${tag}`);
};
useEffect(() => {
setRoomHistory(readRoomHistory());
}, []);
useEffect(() => {
setHasLikedRoom(false);
updateZoomScale();
}, [roomSession?.roomId]);
// Ctrl + mouse wheel over the room zooms through the same steps as the buttons.
const onZoomLink = useEffectEvent((action: string) => handleToolClick(action));
useEffect(() => {
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) => {
const action = url.split('/')[1];
if (action === 'zoom_in' || action === 'zoom_out') onZoomLink(action);
},
eventUrlPrefix: 'room-tools/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
// The renderer can be zoomed from outside this toolbar (keyboard shortcuts,
// other widgets), so resync the displayed level whenever the engine reports it.
useOctaneEvent<RoomEngineEvent>(RoomEngineEvent.ROOM_ZOOMED, (event) => {
if (!roomSession || event.roomId !== roomSession.roomId) return;
updateZoomScale();
});
const tools = [
...(hasWiredAchievements ? [{ action: 'achievements', icon: 'icon-room-achievements', label: localizeWithFallback('room.achievements.button.text', 'Achievements') }] : []),
{ action: 'settings', icon: 'icon-cog', label: LocalizeText('room.settings.button.text') },
{ action: 'chat_history', icon: 'icon-chat-history', label: LocalizeText('room.chathistory.button.text') },
...(navigatorData.canRate || hasLikedRoom ? [{ action: 'like_room', icon: 'icon-like-room', label: LocalizeText('room.like.button.text'), disabled: hasLikedRoom }] : []),
{ action: 'toggle_room_link', icon: 'icon-room-link', label: LocalizeText('navigator.embed.caption') }
];
const canZoomIn = getNextZoomScale(zoomScale, 1) !== getNearestZoomScale(zoomScale);
const canZoomOut = getNextZoomScale(zoomScale, -1) !== getNearestZoomScale(zoomScale);
return (
<NativeTextTree>
<div className={classNames('octane-room-tools-container', !isToolsOpen && 'is-collapsed')}>
<BaseButton className="room-tools-collapse-toggle" type="button" onClick={() => setIsToolsOpen((prevValue) => !prevValue)}>
{isToolsOpen ? '‹' : '›'}
</BaseButton>
{isToolsOpen && (
<div className="octane-room-tools">
<div className="room-tools-zoom-row">
<span>{LocalizeText('room.zoom.text', ['zoom_level'], [getZoomText(zoomScale)])}</span>
<Hint content={LocalizeText('room.zoom.zoom_in.tooltip')}><BaseButton aria-label={LocalizeText('room.zoom.zoom_in.tooltip')} className="room-tools-zoom-button is-zoom-in" type="button" disabled={!canZoomIn} onClick={() => handleToolClick('zoom_in')} /></Hint>
<Hint content={LocalizeText('room.zoom.zoom_out.tooltip')}><BaseButton aria-label={LocalizeText('room.zoom.zoom_out.tooltip')} className="room-tools-zoom-button is-zoom-out" type="button" disabled={!canZoomOut} onClick={() => handleToolClick('zoom_out')} /></Hint>
</div>
{tools.map((tool) => (
<Hint key={tool.action} content={tool.label}><BaseButton aria-label={tool.label} type="button"
className={classNames('room-tool-row', tool.disabled && 'is-disabled')}
aria-disabled={tool.disabled || undefined}
onClick={() => !tool.disabled && handleToolClick(tool.action)}
>
<div className={classNames('octane-icon', tool.icon)} />
<span className="room-tool-label">{tool.label}</span>
</BaseButton></Hint>
))}
{plugins.map((plugin) => (
<Hint key={plugin.name} content={plugin.label}><BaseButton aria-label={plugin.label} type="button"
className="room-tool-row"
onClick={() => plugin.onOpen()}
>
<div className={classNames('octane-icon', plugin.icon || 'icon-cog')} />
<span className="room-tool-label">{plugin.label}</span>
</BaseButton></Hint>
))}
<div className="room-history-controls">
<Hint content={LocalizeText('room.history.button.back.tooltip')}><BaseButton aria-label={LocalizeText('room.history.button.back.tooltip')} type="button"
className={classNames('octane-icon', canGoBack ? 'cursor-pointer icon-room-history-back-enabled' : 'icon-room-history-back-disabled')}
aria-disabled={!canGoBack}
onClick={() => canGoBack && handleToolClick('room_history_back')}
/></Hint>
<Hint content={LocalizeText('room.history.button.tooltip')}><BaseButton aria-label={LocalizeText('room.history.button.tooltip')} type="button"
className={classNames('octane-icon', hasHistory ? 'cursor-pointer icon-room-history-enabled' : 'icon-room-history-disabled')}
aria-disabled={!hasHistory}
onClick={() => hasHistory && handleToolClick('room_history')}
/></Hint>
<Hint content={LocalizeText('room.history.button.forward.tooltip')}><BaseButton aria-label={LocalizeText('room.history.button.forward.tooltip')} type="button"
className={classNames('octane-icon', canGoNext ? 'cursor-pointer icon-room-history-next-enabled' : 'icon-room-history-next-disabled')}
aria-disabled={!canGoNext}
onClick={() => canGoNext && handleToolClick('room_history_next')}
/></Hint>
</div>
</div>
)}
<AnimatePresence>
{roomEnterInfo && roomEnterInfo.roomId === roomSession?.roomId && !isOpenHistory && (
<motion.div
key={roomEnterInfo.roomId}
initial={{ x: -320, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -320, opacity: 0 }}
transition={{ duration: 0.2 }}
className="octane-room-tools-info octane-room-tools-info--enter"
onClick={() => setRoomEnterInfo(null)}
onMouseEnter={() => setIsRoomInfoHovered(true)}
onMouseLeave={() => setIsRoomInfoHovered(false)}
>
<Text bold variant="white" className="block truncate text-[14px]">{roomEnterInfo.roomName}</Text>
<Text small className="block truncate text-[#999999]">{roomEnterInfo.ownerLine}</Text>
{roomEnterInfo.tags.length > 0 && (
<div className="flex gap-2 mt-1">
{roomEnterInfo.tags.map((tag) => (
<BaseButton
key={tag}
className="text-[12px] text-[#1b79ab] hover:text-[#47a8e6] bg-transparent border-0 p-0 cursor-pointer"
type="button"
onClick={(event) => {
event.stopPropagation();
searchRoomTag(tag);
}}
>
#{trimRoomTag(tag)}
</BaseButton>
))}
</div>
)}
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{isOpenHistory && isToolsOpen && (
<motion.div
initial={{ x: -100 }}
animate={{ x: 0 }}
exit={{ x: -100 }}
transition={{ duration: 0.3 }}
className="octane-room-tools-history"
>
<div className="flex flex-col px-3 py-2 rounded octane-room-history">
{roomHistory.map((history) => (
<Text component={BaseButton} type="button"
key={history.roomId}
bold={history.roomId === navigatorData.currentRoomId}
variant="white"
pointer
className={classNames(
'room-history-item',
history.roomId === navigatorData.currentRoomId && 'room-history-item--current'
)}
onClick={() => TryVisitRoom(history.roomId)}
>
{history.roomName}
</Text>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</NativeTextTree>
);
};
