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. */}
+
>
)
}