useViewport re-syncs the snapshot on pageshow/visibilitychange/resize/orientationchange — iOS reported a stale width on backgrounded-tab restore, leaving isMobile=false so the sidebar rendered as a permanent column with no close affordance. flattenToMessage now inserts pasted-text chips verbatim instead of wrapping them in a triple-backtick fence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
81 lines
3.0 KiB
TypeScript
81 lines
3.0 KiB
TypeScript
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<ViewportSnapshot>(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;
|
|
}
|