diff --git a/src/components/navigator/NavigatorView.tsx b/src/components/navigator/NavigatorView.tsx index 332bbbd..ec201b5 100644 --- a/src/components/navigator/NavigatorView.tsx +++ b/src/components/navigator/NavigatorView.tsx @@ -246,7 +246,7 @@ export const NavigatorView: FC<{}> = props => { (searchResult && searchResult.results.map((result, index) => )) } { (searchResult && (!searchResult.results || (searchResult.results.length === 0))) &&
- { LocalizeText(searchResult.code === 'myworld_view' ? 'navigator.no.user.rooms.to.show' : 'navigator.no.results') } + { LocalizeText(searchResult.code === 'myworld_view' ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results') }
} diff --git a/src/components/navigator/views/search/NavigatorSearchResultView.tsx b/src/components/navigator/views/search/NavigatorSearchResultView.tsx index 50bb5cb..e8432c7 100644 --- a/src/components/navigator/views/search/NavigatorSearchResultView.tsx +++ b/src/components/navigator/views/search/NavigatorSearchResultView.tsx @@ -107,7 +107,7 @@ export const NavigatorSearchResultView: FC = pro } { (searchResult.rooms.length === 0) && - { LocalizeText(searchResult.code === 'myworld_view' ? 'navigator.no.user.rooms.to.show' : 'navigator.no.results') } + { LocalizeText(searchResult.code === 'myworld_view' ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results') } } } diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index 2e717f2..f4e0e18 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -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 | 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(() => ({ 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(() => ({ hidden: { opacity: 0, x: 10, pointerEvents: 'none' }, visible: { opacity: 1, x: 0, pointerEvents: 'auto' } - }; - const mobileNavVariants: Variants = { + }), []); + const mobileNavVariants = useMemo(() => ({ hidden: { opacity: 0, y: 8, pointerEvents: 'none' }, visible: { opacity: 1, y: 0, pointerEvents: 'auto' } - }; + }), []); useMessageEvent(YouTubeRoomSettingsEvent, event => { @@ -163,46 +162,39 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { youtubeEnabled && } { isInRoom && -
+
setIsToolbarOpen(value => !value) } + className="tb-toggle pointer-events-auto mr-2 flex-shrink-0" + onClick={ handleToggleClick } whileTap={ { scale: 0.9 } }> - - + -
- CreateLinkEvent('friends/toggle') } className="tb-icon" /> - { (requests.length > 0) && - } -
} - - { /* Desktop backplate. Always mounted; opacity-driven. */ } - - { /* Left nav — desktop. Container variant inheritance staggers items in/out. */ } + className={ `tb-nav-clip fixed bottom-0 left-0 z-40 h-[52px] max-w-[calc(50vw-242px)] items-center pl-3 ${ desktopFlexClasses }` }> @@ -250,7 +242,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => setMeExpanded(value => !value); event.stopPropagation(); } }> - + { (getTotalUnseen > 0) && } @@ -279,14 +271,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => } - - { /* Right nav — desktop */ } + 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' }` }> @@ -303,14 +293,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
- - { /* Mobile nav. Two staggered halves split by the Me avatar. */ } + 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)]' : '' }` }> @@ -359,7 +347,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => setMeExpanded(value => !value); event.stopPropagation(); } }> - + { (getTotalUnseen > 0) && } @@ -389,12 +377,11 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => CreateLinkEvent('furni-editor/toggle') } className="tb-icon" /> } - { !isInRoom && - - CreateLinkEvent('friends/toggle') } className="tb-icon" /> - { (requests.length > 0) && - } - } + + CreateLinkEvent('friends/toggle') } className="tb-icon" /> + { (requests.length > 0) && + } + @@ -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; diff --git a/src/hooks/catalog/useSellablePetPalette.ts b/src/hooks/catalog/useSellablePetPalette.ts index 700cffc..f1f8556 100644 --- a/src/hooks/catalog/useSellablePetPalette.ts +++ b/src/hooks/catalog/useSellablePetPalette.ts @@ -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(); -/** - * 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 => - useNitroQuery({ - 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(() => 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, handler); + + useEffect(() => + { + if(!enabled) return; + if(palettesCache.has(breed)) return; + + SendMessageComposer(new GetSellablePetPalettesComposer(breed)); + }, [ enabled, breed ]); + + return { data }; +};