import { useEffect, useState } from 'react'; // Breakpoints (px): mobile <768, tablet 768-1023, desktop >=1024. const MOBILE_MAX = 767; const TABLET_MAX = 1023; export interface ViewportSnapshot { isMobile: boolean; isTablet: boolean; width: number; } function snapshot(): ViewportSnapshot { if (typeof window === 'undefined') { return { isMobile: false, isTablet: false, width: 1280 }; } const width = window.innerWidth; return { isMobile: width <= MOBILE_MAX, isTablet: width > MOBILE_MAX && width <= TABLET_MAX, width, }; } // matchMedia-based, no resize polling. We listen to two breakpoint queries // and recompute the snapshot on any change. export function useViewport(): ViewportSnapshot { const [state, setState] = useState(snapshot); useEffect(() => { if (typeof window === 'undefined') return; const mobileMq = window.matchMedia(`(max-width: ${MOBILE_MAX}px)`); const tabletMq = window.matchMedia(`(min-width: ${MOBILE_MAX + 1}px) and (max-width: ${TABLET_MAX}px)`); const update = () => setState((prev) => { const next = snapshot(); // Bail if nothing changed — visualViewport 'resize' fires on every // URL-bar show/hide and scroll, and a fresh object would re-render // every consumer needlessly. if ( prev.isMobile === next.isMobile && prev.isTablet === next.isTablet && prev.width === next.width ) { return prev; } return next; }); // matchMedia 'change' alone is not enough on iOS Safari/Vivaldi: when a // backgrounded tab is restored (bfcache) or refocused, no 'change' fires, // and the width captured at first paint can be a stale/oversized value // (iOS reports the wrong innerWidth for a beat before layout settles). That // leaves isMobile=false on a phone, so the sidebar renders as a permanent // desktop column with no way to close it. Re-snapshot on every signal that // accompanies a rejoin/viewport correction, not just breakpoint crossings. const onVisibility = () => { if (document.visibilityState === 'visible') update(); }; mobileMq.addEventListener('change', update); tabletMq.addEventListener('change', update); window.addEventListener('resize', update); window.addEventListener('orientationchange', update); window.addEventListener('pageshow', update); document.addEventListener('visibilitychange', onVisibility); window.visualViewport?.addEventListener('resize', update); update(); return () => { mobileMq.removeEventListener('change', update); tabletMq.removeEventListener('change', update); window.removeEventListener('resize', update); window.removeEventListener('orientationchange', update); window.removeEventListener('pageshow', update); document.removeEventListener('visibilitychange', onVisibility); window.visualViewport?.removeEventListener('resize', update); }; }, []); return state; }