import { CreateLinkEvent, Dispose, DropBounce, EaseOut, JumpBy, Motions, NitroToolbarAnimateIconEvent, PerkAllowancesMessageEvent, PerkEnum, Queue, Wait, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer'; import { AnimatePresence, motion, Variants } from 'framer-motion'; import { FC, useEffect, useMemo, useState } from 'react'; import { GetConfigurationValue, isHousekeepingEnabled, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api'; import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common'; import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMentionsSnapshot, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useSoundboard, useWiredTools } from '../../hooks'; import { ToolbarItemView } from './ToolbarItemView'; import { ToolbarMeView } from './ToolbarMeView'; import { YouTubePlayerView } from './YouTubePlayerView'; const containerVariants: Variants = { hidden: { transition: { staggerChildren: 0.015, staggerDirection: -1 } }, visible: { transition: { staggerChildren: 0.025 } } }; const itemVariants: Variants = { hidden: { opacity: 0, y: 10, scale: 0.8 }, visible: { opacity: 1, y: 0, scale: 1, transition: { type: 'spring', stiffness: 400, damping: 22 } } }; const shellVariants: Variants = { hidden: { opacity: 0, y: 8 }, visible: { opacity: 1, y: 0 } }; const SHELL_TRANSITION = { type: 'spring' as const, stiffness: 260, damping: 26 }; const NAV_TRANSITION = { type: 'spring' as const, stiffness: 300, damping: 28 }; const ME_POPOVER_TRANSITION = { type: 'spring' as const, stiffness: 420, damping: 28 }; export const ToolbarView: FC<{ isInRoom: boolean }> = props => { const { isInRoom } = props; const [ isMeExpanded, setMeExpanded ] = useState(false); const [ isTouchLayout, setIsTouchLayout ] = useState(false); const [ leftCollapsed, setLeftCollapsed ] = useState(false); const [ rightCollapsed, setRightCollapsed ] = useState(false); const [ staffStackBottom, setStaffStackBottom ] = useState(null); const [ useGuideTool, setUseGuideTool ] = useState(false); const [ youtubeEnabled, setYoutubeEnabled ] = useState(false); const { userFigure = null } = useSessionInfo(); const { getFullCount = 0 } = useInventoryUnseenTracker(); const { getTotalUnseen = 0 } = useAchievements(); const { requests = [] } = useFriends(); const { iconState = MessengerIconState.HIDDEN } = useMessenger(); const { unreadCount: mentionsUnread = 0 } = useMentionsSnapshot(); const mentionsEnabled = useMemo(() => GetConfigurationValue('mentions_ui.enabled', true), []); const { openMonitor, showToolbarButton } = useWiredTools(); const { enabled: soundboardEnabled, reset: resetSoundboard } = useSoundboard(); const isMod = useHasPermission('acc_supporttool'); const isHk = useHasPermission('acc_housekeeping'); const hkEnabled = useMemo(() => isHousekeepingEnabled(), []); const { tickets = [] } = useModTools(); const openTicketsCount = useMemo( () => isMod ? tickets.filter(ticket => ticket && (ticket.state === 1)).length : 0, [ isMod, tickets ] ); const visibilityVariant = 'visible'; const compactFramePosition = 'bottom-[90px] min-[1700px]:bottom-0'; const mobileOnlyClasses = isTouchLayout ? '' : 'min-[1700px]:hidden'; const desktopBlockClasses = isTouchLayout ? 'hidden' : 'hidden min-[1700px]:block'; const desktopFlexClasses = isTouchLayout ? 'hidden' : 'hidden min-[1700px]:flex'; const leftNavVariants = useMemo(() => ({ hidden: { opacity: 0, x: isInRoom ? -10 : 0, y: isInRoom ? 0 : 8, pointerEvents: 'none' }, visible: { opacity: 1, x: 0, y: 0, pointerEvents: 'auto' } }), [ isInRoom ]); const rightNavVariants = useMemo(() => ({ hidden: { opacity: 0, x: 10, pointerEvents: 'none' }, visible: { opacity: 1, x: 0, pointerEvents: 'auto' } }), []); const mobileNavVariants = useMemo(() => ({ hidden: { opacity: 0, y: 8, pointerEvents: 'none' }, visible: { opacity: 1, y: 0, pointerEvents: 'auto' } }), []); useMessageEvent(YouTubeRoomSettingsEvent, event => { const enabled = event.getParser().youtubeEnabled; setYoutubeEnabled(enabled); setYoutubeRoomEnabled(enabled); }); useEffect(() => { if(!isInRoom) { setYoutubeEnabled(false); setYoutubeRoomEnabled(false); resetSoundboard(); } }, [ isInRoom, resetSoundboard ]); useEffect(() => { const query = window.matchMedia('(pointer: coarse), (hover: none)'); const updateTouchLayout = () => setIsTouchLayout(query.matches); updateTouchLayout(); query.addEventListener('change', updateTouchLayout); return () => query.removeEventListener('change', updateTouchLayout); }, []); // Keep the left staff-tools stack pinned 15px above the room tools rail // (its height is dynamic, so measure it). Falls back to null (CSS // default) when the room tools aren't present, e.g. outside a room. useEffect(() => { const measure = () => { const roomTools = document.querySelector('.nitro-room-tools-container') as HTMLElement | null; const next = roomTools ? Math.max(8, Math.round(window.innerHeight - roomTools.getBoundingClientRect().top + 15)) : null; setStaffStackBottom(prevValue => (prevValue === next ? prevValue : next)); }; measure(); const interval = window.setInterval(measure, 400); window.addEventListener('resize', measure); return () => { window.clearInterval(interval); window.removeEventListener('resize', measure); }; }, [ isInRoom ]); const openYouTubePlayer = () => window.dispatchEvent(new CustomEvent('youtube:toggle')); useMessageEvent(PerkAllowancesMessageEvent, event => { setUseGuideTool(event.getParser().isAllowed(PerkEnum.USE_GUIDE_TOOL)); }); useNitroEvent(NitroToolbarAnimateIconEvent.ANIMATE_ICON, event => { const animationIconToToolbar = (iconName: string, image: HTMLImageElement, x: number, y: number) => { const target = (document.body.getElementsByClassName(iconName)[0] as HTMLElement); if(!target) return; image.className = 'toolbar-icon-animation'; image.style.visibility = 'visible'; image.style.left = (x + 'px'); image.style.top = (y + 'px'); document.body.append(image); const targetBounds = target.getBoundingClientRect(); const imageBounds = image.getBoundingClientRect(); const left = (imageBounds.x - targetBounds.x); const top = (imageBounds.y - targetBounds.y); const squared = Math.sqrt(((left * left) + (top * top))); const wait = (500 - Math.abs(((((1 / squared) * 100) * 500) * 0.5))); const height = 20; const motionName = (`ToolbarBouncing[${ iconName }]`); if(!Motions.getMotionByTag(motionName)) { Motions.runMotion(new Queue(new Wait((wait + 8)), new DropBounce(target, 400, 12))).tag = motionName; } const motion = new Queue(new EaseOut(new JumpBy(image, wait, ((targetBounds.x - imageBounds.x) + height), (targetBounds.y - imageBounds.y), 100, 1), 1), new Dispose(image)); Motions.runMotion(motion); }; animationIconToToolbar('icon-inventory', event.image, event.x, event.y); }); return ( <> { youtubeEnabled && } { isInRoom &&
} { !leftCollapsed && (<> { isInRoom ? VisitDesktop() } className="tb-icon" /> : CreateLinkEvent('navigator/goto/home') } className="tb-icon" /> } CreateLinkEvent('navigator/toggle') } className="tb-icon" /> { GetConfigurationValue('game.center.enabled') && CreateLinkEvent('games/toggle') } className="tb-icon" /> } ) } CreateLinkEvent('catalog/toggle/normal') } className="tb-icon" /> { isMeExpanded && } { setMeExpanded(value => !value); event.stopPropagation(); } }> { (getTotalUnseen > 0) && } CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" /> CreateLinkEvent('inventory/toggle') } className="tb-icon" /> { (getFullCount > 0) && } { !leftCollapsed && (<> CreateLinkEvent('rare-values/toggle') } className="tb-icon" /> CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" /> { (isInRoom && showToolbarButton) && } ) } { isInRoom && CreateLinkEvent('camera/toggle') } className="tb-icon" /> } { !leftCollapsed && (<> { (isInRoom && youtubeEnabled) && } { (isInRoom && soundboardEnabled) && CreateLinkEvent('soundboard/toggle') } className="tb-icon" /> } { isMod && CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> { (openTicketsCount > 0) && } } { (isHk && hkEnabled) && CreateLinkEvent('housekeeping/toggle') } className="tb-icon" /> } { isMod && CreateLinkEvent('furni-editor/toggle') } className="tb-icon" /> } ) } { !rightCollapsed && CreateLinkEvent('friends/toggle') } className="tb-icon" /> { (requests.length > 0) && } { mentionsEnabled && CreateLinkEvent('mentions/toggle') } className="tb-icon" /> { (mentionsUnread > 0) && } } { ((iconState === MessengerIconState.SHOW) || (iconState === MessengerIconState.UNREAD)) && OpenMessengerChat() } /> }
} { isInRoom ? VisitDesktop() } className="tb-icon" /> : CreateLinkEvent('navigator/goto/home') } className="tb-icon" /> } CreateLinkEvent('navigator/toggle') } className="tb-icon" /> { GetConfigurationValue('game.center.enabled') && CreateLinkEvent('games/toggle') } className="tb-icon" /> } CreateLinkEvent('catalog/toggle/normal') } className="tb-icon" /> { isMeExpanded && } { setMeExpanded(value => !value); event.stopPropagation(); } }> { (getTotalUnseen > 0) && } CreateLinkEvent('inventory/toggle') } className="tb-icon" /> { (getFullCount > 0) && } CreateLinkEvent('rare-values/toggle') } className="tb-icon" /> CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" /> { (isInRoom && showToolbarButton) && } { (isInRoom && youtubeEnabled) && } { (isInRoom && soundboardEnabled) && CreateLinkEvent('soundboard/toggle') } className="tb-icon" /> } CreateLinkEvent('friends/toggle') } className="tb-icon" /> { (requests.length > 0) && } { mentionsEnabled && CreateLinkEvent('mentions/toggle') } className="tb-icon" /> { (mentionsUnread > 0) && } } { /* Mobile side tools — moved out of the bottom bar into a vertical pill stack on the left edge so the bottom bar has room. Always present (Builders Club), plus camera in-room and the staff-only tools when permitted. */ } CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" /> { isInRoom && CreateLinkEvent('camera/toggle') } className="tb-icon" /> } { isMod && CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> { (openTicketsCount > 0) && } } { (isHk && hkEnabled) && CreateLinkEvent('housekeeping/toggle') } className="tb-icon" /> } { isMod && CreateLinkEvent('furni-editor/toggle') } className="tb-icon" /> } ); }; const TOOLBAR_STYLES = ` /* The frame's background / border / shadow swap when the toolbar toggles is a plain class change, so without an explicit transition the visuals snap instantly while framer-motion is still animating the nav children — looked broken on rapid toggles. Easing it over the same timing as the spring smooths the burst-click case out. (No 'will-change' here — those props change about once per toggle, but a permanent compositor layer would be re-rasterised on every browser-window resize tick, which is what made dragging the window corner feel sluggish.) */ .tb-frame { transition: background-color 220ms ease, border-color 220ms ease, box-shadow 220ms ease, border-radius 220ms ease; } /* Left + right nav containers shrink with the viewport, but the icons inside don't. Without horizontal clipping they overflow into the centred chat input around the md breakpoint. 'overflow-x: clip' clips horizontally WITHOUT creating a scroll container the way 'overflow-x: hidden' would — so the Me popover that animates upwards from the avatar still escapes vertically, and the browser doesn't render a stray vertical scrollbar thumb on the nav. Negative inset margins on the clip path keep vertical breathing room for the popover even on engines that fall back to 'hidden'. */ .tb-nav-clip { overflow-x: clip; overflow-y: visible; overflow-clip-margin: 0 0 200px 0; } .tb-icon { opacity: 1; transition: transform 0.15s ease; cursor: pointer; } .tb-icon:hover { transform: translateY(-2px); } .tb-icon:active { transform: translateY(0); } .tb-collapse { width: 15px; height: 34px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; border-radius: 6px; background: rgba(62, 64, 72, 0.55); border: 1px solid rgba(255, 255, 255, 0.10); color: rgba(255, 255, 255, 0.70); cursor: pointer; transition: color 0.15s, background 0.15s; } .tb-collapse:hover { color: #fff; background: rgba(80, 82, 90, 0.65); } .tb-bar-scroll { overflow-x: auto; overflow-y: visible; scrollbar-width: none; -ms-overflow-style: none; flex-wrap: nowrap; } /* Keep each icon at its natural size so the mobile bar scrolls horizontally instead of squashing the items into each other. (Default flex-shrink:1 let the fixed-size icon backgrounds overlap once enough icons were present to exceed the bar width.) */ .tb-bar-scroll > * { flex-shrink: 0; } .tb-bar-scroll::-webkit-scrollbar { display: none; } .tb-open-shell { scrollbar-width: none; -ms-overflow-style: none; } .tb-open-shell::-webkit-scrollbar { display: none; } `;