Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client

iOS 26 (#9047)

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

authored by samuel.fm

Eric Bailey
Claude Opus 4.6
and committed by
GitHub
73b096e4 9b0f0a8d

+90 -71
-6
__mocks__/@notifee/react-native.ts
··· 1 - export default { 2 - requestPermission: jest.fn(), 3 - onForegroundEvent: jest.fn(), 4 - setBadgeCount: jest.fn(), 5 - displayNotification: jest.fn(), 6 - }
-9
__mocks__/@react-native-camera-roll/camera-roll.js
··· 1 - export const CameraRoll = { 2 - getPhotos: jest.fn().mockResolvedValue({ 3 - edges: [ 4 - {node: {image: {uri: 'path/to/image1.jpg'}}}, 5 - {node: {image: {uri: 'path/to/image2.jpg'}}}, 6 - {node: {image: {uri: 'path/to/image3.jpg'}}}, 7 - ], 8 - }), 9 - }
-4
__mocks__/react-native-background-fetch.ts
··· 1 - export default { 2 - configure: jest.fn().mockResolvedValue(0), 3 - finish: jest.fn(), 4 - }
-1
__mocks__/react-native-fs.js
··· 1 - export default {}
-10
__mocks__/rn-fetch-blob.js
··· 1 - jest.mock('rn-fetch-blob', () => { 2 - return { 3 - __esModule: true, 4 - default: { 5 - fs: { 6 - unlink: jest.fn(), 7 - }, 8 - }, 9 - } 10 - })
-2
__mocks__/zeego/dropdown-menu.js
··· 1 - export const DropdownMenu = jest.fn().mockImplementation(() => {}) 2 - export const create = jest.fn().mockImplementation(() => {})
-1
app.config.js
··· 112 112 'zh-Hans', 113 113 'zh-Hant', 114 114 ], 115 - UIDesignRequiresCompatibility: true, 116 115 }, 117 116 associatedDomains: ASSOCIATED_DOMAINS, 118 117 entitlements: {
+14 -1
modules/bottom-sheet/ios/SheetViewController.swift
··· 27 27 return 28 28 } 29 29 30 + // On iOS 26, the floaty sheet presentation adds the device bottom safe area 31 + // on top of the custom detent value, creating visible padding inside the pill. 32 + // Subtract it so the pill height matches our actual content. 33 + var bottomSafeAreaAdjustment: CGFloat = 0 34 + if #available(iOS 26.0, *) { 35 + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, 36 + let window = windowScene.windows.first { 37 + bottomSafeAreaAdjustment = window.safeAreaInsets.bottom 38 + } 39 + } 40 + 41 + let adjustedHeight = contentHeight - bottomSafeAreaAdjustment 42 + 30 43 if #available(iOS 16.0, *) { 31 44 if contentHeight > screenHeight - 100 { 32 45 sheet.detents = [ ··· 36 49 } else { 37 50 sheet.detents = [ 38 51 .custom { _ in 39 - return contentHeight 52 + return adjustedHeight 40 53 } 41 54 ] 42 55 if !preventExpansion {
+3 -2
modules/bottom-sheet/src/BottomSheetNativeComponent.tsx
··· 5 5 type NativeSyntheticEvent, 6 6 Platform, 7 7 type StyleProp, 8 + useWindowDimensions, 8 9 View, 9 10 type ViewStyle, 10 11 } from 'react-native' ··· 20 21 BottomSheetPortalProvider, 21 22 Context as PortalContext, 22 23 } from './BottomSheetPortal' 23 - 24 - const screenHeight = Dimensions.get('screen').height 25 24 26 25 const NativeView: React.ComponentType< 27 26 BottomSheetViewProps & { ··· 94 93 95 94 let extraStyles 96 95 if (IS_IOS15 && this.state.viewHeight) { 96 + const screenHeight = Dimensions.get('screen').height 97 97 const {viewHeight} = this.state 98 98 const cornerRadius = this.props.cornerRadius ?? 0 99 99 if (viewHeight < screenHeight / 2) { ··· 154 154 }) { 155 155 const insets = useSafeAreaInsets() 156 156 const cornerRadius = rest.cornerRadius ?? 0 157 + const {height: screenHeight} = useWindowDimensions() 157 158 158 159 const sheetHeight = IS_IOS ? screenHeight - insets.top : screenHeight 159 160
+7
package.json
··· 16 16 "expo-image-picker" 17 17 ] 18 18 } 19 + }, 20 + "install": { 21 + "exclude": [ 22 + "react-native-reanimated", 23 + "@sentry/react-native", 24 + "react-native-pager-view" 25 + ] 19 26 } 20 27 }, 21 28 "scripts": {
+4
src/alf/atoms.ts
··· 10 10 export const atoms = { 11 11 ...baseAtoms, 12 12 13 + rounded_sheet: { 14 + borderRadius: 40, 15 + }, 16 + 13 17 h_full_vh: web({ 14 18 height: '100vh', 15 19 }),
+11 -7
src/components/Dialog/index.tsx
··· 38 38 type DialogOuterProps, 39 39 } from '#/components/Dialog/types' 40 40 import {createInput} from '#/components/forms/TextField' 41 - import {IS_ANDROID, IS_IOS} from '#/env' 41 + import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS} from '#/env' 42 42 import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet' 43 43 import { 44 44 type BottomSheetSnapPointChangeEvent, ··· 166 166 return ( 167 167 <BottomSheet 168 168 ref={ref} 169 - cornerRadius={20} 169 + // device-bezel radius when undefined 170 + cornerRadius={IS_LIQUID_GLASS ? undefined : 20} 170 171 backgroundColor={t.atoms.bg.backgroundColor} 171 172 {...nativeOptions} 172 173 onSnapPointChange={onSnapPointChange} ··· 181 182 ) 182 183 } 183 184 185 + /** 186 + * @deprecated use `Dialog.ScrollableInner` instead 187 + */ 184 188 export function Inner({children, style, header}: DialogInnerProps) { 185 189 const insets = useSafeAreaInsets() 186 190 return ( ··· 190 194 style={[ 191 195 a.pt_2xl, 192 196 a.px_xl, 193 - { 194 - paddingBottom: insets.bottom + insets.top, 195 - }, 197 + IS_LIQUID_GLASS 198 + ? a.pb_2xl 199 + : {paddingBottom: insets.bottom + insets.top}, 196 200 style, 197 201 ]}> 198 202 {children} ··· 253 257 <KeyboardAwareScrollView 254 258 contentContainerStyle={[ 255 259 a.pt_2xl, 256 - a.px_xl, 260 + IS_LIQUID_GLASS ? a.px_2xl : a.px_xl, 257 261 {paddingBottom}, 258 262 contentContainerStyle, 259 263 ]} ··· 342 346 a.pt_md, 343 347 { 344 348 paddingBottom: platform({ 345 - ios: tokens.space.md + bottom, 349 + ios: tokens.space.md + bottom + (IS_LIQUID_GLASS ? top : 0), 346 350 android: tokens.space.md + bottom + top, 347 351 }), 348 352 },
+8 -3
src/components/Dialog/shared.tsx
··· 8 8 9 9 import {atoms as a, useTheme} from '#/alf' 10 10 import {Text} from '#/components/Typography' 11 + import {IS_LIQUID_GLASS} from '#/env' 11 12 12 13 export function Header({ 13 14 renderLeft, ··· 35 36 a.flex_row, 36 37 a.justify_center, 37 38 a.align_center, 38 - {minHeight: 50}, 39 + {minHeight: IS_LIQUID_GLASS ? 64 : 50}, 39 40 a.border_b, 40 41 t.atoms.border_contrast_medium, 41 42 t.atoms.bg, ··· 44 45 style, 45 46 ]}> 46 47 {renderLeft && ( 47 - <View style={[a.absolute, {left: 6}]}>{renderLeft()}</View> 48 + <View style={[a.absolute, {left: IS_LIQUID_GLASS ? 12 : 6}]}> 49 + {renderLeft()} 50 + </View> 48 51 )} 49 52 {children} 50 53 {renderRight && ( 51 - <View style={[a.absolute, {right: 6}]}>{renderRight()}</View> 54 + <View style={[a.absolute, {right: IS_LIQUID_GLASS ? 12 : 6}]}> 55 + {renderRight()} 56 + </View> 52 57 )} 53 58 </View> 54 59 )
+2 -2
src/components/Dialog/sheet-wrapper.ts
··· 1 1 import {useCallback} from 'react' 2 2 import {SystemBars} from 'react-native-edge-to-edge' 3 3 4 - import {IS_IOS} from '#/env' 4 + import {IS_IOS, IS_LIQUID_GLASS} from '#/env' 5 5 6 6 /** 7 7 * If we're calling a system API like the image picker that opens a sheet ··· 9 9 */ 10 10 export function useSheetWrapper() { 11 11 return useCallback(async <T>(promise: Promise<T>): Promise<T> => { 12 - if (IS_IOS) { 12 + if (IS_IOS && !IS_LIQUID_GLASS) { 13 13 const entry = SystemBars.pushStackEntry({ 14 14 style: { 15 15 statusBar: 'light',
+4 -2
src/components/Menu/index.tsx
··· 273 273 a.align_center, 274 274 a.gap_sm, 275 275 a.px_md, 276 - a.rounded_md, 276 + a.rounded_lg, 277 + a.curve_continuous, 277 278 a.border, 278 279 t.atoms.bg_contrast_25, 279 280 t.atoms.border_contrast_low, ··· 311 312 return ( 312 313 <View 313 314 style={[ 314 - a.rounded_md, 315 + a.rounded_lg, 316 + a.curve_continuous, 315 317 a.overflow_hidden, 316 318 a.border, 317 319 t.atoms.border_contrast_low,
+4 -2
src/components/dialogs/LanguageSelectDialog.tsx
··· 17 17 import * as Toggle from '#/components/forms/Toggle' 18 18 import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 19 19 import {Text} from '#/components/Typography' 20 - import {IS_NATIVE, IS_WEB} from '#/env' 20 + import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' 21 21 22 22 export function LanguageSelectDialog({ 23 23 titleText, ··· 52 52 return ( 53 53 <Dialog.Outer 54 54 control={control} 55 - nativeOptions={{minHeight: height - insets.top}}> 55 + nativeOptions={{ 56 + minHeight: IS_LIQUID_GLASS ? height : height - insets.top, 57 + }}> 56 58 <Dialog.Handle /> 57 59 <ErrorBoundary renderError={renderErrorBoundary}> 58 60 <DialogInner
+8
src/env/index.ts
··· 5 5 6 6 export * from '#/env/common' 7 7 8 + // for some reason Platform.OS === 'ios' AND Platform.Version is undefined in our CI unit tests -sfn 9 + const iOSMajorVersion = 10 + Platform.OS === 'ios' && typeof Platform.Version === 'string' 11 + ? parseInt(Platform.Version.split('.')[0], 10) 12 + : 0 13 + 8 14 /** 9 15 * The semver version of the app, specified in our `package.json`.file. On 10 16 * iOs/Android, the native build version is appended to the semver version, so ··· 41 47 * Misc 42 48 */ 43 49 export const IS_HIGH_DPI: boolean = true 50 + // ideally we'd use isLiquidGlassAvailable() from expo-glass-effect but checking iOS version is good enough for now 51 + export const IS_LIQUID_GLASS: boolean = iOSMajorVersion >= 26
+1
src/env/index.web.ts
··· 47 47 export const IS_HIGH_DPI: boolean = window.matchMedia( 48 48 '(min-resolution: 2dppx)', 49 49 ).matches 50 + export const IS_LIQUID_GLASS: boolean = false
+4 -2
src/screens/SignupQueued.tsx
··· 14 14 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 15 15 import {Loader} from '#/components/Loader' 16 16 import {P, Text} from '#/components/Typography' 17 - import {IS_IOS, IS_WEB} from '#/env' 17 + import {IS_IOS, IS_LIQUID_GLASS, IS_WEB} from '#/env' 18 18 19 19 const COL_WIDTH = 400 20 20 ··· 107 107 animationType={native('slide')} 108 108 presentationStyle="formSheet" 109 109 style={[web(a.util_screen_outer)]}> 110 - {IS_IOS && <SystemBars style={{statusBar: 'light'}} />} 110 + {IS_IOS && !IS_LIQUID_GLASS && ( 111 + <SystemBars style={{statusBar: 'light'}} /> 112 + )} 111 113 <ScrollView 112 114 style={[a.flex_1, t.atoms.bg]} 113 115 contentContainerStyle={{borderWidth: 0}}
+8 -10
src/view/com/composer/Composer.tsx
··· 133 133 import * as Toast from '#/components/Toast' 134 134 import {Text} from '#/components/Typography' 135 135 import {useAnalytics} from '#/analytics' 136 - import {IS_ANDROID, IS_IOS, IS_NATIVE, IS_WEB} from '#/env' 136 + import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' 137 137 import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' 138 138 import { 139 139 draftToComposerPosts, ··· 1522 1522 <Animated.View 1523 1523 style={topBarAnimatedStyle} 1524 1524 layout={native(LinearTransition)}> 1525 - <View style={styles.topbarInner}> 1525 + <View style={[a.flex_row, a.align_center, a.gap_xs, a.p_lg, a.pb_md]}> 1526 1526 <Button 1527 1527 label={_(msg`Cancel`)} 1528 1528 variant="ghost" ··· 2138 2138 return bottom * -1 2139 2139 } 2140 2140 2141 + // they ditched the gap behaviour on 26 2142 + if (IS_LIQUID_GLASS) { 2143 + return top 2144 + } 2145 + 2141 2146 // iPhone SE 2142 2147 if (top === 20) return 40 2143 2148 2144 - // all other iPhones 2149 + // all other iPhones on <26 2145 2150 return top + 10 2146 2151 } 2147 2152 ··· 2186 2191 } 2187 2192 2188 2193 const styles = StyleSheet.create({ 2189 - topbarInner: { 2190 - flexDirection: 'row', 2191 - alignItems: 'center', 2192 - paddingHorizontal: 8, 2193 - height: 54, 2194 - gap: 4, 2195 - }, 2196 2194 postBtn: { 2197 2195 borderRadius: 20, 2198 2196 paddingHorizontal: 20,
+4 -2
src/view/com/composer/GifAltText.tsx
··· 1 1 import {useState} from 'react' 2 - import {TouchableOpacity, View} from 'react-native' 2 + import {TouchableOpacity, useWindowDimensions, View} from 'react-native' 3 3 import {msg} from '@lingui/core/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 import {Plural, Trans} from '@lingui/react/macro' ··· 69 69 const {_} = useLingui() 70 70 const t = useTheme() 71 71 const [altTextDraft, setAltTextDraft] = useState(altText || vendorAltText) 72 + const {height: minHeight} = useWindowDimensions() 72 73 return ( 73 74 <> 74 75 <TouchableOpacity ··· 108 109 control={control} 109 110 onClose={() => { 110 111 onSubmit(altTextDraft) 111 - }}> 112 + }} 113 + nativeOptions={{minHeight}}> 112 114 <Dialog.Handle /> 113 115 <AltTextInner 114 116 vendorAltText={vendorAltText}
+5 -3
src/view/shell/Composer.ios.tsx
··· 6 6 import {ComposePost, useComposerCancelRef} from '#/view/com/composer/Composer' 7 7 import {atoms as a, useTheme} from '#/alf' 8 8 import {SheetCompatProvider as TooltipSheetCompatProvider} from '#/components/Tooltip' 9 + import {IS_LIQUID_GLASS} from '#/env' 9 10 10 11 export function Composer({}: {winHeight: number}) { 11 12 const {setFullyExpandedCount} = useDialogStateControlContext() ··· 32 33 visible={open} 33 34 presentationStyle="pageSheet" 34 35 animationType="slide" 35 - onRequestClose={() => ref.current?.onPressCancel()}> 36 - <View style={[t.atoms.bg, a.flex_1]}> 36 + onRequestClose={() => ref.current?.onPressCancel()} 37 + backdropColor="transparent" 38 + style={[!IS_LIQUID_GLASS && a.rounded_sheet]}> 39 + <View style={[a.flex_1, a.curve_continuous, t.atoms.bg]}> 37 40 <TooltipSheetCompatProvider> 38 41 <ComposePost 39 42 cancelRef={ref} ··· 45 48 text={state?.text} 46 49 imageUris={state?.imageUris} 47 50 videoUri={state?.videoUri} 48 - openGallery={state?.openGallery} 49 51 /> 50 52 </TooltipSheetCompatProvider> 51 53 </View>
+3 -2
src/view/shell/index.tsx
··· 43 43 import {NoAccessScreen} from '#/ageAssurance/components/NoAccessScreen' 44 44 import {RedirectOverlay} from '#/ageAssurance/components/RedirectOverlay' 45 45 import {PassiveAnalytics} from '#/analytics/PassiveAnalytics' 46 - import {IS_ANDROID, IS_IOS} from '#/env' 46 + import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS} from '#/env' 47 47 import {RoutesContainer, TabsNavigator} from '#/Navigation' 48 48 import {BottomSheetOutlet} from '../../../modules/bottom-sheet' 49 49 import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView' ··· 223 223 <SystemBars 224 224 style={{ 225 225 statusBar: 226 - t.name !== 'light' || (IS_IOS && fullyExpandedCount > 0) 226 + t.name !== 'light' || 227 + (IS_IOS && !IS_LIQUID_GLASS && fullyExpandedCount > 0) 227 228 ? 'light' 228 229 : 'dark', 229 230 navigationBar: t.name !== 'light' ? 'light' : 'dark',