Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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.

xan.lol 5e6ecf7c 3d818ade

+315 -44
+19 -16
src/App.web.tsx
··· 9 9 import * as Sentry from '@sentry/react-native' 10 10 11 11 import {Provider as HotkeysProvider} from '#/lib/hotkeys' 12 + import {SafeAreaOverride} from '#/lib/pwa-safe-area' 12 13 import {QueryProvider} from '#/lib/react-query' 13 14 import {ThemeProvider} from '#/lib/ThemeContext' 14 15 import {Provider as TranslateOnDeviceProvider} from '#/lib/translation' ··· 200 201 <BackgroundNotificationPreferencesProvider> 201 202 <MutedThreadsProvider> 202 203 <SafeAreaProvider> 203 - <ProgressGuideProvider> 204 - <ServiceConfigProvider> 205 - <EmailVerificationProvider> 206 - <HideBottomBarBorderProvider> 207 - <IntentDialogProvider> 208 - <TranslateOnDeviceProvider> 209 - <HotkeysProvider> 210 - <Shell /> 211 - <ToastOutlet /> 212 - </HotkeysProvider> 213 - </TranslateOnDeviceProvider> 214 - </IntentDialogProvider> 215 - </HideBottomBarBorderProvider> 216 - </EmailVerificationProvider> 217 - </ServiceConfigProvider> 218 - </ProgressGuideProvider> 204 + <SafeAreaOverride> 205 + <ProgressGuideProvider> 206 + <ServiceConfigProvider> 207 + <EmailVerificationProvider> 208 + <HideBottomBarBorderProvider> 209 + <IntentDialogProvider> 210 + <TranslateOnDeviceProvider> 211 + <HotkeysProvider> 212 + <Shell /> 213 + <ToastOutlet /> 214 + </HotkeysProvider> 215 + </TranslateOnDeviceProvider> 216 + </IntentDialogProvider> 217 + </HideBottomBarBorderProvider> 218 + </EmailVerificationProvider> 219 + </ServiceConfigProvider> 220 + </ProgressGuideProvider> 221 + </SafeAreaOverride> 219 222 </SafeAreaProvider> 220 223 </MutedThreadsProvider> 221 224 </BackgroundNotificationPreferencesProvider>
+9
src/alf/atoms.ts
··· 107 107 zoom_fade_in: web({ 108 108 animation: `zoomIn ${EXP_CURVE} 0.3s, fadeIn ${EXP_CURVE} 0.3s`, 109 109 }), 110 + // bottom-anchored sheet entrance animation 111 + slide_up_in: web({ 112 + animation: `slideUp ${EXP_CURVE} 0.25s, fadeIn ${EXP_CURVE} 0.25s`, 113 + }), 114 + // bottom-anchored sheet exit animation 115 + slide_down_out: web({ 116 + animation: `slideDown ease-in 0.2s, fadeOut ease-in 0.2s`, 117 + animationFillMode: 'forwards', 118 + }), 110 119 111 120 /** 112 121 * Visually hidden but available to screen readers (web).
+1 -7
src/components/Layout/Header/index.tsx
··· 60 60 gutters, 61 61 platform({ 62 62 native: [a.pb_xs, {minHeight: 48}], 63 - web: [ 64 - a.py_xs, 65 - { 66 - minHeight: 52, 67 - paddingTop: 'env(safe-area-inset-top)', 68 - }, 69 - ], 63 + web: [a.py_xs, {minHeight: 52}], 70 64 }), 71 65 t.atoms.border_contrast_low, 72 66 gtMobile && [a.mx_auto, {maxWidth: 600}],
+9
src/lib/hooks/useStickyTop.ts
··· 1 + // sticky headers need top offset for safe area on iOS PWA 2 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 3 + 4 + import {atoms as a, web} from '#/alf' 5 + 6 + export function useStickyTop() { 7 + const {top} = useSafeAreaInsets() 8 + return web([a.sticky, {top}, a.z_10]) 9 + }
+73
src/lib/pwa-safe-area.tsx
··· 1 + // measure safe area insets via getBoundingClientRect() on elements 2 + // with height: env(safe-area-inset-*). Safari WebKit returns 0 for env() via 3 + // getComputedStyle() in standalone PWA mode, but CSS rendering works — so we 4 + // create elements sized by env() and measure their rendered dimensions. 5 + // 6 + // Usage: wrap children of SafeAreaProvider with <SafeAreaOverride> to replace 7 + // the broken insets with our measured values. 8 + 9 + import {type PropsWithChildren, useEffect, useState} from 'react' 10 + import {type EdgeInsets} from 'react-native-safe-area-context' 11 + 12 + // Access the context — exported as SafeAreaContext (alias for SafeAreaInsetsContext) 13 + const {SafeAreaContext} = require('react-native-safe-area-context') 14 + 15 + function createMeasureEl(id: string, heightEnv: string): HTMLElement { 16 + let el = document.getElementById(id) 17 + if (!el) { 18 + el = document.createElement('div') 19 + el.id = id 20 + el.style.cssText = [ 21 + 'position:fixed', 22 + 'left:0', 23 + 'top:0', 24 + 'width:1px', 25 + 'visibility:hidden', 26 + 'pointer-events:none', 27 + `height:${heightEnv}`, 28 + ].join(';') 29 + document.documentElement.appendChild(el) 30 + } 31 + return el 32 + } 33 + 34 + function measure(): EdgeInsets { 35 + if (typeof document === 'undefined') { 36 + return {top: 0, bottom: 0, left: 0, right: 0} 37 + } 38 + 39 + const topEl = createMeasureEl( 40 + 'safe-area-measure-top', 41 + 'env(safe-area-inset-top, 0px)', 42 + ) 43 + const bottomEl = createMeasureEl( 44 + 'safe-area-measure-bottom', 45 + 'env(safe-area-inset-bottom, 0px)', 46 + ) 47 + 48 + return { 49 + top: topEl.getBoundingClientRect().height, 50 + bottom: bottomEl.getBoundingClientRect().height, 51 + left: 0, 52 + right: 0, 53 + } 54 + } 55 + 56 + export function SafeAreaOverride({children}: PropsWithChildren) { 57 + const [insets, setInsets] = useState<EdgeInsets>({ 58 + top: 0, 59 + bottom: 0, 60 + left: 0, 61 + right: 0, 62 + }) 63 + 64 + useEffect(() => { 65 + // Measure after mount + a frame to ensure CSS env() has been resolved 66 + requestAnimationFrame(() => { 67 + setInsets(measure()) 68 + }) 69 + }, []) 70 + 71 + const Provider = SafeAreaContext.Provider 72 + return <Provider value={insets}>{children}</Provider> 73 + }
+2 -16
src/screens/Profile/Header/Shell.tsx
··· 1 - import {memo, useCallback, useEffect, useMemo, useRef} from 'react' 1 + import {memo, useCallback, useEffect, useMemo} from 'react' 2 2 import {Pressable, View} from 'react-native' 3 3 import Animated, { 4 4 measure, ··· 75 75 76 76 const aviRef = useAnimatedRef() 77 77 const bannerRef = useAnimatedRef<Animated.View>() 78 - const containerRef = useRef<View>(null) 79 - 80 - // Apply safe-area CSS on web 81 - useEffect(() => { 82 - if (containerRef.current && typeof window !== 'undefined') { 83 - const element = containerRef.current as any 84 - if (element.style) { 85 - element.style.paddingTop = 'env(safe-area-inset-top)' 86 - } 87 - } 88 - }, []) 89 78 90 79 const onPressBack = useCallback(() => { 91 80 if (navigation.canGoBack()) { ··· 209 198 }, [profile.banner, moderation, _openLightboxBanner, bannerRef]) 210 199 211 200 return ( 212 - <View 213 - ref={containerRef} 214 - style={t.atoms.bg} 215 - pointerEvents={IS_IOS ? 'auto' : 'box-none'}> 201 + <View style={t.atoms.bg} pointerEvents={IS_IOS ? 'auto' : 'box-none'}> 216 202 <View 217 203 pointerEvents={IS_IOS ? 'auto' : 'box-none'} 218 204 style={[a.relative, {height: 150}]}>
+34
src/style.css
··· 232 232 } 233 233 } 234 234 235 + @keyframes slideUp { 236 + from { 237 + transform: translateY(100%); 238 + } 239 + to { 240 + transform: translateY(0); 241 + } 242 + } 243 + 244 + @keyframes slideDown { 245 + from { 246 + transform: translateY(0); 247 + } 248 + to { 249 + transform: translateY(100%); 250 + } 251 + } 252 + 253 + /* suppress native touch gestures on PWA bottom bar and dialogs */ 254 + @media (pointer: coarse) { 255 + [role='navigation'], 256 + [role='navigation'] * { 257 + -webkit-touch-callout: none !important; 258 + -webkit-user-select: none !important; 259 + user-select: none !important; 260 + } 261 + [role='dialog'], 262 + [role='dialog'] * { 263 + -webkit-touch-callout: none !important; 264 + -webkit-user-select: none !important; 265 + user-select: none !important; 266 + } 267 + } 268 + 235 269 /* animating radix dropdowns requires knowing the data attributes */ 236 270 .dropdown-menu-transform-origin > * { 237 271 transform-origin: var(--radix-dropdown-menu-content-transform-origin);
+153 -5
src/view/shell/bottom-bar/BottomBarWeb.tsx
··· 1 - import {useCallback} from 'react' 2 - import {View} from 'react-native' 1 + import {useCallback, useRef, useState} from 'react' 2 + import {Pressable, View} from 'react-native' 3 3 import Animated from 'react-native-reanimated' 4 + import {useSafeAreaInsets} from 'react-native-safe-area-context' 5 + import {sanitizeUrl} from '@braintree/sanitize-url' 4 6 import {msg, plural} from '@lingui/core/macro' 5 7 import {useLingui} from '@lingui/react' 6 8 import {Trans} from '@lingui/react/macro' 7 - import {useNavigationState} from '@react-navigation/native' 9 + import {StackActions, useNavigationState} from '@react-navigation/native' 8 10 9 11 import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder' 10 12 import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform' 11 - import {getCurrentRoute, isTab} from '#/lib/routes/helpers' 13 + import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped' 14 + import { 15 + getCurrentRoute, 16 + getTabState, 17 + isTab, 18 + TabState, 19 + } from '#/lib/routes/helpers' 12 20 import {makeProfileLink} from '#/lib/routes/links' 13 21 import {type CommonNavigatorParams} from '#/lib/routes/types' 22 + import {convertBskyAppUrlIfNeeded} from '#/lib/strings/url-helpers' 23 + import {emitSoftReset} from '#/state/events' 24 + import {useModalControls} from '#/state/modals' 14 25 import {useUnreadMessageCount} from '#/state/queries/messages/list-conversations' 15 26 import {useUnreadNotifications} from '#/state/queries/notifications/unread' 16 27 import {useProfileQuery} from '#/state/queries/profile' ··· 44 55 } from '#/components/icons/Message' 45 56 import {Text} from '#/components/Typography' 46 57 import {useAgeAssurance} from '#/ageAssurance' 58 + import {IS_WEB_TOUCH_DEVICE} from '#/env' 59 + import {router} from '#/routes' 47 60 import {styles} from './BottomBarStyles' 48 61 49 62 export function BottomBarWeb() { 50 63 const {_} = useLingui() 51 64 const {hasSession, currentAccount} = useSession() 52 65 const t = useTheme() 66 + const {bottom: bottomInset} = useSafeAreaInsets() 53 67 const footerMinimalShellTransform = useMinimalShellFooterTransform() 54 68 const {requestSwitchToAccount} = useLoggedOutViewControls() 55 69 const closeAllActiveElements = useCloseAllActiveElements() ··· 88 102 styles.bottomBar, 89 103 styles.bottomBarWeb, 90 104 t.atoms.bg, 105 + IS_WEB_TOUCH_DEVICE 106 + ? {paddingBottom: Math.max(bottomInset, 15)} 107 + : {paddingBottom: bottomInset}, 91 108 hideBorder 92 109 ? {borderColor: t.atoms.bg.backgroundColor} 93 110 : t.atoms.border_contrast_low, ··· 263 280 }> = ({children, href, routeName, hasNew, notificationCount, onLongPress}) => { 264 281 const t = useTheme() 265 282 const {_} = useLingui() 283 + const {bottom: bottomInset} = useSafeAreaInsets() 266 284 const {currentAccount} = useSession() 267 285 const currentRoute = useNavigationState(state => { 268 286 if (!state) { ··· 287 305 : (currentRoute.params as CommonNavigatorParams['Profile']).name) 288 306 : isTab(currentRoute.name, routeName) 289 307 308 + if (IS_WEB_TOUCH_DEVICE) { 309 + return ( 310 + <TouchNavItem 311 + href={href} 312 + routeName={routeName} 313 + isActive={isActive} 314 + isOnDifferentProfile={isOnDifferentProfile} 315 + hasNew={hasNew} 316 + notificationCount={notificationCount} 317 + onLongPress={onLongPress}> 318 + {children} 319 + </TouchNavItem> 320 + ) 321 + } 322 + 290 323 return ( 291 324 <Link 292 325 href={href} 293 - style={[styles.ctrl, a.pb_lg]} 326 + style={[styles.ctrl, bottomInset === 0 && a.pb_lg]} 294 327 navigationAction={isOnDifferentProfile ? 'push' : 'navigate'} 295 328 aria-role="link" 296 329 aria-label={routeName} ··· 320 353 </Link> 321 354 ) 322 355 } 356 + 357 + function TouchNavItem({ 358 + children, 359 + href, 360 + routeName, 361 + isActive, 362 + isOnDifferentProfile, 363 + hasNew, 364 + notificationCount, 365 + onLongPress, 366 + }: { 367 + children: (props: {isActive: boolean}) => React.ReactNode 368 + href: string 369 + routeName: string 370 + isActive: boolean 371 + isOnDifferentProfile: boolean 372 + hasNew?: boolean 373 + notificationCount?: string 374 + onLongPress?: () => void 375 + }) { 376 + const t = useTheme() 377 + const {_} = useLingui() 378 + const {bottom: bottomInset} = useSafeAreaInsets() 379 + const navigation = useNavigationDeduped() 380 + const {closeModal} = useModalControls() 381 + 382 + // CSS transition press animation — runs on compositor thread 383 + // so navigation re-renders don't cause jank 384 + const [pressed, setPressed] = useState(false) 385 + const pressInTime = useRef(0) 386 + const pressOutTimer = useRef<ReturnType<typeof setTimeout> | undefined>( 387 + undefined, 388 + ) 389 + const ANIM_MS = 100 390 + 391 + const handlePressIn = () => { 392 + if (pressOutTimer.current) { 393 + clearTimeout(pressOutTimer.current) 394 + pressOutTimer.current = undefined 395 + } 396 + pressInTime.current = Date.now() 397 + setPressed(true) 398 + } 399 + 400 + const handlePressOut = () => { 401 + const elapsed = Date.now() - pressInTime.current 402 + const remaining = Math.max(0, ANIM_MS - elapsed) 403 + // Wait for scale-down to finish before starting scale-up 404 + pressOutTimer.current = setTimeout(() => { 405 + setPressed(false) 406 + pressOutTimer.current = undefined 407 + }, remaining) 408 + } 409 + 410 + const onPress = () => { 411 + closeModal() 412 + 413 + const sanitizedHref = convertBskyAppUrlIfNeeded(sanitizeUrl(href)) 414 + const [resolvedRouteName, params] = router.matchPath(sanitizedHref) 415 + 416 + if (isOnDifferentProfile) { 417 + // @ts-ignore we're not able to type check on this one -prf 418 + navigation.dispatch(StackActions.push(resolvedRouteName, params)) 419 + } else { 420 + const state = navigation.getState() 421 + const tabState = getTabState(state, resolvedRouteName) 422 + if (tabState === TabState.InsideAtRoot) { 423 + emitSoftReset() 424 + } else { 425 + // @ts-ignore we're not able to type check on this one -prf 426 + navigation.navigate(resolvedRouteName, params, {pop: true}) 427 + } 428 + } 429 + } 430 + 431 + return ( 432 + <Pressable 433 + style={[ 434 + styles.ctrl, 435 + bottomInset === 0 && a.pb_lg, 436 + { 437 + transition: `transform ${ANIM_MS}ms`, 438 + transform: [{scale: pressed ? 0.8 : 1}], 439 + } as any, 440 + ]} 441 + onPress={onPress} 442 + onLongPress={onLongPress} 443 + onPressIn={handlePressIn} 444 + onPressOut={handlePressOut} 445 + unstable_pressDelay={0} 446 + accessibilityRole="link" 447 + accessibilityLabel={routeName} 448 + accessibilityHint=""> 449 + {children({isActive})} 450 + {notificationCount ? ( 451 + <View 452 + style={[ 453 + styles.notificationCount, 454 + styles.notificationCountWeb, 455 + {backgroundColor: t.palette.primary_500}, 456 + ]} 457 + aria-label={_( 458 + msg`${plural(notificationCount, { 459 + one: '# unread item', 460 + other: '# unread items', 461 + })}`, 462 + )}> 463 + <Text style={styles.notificationCountLabel}>{notificationCount}</Text> 464 + </View> 465 + ) : hasNew ? ( 466 + <View style={styles.hasNewBadge} /> 467 + ) : null} 468 + </Pressable> 469 + ) 470 + }
+15
src/view/shell/index.web.tsx
··· 119 119 )} 120 120 121 121 <PolicyUpdateOverlayPortalOutlet /> 122 + 123 + {/* workaround for a WebKit compositing bug. After a 124 + dialog (which uses Portal + fixed elements + CSS animations) 125 + closes, WebKit can skip painting subsequent view transitions. 126 + A persistent zero-size fixed element keeps the compositing 127 + tree from entering this broken state. */} 128 + <div 129 + aria-hidden 130 + style={{ 131 + position: 'fixed', 132 + width: 0, 133 + height: 0, 134 + pointerEvents: 'none', 135 + }} 136 + /> 122 137 </> 123 138 ) 124 139 }