mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
🆙 Fix Toolbar & Pets layout
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
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 { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { GetConfigurationValue, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api';
|
||||
import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common';
|
||||
import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks';
|
||||
@@ -8,17 +8,9 @@ import { ToolbarItemView } from './ToolbarItemView';
|
||||
import { ToolbarMeView } from './ToolbarMeView';
|
||||
import { YouTubePlayerView } from './YouTubePlayerView';
|
||||
|
||||
// The 4 nav rows + backplate are ALWAYS mounted and animate between
|
||||
// hidden/visible via framer-motion variants. Rapid show/hide toggles
|
||||
// just retarget the in-flight spring instead of interrupting an
|
||||
// AnimatePresence enter/exit cycle, so the pre-refactor artifacts
|
||||
// (icons stuck at opacity 0 or scale 0.8 after spam-clicking the
|
||||
// toggle) can no longer happen — framer's spring solver picks up from
|
||||
// whatever the current animated value is.
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: { transition: { staggerChildren: 0.03, staggerDirection: -1 } },
|
||||
visible: { transition: { staggerChildren: 0.05 } }
|
||||
hidden: { transition: { staggerChildren: 0.015, staggerDirection: -1 } },
|
||||
visible: { transition: { staggerChildren: 0.025 } }
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
@@ -34,6 +26,7 @@ const shellVariants: Variants = {
|
||||
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 };
|
||||
const TOGGLE_LOCK_MS = 220;
|
||||
|
||||
export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
{
|
||||
@@ -50,9 +43,6 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
const { iconState = MessengerIconState.HIDDEN } = useMessenger();
|
||||
const { openMonitor, showToolbarButton } = useWiredTools();
|
||||
const isMod = useHasPermission('acc_supporttool');
|
||||
// Surface the open-ticket count on the toolbar ModTools button so a
|
||||
// new CFH pings the mod even when the launcher itself is closed.
|
||||
// useBetween-shared state — no extra subscription cost.
|
||||
const { tickets = [] } = useModTools();
|
||||
const openTicketsCount = useMemo(
|
||||
() => isMod ? tickets.filter(ticket => ticket && (ticket.state === 1)).length : 0,
|
||||
@@ -60,31 +50,40 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
);
|
||||
const isVisible = (isToolbarOpen || !isInRoom);
|
||||
const visibilityVariant = isVisible ? 'visible' : 'hidden';
|
||||
const toggleLockRef = useRef(false);
|
||||
const toggleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const desktopToolbarFrameClasses = isTouchLayout ? '' : 'md:left-1/2 md:right-auto md:h-[52px] md:w-[420px] md:-translate-x-1/2 md:items-center md:px-[6px] md:py-[4px] lg:w-[460px]';
|
||||
const desktopToolbarOpenClasses = isTouchLayout ? '' : 'md:rounded-none md:border-0 md:bg-transparent md:shadow-none';
|
||||
const desktopToggleClasses = isTouchLayout ? '' : 'md:mb-0';
|
||||
const desktopToggleIconClasses = isTouchLayout ? '' : (isToolbarOpen ? 'md:-rotate-90' : 'md:rotate-90');
|
||||
const desktopChatInputClasses = isTouchLayout ? '' : 'md:px-0';
|
||||
const mobileOnlyClasses = isTouchLayout ? '' : 'md:hidden';
|
||||
const desktopBlockClasses = isTouchLayout ? 'hidden' : 'hidden md:block';
|
||||
const desktopFlexClasses = isTouchLayout ? 'hidden' : 'hidden md:flex';
|
||||
useEffect(() => () =>
|
||||
{
|
||||
if(toggleTimeoutRef.current) clearTimeout(toggleTimeoutRef.current);
|
||||
}, []);
|
||||
|
||||
// Compute the wrapper variants. `isInRoom` affects the hidden-state
|
||||
// offset (the nav slides in from the side when in a room, from the
|
||||
// bottom otherwise) so the variant object is derived from props.
|
||||
const leftNavVariants: Variants = {
|
||||
const handleToggleClick = useCallback(() =>
|
||||
{
|
||||
if(toggleLockRef.current) return;
|
||||
toggleLockRef.current = true;
|
||||
setIsToolbarOpen(value => !value);
|
||||
if(toggleTimeoutRef.current) clearTimeout(toggleTimeoutRef.current);
|
||||
toggleTimeoutRef.current = setTimeout(() => { toggleLockRef.current = false; }, TOGGLE_LOCK_MS);
|
||||
}, []);
|
||||
|
||||
const compactFramePosition = (isToolbarOpen && isInRoom) ? 'bottom-[55px] min-[1540px]:bottom-0' : 'bottom-0';
|
||||
const desktopFrameTransparencyClasses = isTouchLayout ? '' : 'min-[1540px]:rounded-none min-[1540px]:border-0 min-[1540px]:bg-transparent min-[1540px]:shadow-none';
|
||||
const mobileOnlyClasses = isTouchLayout ? '' : 'min-[1540px]:hidden';
|
||||
const desktopBlockClasses = isTouchLayout ? 'hidden' : 'hidden min-[1540px]:block';
|
||||
const desktopFlexClasses = isTouchLayout ? 'hidden' : 'hidden min-[1540px]:flex';
|
||||
const leftNavVariants = useMemo<Variants>(() => ({
|
||||
hidden: { opacity: 0, x: isInRoom ? -10 : 0, y: isInRoom ? 0 : 8, pointerEvents: 'none' },
|
||||
visible: { opacity: 1, x: 0, y: 0, pointerEvents: 'auto' }
|
||||
};
|
||||
const rightNavVariants: Variants = {
|
||||
}), [ isInRoom ]);
|
||||
const rightNavVariants = useMemo<Variants>(() => ({
|
||||
hidden: { opacity: 0, x: 10, pointerEvents: 'none' },
|
||||
visible: { opacity: 1, x: 0, pointerEvents: 'auto' }
|
||||
};
|
||||
const mobileNavVariants: Variants = {
|
||||
}), []);
|
||||
const mobileNavVariants = useMemo<Variants>(() => ({
|
||||
hidden: { opacity: 0, y: 8, pointerEvents: 'none' },
|
||||
visible: { opacity: 1, y: 0, pointerEvents: 'auto' }
|
||||
};
|
||||
}), []);
|
||||
|
||||
useMessageEvent<YouTubeRoomSettingsEvent>(YouTubeRoomSettingsEvent, event =>
|
||||
{
|
||||
@@ -163,46 +162,39 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
{ youtubeEnabled && <YouTubePlayerView /> }
|
||||
|
||||
{ isInRoom &&
|
||||
<div className={ `fixed bottom-0 left-0 right-0 z-40 flex h-[52px] items-end px-0 pt-[2px] pb-0 pointer-events-none ${ desktopToolbarFrameClasses } ${ isToolbarOpen ? `${ desktopToolbarOpenClasses } rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]` : `border-0 bg-transparent shadow-none ${ desktopToolbarOpenClasses }` }` }>
|
||||
<div className={ `tb-frame fixed ${ compactFramePosition } left-1/2 -translate-x-1/2 z-40 flex h-[52px] w-[420px] max-w-[95vw] items-center px-[6px] py-[4px] pointer-events-none ${ isToolbarOpen ? `${ desktopFrameTransparencyClasses } rounded-[12px] border border-white/8 bg-[rgba(10,10,12,0.58)] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]` : `border-0 bg-transparent shadow-none ${ desktopFrameTransparencyClasses }` }` }>
|
||||
<motion.div
|
||||
className={ `tb-toggle pointer-events-auto mr-2 mb-[4px] flex-shrink-0 ${ desktopToggleClasses }` }
|
||||
onClick={ () => setIsToolbarOpen(value => !value) }
|
||||
className="tb-toggle pointer-events-auto mr-2 flex-shrink-0"
|
||||
onClick={ handleToggleClick }
|
||||
whileTap={ { scale: 0.9 } }>
|
||||
<svg
|
||||
className={ `h-3.5 w-3.5 text-white/70 transition-transform duration-300 ${ isToolbarOpen ? 'rotate-180' : 'rotate-0' } ${ desktopToggleIconClasses }` }
|
||||
<motion.svg
|
||||
className="h-3.5 w-3.5 text-white/70"
|
||||
animate={ { rotate: isToolbarOpen ? 180 : 0 } }
|
||||
transition={ { type: 'spring', stiffness: 320, damping: 24 } }
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={ 2.5 } d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</motion.svg>
|
||||
</motion.div>
|
||||
<Flex
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
className={ `pointer-events-auto h-full w-full min-w-0 flex-1 px-[6px] ${ desktopChatInputClasses }` }
|
||||
className="pointer-events-auto h-full w-full min-w-0 flex-1"
|
||||
id="toolbar-chat-input-container" />
|
||||
<div className={ `pointer-events-auto relative mr-[6px] shrink-0 ${ mobileOnlyClasses }` }>
|
||||
<ToolbarItemView icon="friendall" onClick={ () => CreateLinkEvent('friends/toggle') } className="tb-icon" />
|
||||
{ (requests.length > 0) &&
|
||||
<LayoutItemCountView count={ requests.length } className="absolute -right-1 top-0" /> }
|
||||
</div>
|
||||
</div> }
|
||||
|
||||
{ /* Desktop backplate. Always mounted; opacity-driven. */ }
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate={ visibilityVariant }
|
||||
variants={ shellVariants }
|
||||
transition={ SHELL_TRANSITION }
|
||||
className={ `pointer-events-none fixed bottom-0 left-0 right-0 z-[39] h-[52px] rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] shadow-[0_-6px_18px_rgba(0,0,0,0.18)] ${ desktopBlockClasses }` } />
|
||||
|
||||
{ /* Left nav — desktop. Container variant inheritance staggers items in/out. */ }
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate={ visibilityVariant }
|
||||
variants={ leftNavVariants }
|
||||
transition={ NAV_TRANSITION }
|
||||
className={ `fixed bottom-0 left-0 z-40 h-[52px] max-w-[calc(50vw-242px)] items-center overflow-visible pl-3 ${ desktopFlexClasses }` }>
|
||||
className={ `tb-nav-clip fixed bottom-0 left-0 z-40 h-[52px] max-w-[calc(50vw-242px)] items-center pl-3 ${ desktopFlexClasses }` }>
|
||||
<motion.div
|
||||
variants={ containerVariants }
|
||||
className="tb-open-shell flex h-[52px] max-w-full items-center gap-2 overflow-visible bg-transparent px-[8px] pt-[10px] pb-[2px]">
|
||||
@@ -250,7 +242,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
setMeExpanded(value => !value);
|
||||
event.stopPropagation();
|
||||
} }>
|
||||
<LayoutAvatarImageView headOnly={ true } direction={ 2 } figure={ userFigure } className="tb-icon !h-[44px] !w-[32px] !bg-center !bg-no-repeat" style={ { marginTop: '4px' } } />
|
||||
<LayoutAvatarImageView headOnly={ true } direction={ 2 } figure={ userFigure } className="tb-icon !h-[64px] !w-[32px] !bg-center !bg-no-repeat" style={ { marginTop: "8px" } } />
|
||||
</motion.div>
|
||||
{ (getTotalUnseen > 0) &&
|
||||
<LayoutItemCountView count={ getTotalUnseen } className="pointer-events-none absolute -right-1 -top-1 z-10" /> }
|
||||
@@ -279,14 +271,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
</motion.div> }
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{ /* Right nav — desktop */ }
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate={ visibilityVariant }
|
||||
variants={ rightNavVariants }
|
||||
transition={ NAV_TRANSITION }
|
||||
className={ `fixed bottom-0 z-40 h-[52px] max-w-[calc(50vw-242px)] items-center overflow-visible pr-3 ${ desktopFlexClasses } ${ isInRoom ? 'right-0' : 'right-3' }` }>
|
||||
className={ `tb-nav-clip fixed bottom-0 z-40 h-[52px] max-w-[calc(50vw-242px)] items-center pr-3 ${ desktopFlexClasses } ${ isInRoom ? 'right-0' : 'right-3' }` }>
|
||||
<motion.div
|
||||
variants={ containerVariants }
|
||||
className="tb-open-shell flex h-[52px] max-w-full items-center gap-3 overflow-visible bg-transparent px-[8px] pt-[10px] pb-[2px]">
|
||||
@@ -303,14 +293,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
<div className={ `h-full shrink-0 ${ desktopBlockClasses }` } id="toolbar-friend-bar-container-desktop" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{ /* Mobile nav. Two staggered halves split by the Me avatar. */ }
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate={ visibilityVariant }
|
||||
variants={ mobileNavVariants }
|
||||
transition={ NAV_TRANSITION }
|
||||
className={ `fixed left-1/2 z-40 flex w-[95vw] -translate-x-1/2 items-center overflow-visible ${ mobileOnlyClasses } ${ isInRoom ? 'bottom-[52px] rounded-t-[12px] border border-b-0 border-white/8 bg-[rgba(10,10,12,0.58)] px-[6px] py-[4px] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]' : 'bottom-0' }` }>
|
||||
className={ `fixed left-1/2 bottom-0 z-40 flex w-[95vw] -translate-x-1/2 items-center overflow-visible ${ mobileOnlyClasses } ${ isInRoom ? 'rounded-[12px] border border-white/8 bg-[rgba(10,10,12,0.58)] px-[6px] py-[4px] mb-[3px] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]' : '' }` }>
|
||||
<motion.div
|
||||
variants={ containerVariants }
|
||||
className="tb-bar-scroll flex h-full min-w-0 flex-1 items-center gap-2 overflow-x-auto overflow-y-visible px-1">
|
||||
@@ -359,7 +347,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
setMeExpanded(value => !value);
|
||||
event.stopPropagation();
|
||||
} }>
|
||||
<LayoutAvatarImageView headOnly={ true } direction={ 2 } figure={ userFigure } className="tb-icon !h-[44px] !w-[32px] !bg-center !bg-no-repeat" style={ { marginTop: '4px' } } />
|
||||
<LayoutAvatarImageView headOnly={ true } direction={ 2 } figure={ userFigure } className="tb-icon !h-[64px] !w-[32px] !bg-center !bg-no-repeat" style={ { marginTop: "8px" } } />
|
||||
</motion.div>
|
||||
{ (getTotalUnseen > 0) &&
|
||||
<LayoutItemCountView count={ getTotalUnseen } className="pointer-events-none absolute -right-1 -top-1 z-10" /> }
|
||||
@@ -389,12 +377,11 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="furnieditor" onClick={ () => CreateLinkEvent('furni-editor/toggle') } className="tb-icon" />
|
||||
</motion.div> }
|
||||
{ !isInRoom &&
|
||||
<motion.div variants={ itemVariants } className="relative">
|
||||
<ToolbarItemView icon="friendall" onClick={ () => CreateLinkEvent('friends/toggle') } className="tb-icon" />
|
||||
{ (requests.length > 0) &&
|
||||
<LayoutItemCountView count={ requests.length } className="absolute -right-2 -top-1" /> }
|
||||
</motion.div> }
|
||||
<motion.div variants={ itemVariants } className="relative">
|
||||
<ToolbarItemView icon="friendall" onClick={ () => CreateLinkEvent('friends/toggle') } className="tb-icon" />
|
||||
{ (requests.length > 0) &&
|
||||
<LayoutItemCountView count={ requests.length } className="absolute -right-2 -top-1" /> }
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</>
|
||||
@@ -402,6 +389,34 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,32 +1,44 @@
|
||||
import { GetSellablePetPalettesComposer, SellablePetPalettesMessageEvent } from '@nitrots/nitro-renderer';
|
||||
import { UseQueryResult } from '@tanstack/react-query';
|
||||
import { CatalogPetPalette } from '../../api';
|
||||
import { useNitroQuery } from '../../api/nitro-query';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { CatalogPetPalette, SendMessageComposer } from '../../api';
|
||||
import { useMessageEvent } from '../events';
|
||||
|
||||
const palettesCache = new Map<string, CatalogPetPalette>();
|
||||
|
||||
/**
|
||||
* Sellable palettes for a given pet breed, as returned by
|
||||
* GetSellablePetPalettesComposer(breed) → SellablePetPalettesMessageEvent.
|
||||
* The renderer multiplexes one event type for every breed; accept()
|
||||
* keeps each query slot listening only for the matching productCode.
|
||||
*
|
||||
* Replaces the per-breed accumulator that previously lived in
|
||||
* useCatalog (writing to catalogOptions.petPalettes). The catalog pet
|
||||
* page now reads via `useSellablePetPalette(productData.type)`.
|
||||
*
|
||||
* The breed identifier is the localization product code string
|
||||
* (e.g. 'pet_egg', 'pet_dog', ...). Disabled while breed is empty so
|
||||
* we don't spam composers at mount before the offer is known.
|
||||
*/
|
||||
export const useSellablePetPalette = (
|
||||
breed: string,
|
||||
options: { enabled?: boolean } = {}
|
||||
): UseQueryResult<CatalogPetPalette> =>
|
||||
useNitroQuery<SellablePetPalettesMessageEvent, CatalogPetPalette>({
|
||||
key: [ 'nitro', 'catalog', 'petPalette', breed ],
|
||||
request: () => new GetSellablePetPalettesComposer(breed),
|
||||
parser: SellablePetPalettesMessageEvent,
|
||||
accept: event => (event.getParser().productCode === breed),
|
||||
select: event => new CatalogPetPalette(event.getParser().productCode, event.getParser().palettes.slice()),
|
||||
enabled: (options.enabled ?? true) && !!breed,
|
||||
staleTime: Infinity
|
||||
});
|
||||
): { data: CatalogPetPalette | null } =>
|
||||
{
|
||||
const enabled = (options.enabled ?? true) && !!breed;
|
||||
const [ data, setData ] = useState<CatalogPetPalette | null>(() => breed ? (palettesCache.get(breed) ?? null) : null);
|
||||
const [ trackedBreed, setTrackedBreed ] = useState(breed);
|
||||
|
||||
if(trackedBreed !== breed)
|
||||
{
|
||||
setTrackedBreed(breed);
|
||||
setData(breed ? (palettesCache.get(breed) ?? null) : null);
|
||||
}
|
||||
|
||||
const handler = useCallback((event: SellablePetPalettesMessageEvent) =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
if(!parser || parser.productCode !== breed) return;
|
||||
|
||||
const palette = new CatalogPetPalette(parser.productCode, parser.palettes.slice());
|
||||
palettesCache.set(breed, palette);
|
||||
setData(palette);
|
||||
}, [ breed ]);
|
||||
|
||||
useMessageEvent<SellablePetPalettesMessageEvent>(SellablePetPalettesMessageEvent, handler);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!enabled) return;
|
||||
if(palettesCache.has(breed)) return;
|
||||
|
||||
SendMessageComposer(new GetSellablePetPalettesComposer(breed));
|
||||
}, [ enabled, breed ]);
|
||||
|
||||
return { data };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user