From 5e6ecf7c34ad44b40b209fcfa7ee1370b696f03b Mon Sep 17 00:00:00 2001 From: "xan.lol" Date: Sun, 12 Apr 2026 18:15:10 -0700 Subject: [PATCH] fix(PWA): padding for (iOS) navigation/status bars includes a revert of "fix: (iOS) PWA header for Home and Profile pages" This reverts commit 9a239e9e5046daf9bb4eeabee26986ba1e786b46. --- src/App.web.tsx | 35 ++--- src/alf/atoms.ts | 9 ++ src/components/Layout/Header/index.tsx | 8 +- src/lib/hooks/useStickyTop.ts | 9 ++ src/lib/pwa-safe-area.tsx | 73 ++++++++++ src/screens/Profile/Header/Shell.tsx | 18 +-- src/style.css | 34 +++++ src/view/shell/bottom-bar/BottomBarWeb.tsx | 158 ++++++++++++++++++++- src/view/shell/index.web.tsx | 15 ++ 9 files changed, 315 insertions(+), 44 deletions(-) create mode 100644 src/lib/hooks/useStickyTop.ts create mode 100644 src/lib/pwa-safe-area.tsx diff --git a/src/App.web.tsx b/src/App.web.tsx index a7cc3b210..7dcbaa91c 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -9,6 +9,7 @@ import {useLingui} from '@lingui/react/macro' import * as Sentry from '@sentry/react-native' import {Provider as HotkeysProvider} from '#/lib/hotkeys' +import {SafeAreaOverride} from '#/lib/pwa-safe-area' import {QueryProvider} from '#/lib/react-query' import {ThemeProvider} from '#/lib/ThemeContext' import {Provider as TranslateOnDeviceProvider} from '#/lib/translation' @@ -200,22 +201,24 @@ function InnerApp() { - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index ffac94a5d..e4240d57f 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -107,6 +107,15 @@ export const atoms = { zoom_fade_in: web({ animation: `zoomIn ${EXP_CURVE} 0.3s, fadeIn ${EXP_CURVE} 0.3s`, }), + // bottom-anchored sheet entrance animation + slide_up_in: web({ + animation: `slideUp ${EXP_CURVE} 0.25s, fadeIn ${EXP_CURVE} 0.25s`, + }), + // bottom-anchored sheet exit animation + slide_down_out: web({ + animation: `slideDown ease-in 0.2s, fadeOut ease-in 0.2s`, + animationFillMode: 'forwards', + }), /** * Visually hidden but available to screen readers (web). diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx index fae6b6b90..8109914dc 100644 --- a/src/components/Layout/Header/index.tsx +++ b/src/components/Layout/Header/index.tsx @@ -60,13 +60,7 @@ export function Outer({ gutters, platform({ native: [a.pb_xs, {minHeight: 48}], - web: [ - a.py_xs, - { - minHeight: 52, - paddingTop: 'env(safe-area-inset-top)', - }, - ], + web: [a.py_xs, {minHeight: 52}], }), t.atoms.border_contrast_low, gtMobile && [a.mx_auto, {maxWidth: 600}], diff --git a/src/lib/hooks/useStickyTop.ts b/src/lib/hooks/useStickyTop.ts new file mode 100644 index 000000000..5eb1cd8a4 --- /dev/null +++ b/src/lib/hooks/useStickyTop.ts @@ -0,0 +1,9 @@ +// sticky headers need top offset for safe area on iOS PWA +import {useSafeAreaInsets} from 'react-native-safe-area-context' + +import {atoms as a, web} from '#/alf' + +export function useStickyTop() { + const {top} = useSafeAreaInsets() + return web([a.sticky, {top}, a.z_10]) +} diff --git a/src/lib/pwa-safe-area.tsx b/src/lib/pwa-safe-area.tsx new file mode 100644 index 000000000..bb2520381 --- /dev/null +++ b/src/lib/pwa-safe-area.tsx @@ -0,0 +1,73 @@ +// measure safe area insets via getBoundingClientRect() on elements +// with height: env(safe-area-inset-*). Safari WebKit returns 0 for env() via +// getComputedStyle() in standalone PWA mode, but CSS rendering works — so we +// create elements sized by env() and measure their rendered dimensions. +// +// Usage: wrap children of SafeAreaProvider with to replace +// the broken insets with our measured values. + +import {type PropsWithChildren, useEffect, useState} from 'react' +import {type EdgeInsets} from 'react-native-safe-area-context' + +// Access the context — exported as SafeAreaContext (alias for SafeAreaInsetsContext) +const {SafeAreaContext} = require('react-native-safe-area-context') + +function createMeasureEl(id: string, heightEnv: string): HTMLElement { + let el = document.getElementById(id) + if (!el) { + el = document.createElement('div') + el.id = id + el.style.cssText = [ + 'position:fixed', + 'left:0', + 'top:0', + 'width:1px', + 'visibility:hidden', + 'pointer-events:none', + `height:${heightEnv}`, + ].join(';') + document.documentElement.appendChild(el) + } + return el +} + +function measure(): EdgeInsets { + if (typeof document === 'undefined') { + return {top: 0, bottom: 0, left: 0, right: 0} + } + + const topEl = createMeasureEl( + 'safe-area-measure-top', + 'env(safe-area-inset-top, 0px)', + ) + const bottomEl = createMeasureEl( + 'safe-area-measure-bottom', + 'env(safe-area-inset-bottom, 0px)', + ) + + return { + top: topEl.getBoundingClientRect().height, + bottom: bottomEl.getBoundingClientRect().height, + left: 0, + right: 0, + } +} + +export function SafeAreaOverride({children}: PropsWithChildren) { + const [insets, setInsets] = useState({ + top: 0, + bottom: 0, + left: 0, + right: 0, + }) + + useEffect(() => { + // Measure after mount + a frame to ensure CSS env() has been resolved + requestAnimationFrame(() => { + setInsets(measure()) + }) + }, []) + + const Provider = SafeAreaContext.Provider + return {children} +} diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 6308f0b18..ed28696a1 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -1,4 +1,4 @@ -import {memo, useCallback, useEffect, useMemo, useRef} from 'react' +import {memo, useCallback, useEffect, useMemo} from 'react' import {Pressable, View} from 'react-native' import Animated, { measure, @@ -75,17 +75,6 @@ let ProfileHeaderShell = ({ const aviRef = useAnimatedRef() const bannerRef = useAnimatedRef() - const containerRef = useRef(null) - - // Apply safe-area CSS on web - useEffect(() => { - if (containerRef.current && typeof window !== 'undefined') { - const element = containerRef.current as any - if (element.style) { - element.style.paddingTop = 'env(safe-area-inset-top)' - } - } - }, []) const onPressBack = useCallback(() => { if (navigation.canGoBack()) { @@ -209,10 +198,7 @@ let ProfileHeaderShell = ({ }, [profile.banner, moderation, _openLightboxBanner, bannerRef]) return ( - + diff --git a/src/style.css b/src/style.css index 29c8d0b48..a0a51aa12 100644 --- a/src/style.css +++ b/src/style.css @@ -232,6 +232,40 @@ input:focus { } } +@keyframes slideUp { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +@keyframes slideDown { + from { + transform: translateY(0); + } + to { + transform: translateY(100%); + } +} + +/* suppress native touch gestures on PWA bottom bar and dialogs */ +@media (pointer: coarse) { + [role='navigation'], + [role='navigation'] * { + -webkit-touch-callout: none !important; + -webkit-user-select: none !important; + user-select: none !important; + } + [role='dialog'], + [role='dialog'] * { + -webkit-touch-callout: none !important; + -webkit-user-select: none !important; + user-select: none !important; + } +} + /* animating radix dropdowns requires knowing the data attributes */ .dropdown-menu-transform-origin > * { transform-origin: var(--radix-dropdown-menu-content-transform-origin); diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index 2bf9807e4..8342d2a28 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -1,16 +1,27 @@ -import {useCallback} from 'react' -import {View} from 'react-native' +import {useCallback, useRef, useState} from 'react' +import {Pressable, View} from 'react-native' import Animated from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {sanitizeUrl} from '@braintree/sanitize-url' import {msg, plural} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' -import {useNavigationState} from '@react-navigation/native' +import {StackActions, useNavigationState} from '@react-navigation/native' import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder' import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform' -import {getCurrentRoute, isTab} from '#/lib/routes/helpers' +import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped' +import { + getCurrentRoute, + getTabState, + isTab, + TabState, +} from '#/lib/routes/helpers' import {makeProfileLink} from '#/lib/routes/links' import {type CommonNavigatorParams} from '#/lib/routes/types' +import {convertBskyAppUrlIfNeeded} from '#/lib/strings/url-helpers' +import {emitSoftReset} from '#/state/events' +import {useModalControls} from '#/state/modals' import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations' import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useProfileQuery} from '#/state/queries/profile' @@ -44,12 +55,15 @@ import { } from '#/components/icons/Message' import {Text} from '#/components/Typography' import {useAgeAssurance} from '#/ageAssurance' +import {IS_WEB_TOUCH_DEVICE} from '#/env' +import {router} from '#/routes' import {styles} from './BottomBarStyles' export function BottomBarWeb() { const {_} = useLingui() const {hasSession, currentAccount} = useSession() const t = useTheme() + const {bottom: bottomInset} = useSafeAreaInsets() const footerMinimalShellTransform = useMinimalShellFooterTransform() const {requestSwitchToAccount} = useLoggedOutViewControls() const closeAllActiveElements = useCloseAllActiveElements() @@ -88,6 +102,9 @@ export function BottomBarWeb() { styles.bottomBar, styles.bottomBarWeb, t.atoms.bg, + IS_WEB_TOUCH_DEVICE + ? {paddingBottom: Math.max(bottomInset, 15)} + : {paddingBottom: bottomInset}, hideBorder ? {borderColor: t.atoms.bg.backgroundColor} : t.atoms.border_contrast_low, @@ -263,6 +280,7 @@ const NavItem: React.FC<{ }> = ({children, href, routeName, hasNew, notificationCount, onLongPress}) => { const t = useTheme() const {_} = useLingui() + const {bottom: bottomInset} = useSafeAreaInsets() const {currentAccount} = useSession() const currentRoute = useNavigationState(state => { if (!state) { @@ -287,10 +305,25 @@ const NavItem: React.FC<{ : (currentRoute.params as CommonNavigatorParams['Profile']).name) : isTab(currentRoute.name, routeName) + if (IS_WEB_TOUCH_DEVICE) { + return ( + + {children} + + ) + } + return ( ) } + +function TouchNavItem({ + children, + href, + routeName, + isActive, + isOnDifferentProfile, + hasNew, + notificationCount, + onLongPress, +}: { + children: (props: {isActive: boolean}) => React.ReactNode + href: string + routeName: string + isActive: boolean + isOnDifferentProfile: boolean + hasNew?: boolean + notificationCount?: string + onLongPress?: () => void +}) { + const t = useTheme() + const {_} = useLingui() + const {bottom: bottomInset} = useSafeAreaInsets() + const navigation = useNavigationDeduped() + const {closeModal} = useModalControls() + + // CSS transition press animation — runs on compositor thread + // so navigation re-renders don't cause jank + const [pressed, setPressed] = useState(false) + const pressInTime = useRef(0) + const pressOutTimer = useRef | undefined>( + undefined, + ) + const ANIM_MS = 100 + + const handlePressIn = () => { + if (pressOutTimer.current) { + clearTimeout(pressOutTimer.current) + pressOutTimer.current = undefined + } + pressInTime.current = Date.now() + setPressed(true) + } + + const handlePressOut = () => { + const elapsed = Date.now() - pressInTime.current + const remaining = Math.max(0, ANIM_MS - elapsed) + // Wait for scale-down to finish before starting scale-up + pressOutTimer.current = setTimeout(() => { + setPressed(false) + pressOutTimer.current = undefined + }, remaining) + } + + const onPress = () => { + closeModal() + + const sanitizedHref = convertBskyAppUrlIfNeeded(sanitizeUrl(href)) + const [resolvedRouteName, params] = router.matchPath(sanitizedHref) + + if (isOnDifferentProfile) { + // @ts-ignore we're not able to type check on this one -prf + navigation.dispatch(StackActions.push(resolvedRouteName, params)) + } else { + const state = navigation.getState() + const tabState = getTabState(state, resolvedRouteName) + if (tabState === TabState.InsideAtRoot) { + emitSoftReset() + } else { + // @ts-ignore we're not able to type check on this one -prf + navigation.navigate(resolvedRouteName, params, {pop: true}) + } + } + } + + return ( + + {children({isActive})} + {notificationCount ? ( + + {notificationCount} + + ) : hasNew ? ( + + ) : null} + + ) +} diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 05adc7670..143d48332 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -119,6 +119,21 @@ function ShellInner() { )} + + {/* workaround for a WebKit compositing bug. After a + dialog (which uses Portal + fixed elements + CSS animations) + closes, WebKit can skip painting subsequent view transitions. + A persistent zero-size fixed element keeps the compositing + tree from entering this broken state. */} +
) } -- 2.51.2