Merge main into app-2670-toolbox-video-compressor
This commit is contained in:
+22
-43
@@ -9,13 +9,13 @@ import {
|
||||
import Animated, {
|
||||
Easing,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import Svg, {Path, type SvgProps} from 'react-native-svg'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {Image} from 'expo-image'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
|
||||
@@ -72,21 +72,26 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
const isDarkMode = colorScheme === 'dark'
|
||||
|
||||
const logoAnimation = useAnimatedStyle(() => {
|
||||
const introScale = interpolate(intro.get(), [0, 1], [0.8, 1], 'clamp')
|
||||
const outroScale =
|
||||
reduceMotion === true
|
||||
? 1
|
||||
: interpolate(outroLogo.get(), [0, 0.08, 1], [1, 0.8, 500], 'clamp')
|
||||
|
||||
const introOpacity = interpolate(intro.get(), [0, 1], [0, 1], 'clamp')
|
||||
const outroOpacity = interpolate(
|
||||
outroAppOpacity.get(),
|
||||
[0, 0.1, 0.2, 1],
|
||||
[1, 1, 0, 0],
|
||||
'clamp',
|
||||
)
|
||||
|
||||
return {
|
||||
opacity: introOpacity * outroOpacity,
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(intro.get(), [0, 1], [0.8, 1], 'clamp'),
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
outroLogo.get(),
|
||||
[0, 0.08, 1],
|
||||
[1, 0.8, 500],
|
||||
'clamp',
|
||||
),
|
||||
},
|
||||
{translateY: -(insets.top / 2)},
|
||||
{scale: 0.1 * outroScale * introScale},
|
||||
],
|
||||
opacity: interpolate(intro.get(), [0, 1], [0, 1], 'clamp'),
|
||||
}
|
||||
})
|
||||
const bottomLogoAnimation = useAnimatedStyle(() => {
|
||||
@@ -94,27 +99,6 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
opacity: interpolate(intro.get(), [0, 1], [0, 1], 'clamp'),
|
||||
}
|
||||
})
|
||||
const reducedLogoAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(intro.get(), [0, 1], [0.8, 1], 'clamp'),
|
||||
},
|
||||
],
|
||||
opacity: interpolate(intro.get(), [0, 1], [0, 1], 'clamp'),
|
||||
}
|
||||
})
|
||||
|
||||
const logoWrapperAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: interpolate(
|
||||
outroAppOpacity.get(),
|
||||
[0, 0.1, 0.2, 1],
|
||||
[1, 1, 0, 0],
|
||||
'clamp',
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const appAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
@@ -126,7 +110,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
opacity: interpolate(
|
||||
outroAppOpacity.get(),
|
||||
[0, 0.1, 0.2, 1],
|
||||
[0, 0, 1, 1],
|
||||
[0.02, 0.02, 1, 1], // first two values cant be 0 for the iOS blur/glass effects to work, the values obtained by trial and error
|
||||
'clamp',
|
||||
),
|
||||
}
|
||||
@@ -152,7 +136,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
1,
|
||||
{duration: 1200, easing: Easing.in(Easing.cubic)},
|
||||
() => {
|
||||
runOnJS(onFinish)()
|
||||
scheduleOnRN(onFinish)
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -180,8 +164,6 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion)
|
||||
}, [])
|
||||
|
||||
const logoAnimations =
|
||||
reduceMotion === true ? reducedLogoAnimation : logoAnimation
|
||||
// special off-spec color for dark mode
|
||||
const logoBg = isDarkMode ? '#0F1824' : '#fff'
|
||||
|
||||
@@ -224,17 +206,14 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
<Animated.View
|
||||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
logoWrapperAnimation,
|
||||
logoAnimation,
|
||||
{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
|
||||
},
|
||||
]}>
|
||||
<Animated.View style={[logoAnimations]}>
|
||||
<Logo fill={logoBg} />
|
||||
</Animated.View>
|
||||
<Logo fill={logoBg} />
|
||||
</Animated.View>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -4,13 +4,30 @@ import {
|
||||
} from '@atproto/api'
|
||||
|
||||
import {AgeAssuranceAccess} from '#/ageAssurance/types'
|
||||
import {ANDROID_API_LEVEL, IOS_MAJOR_VERSION, IS_ANDROID, IS_IOS} from '#/env'
|
||||
import {
|
||||
ANDROID_API_LEVEL,
|
||||
IOS_MAJOR_VERSION,
|
||||
IS_ANDROID,
|
||||
IS_IOS,
|
||||
IS_WEB,
|
||||
} from '#/env'
|
||||
|
||||
/**
|
||||
* Minimum age required to access the app at all.
|
||||
*/
|
||||
export const MIN_ACCESS_AGE = 13
|
||||
|
||||
/**
|
||||
* The identifier for the current platform, matching the `knownValues` of the
|
||||
* `platforms` property on `app.bsky.ageassurance.defs#configRegion`. Used to
|
||||
* filter out region configs that don't apply to this platform.
|
||||
*/
|
||||
export const AGE_ASSURANCE_PLATFORM: 'web' | 'ios' | 'android' = IS_WEB
|
||||
? 'web'
|
||||
: IS_IOS
|
||||
? 'ios'
|
||||
: 'android'
|
||||
|
||||
/**
|
||||
* Whether the current device can provide the native on-device age signals we
|
||||
* use for age assurance (via `expo-age-range`). We gate on OS version because
|
||||
|
||||
@@ -59,9 +59,10 @@ export const config: AppBskyAgeassuranceDefs.Config = {
|
||||
],
|
||||
},
|
||||
{
|
||||
// On-device verification region. KWS is included as a fallback for
|
||||
// platforms without the native age API (e.g. web) or when the device
|
||||
// On-device verification region, native-only (web users in TX are not
|
||||
// age assured). KWS is included as a fallback for when the device
|
||||
// result is insufficient.
|
||||
platforms: ['ios', 'android'],
|
||||
countryCode: 'US',
|
||||
regionCode: 'TX',
|
||||
minAccessAge: 18,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {getAgeAssuranceRegionConfig} from '@atproto/api'
|
||||
|
||||
import {getAgeAssuranceRegionConfigForGeolocation} from '#/ageAssurance/util'
|
||||
|
||||
jest.mock('#/ageAssurance/data')
|
||||
jest.mock('@atproto/api', () => ({
|
||||
...jest.requireActual('@atproto/api'),
|
||||
getAgeAssuranceRegionConfig: jest.fn(),
|
||||
}))
|
||||
|
||||
/*
|
||||
* Platform-based region filtering itself is implemented and tested in
|
||||
* `@atproto/api` (see `getAgeAssuranceRegionConfig`). What we own - and test
|
||||
* here - is that region resolution passes the current platform through. The
|
||||
* jest preset is `jest-expo/ios`, so `AGE_ASSURANCE_PLATFORM` resolves to
|
||||
* `ios` in these tests.
|
||||
*/
|
||||
describe('getAgeAssuranceRegionConfigForGeolocation', () => {
|
||||
it('passes the current platform to the SDK region matcher', () => {
|
||||
const config = {regions: []}
|
||||
getAgeAssuranceRegionConfigForGeolocation(config, {
|
||||
countryCode: 'US',
|
||||
regionCode: 'TX',
|
||||
})
|
||||
expect(getAgeAssuranceRegionConfig).toHaveBeenCalledWith(config, {
|
||||
countryCode: 'US',
|
||||
regionCode: 'TX',
|
||||
platform: 'ios',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import {getAge} from '#/lib/strings/time'
|
||||
import {regionName} from '#/locale/helpers'
|
||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
|
||||
import {
|
||||
AGE_ASSURANCE_PLATFORM,
|
||||
DEVICE_SIGNALS_SUPPORTED,
|
||||
FALLBACK_REGION_CONFIG,
|
||||
MIN_ACCESS_AGE,
|
||||
@@ -29,6 +30,10 @@ import {USRegionNameToRegionCode} from '#/geolocation/util'
|
||||
* Resolves a geolocation to its matched age assurance region config, or
|
||||
* undefined when the geolocation matches no AA region.
|
||||
*
|
||||
* Regions scoped to other platforms via `platforms` are passed over entirely,
|
||||
* as if they weren't in the config - a later region matching the same
|
||||
* geolocation can still apply.
|
||||
*
|
||||
* This is the single source of truth for geolocation -> region resolution.
|
||||
* Device signals are written and read back under a key derived from the
|
||||
* matched region (see `createRegionKey`), so every site that resolves a region
|
||||
@@ -42,6 +47,7 @@ export function getAgeAssuranceRegionConfigForGeolocation(
|
||||
return getAgeAssuranceRegionConfig(config, {
|
||||
countryCode: geolocation.countryCode ?? '',
|
||||
regionCode: geolocation.regionCode,
|
||||
platform: AGE_ASSURANCE_PLATFORM,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type TextProps as RNTextProps,
|
||||
type TextStyle,
|
||||
} from 'react-native'
|
||||
import {UITextView} from 'react-native-uitextview'
|
||||
import {UITextView} from '@bsky.app/react-native-uitextview'
|
||||
import createEmojiRegex from 'emoji-regex'
|
||||
|
||||
import {type Alf, applyFonts, atoms, flatten} from '#/alf'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {MMKV} from '@bsky.app/react-native-mmkv'
|
||||
import {MMKV} from 'react-native-mmkv'
|
||||
import {setPolyfills} from '@growthbook/growthbook'
|
||||
import {GrowthBook} from '@growthbook/growthbook-react'
|
||||
import {type I18n} from '@lingui/core'
|
||||
@@ -40,6 +40,7 @@ const TIMEOUT_PREFER_FRESH_GATES = 1500
|
||||
export const features = new GrowthBook({
|
||||
apiHost: env.GROWTHBOOK_API_HOST,
|
||||
clientKey: env.GROWTHBOOK_CLIENT_KEY,
|
||||
enableDevMode: env.IS_INTERNAL,
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -59,8 +60,7 @@ export const init = features.init({timeout: TIMEOUT_INIT}).then(res => {
|
||||
})
|
||||
|
||||
/**
|
||||
* Refresh feature gates from GrowthBook. Updates attributes based on the
|
||||
* provided account, if any.
|
||||
* Refresh feature gates from GrowthBook.
|
||||
*/
|
||||
export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) {
|
||||
await features.refreshFeatures({
|
||||
|
||||
@@ -21,6 +21,11 @@ export enum Features {
|
||||
CustomLogoJapanEnable = 'custom_logo:japan:enable',
|
||||
VideoMultipartUploadEnable = 'video:multipart_upload:enable',
|
||||
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
|
||||
FollowSortEnable = 'follow_sort:enable',
|
||||
|
||||
// values
|
||||
TrendingDiscoverValues = 'trending_discover:values',
|
||||
TrendingExploreTopicsCountValue = 'trending_explore_topics_count:value',
|
||||
|
||||
AATest = 'aa-test',
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import {Platform} from 'react-native'
|
||||
import {type Result} from '@growthbook/growthbook-react'
|
||||
import {type Result, type WidenPrimitives} from '@growthbook/growthbook-react'
|
||||
|
||||
import {Logger} from '#/logger'
|
||||
import {
|
||||
@@ -67,6 +67,7 @@ export type AnalyticsContextType = {
|
||||
) => void
|
||||
features: typeof Features & {
|
||||
enabled(feature: Features): boolean
|
||||
getValue<T>(feature: Features, defaultValue: T): WidenPrimitives<T>
|
||||
}
|
||||
}
|
||||
export type AnalyticsBaseContextType = Omit<AnalyticsContextType, 'features'>
|
||||
@@ -83,6 +84,7 @@ function createLogger(
|
||||
warn: logger.warn.bind(logger),
|
||||
error: logger.error.bind(logger),
|
||||
useChild: (context: Exclude<Logger['context'], undefined>) => {
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
return useMemo(() => createLogger(context, metadata), [context, metadata])
|
||||
},
|
||||
Context: Logger.Context,
|
||||
@@ -314,6 +316,7 @@ export function AnalyticsFeaturesContext({
|
||||
...parentContext,
|
||||
features: {
|
||||
enabled: feats.isOn.bind(feats),
|
||||
getValue: feats.getFeatureValue.bind(feats),
|
||||
...Features,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -106,6 +106,10 @@ export type Events = {
|
||||
}
|
||||
'signup:captchaSuccess': {}
|
||||
'signup:captchaFailure': {}
|
||||
'signup:captchaBackPress': {}
|
||||
'signup:createAccountFailure': {
|
||||
reason: string
|
||||
}
|
||||
'signup:fieldError': {
|
||||
field: string
|
||||
errorCount: number
|
||||
@@ -478,25 +482,30 @@ export type Events = {
|
||||
'profile:followers:view': {
|
||||
contextProfileDid: string
|
||||
isOwnProfile: boolean
|
||||
sort?: 'latest' | 'top'
|
||||
}
|
||||
'profile:followers:paginate': {
|
||||
contextProfileDid: string
|
||||
itemCount: number
|
||||
page: number
|
||||
sort?: 'latest' | 'top'
|
||||
}
|
||||
'profile:following:view': {
|
||||
contextProfileDid: string
|
||||
isOwnProfile: boolean
|
||||
sort?: 'latest' | 'top'
|
||||
}
|
||||
'profile:following:paginate': {
|
||||
contextProfileDid: string
|
||||
itemCount: number
|
||||
page: number
|
||||
sort?: 'latest' | 'top'
|
||||
}
|
||||
'profileCard:seen': {
|
||||
contextProfileDid?: string
|
||||
profileDid: string
|
||||
position?: number
|
||||
sort?: 'latest' | 'top'
|
||||
}
|
||||
'profile:mute': {}
|
||||
'profile:unmute': {}
|
||||
@@ -744,9 +753,17 @@ export type Events = {
|
||||
'trendingTopics:hide': {
|
||||
context: 'settings' | 'sidebar' | 'interstitial' | 'explore:trending'
|
||||
}
|
||||
'trendingTopic:seen': {
|
||||
context: 'sidebar' | 'interstitial' | 'explore'
|
||||
recId?: string
|
||||
rank: number
|
||||
feedSliceIndex?: number
|
||||
}
|
||||
'trendingTopic:click': {
|
||||
context: 'sidebar' | 'interstitial' | 'explore'
|
||||
recId?: string
|
||||
rank: number
|
||||
feedSliceIndex?: number
|
||||
}
|
||||
'trendingVideos:show': {
|
||||
context: 'settings'
|
||||
|
||||
@@ -30,7 +30,6 @@ import {KeyboardEvents} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
clamp,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
@@ -44,6 +43,7 @@ import {
|
||||
useSafeAreaInsets,
|
||||
} from 'react-native-safe-area-context'
|
||||
import {captureRef} from 'react-native-view-shot'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {Image, type ImageErrorEventData} from 'expo-image'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -89,14 +89,12 @@ const SPRING_IN: WithSpringConfig = {
|
||||
mass: 0.75,
|
||||
damping: 300,
|
||||
stiffness: 1200,
|
||||
restDisplacementThreshold: 0.01,
|
||||
}
|
||||
|
||||
const SPRING_OUT: WithSpringConfig = {
|
||||
mass: IS_IOS ? 1.25 : 0.75,
|
||||
damping: 150,
|
||||
stiffness: 1000,
|
||||
restDisplacementThreshold: 0.01,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,7 +166,7 @@ export function Root({children}: {children: React.ReactNode}) {
|
||||
// note: return location has to be reset on open,
|
||||
// rather than on close, otherwise there's a flicker
|
||||
// where the reanimated update is faster than the react render
|
||||
runOnJS(onCompletedClose)()
|
||||
scheduleOnRN(onCompletedClose)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -333,7 +331,7 @@ export function Trigger({
|
||||
() => hoveredItemSV.get(),
|
||||
(hovered, prev) => {
|
||||
if (hovered !== prev) {
|
||||
runOnJS(setHoveredMenuItem)(hovered)
|
||||
scheduleOnRN(setHoveredMenuItem, hovered)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -345,7 +343,7 @@ export function Trigger({
|
||||
.averageTouches(true)
|
||||
.onStart(() => {
|
||||
'worklet'
|
||||
runOnJS(open)('full')
|
||||
scheduleOnRN(open, 'full')
|
||||
})
|
||||
.onUpdate(evt => {
|
||||
'worklet'
|
||||
@@ -359,7 +357,7 @@ export function Trigger({
|
||||
// as the menu may have slid into place beneath their finger
|
||||
const item = hoveredItemSV.get()
|
||||
if (item) {
|
||||
runOnJS(onTouchUpMenuItem)(item)
|
||||
scheduleOnRN(onTouchUpMenuItem, item)
|
||||
}
|
||||
})
|
||||
}, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV])
|
||||
|
||||
@@ -21,11 +21,11 @@ import {
|
||||
} from 'react-native'
|
||||
import {useReanimatedKeyboardAnimation} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
type ScrollEvent,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -197,24 +197,8 @@ export function Outer({
|
||||
/**
|
||||
* @deprecated use `Dialog.ScrollableInner` instead
|
||||
*/
|
||||
export function Inner({children, style, header}: DialogInnerProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<View
|
||||
style={[
|
||||
a.pt_2xl,
|
||||
a.px_xl,
|
||||
IS_LIQUID_GLASS
|
||||
? a.pb_2xl
|
||||
: {paddingBottom: insets.bottom + insets.top},
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
export function Inner(props: DialogInnerProps) {
|
||||
return <ScrollableInner {...props} />
|
||||
}
|
||||
|
||||
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
|
||||
@@ -315,9 +299,9 @@ export const InnerFlatList = forwardRef<
|
||||
}
|
||||
const {contentOffset} = e
|
||||
if (contentOffset.y > 0 && !disableDrag) {
|
||||
runOnJS(setDisableDrag)(true)
|
||||
scheduleOnRN(setDisableDrag, true)
|
||||
} else if (contentOffset.y <= 1 && disableDrag) {
|
||||
runOnJS(setDisableDrag)(false)
|
||||
scheduleOnRN(setDisableDrag, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,6 +165,9 @@ export function Outer({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use `Dialog.ScrollableInner` instead
|
||||
*/
|
||||
export function Inner({
|
||||
children,
|
||||
style,
|
||||
|
||||
@@ -3,7 +3,7 @@ import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
type AnimatedRef,
|
||||
measure,
|
||||
runOnJS,
|
||||
Reanimated3DefaultSpringConfig,
|
||||
scrollTo,
|
||||
type SharedValue,
|
||||
useAnimatedRef,
|
||||
@@ -13,6 +13,7 @@ import Animated, {
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
@@ -25,7 +26,7 @@ import {IS_IOS} from '#/env'
|
||||
*
|
||||
* All positioning is driven by a `slots` map (key → index) and translateY
|
||||
* (no discrete `top` changes). On drag end the new slot assignment is
|
||||
* computed on the UI thread first, then React state is updated via runOnJS.
|
||||
* computed on the UI thread first, then React state is updated via scheduleOnRN.
|
||||
*
|
||||
* See SortableList.web.tsx for the web implementation using pointer events.
|
||||
*/
|
||||
@@ -41,7 +42,7 @@ interface SortableListProps<T> {
|
||||
itemHeight: number
|
||||
/** Ref to the parent Animated.ScrollView for auto-scroll. */
|
||||
scrollRef?: AnimatedRef<Animated.ScrollView>
|
||||
/** Scroll offset shared value from useScrollViewOffset. */
|
||||
/** Scroll offset shared value from useScrollOffset. */
|
||||
scrollOffset?: SharedValue<number>
|
||||
}
|
||||
|
||||
@@ -237,8 +238,8 @@ function SortableItem<T>({
|
||||
itemKey: string
|
||||
itemCount: number
|
||||
itemHeight: number
|
||||
state: Animated.SharedValue<DragState>
|
||||
dragY: Animated.SharedValue<number>
|
||||
state: SharedValue<DragState>
|
||||
dragY: SharedValue<number>
|
||||
scrollCompensation: SharedValue<number>
|
||||
isGestureActive: SharedValue<boolean>
|
||||
measureDone: SharedValue<boolean>
|
||||
@@ -264,9 +265,9 @@ function SortableItem<T>({
|
||||
measureDone.set(false)
|
||||
lastHapticSlot.set(mySlot)
|
||||
if (onDragStart) {
|
||||
runOnJS(onDragStart)()
|
||||
scheduleOnRN(onDragStart)
|
||||
}
|
||||
runOnJS(playHaptic)()
|
||||
scheduleOnRN(playHaptic)
|
||||
})
|
||||
.onChange(e => {
|
||||
'worklet'
|
||||
@@ -284,7 +285,7 @@ function SortableItem<T>({
|
||||
const clampedSlot = Math.max(0, Math.min(currentSlot, itemCount - 1))
|
||||
if (IS_IOS && clampedSlot !== lastHapticSlot.get()) {
|
||||
lastHapticSlot.set(clampedSlot)
|
||||
runOnJS(playHaptic)('Light')
|
||||
scheduleOnRN(playHaptic, 'Light')
|
||||
}
|
||||
})
|
||||
.onEnd(() => {
|
||||
@@ -325,13 +326,13 @@ function SortableItem<T>({
|
||||
dragStartSlot: -1,
|
||||
})
|
||||
dragY.set(0)
|
||||
runOnJS(onCommitReorder)(sorted)
|
||||
scheduleOnRN(onCommitReorder, sorted)
|
||||
} else {
|
||||
const s = state.get()
|
||||
state.set({...s, activeKey: '', dragStartSlot: -1})
|
||||
dragY.set(0)
|
||||
if (onDragEnd) {
|
||||
runOnJS(onDragEnd)()
|
||||
scheduleOnRN(onDragEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,7 +347,7 @@ function SortableItem<T>({
|
||||
const s = state.get()
|
||||
state.set({...s, activeKey: '', dragStartSlot: -1})
|
||||
if (onDragEnd) {
|
||||
runOnJS(onDragEnd)()
|
||||
scheduleOnRN(onDragEnd)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -370,18 +371,18 @@ function SortableItem<T>({
|
||||
return {
|
||||
transform: [
|
||||
{translateY: s.dragStartSlot * itemHeight + dragY.get()},
|
||||
{scale: withSpring(1.03)},
|
||||
{scale: withSpring(1.03, Reanimated3DefaultSpringConfig)},
|
||||
],
|
||||
zIndex: 999,
|
||||
...(IS_IOS
|
||||
? {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {width: 0, height: 1},
|
||||
shadowOpacity: withSpring(0.08),
|
||||
shadowRadius: withSpring(4),
|
||||
shadowOpacity: withSpring(0.08, Reanimated3DefaultSpringConfig),
|
||||
shadowRadius: withSpring(4, Reanimated3DefaultSpringConfig),
|
||||
}
|
||||
: {
|
||||
elevation: withSpring(3),
|
||||
elevation: withSpring(3, Reanimated3DefaultSpringConfig),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -391,11 +392,11 @@ function SortableItem<T>({
|
||||
const inactive = {
|
||||
...(IS_IOS
|
||||
? {
|
||||
shadowOpacity: withSpring(0),
|
||||
shadowRadius: withSpring(0),
|
||||
shadowOpacity: withSpring(0, Reanimated3DefaultSpringConfig),
|
||||
shadowRadius: withSpring(0, Reanimated3DefaultSpringConfig),
|
||||
}
|
||||
: {
|
||||
elevation: withSpring(0),
|
||||
elevation: withSpring(0, Reanimated3DefaultSpringConfig),
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -425,7 +426,7 @@ function SortableItem<T>({
|
||||
return {
|
||||
transform: [
|
||||
{translateY: withTiming(baseY + offset, {duration: 200})},
|
||||
{scale: withSpring(1)},
|
||||
{scale: withSpring(1, Reanimated3DefaultSpringConfig)},
|
||||
],
|
||||
zIndex: 0,
|
||||
...inactive,
|
||||
|
||||
@@ -22,7 +22,7 @@ interface SortableListProps<T> {
|
||||
itemHeight: number
|
||||
/** Ref to the parent Animated.ScrollView for auto-scroll. Ignored on web. */
|
||||
scrollRef?: AnimatedRef<Animated.ScrollView>
|
||||
/** Scroll offset shared value from useScrollViewOffset. Ignored on web. */
|
||||
/** Scroll offset shared value from useScrollOffset. Ignored on web. */
|
||||
scrollOffset?: SharedValue<number>
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
atoms as a,
|
||||
native,
|
||||
useBreakpoints,
|
||||
useGutters,
|
||||
useTheme,
|
||||
type ViewStyleProp,
|
||||
web,
|
||||
@@ -60,10 +61,10 @@ function CardOuter({
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
a.p_md,
|
||||
a.rounded_lg,
|
||||
a.rounded_xl,
|
||||
a.border,
|
||||
t.atoms.bg,
|
||||
t.atoms.shadow_sm,
|
||||
t.atoms.shadow_md,
|
||||
t.atoms.border_contrast_low,
|
||||
!gtMobile && {
|
||||
width: MOBILE_CARD_WIDTH,
|
||||
@@ -204,6 +205,7 @@ export function ProfileGrid({
|
||||
const {t: l} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const gutters = useGutters([0, 'base'])
|
||||
const followDialogControl = useDialogControl()
|
||||
|
||||
const isLoading = isSuggestionsLoading || !moderationOpts
|
||||
@@ -414,7 +416,7 @@ export function ProfileGrid({
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="FeedInterstitial"
|
||||
withIcon={false}
|
||||
style={[a.rounded_sm]}
|
||||
style={[a.rounded_full]}
|
||||
onFollow={() => {
|
||||
ax.metric('suggestedUser:follow', {
|
||||
logContext,
|
||||
@@ -458,40 +460,43 @@ export function ProfileGrid({
|
||||
pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
|
||||
<View
|
||||
style={[
|
||||
a.px_lg,
|
||||
gutters,
|
||||
a.pt_md,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
]}
|
||||
pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
|
||||
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}>
|
||||
<Trans>Suggested for you</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={l`See more suggested profiles`}
|
||||
onPress={() => {
|
||||
followDialogControl.open()
|
||||
ax.metric('suggestedUser:seeMore', {
|
||||
logContext,
|
||||
recId,
|
||||
})
|
||||
}}>
|
||||
{({hovered}) => (
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
t.atoms.text_link,
|
||||
hovered &&
|
||||
web({
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor: t.atoms.text_link.color,
|
||||
}),
|
||||
]}>
|
||||
<Trans>See more</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
<View style={[a.w_full, a.pl_xs, a.flex_row, a.align_center]}>
|
||||
<Text style={[a.flex_1, a.text_md, a.font_semi_bold]}>
|
||||
<Trans>Suggested for you</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={l`See more suggested profiles`}
|
||||
onPress={() => {
|
||||
followDialogControl.open()
|
||||
ax.metric('suggestedUser:seeMore', {
|
||||
logContext,
|
||||
recId,
|
||||
})
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.font_medium,
|
||||
{
|
||||
color:
|
||||
hovered || pressed
|
||||
? t.palette.contrast_800
|
||||
: t.palette.contrast_500,
|
||||
},
|
||||
]}>
|
||||
<Trans>See more</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<FollowDialogWithoutGuide control={followDialogControl} />
|
||||
<LayoutAnimationConfig skipExiting skipEntering>
|
||||
@@ -540,7 +545,7 @@ function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
|
||||
a.justify_center,
|
||||
a.gap_sm,
|
||||
a.p_md,
|
||||
a.rounded_lg,
|
||||
a.rounded_xl,
|
||||
{width: FINAL_CARD_WIDTH},
|
||||
]}>
|
||||
<ButtonIcon icon={ArrowRight} size="lg" />
|
||||
|
||||
@@ -15,6 +15,8 @@ export const IS_GLASS_AVAILABLE =
|
||||
* Liquid Glass View that uses `expo-glass-effect`
|
||||
*
|
||||
* If unavailable, falls back to a regular `View`. Use `fallbackStyle` to customize the fallback appearance.
|
||||
* Note: Setting opacity to 0 on Expo GlassView or any of its parent views causes the glass effect to not render at all. https://docs.expo.dev/versions/v56.0.0/sdk/glass-effect/#known-issues
|
||||
* If animating the opacity of a parent view, start from a non-zero opacity to avoid this issue.
|
||||
*/
|
||||
export const GlassView = IS_GLASS_AVAILABLE ? InnerGlassView : FallbackView
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
|
||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||
import {atoms as a, tokens, useTheme, web} from '#/alf'
|
||||
import {transparentifyColor} from '#/alf/util/colorGeneration'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
@@ -200,42 +201,44 @@ export function InterestTabs({
|
||||
|
||||
return (
|
||||
<View style={[a.relative, a.flex_row]}>
|
||||
<DraggableScrollView
|
||||
ref={listRef}
|
||||
contentContainerStyle={[
|
||||
a.gap_sm,
|
||||
{paddingHorizontal: gutterWidth},
|
||||
contentContainerStyle,
|
||||
]}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
decelerationRate="fast"
|
||||
snapToOffsets={
|
||||
tabOffsets.filter(o => !!o).length === interests.length
|
||||
? tabOffsets.map(o => o.x - tokens.space.xl)
|
||||
: undefined
|
||||
}
|
||||
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
|
||||
onContentSizeChange={width => setContentWidth(width)}
|
||||
onScroll={evt => {
|
||||
const newScrollX = evt.nativeEvent.contentOffset.x
|
||||
setScrollX(newScrollX)
|
||||
}}
|
||||
scrollEventThrottle={16}>
|
||||
{interests.map((interest, i) => {
|
||||
const active = interest === selectedInterest && !disabled
|
||||
return (
|
||||
<TabComponent
|
||||
key={interest}
|
||||
onSelectTab={handleSelectTab}
|
||||
active={active}
|
||||
index={i}
|
||||
interest={interest}
|
||||
interestsDisplayName={interestsDisplayNames[interest]}
|
||||
onLayout={handleTabLayout}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</DraggableScrollView>
|
||||
<BlockDrawerGesture>
|
||||
<DraggableScrollView
|
||||
ref={listRef}
|
||||
contentContainerStyle={[
|
||||
a.gap_sm,
|
||||
{paddingHorizontal: gutterWidth},
|
||||
contentContainerStyle,
|
||||
]}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
decelerationRate="fast"
|
||||
snapToOffsets={
|
||||
tabOffsets.filter(o => !!o).length === interests.length
|
||||
? tabOffsets.map(o => o.x - tokens.space.xl)
|
||||
: undefined
|
||||
}
|
||||
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
|
||||
onContentSizeChange={width => setContentWidth(width)}
|
||||
onScroll={evt => {
|
||||
const newScrollX = evt.nativeEvent.contentOffset.x
|
||||
setScrollX(newScrollX)
|
||||
}}
|
||||
scrollEventThrottle={16}>
|
||||
{interests.map((interest, i) => {
|
||||
const active = interest === selectedInterest && !disabled
|
||||
return (
|
||||
<TabComponent
|
||||
key={interest}
|
||||
onSelectTab={handleSelectTab}
|
||||
active={active}
|
||||
index={i}
|
||||
interest={interest}
|
||||
interestsDisplayName={interestsDisplayNames[interest]}
|
||||
onLayout={handleTabLayout}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</DraggableScrollView>
|
||||
</BlockDrawerGesture>
|
||||
{IS_WEB && canScrollLeft && (
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {forwardRef, memo, useContext, useMemo} from 'react'
|
||||
import {memo, useContext, useMemo} from 'react'
|
||||
import {
|
||||
type StyleProp,
|
||||
View,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
type AnimatedRef,
|
||||
type AnimatedScrollViewProps,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
@@ -73,67 +74,64 @@ export type ContentProps = AnimatedScrollViewProps & {
|
||||
style?: StyleProp<ViewStyle>
|
||||
contentContainerStyle?: StyleProp<ViewStyle>
|
||||
ignoreTabletLayoutOffset?: boolean
|
||||
ref?: AnimatedRef<Animated.ScrollView>
|
||||
}
|
||||
|
||||
/**
|
||||
* Default scroll view for simple pages
|
||||
*/
|
||||
export const Content = memo(
|
||||
forwardRef<Animated.ScrollView, ContentProps>(function Content(
|
||||
{
|
||||
children,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
ignoreTabletLayoutOffset,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const t = useTheme()
|
||||
const {footerHeight} = useShellLayout()
|
||||
const {isWithinSplitView} = useIsWithinSplitView()
|
||||
export const Content = memo(function Content({
|
||||
children,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
ignoreTabletLayoutOffset,
|
||||
ref,
|
||||
...props
|
||||
}: ContentProps) {
|
||||
const t = useTheme()
|
||||
const {footerHeight} = useShellLayout()
|
||||
const {isWithinSplitView} = useIsWithinSplitView()
|
||||
|
||||
// note - if we ever make the footer transparent in any way,
|
||||
// we'll need to change this to use contentInsets/scrollIndicatorInsets
|
||||
// on iOS and contentContainerStyle padding on Android -sfn
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
marginBottom: footerHeight.get(),
|
||||
}
|
||||
})
|
||||
// note - if we ever make the footer transparent in any way,
|
||||
// we'll need to change this to use contentInsets/scrollIndicatorInsets
|
||||
// on iOS and contentContainerStyle padding on Android -sfn
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
marginBottom: footerHeight.get(),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={ref}
|
||||
id="content"
|
||||
automaticallyAdjustsScrollIndicatorInsets={false}
|
||||
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
|
||||
style={[
|
||||
a.w_full,
|
||||
animatedStyle,
|
||||
isWithinSplitView &&
|
||||
web({
|
||||
flex: 1,
|
||||
overflowY: 'scroll',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||
}),
|
||||
style,
|
||||
]}
|
||||
contentContainerStyle={[contentContainerStyle]}
|
||||
{...props}>
|
||||
{IS_WEB ? (
|
||||
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
|
||||
{/* @ts-expect-error web only -esb */}
|
||||
{children}
|
||||
</Center>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Animated.ScrollView>
|
||||
)
|
||||
}),
|
||||
)
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={ref}
|
||||
id="content"
|
||||
automaticallyAdjustsScrollIndicatorInsets={false}
|
||||
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
|
||||
style={[
|
||||
a.w_full,
|
||||
animatedStyle,
|
||||
isWithinSplitView &&
|
||||
web({
|
||||
flex: 1,
|
||||
overflowY: 'scroll',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||
}),
|
||||
style,
|
||||
]}
|
||||
contentContainerStyle={[contentContainerStyle]}
|
||||
{...props}>
|
||||
{IS_WEB ? (
|
||||
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
|
||||
{/* @ts-expect-error web only -esb */}
|
||||
{children}
|
||||
</Center>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Animated.ScrollView>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Utility component to center content within the screen
|
||||
|
||||
@@ -2,12 +2,12 @@ import {useRef, useState} from 'react'
|
||||
import {Modal, Pressable, StyleSheet, View} from 'react-native'
|
||||
import Animated, {
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
@@ -52,7 +52,7 @@ export function ImageMenu({onPressShare, onPressSave}: Props) {
|
||||
progress.set(
|
||||
withTiming(0, TIMING_OUT, finished => {
|
||||
if (finished) {
|
||||
runOnJS(setIsMounted)(false)
|
||||
scheduleOnRN(setIsMounted, false)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
type AnimatableValue,
|
||||
runOnJS,
|
||||
Reanimated3DefaultSpringConfig,
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
useAnimatedRef,
|
||||
@@ -15,6 +15,7 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {Image} from 'expo-image'
|
||||
|
||||
import {
|
||||
@@ -94,7 +95,7 @@ const ImageItem = ({
|
||||
},
|
||||
(nextIsScaled, prevIsScaled) => {
|
||||
if (nextIsScaled !== prevIsScaled) {
|
||||
runOnJS(handleZoom)(nextIsScaled)
|
||||
scheduleOnRN(handleZoom, nextIsScaled)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -244,7 +245,7 @@ const ImageItem = ({
|
||||
|
||||
const singleTap = Gesture.Tap().onEnd(() => {
|
||||
'worklet'
|
||||
runOnJS(onTap)()
|
||||
scheduleOnRN(onTap)
|
||||
})
|
||||
|
||||
const doubleTap = Gesture.Tap()
|
||||
@@ -358,9 +359,9 @@ const ImageItem = ({
|
||||
},
|
||||
(show, prevShow) => {
|
||||
if (!prevShow && show) {
|
||||
runOnJS(setShowLoader)(true)
|
||||
scheduleOnRN(setShowLoader, true)
|
||||
} else if (prevShow && !show) {
|
||||
runOnJS(setShowLoader)(false)
|
||||
scheduleOnRN(setShowLoader, false)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -463,7 +464,10 @@ function clampTranslation(
|
||||
|
||||
function withClampedSpring<T extends AnimatableValue>(value: T): T {
|
||||
'worklet'
|
||||
return withSpring(value, {overshootClamping: true})
|
||||
return withSpring(value, {
|
||||
...Reanimated3DefaultSpringConfig,
|
||||
overshootClamping: true,
|
||||
})
|
||||
}
|
||||
|
||||
export default memo(ImageItem)
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
type PanGesture,
|
||||
} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
type SharedValue,
|
||||
useAnimatedProps,
|
||||
useAnimatedReaction,
|
||||
@@ -24,6 +23,7 @@ import Animated, {
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaFrame} from 'react-native-safe-area-context'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {Image} from 'expo-image'
|
||||
|
||||
import {
|
||||
@@ -84,7 +84,7 @@ const ImageItem = ({
|
||||
'worklet'
|
||||
const nextIsScaled = e.zoomScale > 1
|
||||
if (scaled !== nextIsScaled) {
|
||||
runOnJS(handleZoom)(nextIsScaled)
|
||||
scheduleOnRN(handleZoom, nextIsScaled)
|
||||
}
|
||||
},
|
||||
onBeginDrag() {
|
||||
@@ -118,7 +118,7 @@ const ImageItem = ({
|
||||
|
||||
const singleTap = Gesture.Tap().onEnd(() => {
|
||||
'worklet'
|
||||
runOnJS(onTap)()
|
||||
scheduleOnRN(onTap)
|
||||
})
|
||||
|
||||
const doubleTap = Gesture.Tap()
|
||||
@@ -142,7 +142,7 @@ const ImageItem = ({
|
||||
screenSize,
|
||||
)
|
||||
}
|
||||
runOnJS(zoomTo)(nextZoomRect)
|
||||
scheduleOnRN(zoomTo, nextZoomRect)
|
||||
})
|
||||
|
||||
const composedGesture = Gesture.Exclusive(
|
||||
@@ -170,8 +170,6 @@ const ImageItem = ({
|
||||
width: screenSize.width,
|
||||
maxHeight: screenSize.height,
|
||||
alignSelf: 'center',
|
||||
aspectRatio: imageAspect ?? 1 /* force onLoad */,
|
||||
opacity: imageAspect === undefined ? 0 : 1,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -180,11 +178,19 @@ const ImageItem = ({
|
||||
return {
|
||||
transform: cropContentTransform,
|
||||
width: '100%',
|
||||
aspectRatio: imageAspect ?? 1 /* force onLoad */,
|
||||
opacity: imageAspect === undefined ? 0 : 1,
|
||||
}
|
||||
})
|
||||
|
||||
/*
|
||||
* When the aspect ratio is unknown until onLoad fires, these layout props
|
||||
* change after mount. They must be applied via a React render rather than
|
||||
* useAnimatedStyle
|
||||
*/
|
||||
const imageLayoutStyle = {
|
||||
aspectRatio: imageAspect ?? 1 /* force onLoad */,
|
||||
opacity: imageAspect === undefined ? 0 : 1,
|
||||
}
|
||||
|
||||
const [showLoader, setShowLoader] = useState(false)
|
||||
const [hasLoaded, setHasLoaded] = useState(false)
|
||||
useAnimatedReaction(
|
||||
@@ -193,9 +199,9 @@ const ImageItem = ({
|
||||
},
|
||||
(show, prevShow) => {
|
||||
if (!prevShow && show) {
|
||||
runOnJS(setShowLoader)(true)
|
||||
scheduleOnRN(setShowLoader, true)
|
||||
} else if (prevShow && !show) {
|
||||
runOnJS(setShowLoader)(false)
|
||||
scheduleOnRN(setShowLoader, false)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -225,8 +231,8 @@ const ImageItem = ({
|
||||
{showLoader && (
|
||||
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
||||
)}
|
||||
<Animated.View style={imageCropStyle}>
|
||||
<Animated.View style={imageStyle}>
|
||||
<Animated.View style={[imageCropStyle, imageLayoutStyle]}>
|
||||
<Animated.View style={[imageStyle, imageLayoutStyle]}>
|
||||
<Image
|
||||
contentFit="contain"
|
||||
source={{uri: imageSrc.uri}}
|
||||
|
||||
@@ -20,8 +20,6 @@ import Animated, {
|
||||
measure,
|
||||
type MeasuredDimensions,
|
||||
ReduceMotion,
|
||||
runOnJS,
|
||||
runOnUI,
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
useAnimatedRef,
|
||||
@@ -32,6 +30,7 @@ import Animated, {
|
||||
withSpring,
|
||||
type WithSpringConfig,
|
||||
} from 'react-native-reanimated'
|
||||
import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'
|
||||
import {Image} from 'expo-image'
|
||||
import * as ScreenOrientation from 'expo-screen-orientation'
|
||||
|
||||
@@ -60,13 +59,11 @@ const SLOW_SPRING: WithSpringConfig = {
|
||||
mass: IS_IOS ? 1.25 : 0.75,
|
||||
damping: 300,
|
||||
stiffness: 800,
|
||||
restDisplacementThreshold: 0.001,
|
||||
}
|
||||
const FAST_SPRING: WithSpringConfig = {
|
||||
mass: IS_IOS ? 1.25 : 0.75,
|
||||
damping: 150,
|
||||
stiffness: 900,
|
||||
restDisplacementThreshold: 0.001,
|
||||
}
|
||||
|
||||
function canAnimate(lightbox: Lightbox): boolean {
|
||||
@@ -138,10 +135,10 @@ export default function ImageViewRoot({
|
||||
|
||||
const onFullyClosed = useCallback(() => {
|
||||
setActiveLightbox(null)
|
||||
runOnUI(() => {
|
||||
scheduleOnUI(() => {
|
||||
'worklet'
|
||||
thumbRects.set({})
|
||||
})()
|
||||
})
|
||||
requestIdleCallback(() => {
|
||||
void Image.clearMemoryCache()
|
||||
})
|
||||
@@ -151,7 +148,7 @@ export default function ImageViewRoot({
|
||||
() => openProgress.get() === 0,
|
||||
(isGone, wasGone) => {
|
||||
if (isGone && !wasGone) {
|
||||
runOnJS(onFullyClosed)()
|
||||
scheduleOnRN(onFullyClosed)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -162,10 +159,10 @@ export default function ImageViewRoot({
|
||||
() => openProgress.get() === 1,
|
||||
(isOpen, wasOpen) => {
|
||||
if (isOpen && !wasOpen) {
|
||||
runOnJS(ScreenOrientation.unlockAsync)()
|
||||
scheduleOnRN(ScreenOrientation.unlockAsync)
|
||||
} else if (!isOpen && wasOpen) {
|
||||
// default is PORTRAIT_UP - set via config plugin in app.config.js -sfn
|
||||
runOnJS(ScreenOrientation.lockAsync)(PORTRAIT_UP)
|
||||
scheduleOnRN(ScreenOrientation.lockAsync, PORTRAIT_UP)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -173,7 +170,7 @@ export default function ImageViewRoot({
|
||||
const onFlyAway = useCallback(() => {
|
||||
'worklet'
|
||||
openProgress.set(0)
|
||||
runOnJS(onRequestClose)()
|
||||
scheduleOnRN(onRequestClose)
|
||||
}, [onRequestClose, openProgress])
|
||||
|
||||
return (
|
||||
@@ -322,7 +319,7 @@ function ImageView({
|
||||
const handleRequestClose = useCallback(() => {
|
||||
const activeRef = images[imageIndex]?.thumbRef
|
||||
if (isAnimated && activeRef) {
|
||||
runOnUI(() => {
|
||||
scheduleOnUI(() => {
|
||||
'worklet'
|
||||
const rect = measure(activeRef)
|
||||
thumbRects.modify(rects => {
|
||||
@@ -330,8 +327,8 @@ function ImageView({
|
||||
rects[imageIndex] = rect
|
||||
return rects
|
||||
})
|
||||
runOnJS(onRequestClose)()
|
||||
})()
|
||||
scheduleOnRN(onRequestClose)
|
||||
})
|
||||
} else {
|
||||
onRequestClose()
|
||||
}
|
||||
@@ -532,7 +529,7 @@ function LightboxImage({
|
||||
const dismissTranslateY =
|
||||
isActive && openProgressValue === 1 ? dismissSwipeTranslateY.get() : 0
|
||||
|
||||
if (openProgressValue === 0 && isFlyingAway.get()) {
|
||||
if (openProgressValue === 0) {
|
||||
return {
|
||||
isHidden: true,
|
||||
isResting: false,
|
||||
@@ -609,6 +606,7 @@ function LightboxImage({
|
||||
return withSpring(0, {
|
||||
stiffness: 700,
|
||||
damping: 50,
|
||||
mass: 1,
|
||||
reduceMotion: ReduceMotion.Never,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import {createContext, useContext, useEffect, useMemo, useState} from 'react'
|
||||
import {
|
||||
measure,
|
||||
type MeasuredDimensions,
|
||||
runOnJS,
|
||||
runOnUI,
|
||||
} from 'react-native-reanimated'
|
||||
import {measure, type MeasuredDimensions} from 'react-native-reanimated'
|
||||
import {scheduleOnRN, scheduleOnUI} from 'react-native-worklets'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
@@ -73,7 +69,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
if (thumbRef) {
|
||||
// Measure the tapped image on the UI thread, then open with
|
||||
// the rect baked in so it's available from the first render.
|
||||
// Only the rect (plain data) goes through runOnJS — AnimatedRef
|
||||
// Only the rect (plain data) goes through scheduleOnRN — AnimatedRef
|
||||
// objects can't survive serialization across threads.
|
||||
const openWithRect = (rect: MeasuredDimensions | null) => {
|
||||
doOpen({
|
||||
@@ -83,11 +79,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
),
|
||||
})
|
||||
}
|
||||
runOnUI(() => {
|
||||
scheduleOnUI(() => {
|
||||
'worklet'
|
||||
const rect = measure(thumbRef)
|
||||
runOnJS(openWithRect)(rect)
|
||||
})()
|
||||
scheduleOnRN(openWithRect, rect)
|
||||
})
|
||||
} else {
|
||||
doOpen(lightbox)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import {type Component} from 'react'
|
||||
import {type TransformsStyle} from 'react-native'
|
||||
import {
|
||||
type AnimatedRef,
|
||||
@@ -29,7 +28,7 @@ export type ImageSource = {
|
||||
thumbUri: string
|
||||
thumbDimensions: Dimensions | null
|
||||
thumbRect: MeasuredDimensions | null
|
||||
thumbRef?: AnimatedRef<Component> | null
|
||||
thumbRef?: AnimatedRef | null
|
||||
thumbBorderRadius?: number
|
||||
alt?: string
|
||||
type: 'image' | 'circle-avi' | 'rect-avi'
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useApplyPullRequestOTAUpdate} from '#/lib/hooks/useOTAUpdates'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Admonition from '#/components/Admonition'
|
||||
import {ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
/**
|
||||
* Warns that the running bundle came from a channel this build doesn't normally
|
||||
* receive updates from, e.g. a pull request deployment applied from the dev
|
||||
* settings. Renders nothing on a standard channel, and never on web.
|
||||
*/
|
||||
export function OTAChannelNotice({style}: {style?: StyleProp<ViewStyle>}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const {
|
||||
currentChannel,
|
||||
defaultChannel,
|
||||
isCurrentlyRunningNonStandardChannel,
|
||||
restoreDefaultChannel,
|
||||
pending,
|
||||
} = useApplyPullRequestOTAUpdate()
|
||||
|
||||
if (!isCurrentlyRunningNonStandardChannel) return null
|
||||
|
||||
return (
|
||||
<Admonition.Outer
|
||||
type="warning"
|
||||
style={[t.atoms.bg_contrast_25, a.gap_sm, style]}>
|
||||
<Admonition.Row>
|
||||
<Admonition.Icon />
|
||||
<Admonition.Content style={[a.gap_2xs]}>
|
||||
<Text style={[a.text_sm, a.font_bold, a.leading_snug]}>
|
||||
<Trans>Non-standard OTA channel</Trans>
|
||||
</Text>
|
||||
<Admonition.Text>
|
||||
<Trans>
|
||||
This app is running a deployment of{' '}
|
||||
<Text style={[a.text_sm, a.font_bold, a.leading_snug]}>
|
||||
{currentChannel}
|
||||
</Text>
|
||||
. Restore the {defaultChannel} deployment to get back to a
|
||||
standard build.
|
||||
</Trans>
|
||||
</Admonition.Text>
|
||||
</Admonition.Content>
|
||||
</Admonition.Row>
|
||||
<View style={[a.flex_row]}>
|
||||
<Admonition.Button
|
||||
color="secondary_inverted"
|
||||
label={l`Restore the ${defaultChannel} deployment`}
|
||||
disabled={pending}
|
||||
onPress={() => void restoreDefaultChannel()}>
|
||||
<ButtonText>
|
||||
<Trans>Restore default</Trans>
|
||||
</ButtonText>
|
||||
{pending && <ButtonIcon icon={Loader} />}
|
||||
</Admonition.Button>
|
||||
</View>
|
||||
</Admonition.Outer>
|
||||
)
|
||||
}
|
||||
@@ -177,6 +177,7 @@ export function LabelBase({
|
||||
text,
|
||||
a.font_semi_bold,
|
||||
a.leading_tight,
|
||||
a.flex_shrink,
|
||||
t.atoms.text_contrast_medium,
|
||||
{paddingRight: 3},
|
||||
]}>
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
measure,
|
||||
runOnJS,
|
||||
useAnimatedRef,
|
||||
useFrameCallback,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {WebView} from 'react-native-webview'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {Image} from 'expo-image'
|
||||
import {type AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
@@ -164,7 +164,7 @@ export function ExternalPlayer({
|
||||
const isVisible = top <= realWinHeight - insets.bottom && bot >= insets.top
|
||||
|
||||
if (!isVisible) {
|
||||
runOnJS(setIsPlayerActive)(false)
|
||||
scheduleOnRN(setIsPlayerActive, false)
|
||||
}
|
||||
}, false) // False here disables autostarting the callback
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export function ImageEmbed({
|
||||
|
||||
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
|
||||
// ref + dims that a tap would — keeps the lightbox's return animation intact.
|
||||
const singleContainerRef = useRef<AnimatedRef<React.Component> | null>(null)
|
||||
const singleContainerRef = useRef<AnimatedRef | null>(null)
|
||||
const singleDimsRef = useRef<Dimensions | null>(null)
|
||||
|
||||
if (images.length > 0) {
|
||||
@@ -71,7 +71,7 @@ export function ImageEmbed({
|
||||
}))
|
||||
const onPress = (
|
||||
index: number,
|
||||
refs: AnimatedRef<React.Component>[],
|
||||
refs: AnimatedRef[],
|
||||
fetchedDims: (Dimensions | null)[],
|
||||
) => {
|
||||
if (postContext) {
|
||||
|
||||
@@ -601,7 +601,7 @@ export function FollowButtonPlaceholder({style}: ViewStyleProp) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_50,
|
||||
a.w_full,
|
||||
{
|
||||
|
||||
@@ -5,9 +5,7 @@ import {
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
@@ -29,7 +27,7 @@ const TOTAL_AVATARS = 10
|
||||
|
||||
export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
const {rightNavVisible} = useLayoutBreakpoints()
|
||||
const {currentAccount} = useSession()
|
||||
@@ -79,7 +77,7 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
|
||||
size="tiny"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
label={_(msg`Dismiss getting started guide`)}
|
||||
label={l`Dismiss getting started guide`}
|
||||
onPress={endProgressGuide}
|
||||
style={[a.bg_transparent, {marginTop: -6, marginRight: -6}]}>
|
||||
<ButtonIcon icon={Times} size="xs" />
|
||||
@@ -107,14 +105,14 @@ export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
|
||||
<ProgressGuideTask
|
||||
current={guide.numLikes + 1}
|
||||
total={10 + 1}
|
||||
title={_(msg`Like 10 posts`)}
|
||||
subtitle={_(msg`Teach our algorithm what you like`)}
|
||||
title={l`Like 10 posts`}
|
||||
subtitle={l`Teach our algorithm what you like`}
|
||||
/>
|
||||
<ProgressGuideTask
|
||||
current={guide.numFollows + 1}
|
||||
total={7 + 1}
|
||||
title={_(msg`Follow 7 accounts`)}
|
||||
subtitle={_(msg`Bluesky is better with friends!`)}
|
||||
title={l`Follow 7 accounts`}
|
||||
subtitle={l`Bluesky is better with friends!`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {View} from 'react-native'
|
||||
import * as Progress from 'react-native-progress'
|
||||
import {Circle as ProgressCircle} from 'react-native-progress'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {AnimatedCheck} from '../anim/AnimatedCheck'
|
||||
@@ -25,7 +25,7 @@ export function ProgressGuideTask({
|
||||
{current === total ? (
|
||||
<AnimatedCheck playOnMount fill={t.palette.primary_500} width={20} />
|
||||
) : (
|
||||
<Progress.Circle
|
||||
<ProgressCircle
|
||||
progress={current / total}
|
||||
color={t.palette.primary_400}
|
||||
size={20}
|
||||
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
import {Pressable, useWindowDimensions, View} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -69,7 +69,7 @@ export const ProgressGuideToast = forwardRef<
|
||||
duration: 400,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
},
|
||||
() => runOnJS(setIsntOpen)(),
|
||||
() => scheduleOnRN(setIsntOpen),
|
||||
),
|
||||
)
|
||||
}, [setIsOpen, opacity])
|
||||
@@ -88,7 +88,7 @@ export const ProgressGuideToast = forwardRef<
|
||||
duration: 100,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
},
|
||||
() => runOnJS(playCheckmark)(),
|
||||
() => scheduleOnRN(playCheckmark),
|
||||
),
|
||||
)
|
||||
translateY.set(0)
|
||||
@@ -119,7 +119,8 @@ export const ProgressGuideToast = forwardRef<
|
||||
left = right = (winDim.width - 380) / 2
|
||||
}
|
||||
return {
|
||||
position: IS_WEB ? 'fixed' : 'absolute',
|
||||
// position: fixed is web only
|
||||
position: (IS_WEB ? 'fixed' : 'absolute') as 'absolute',
|
||||
top: 0,
|
||||
left,
|
||||
right,
|
||||
@@ -134,12 +135,7 @@ export const ProgressGuideToast = forwardRef<
|
||||
return (
|
||||
isOpen && (
|
||||
<Portal>
|
||||
<Animated.View
|
||||
style={[
|
||||
// @ts-ignore position: fixed is web only
|
||||
containerStyle,
|
||||
animatedStyle,
|
||||
]}>
|
||||
<Animated.View style={[containerStyle, animatedStyle]}>
|
||||
<Pressable
|
||||
style={[
|
||||
t.atoms.bg,
|
||||
|
||||
@@ -425,9 +425,10 @@ function Bubble({
|
||||
},
|
||||
]}
|
||||
onLayout={e => {
|
||||
setBubbleMeasurements({
|
||||
width: e.nativeEvent.layout.width,
|
||||
height: e.nativeEvent.layout.height,
|
||||
const {width, height} = e.nativeEvent.layout
|
||||
setBubbleMeasurements(prev => {
|
||||
if (prev?.width === width && prev.height === height) return prev
|
||||
return {width, height}
|
||||
})
|
||||
}}>
|
||||
{children}
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import {useMemo} from 'react'
|
||||
import {useEffect, useMemo} from 'react'
|
||||
import {type AppBskyUnspeccedDefs, type AtUri} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
import {useCallOnce} from '#/lib/once'
|
||||
// import {makeProfileLink} from '#/lib/routes/links'
|
||||
// import {feedUriToHref} from '#/lib/strings/url-helpers'
|
||||
import {native} from '#/alf'
|
||||
import {Link as InternalLink, type LinkProps} from '#/components/Link'
|
||||
import {type Metrics, useAnalytics} from '#/analytics'
|
||||
|
||||
export function TrendingTopicLink({
|
||||
topic: raw,
|
||||
metricContext,
|
||||
rank,
|
||||
recId,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
topic: AppBskyUnspeccedDefs.TrendView
|
||||
metricContext: Metrics['trendingTopic:seen']['context']
|
||||
rank: number
|
||||
recId?: string
|
||||
} & Omit<LinkProps, 'to' | 'label'>) {
|
||||
const topic = useTopic(raw)
|
||||
useTrendingTopicSeen(metricContext, rank, recId)
|
||||
|
||||
return (
|
||||
<InternalLink
|
||||
@@ -29,6 +37,27 @@ export function TrendingTopicLink({
|
||||
)
|
||||
}
|
||||
|
||||
export function useTrendingTopicSeen(
|
||||
context: Metrics['trendingTopic:seen']['context'],
|
||||
rank: number,
|
||||
recId?: string,
|
||||
feedSliceIndex?: number,
|
||||
) {
|
||||
const ax = useAnalytics()
|
||||
const trackSeen = useCallOnce(() => {
|
||||
ax.metric('trendingTopic:seen', {
|
||||
context,
|
||||
rank,
|
||||
feedSliceIndex,
|
||||
recId,
|
||||
})
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
trackSeen()
|
||||
}, [trackSeen])
|
||||
}
|
||||
|
||||
type ParsedTrendingTopic =
|
||||
| {
|
||||
type: 'topic' | 'tag' | 'starter-pack' | 'unknown'
|
||||
@@ -48,14 +77,14 @@ type ParsedTrendingTopic =
|
||||
export function useTopic(
|
||||
raw: AppBskyUnspeccedDefs.TrendView,
|
||||
): ParsedTrendingTopic {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
return useMemo(() => {
|
||||
const {topic: displayName, link} = raw
|
||||
|
||||
if (link.startsWith('/search')) {
|
||||
return {
|
||||
type: 'topic',
|
||||
label: _(msg`Browse posts about ${displayName}`),
|
||||
label: l`Browse posts about ${displayName}`,
|
||||
displayName,
|
||||
uri: undefined,
|
||||
url: link,
|
||||
@@ -63,7 +92,7 @@ export function useTopic(
|
||||
} else if (link.startsWith('/hashtag')) {
|
||||
return {
|
||||
type: 'tag',
|
||||
label: _(msg`Browse posts tagged with ${displayName}`),
|
||||
label: l`Browse posts tagged with ${displayName}`,
|
||||
displayName,
|
||||
// displayName: displayName.replace(/^#/, ''),
|
||||
uri: undefined,
|
||||
@@ -72,7 +101,7 @@ export function useTopic(
|
||||
} else if (link.startsWith('/starter-pack')) {
|
||||
return {
|
||||
type: 'starter-pack',
|
||||
label: _(msg`Browse starter pack ${displayName}`),
|
||||
label: l`Browse starter pack ${displayName}`,
|
||||
displayName,
|
||||
uri: undefined,
|
||||
url: link,
|
||||
@@ -109,10 +138,10 @@ export function useTopic(
|
||||
|
||||
return {
|
||||
type: 'unknown',
|
||||
label: _(msg`Browse topic ${displayName}`),
|
||||
label: l`Browse topic ${displayName}`,
|
||||
displayName,
|
||||
uri: undefined,
|
||||
url: link,
|
||||
}
|
||||
}, [_, raw])
|
||||
}, [l, raw])
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {UITextView} from 'react-native-uitextview'
|
||||
import {UITextView} from '@bsky.app/react-native-uitextview'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {atoms as a, type TextStyleProp, useAlf, useTheme, web} from '#/alf'
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
AppBskyFeedPost,
|
||||
type ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
@@ -52,7 +51,7 @@ export function VideoPostCard({
|
||||
onInteract?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_, i18n} = useLingui()
|
||||
const {t: l, i18n} = useLingui()
|
||||
const embed = post.embed
|
||||
const {
|
||||
state: pressed,
|
||||
@@ -118,8 +117,8 @@ export function VideoPostCard({
|
||||
|
||||
return (
|
||||
<Link
|
||||
accessibilityHint={_(msg`Views video in immersive mode`)}
|
||||
label={_(msg`Video from ${author.handle}: ${text}`)}
|
||||
accessibilityHint={l`Views video in immersive mode`}
|
||||
label={l`Video from ${author.handle}: ${text}`}
|
||||
to={{
|
||||
screen: 'VideoFeed',
|
||||
params: {
|
||||
@@ -174,9 +173,7 @@ export function VideoPostCard({
|
||||
/>
|
||||
<View style={[a.align_center, a.gap_xs]}>
|
||||
<Eye size="lg" fill="white" />
|
||||
<Text style={[a.text_sm, {color: 'white'}]}>
|
||||
{_(msg`Hidden`)}
|
||||
</Text>
|
||||
<Text style={[a.text_sm, {color: 'white'}]}>{l`Hidden`}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -367,7 +364,7 @@ export function CompactVideoPostCard({
|
||||
onInteract?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_, i18n} = useLingui()
|
||||
const {t: l, i18n} = useLingui()
|
||||
const embed = post.embed
|
||||
const {
|
||||
state: pressed,
|
||||
@@ -398,7 +395,7 @@ export function CompactVideoPostCard({
|
||||
|
||||
return (
|
||||
<Link
|
||||
label={_(msg`View video`)}
|
||||
label={l`View video`}
|
||||
to={{
|
||||
screen: 'VideoFeed',
|
||||
params: {
|
||||
@@ -413,7 +410,8 @@ export function CompactVideoPostCard({
|
||||
onPressOut={onPressOut}
|
||||
style={[
|
||||
a.flex_col,
|
||||
t.atoms.shadow_sm,
|
||||
a.rounded_xl,
|
||||
t.atoms.shadow_md,
|
||||
{
|
||||
alignItems: undefined,
|
||||
justifyContent: undefined,
|
||||
@@ -424,7 +422,7 @@ export function CompactVideoPostCard({
|
||||
<View
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.rounded_lg,
|
||||
a.rounded_xl,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
@@ -458,9 +456,7 @@ export function CompactVideoPostCard({
|
||||
/>
|
||||
<View style={[a.align_center, a.gap_xs]}>
|
||||
<Eye size="lg" fill="white" />
|
||||
<Text style={[a.text_sm, {color: 'white'}]}>
|
||||
{_(msg`Hidden`)}
|
||||
</Text>
|
||||
<Text style={[a.text_sm, {color: 'white'}]}>{l`Hidden`}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -469,7 +465,7 @@ export function CompactVideoPostCard({
|
||||
<View
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.rounded_lg,
|
||||
a.rounded_xl,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
@@ -485,10 +481,17 @@ export function CompactVideoPostCard({
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
|
||||
<View style={[a.absolute, a.inset_0, t.atoms.shadow_sm]}>
|
||||
<View style={[a.absolute, a.inset_0, t.atoms.shadow_md]}>
|
||||
<View style={[a.absolute, a.inset_0, a.p_sm, {bottom: 'auto'}]}>
|
||||
<View
|
||||
style={[a.relative, a.rounded_full, {width: 24, height: 24}]}>
|
||||
style={[
|
||||
a.relative,
|
||||
a.rounded_full,
|
||||
{
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
]}>
|
||||
<UserAvatar
|
||||
type="user"
|
||||
size={24}
|
||||
@@ -547,10 +550,10 @@ export function CompactVideoPostCardPlaceholder() {
|
||||
const black = getBlackColor(t)
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, t.atoms.shadow_sm]}>
|
||||
<View style={[a.flex_1, t.atoms.shadow_md]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_lg,
|
||||
a.rounded_xl,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {ImageBackground} from 'expo-image'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {FocusGuards, FocusScope} from 'radix-ui/internal'
|
||||
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
@@ -22,7 +20,7 @@ interface WelcomeModalProps {
|
||||
}
|
||||
|
||||
export function WelcomeModal({control}: WelcomeModalProps) {
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
@@ -152,7 +150,7 @@ export function WelcomeModal({control}: WelcomeModalProps) {
|
||||
<View>
|
||||
<Button
|
||||
onPress={onPressCreateAccount}
|
||||
label={_(msg`Create account`)}
|
||||
label={l`Create account`}
|
||||
size="large"
|
||||
color="primary"
|
||||
style={{
|
||||
@@ -165,7 +163,7 @@ export function WelcomeModal({control}: WelcomeModalProps) {
|
||||
</Button>
|
||||
<Button
|
||||
onPress={onPressExplore}
|
||||
label={_(msg`Explore the app`)}
|
||||
label={l`Explore the app`}
|
||||
size="large"
|
||||
color="primary"
|
||||
variant="ghost"
|
||||
@@ -188,10 +186,11 @@ export function WelcomeModal({control}: WelcomeModalProps) {
|
||||
]}>
|
||||
<Trans>Already have an account?</Trans>{' '}
|
||||
<Pressable
|
||||
onPress={onPressSignIn}
|
||||
onPointerEnter={() => setSignInLinkHovered(true)}
|
||||
onPointerLeave={() => setSignInLinkHovered(false)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Sign in`)}
|
||||
accessibilityLabel={l`Sign in`}
|
||||
accessibilityHint="">
|
||||
<Text
|
||||
style={[
|
||||
@@ -201,8 +200,7 @@ export function WelcomeModal({control}: WelcomeModalProps) {
|
||||
fontSize: undefined,
|
||||
},
|
||||
signInLinkHovered && a.underline,
|
||||
]}
|
||||
onPress={onPressSignIn}>
|
||||
]}>
|
||||
<Trans>Sign in</Trans>
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -211,7 +209,7 @@ export function WelcomeModal({control}: WelcomeModalProps) {
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Close welcome modal`)}
|
||||
label={l`Close welcome modal`}
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
type AnimatedStyle,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
interpolateColor,
|
||||
@@ -662,7 +663,7 @@ function BlockedPlaceholder({
|
||||
style,
|
||||
}: {
|
||||
profile: Shadow<ChatBskyActorDefs.ProfileViewBasic>
|
||||
style?: StyleProp<ViewStyle>
|
||||
style?: AnimatedStyle<ViewStyle>
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {useCallback} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
|
||||
import {
|
||||
ScaleAndFadeIn,
|
||||
@@ -41,7 +41,7 @@ export function NewMessagesPill({
|
||||
}, [scale])
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
runOnJS(playHaptic)()
|
||||
scheduleOnRN(playHaptic)
|
||||
onPressInner?.()
|
||||
}, [onPressInner, playHaptic])
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ import {Gesture, type GestureType} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
clamp,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useReducedMotion,
|
||||
useSharedValue,
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
@@ -111,7 +111,7 @@ export function SwipeToReply({
|
||||
if (pastThreshold && !hit.get()) {
|
||||
hit.set(true)
|
||||
runPop()
|
||||
runOnJS(playHaptic)('Medium')
|
||||
scheduleOnRN(playHaptic, 'Medium')
|
||||
} else if (!pastThreshold && hit.get()) {
|
||||
hit.set(false)
|
||||
}
|
||||
@@ -120,7 +120,7 @@ export function SwipeToReply({
|
||||
'worklet'
|
||||
// Only a clean end (finger lifted past threshold) triggers the reply.
|
||||
if (hit.get()) {
|
||||
runOnJS(onReply)()
|
||||
scheduleOnRN(onReply)
|
||||
}
|
||||
})
|
||||
.onFinalize(() => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {mergeRefs} from '#/lib/merge-refs'
|
||||
import {
|
||||
android,
|
||||
applyFonts,
|
||||
atoms as a,
|
||||
platform,
|
||||
@@ -96,6 +95,8 @@ export function Root({children, isInvalid = false, style}: RootProps) {
|
||||
a.relative,
|
||||
a.w_full,
|
||||
a.px_md,
|
||||
// Contain the input's z-index so it cannot paint over nearby overlays.
|
||||
{zIndex: 0},
|
||||
style,
|
||||
]}
|
||||
{...web({
|
||||
@@ -223,10 +224,6 @@ export function createInput(Component: typeof TextInput) {
|
||||
paddingTop: 13,
|
||||
paddingBottom: 13,
|
||||
},
|
||||
android({
|
||||
paddingTop: 8,
|
||||
paddingBottom: 9,
|
||||
}),
|
||||
/*
|
||||
* Margins are needed here to avoid autofill background overlapping the
|
||||
* top and bottom borders - esb
|
||||
@@ -270,7 +267,16 @@ export function createInput(Component: typeof TextInput) {
|
||||
ctx.onBlur()
|
||||
onBlur?.(e)
|
||||
}}
|
||||
placeholder={placeholder === null ? undefined : placeholder || label}
|
||||
/*
|
||||
* Android sizes an empty input from the font's bounding box instead
|
||||
* of `lineHeight`, so a field with no placeholder shrinks on the
|
||||
* first keystroke.
|
||||
*/
|
||||
placeholder={
|
||||
placeholder === null
|
||||
? platform({android: ' '})
|
||||
: placeholder || label
|
||||
}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
style={flattened}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import {useMemo} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {type AppBskyUnspeccedDefs, moderateProfile} from '@atproto/api'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useTrendingSettings} from '#/state/preferences/trending'
|
||||
import {
|
||||
useTrendingSettings,
|
||||
useTrendingSettingsApi,
|
||||
} from '#/state/preferences/trending'
|
||||
import {useGetTrendsQuery} from '#/state/queries/trending/useGetTrendsQuery'
|
||||
import {useTrendingConfig} from '#/state/service-config'
|
||||
import {LoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
@@ -18,29 +19,40 @@ import {
|
||||
useTheme,
|
||||
type ViewStyleProp,
|
||||
} from '#/alf'
|
||||
import {alpha} from '#/alf/utils'
|
||||
import {AvatarStack} from '#/components/AvatarStack'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
|
||||
import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending'
|
||||
import {Link} from '#/components/Link'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {SubtleHover} from '#/components/SubtleHover'
|
||||
import {useTrendingTopicSeen} from '#/components/TrendingTopics'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
const TOPIC_COUNT = 3
|
||||
|
||||
export function FeedTrendingTopicsInterstitial() {
|
||||
export function FeedTrendingTopicsInterstitial({
|
||||
feedSliceIndex,
|
||||
}: {
|
||||
feedSliceIndex: number
|
||||
}) {
|
||||
const {enabled} = useTrendingConfig()
|
||||
const {trendingDisabled} = useTrendingSettings()
|
||||
const {rightNavVisible} = useLayoutBreakpoints()
|
||||
|
||||
return enabled && !trendingDisabled && !rightNavVisible ? <Inner /> : null
|
||||
return enabled && !trendingDisabled && !rightNavVisible ? (
|
||||
<Inner feedSliceIndex={feedSliceIndex} />
|
||||
) : null
|
||||
}
|
||||
|
||||
function Inner() {
|
||||
function Inner({feedSliceIndex}: {feedSliceIndex: number}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const gutters = useGutters([0, 'base'])
|
||||
const ax = useAnalytics()
|
||||
const trendingPrompt = Prompt.usePromptControl()
|
||||
const {setTrendingDisabled} = useTrendingSettingsApi()
|
||||
const {
|
||||
data: trending,
|
||||
error,
|
||||
@@ -49,113 +61,131 @@ function Inner() {
|
||||
} = useGetTrendsQuery({limit: TOPIC_COUNT})
|
||||
const noTopics = !isLoading && !error && !trending?.trends?.length
|
||||
|
||||
const shadowColor = alpha(t.palette.primary_100, 0.5)
|
||||
|
||||
const gradient = {
|
||||
values: [
|
||||
[0, t.atoms.bg.backgroundColor],
|
||||
[0.1, t.palette.primary_25],
|
||||
[0.9, t.palette.primary_25],
|
||||
[1, t.atoms.bg.backgroundColor],
|
||||
],
|
||||
hover_value: t.palette.white,
|
||||
}
|
||||
|
||||
if (error || noTopics) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
gutters,
|
||||
a.pt_lg,
|
||||
a.pb_xl,
|
||||
a.gap_sm,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={gradient.values.map(c => c[1]) as [string, string, ...string[]]}
|
||||
locations={
|
||||
gradient.values.map(c => c[0]) as [number, number, ...number[]]
|
||||
}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.z_20,
|
||||
a.px_xs,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_sm,
|
||||
gutters,
|
||||
a.pt_xs,
|
||||
a.pb_lg,
|
||||
a.gap_xs,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between, a.gap_xs]}>
|
||||
<TrendingIcon width={18} />
|
||||
<Text
|
||||
style={[a.text_md, a.font_medium, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
<Trans>Trending</Trans>
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.z_20,
|
||||
a.pl_xs,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<View
|
||||
style={[a.flex_row, a.align_center, a.justify_between, a.gap_xs]}>
|
||||
<TrendingIcon width={18} fill={t.atoms.text.color} />
|
||||
<Text style={[a.text_md, a.font_medium]} numberOfLines={1}>
|
||||
<Trans>Trending</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<Link label={l`See more trending topics`} to="/search">
|
||||
{({hovered, pressed}) => (
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.font_medium,
|
||||
{
|
||||
color:
|
||||
hovered || pressed
|
||||
? t.palette.contrast_800
|
||||
: t.palette.contrast_500,
|
||||
},
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
<Trans>See more</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="medium"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
label={l`Trending options`}
|
||||
onPress={() => trendingPrompt.open()}
|
||||
style={[a.bg_transparent]}>
|
||||
<ButtonIcon icon={EllipsisIcon} size="md" />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<Link label={l`See more trending topics`} to="/search">
|
||||
<Text
|
||||
<View style={[a.relative, a.z_10, a.rounded_xl, t.atoms.shadow_md]}>
|
||||
<View
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.font_medium,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_high,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
<Trans>See more</Trans>
|
||||
</Text>
|
||||
</Link>
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
a.rounded_xl,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{isLoading || isRefetching
|
||||
? Array.from({length: TOPIC_COUNT}).map((_, i) => (
|
||||
<TrendingTopicRowSkeleton key={i} rank={i + 1} />
|
||||
))
|
||||
: trending?.trends?.map((trend, index) => {
|
||||
const rank = index + 1
|
||||
return (
|
||||
<TrendRow
|
||||
key={trend.link}
|
||||
trend={trend}
|
||||
rank={rank}
|
||||
feedSliceIndex={feedSliceIndex}
|
||||
recId={trending.recId}
|
||||
onPress={() => {
|
||||
ax.metric('trendingTopic:click', {
|
||||
context: 'interstitial',
|
||||
rank,
|
||||
feedSliceIndex,
|
||||
recId: trending.recId,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.z_10,
|
||||
a.border,
|
||||
a.rounded_xl,
|
||||
t.atoms.bg,
|
||||
{
|
||||
borderColor: t.palette.primary_100,
|
||||
boxShadow: `0 0 16px 0 ${shadowColor}`,
|
||||
elevation: 8,
|
||||
shadowColor: shadowColor,
|
||||
shadowOffset: {width: 0, height: 0},
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 16,
|
||||
},
|
||||
]}>
|
||||
{isLoading || isRefetching
|
||||
? Array.from({length: TOPIC_COUNT}).map((_, i) => (
|
||||
<TrendingTopicRowSkeleton key={i} rank={i + 1} />
|
||||
))
|
||||
: trending?.trends?.map((trend, index) => (
|
||||
<TrendRow
|
||||
key={trend.link}
|
||||
trend={trend}
|
||||
rank={index + 1}
|
||||
onPress={() => {
|
||||
ax.metric('trendingTopic:click', {context: 'interstitial'})
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<Prompt.Basic
|
||||
control={trendingPrompt}
|
||||
title={l`Hide trending topics?`}
|
||||
description={l`You can update this later from your settings.`}
|
||||
confirmButtonCta={l`Hide`}
|
||||
onConfirm={() => {
|
||||
ax.metric('trendingTopics:hide', {context: 'interstitial'})
|
||||
setTrendingDisabled(true)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TrendRow({
|
||||
trend,
|
||||
rank,
|
||||
feedSliceIndex,
|
||||
recId,
|
||||
onPress,
|
||||
}: ViewStyleProp & {
|
||||
trend: AppBskyUnspeccedDefs.TrendView
|
||||
rank: number
|
||||
feedSliceIndex: number
|
||||
recId?: string
|
||||
children?: React.ReactNode
|
||||
onPress?: () => void
|
||||
}) {
|
||||
@@ -163,6 +193,8 @@ function TrendRow({
|
||||
const {t: l, i18n} = useLingui()
|
||||
|
||||
const actors = useModerateTrendingActors(trend.actors)
|
||||
const formattedPostCount = formatCount(i18n, trend.postCount)
|
||||
useTrendingTopicSeen('interstitial', rank, recId, feedSliceIndex)
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -170,12 +202,7 @@ function TrendRow({
|
||||
label={l`Browse topic ${trend.displayName}`}
|
||||
to={trend.link}
|
||||
onPress={onPress}
|
||||
style={[
|
||||
rank < TOPIC_COUNT && a.border_b,
|
||||
{
|
||||
borderColor: t.palette.primary_100,
|
||||
},
|
||||
]}
|
||||
style={[rank < TOPIC_COUNT && a.border_b, t.atoms.border_contrast_low]}
|
||||
PressableComponent={Pressable}>
|
||||
{({hovered, pressed}) => (
|
||||
<>
|
||||
@@ -205,7 +232,7 @@ function TrendRow({
|
||||
</Trans>
|
||||
</Text>
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text style={[a.text_md, a.font_medium]} numberOfLines={1}>
|
||||
<Text style={[a.text_md, a.font_medium]} numberOfLines={2}>
|
||||
{trend.displayName}
|
||||
</Text>
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||
@@ -215,14 +242,14 @@ function TrendRow({
|
||||
<Text
|
||||
style={[a.text_sm, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{trend.postCount >= 1000 ? (
|
||||
<Trans comment="Over 1,000 posts">1K+ posts</Trans>
|
||||
) : (
|
||||
<Trans comment="'{postCount} {posts}', e.g., '1.2K posts'">
|
||||
{formatCount(i18n, trend.postCount)}{' '}
|
||||
{plural(trend.postCount, {one: 'post', other: 'posts'})}
|
||||
</Trans>
|
||||
)}
|
||||
<Trans comment="'{postCount} {posts}', e.g., '1.2K posts'">
|
||||
{formattedPostCount}{' '}
|
||||
<Plural
|
||||
value={{postCount: trend.postCount}}
|
||||
one="post"
|
||||
other="posts"
|
||||
/>
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {useCallback} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {
|
||||
useTrendingSettings,
|
||||
@@ -30,7 +29,7 @@ export function TrendingInterstitial() {
|
||||
|
||||
export function Inner() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const gutters = useGutters([0, 'base', 0, 'base'])
|
||||
const trendingPrompt = Prompt.usePromptControl()
|
||||
@@ -99,30 +98,37 @@ export function Inner() {
|
||||
</View>
|
||||
) : !trending?.trends ? null : (
|
||||
<>
|
||||
{trending.trends.map(topic => (
|
||||
<TrendingTopicLink
|
||||
key={topic.link}
|
||||
topic={topic}
|
||||
onPress={() => {
|
||||
ax.metric('trendingTopic:click', {
|
||||
context: 'interstitial',
|
||||
recId: trending.recId,
|
||||
})
|
||||
}}>
|
||||
<View style={[a.py_lg]}>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_sm,
|
||||
a.font_semi_bold,
|
||||
]}>
|
||||
{topic.topic}
|
||||
</Text>
|
||||
</View>
|
||||
</TrendingTopicLink>
|
||||
))}
|
||||
{trending.trends.map((topic, index) => {
|
||||
const rank = index + 1
|
||||
return (
|
||||
<TrendingTopicLink
|
||||
key={topic.link}
|
||||
topic={topic}
|
||||
metricContext="interstitial"
|
||||
rank={rank}
|
||||
recId={trending.recId}
|
||||
onPress={() => {
|
||||
ax.metric('trendingTopic:click', {
|
||||
context: 'interstitial',
|
||||
rank,
|
||||
recId: trending.recId,
|
||||
})
|
||||
}}>
|
||||
<View style={[a.py_lg]}>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_sm,
|
||||
a.font_semi_bold,
|
||||
]}>
|
||||
{topic.topic}
|
||||
</Text>
|
||||
</View>
|
||||
</TrendingTopicLink>
|
||||
)
|
||||
})}
|
||||
<Button
|
||||
label={_(msg`Hide trending topics`)}
|
||||
label={l`Hide trending topics`}
|
||||
size="tiny"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
@@ -138,9 +144,9 @@ export function Inner() {
|
||||
|
||||
<Prompt.Basic
|
||||
control={trendingPrompt}
|
||||
title={_(msg`Hide trending topics?`)}
|
||||
description={_(msg`You can update this later from your settings.`)}
|
||||
confirmButtonCta={_(msg`Hide`)}
|
||||
title={l`Hide trending topics?`}
|
||||
description={l`You can update this later from your settings.`}
|
||||
confirmButtonCta={l`Hide`}
|
||||
onConfirm={onConfirmHide}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import {useCallback, useEffect, useMemo} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {VIDEO_FEED_URI} from '#/lib/constants'
|
||||
@@ -14,7 +12,7 @@ import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||
import {atoms as a, useGutters, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
|
||||
import {Link} from '#/components/Link'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -35,7 +33,7 @@ const FEED_PARAMS: {
|
||||
|
||||
export function TrendingVideos() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const gutters = useGutters([0, 'base'])
|
||||
const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
|
||||
@@ -48,7 +46,7 @@ export function TrendingVideos() {
|
||||
.getQueryCache()
|
||||
.find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)})
|
||||
if (query && query.getObserversCount() <= 1) {
|
||||
query.fetch()
|
||||
void query.fetch()
|
||||
}
|
||||
}
|
||||
}, [queryClient])
|
||||
@@ -83,20 +81,22 @@ export function TrendingVideos() {
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
]}>
|
||||
<Text style={[a.text_sm, a.font_semi_bold, a.leading_snug]}>
|
||||
<Trans>Trending Videos</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={_(msg`Dismiss this section`)}
|
||||
size="tiny"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
shape="square"
|
||||
onPress={() => trendingPrompt.open()}>
|
||||
<ButtonIcon icon={X} size="sm" />
|
||||
</Button>
|
||||
<View style={[a.pl_xs, a.flex_row, a.align_center]}>
|
||||
<Text style={[a.flex_1, a.text_md, a.font_semi_bold]}>
|
||||
<Trans>Trending videos</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={l`Dismiss this section`}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
style={[a.bg_transparent]}
|
||||
onPress={() => trendingPrompt.open()}>
|
||||
<ButtonIcon icon={EllipsisIcon} size="md" />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<BlockDrawerGesture>
|
||||
<ScrollView
|
||||
horizontal
|
||||
@@ -131,12 +131,11 @@ export function TrendingVideos() {
|
||||
</View>
|
||||
</ScrollView>
|
||||
</BlockDrawerGesture>
|
||||
|
||||
<Prompt.Basic
|
||||
control={trendingPrompt}
|
||||
title={_(msg`Hide trending videos?`)}
|
||||
description={_(msg`You can update this later from your settings.`)}
|
||||
confirmButtonCta={_(msg`Hide`)}
|
||||
title={l`Hide trending videos?`}
|
||||
description={l`You can update this later from your settings.`}
|
||||
confirmButtonCta={l`Hide`}
|
||||
onConfirm={onConfirmHide}
|
||||
/>
|
||||
</View>
|
||||
@@ -186,7 +185,7 @@ function VideoCards({
|
||||
|
||||
function ViewMoreCard() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const href = useMemo(() => {
|
||||
const urip = new AtUri(VIDEO_FEED_URI)
|
||||
@@ -197,16 +196,16 @@ function ViewMoreCard() {
|
||||
<View style={[{width: CARD_WIDTH * 2}]}>
|
||||
<Link
|
||||
to={href}
|
||||
label={_(msg`View more`)}
|
||||
label={l`View more`}
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.flex_1,
|
||||
a.rounded_lg,
|
||||
a.rounded_xl,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
t.atoms.shadow_sm,
|
||||
t.atoms.shadow_md,
|
||||
]}>
|
||||
{({pressed}) => (
|
||||
<View
|
||||
@@ -225,7 +224,7 @@ function ViewMoreCard() {
|
||||
color="primary"
|
||||
size="small"
|
||||
shape="round"
|
||||
label={_(msg`View more trending videos`)}>
|
||||
label={l`View more trending videos`}>
|
||||
<ButtonIcon icon={ChevronRight} />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {type ParsedReportSubject} from '#/components/moderation/ReportDialog/typ
|
||||
|
||||
export const DMCA_LINK = 'https://bsky.social/about/support/copyright'
|
||||
export const SUPPORT_PAGE = 'https://bsky.social/about/support'
|
||||
export const NCII_FORM = 'https://forms.bsky.app/f/ncii'
|
||||
|
||||
export const NEW_TO_OLD_REASON_MAPPING: Record<string, string> = {}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import {XRPCError} from '@atproto/api'
|
||||
|
||||
import {classifyReportError} from './errors'
|
||||
|
||||
describe('classifyReportError', () => {
|
||||
it('treats account takedown as an expected rejection', () => {
|
||||
const result = classifyReportError(
|
||||
new XRPCError(
|
||||
403,
|
||||
'AccountTakedown',
|
||||
'Report not accepted from takendown account',
|
||||
),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: 'account-takedown',
|
||||
shouldReport: false,
|
||||
fingerprint: ['{{ default }}', 'report-dialog:account-takedown'],
|
||||
tags: {
|
||||
report_error_kind: 'account-takedown',
|
||||
report_error_bucket: 'account-takedown',
|
||||
report_xrpc_error: 'AccountTakedown',
|
||||
report_http_status: 403,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
error: new XRPCError(
|
||||
502,
|
||||
'InternalServerError',
|
||||
'Failed to perform upstream request',
|
||||
),
|
||||
bucket: 'upstream-fetch',
|
||||
},
|
||||
{
|
||||
error: new XRPCError(502, 'UpstreamFailure', 'Internal Server Error'),
|
||||
bucket: 'upstream-internal',
|
||||
},
|
||||
{
|
||||
error: new XRPCError(
|
||||
502,
|
||||
'UpstreamFailure',
|
||||
'Upstream server responded with a 502 error',
|
||||
),
|
||||
bucket: 'upstream-http-502',
|
||||
},
|
||||
{
|
||||
error: new XRPCError(
|
||||
504,
|
||||
'UpstreamTimeout',
|
||||
'Upstream server responded with a 504 error',
|
||||
),
|
||||
bucket: 'upstream-http-504',
|
||||
},
|
||||
])('classifies $bucket as unavailable', ({error, bucket}) => {
|
||||
expect(classifyReportError(error)).toMatchObject({
|
||||
kind: 'service-unavailable',
|
||||
shouldReport: true,
|
||||
fingerprint: ['{{ default }}', `report-dialog:${bucket}`],
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies an invalid reason type separately', () => {
|
||||
const result = classifyReportError(
|
||||
new XRPCError(
|
||||
400,
|
||||
'InvalidRequest',
|
||||
'Invalid reason type: tools.ozone.report.defs#reasonOther',
|
||||
),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: 'invalid-reason-type',
|
||||
shouldReport: true,
|
||||
fingerprint: ['{{ default }}', 'report-dialog:invalid-reason-type'],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([400, 404])(
|
||||
'separates a non-retryable upstream %i without calling it temporary',
|
||||
status => {
|
||||
const result = classifyReportError(
|
||||
new XRPCError(
|
||||
502,
|
||||
'UpstreamFailure',
|
||||
`Upstream server responded with a ${status} error`,
|
||||
),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: 'unexpected',
|
||||
shouldReport: true,
|
||||
fingerprint: ['{{ default }}', `report-dialog:upstream-http-${status}`],
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('classifies non-XRPC errors as unexpected', () => {
|
||||
expect(classifyReportError(new Error('boom'))).toMatchObject({
|
||||
kind: 'unexpected',
|
||||
shouldReport: true,
|
||||
fingerprint: ['{{ default }}', 'report-dialog:unexpected'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import {XRPCError} from '@atproto/api'
|
||||
|
||||
import {isRetryableHttpStatus, shouldRetryError} from '#/lib/strings/errors'
|
||||
|
||||
export type ReportErrorKind =
|
||||
| 'account-takedown'
|
||||
| 'invalid-reason-type'
|
||||
| 'service-unavailable'
|
||||
| 'unexpected'
|
||||
|
||||
export type ReportErrorClassification = {
|
||||
kind: ReportErrorKind
|
||||
shouldReport: boolean
|
||||
fingerprint: string[]
|
||||
tags: Record<string, string | number>
|
||||
}
|
||||
|
||||
export function classifyReportError(error: unknown): ReportErrorClassification {
|
||||
if (!(error instanceof XRPCError)) {
|
||||
return classification('unexpected', 'unexpected', true)
|
||||
}
|
||||
|
||||
const xrpcTags = {
|
||||
report_xrpc_error: error.error,
|
||||
report_http_status: error.status,
|
||||
}
|
||||
|
||||
if (error.error === 'AccountTakedown') {
|
||||
return classification(
|
||||
'account-takedown',
|
||||
'account-takedown',
|
||||
false,
|
||||
xrpcTags,
|
||||
)
|
||||
}
|
||||
|
||||
if (error.message.startsWith('Invalid reason type')) {
|
||||
return classification(
|
||||
'invalid-reason-type',
|
||||
'invalid-reason-type',
|
||||
true,
|
||||
xrpcTags,
|
||||
)
|
||||
}
|
||||
|
||||
if (error.message === 'Failed to perform upstream request') {
|
||||
return classification(
|
||||
'service-unavailable',
|
||||
'upstream-fetch',
|
||||
true,
|
||||
xrpcTags,
|
||||
)
|
||||
}
|
||||
|
||||
if (error.message === 'Internal Server Error') {
|
||||
return classification(
|
||||
'service-unavailable',
|
||||
'upstream-internal',
|
||||
true,
|
||||
xrpcTags,
|
||||
)
|
||||
}
|
||||
|
||||
const upstreamStatus = error.message.match(
|
||||
/^Upstream server responded with a (\d{3}) error$/,
|
||||
)?.[1]
|
||||
if (upstreamStatus) {
|
||||
return classification(
|
||||
isRetryableHttpStatus(Number(upstreamStatus))
|
||||
? 'service-unavailable'
|
||||
: 'unexpected',
|
||||
`upstream-http-${upstreamStatus}`,
|
||||
true,
|
||||
xrpcTags,
|
||||
)
|
||||
}
|
||||
|
||||
if (shouldRetryError(error)) {
|
||||
return classification(
|
||||
'service-unavailable',
|
||||
`xrpc-retryable-${error.status}`,
|
||||
true,
|
||||
xrpcTags,
|
||||
)
|
||||
}
|
||||
|
||||
return classification(
|
||||
'unexpected',
|
||||
`xrpc-other-${error.status}`,
|
||||
true,
|
||||
xrpcTags,
|
||||
)
|
||||
}
|
||||
|
||||
function classification(
|
||||
kind: ReportErrorKind,
|
||||
bucket: string,
|
||||
shouldReport: boolean,
|
||||
tags: Record<string, string | number> = {},
|
||||
): ReportErrorClassification {
|
||||
return {
|
||||
kind,
|
||||
shouldReport,
|
||||
fingerprint: ['{{ default }}', `report-dialog:${bucket}`],
|
||||
tags: {
|
||||
report_error_kind: kind,
|
||||
report_error_bucket: bucket,
|
||||
...tags,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -40,11 +40,19 @@ import {useSubmitReportMutation} from './action'
|
||||
import {
|
||||
BSKY_LABELER_ONLY_REPORT_REASONS,
|
||||
BSKY_LABELER_ONLY_SUBJECT_TYPES,
|
||||
NCII_FORM,
|
||||
NEW_TO_OLD_REASONS_MAP,
|
||||
SUPPORT_PAGE,
|
||||
} from './const'
|
||||
import {useCopyForSubject} from './copy'
|
||||
import {initialState, reducer} from './state'
|
||||
import {classifyReportError} from './errors'
|
||||
import {
|
||||
getNciiQualificationOutcome,
|
||||
initialState,
|
||||
type NciiQualification as NciiQualificationState,
|
||||
reducer,
|
||||
type ReportAction,
|
||||
} from './state'
|
||||
import {type ReportDialogProps, type ReportSubject} from './types'
|
||||
import {parseReportSubject} from './utils/parseReportSubject'
|
||||
import {
|
||||
@@ -237,14 +245,39 @@ function Inner(props: ReportDialogProps) {
|
||||
})
|
||||
}, 1e3)
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
const e = err instanceof Error ? err : new Error(String(err))
|
||||
const classification = classifyReportError(e)
|
||||
const tags = {
|
||||
...classification.tags,
|
||||
report_subject_type: props.subject.type,
|
||||
report_labeler: state.selectedLabeler?.creator.did,
|
||||
report_reason: state.selectedOption?.reason,
|
||||
}
|
||||
|
||||
ax.metric('reportDialog:failure', {})
|
||||
logger.error(e, {
|
||||
source: 'ReportDialog',
|
||||
})
|
||||
|
||||
if (classification.shouldReport) {
|
||||
logger.error(e, {
|
||||
source: 'ReportDialog',
|
||||
fingerprint: classification.fingerprint,
|
||||
tags,
|
||||
})
|
||||
} else {
|
||||
logger.warn('Report rejected for taken down account', {tags})
|
||||
}
|
||||
|
||||
let error = l`Something went wrong. Please try again.`
|
||||
if (classification.kind === 'account-takedown') {
|
||||
error = l`Your account cannot submit reports while it is taken down.`
|
||||
} else if (classification.kind === 'invalid-reason-type') {
|
||||
error = l`This moderation service does not support that report reason. Please choose a different reason or moderation service.`
|
||||
} else if (classification.kind === 'service-unavailable') {
|
||||
error = l`The moderation service is temporarily unavailable. Please try again later.`
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'setError',
|
||||
error: l`Something went wrong. Please try again.`,
|
||||
error,
|
||||
})
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
@@ -381,23 +414,28 @@ function Inner(props: ReportDialogProps) {
|
||||
activeIndex1={state.activeStepIndex1}
|
||||
/>
|
||||
{state.selectedOption ? (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||
<View style={[a.flex_1]}>
|
||||
<OptionCard option={state.selectedOption} />
|
||||
<>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||
<View style={[a.flex_1]}>
|
||||
<OptionCard option={state.selectedOption} />
|
||||
</View>
|
||||
<Button
|
||||
testID="report:clearReportOption"
|
||||
label={l`Change report reason`}
|
||||
size="tiny"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={() => {
|
||||
dispatch({type: 'clearOption'})
|
||||
}}>
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
</View>
|
||||
<Button
|
||||
testID="report:clearReportOption"
|
||||
label={l`Change report reason`}
|
||||
size="tiny"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={() => {
|
||||
dispatch({type: 'clearOption'})
|
||||
}}>
|
||||
<ButtonIcon icon={X} />
|
||||
</Button>
|
||||
</View>
|
||||
{state.ncii && (
|
||||
<NciiQualification ncii={state.ncii} dispatch={dispatch} />
|
||||
)}
|
||||
</>
|
||||
) : state.selectedCategory ? (
|
||||
<View style={[a.gap_sm]}>
|
||||
{getCategory(state.selectedCategory.key).options.map(o => (
|
||||
@@ -780,6 +818,126 @@ function OptionCard({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Qualifying question shown when the NCII reason is selected. The depicted
|
||||
* person (or their authorized representative) is directed to the external
|
||||
* NCII report form; everyone else continues with the normal in-app
|
||||
* submission.
|
||||
*/
|
||||
function NciiQualification({
|
||||
ncii,
|
||||
dispatch,
|
||||
}: {
|
||||
ncii: NciiQualificationState
|
||||
dispatch: React.Dispatch<ReportAction>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const outcome = getNciiQualificationOutcome(ncii)
|
||||
return (
|
||||
<View style={[a.gap_md]}>
|
||||
<YesNoQuestion
|
||||
testID="report:ncii:isDepicted"
|
||||
question={l`Are you the person depicted, or an authorized representative acting on behalf of the person depicted?`}
|
||||
value={ncii.isDepicted}
|
||||
onAnswer={answer => {
|
||||
dispatch({
|
||||
type: 'answerNciiQuestion',
|
||||
question: 'isDepicted',
|
||||
answer,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{outcome === 'externalForm' && (
|
||||
<Link
|
||||
to={NCII_FORM}
|
||||
label={l({
|
||||
message:
|
||||
'Submit your report through the Report non-consensual intimate imagery (NCII) form',
|
||||
context: 'english-only-resource',
|
||||
})}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.w_full,
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
a.rounded_sm,
|
||||
a.border,
|
||||
hovered || pressed
|
||||
? [t.atoms.border_contrast_high]
|
||||
: [t.atoms.border_contrast_low],
|
||||
]}>
|
||||
<Text style={[a.flex_1, a.italic, a.leading_snug]}>
|
||||
<Trans context="english-only-resource">
|
||||
Please submit your report through the Report non-consensual
|
||||
intimate imagery (NCII) form.
|
||||
</Trans>
|
||||
</Text>
|
||||
<SquareArrowTopRight size="sm" fill={t.atoms.text.color} />
|
||||
</View>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function YesNoQuestion({
|
||||
question,
|
||||
value,
|
||||
onAnswer,
|
||||
testID,
|
||||
}: {
|
||||
question: string
|
||||
value?: boolean
|
||||
onAnswer: (answer: boolean) => void
|
||||
testID?: string
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
return (
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.text_sm, a.leading_snug]}>{question}</Text>
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<View style={[a.flex_1]}>
|
||||
<Button
|
||||
testID={testID ? `${testID}:yes` : undefined}
|
||||
label={l({
|
||||
message: 'Yes',
|
||||
context: 'Answer to a yes/no question',
|
||||
})}
|
||||
accessibilityHint={question}
|
||||
size="small"
|
||||
color={value === true ? 'primary' : 'secondary'}
|
||||
onPress={() => onAnswer(true)}>
|
||||
<ButtonText>
|
||||
<Trans context="Answer to a yes/no question">Yes</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[a.flex_1]}>
|
||||
<Button
|
||||
testID={testID ? `${testID}:no` : undefined}
|
||||
label={l({
|
||||
message: 'No',
|
||||
context: 'Answer to a yes/no question',
|
||||
})}
|
||||
accessibilityHint={question}
|
||||
size="small"
|
||||
color={value === false ? 'primary' : 'secondary'}
|
||||
onPress={() => onAnswer(false)}>
|
||||
<ButtonText>
|
||||
<Trans context="Answer to a yes/no question">No</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionCardSkeleton() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
type AppBskyLabelerDefs,
|
||||
ToolsOzoneReportDefs as OzoneReportDefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {
|
||||
getNciiQualificationOutcome,
|
||||
initialState,
|
||||
reducer,
|
||||
type ReportState,
|
||||
} from './state'
|
||||
|
||||
const nciiOption = {
|
||||
title: 'Non-consensual intimate imagery',
|
||||
reason: OzoneReportDefs.REASONSEXUALNCII,
|
||||
}
|
||||
|
||||
const otherOption = {
|
||||
title: 'Unlabeled adult content',
|
||||
reason: OzoneReportDefs.REASONSEXUALUNLABELED,
|
||||
}
|
||||
|
||||
function selectNciiOption(state: ReportState = initialState) {
|
||||
return reducer(state, {type: 'selectOption', option: nciiOption})
|
||||
}
|
||||
|
||||
describe('getNciiQualificationOutcome', () => {
|
||||
it('returns undefined when not an NCII report', () => {
|
||||
expect(getNciiQualificationOutcome(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('is pending until the question is answered', () => {
|
||||
expect(getNciiQualificationOutcome({})).toBe('pending')
|
||||
})
|
||||
|
||||
it('directs the depicted person to the external form', () => {
|
||||
expect(getNciiQualificationOutcome({isDepicted: true})).toBe('externalForm')
|
||||
})
|
||||
|
||||
it('directs everyone else to in-app submission', () => {
|
||||
expect(getNciiQualificationOutcome({isDepicted: false})).toBe('inApp')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reducer NCII qualification', () => {
|
||||
it('holds at step 2 when the NCII reason is selected', () => {
|
||||
const state = selectNciiOption()
|
||||
expect(state.activeStepIndex1).toBe(2)
|
||||
expect(state.ncii).toEqual({})
|
||||
})
|
||||
|
||||
it('does not gate non-NCII reasons', () => {
|
||||
const state = reducer(initialState, {
|
||||
type: 'selectOption',
|
||||
option: otherOption,
|
||||
})
|
||||
expect(state.activeStepIndex1).toBe(3)
|
||||
expect(state.ncii).toBeUndefined()
|
||||
})
|
||||
|
||||
it('holds at step 2 for the depicted person (external form)', () => {
|
||||
let state = selectNciiOption()
|
||||
state = reducer(state, {
|
||||
type: 'answerNciiQuestion',
|
||||
question: 'isDepicted',
|
||||
answer: true,
|
||||
})
|
||||
expect(getNciiQualificationOutcome(state.ncii)).toBe('externalForm')
|
||||
expect(state.activeStepIndex1).toBe(2)
|
||||
})
|
||||
|
||||
it('advances to step 3 when not the depicted person', () => {
|
||||
let state = selectNciiOption()
|
||||
state = reducer(state, {
|
||||
type: 'answerNciiQuestion',
|
||||
question: 'isDepicted',
|
||||
answer: false,
|
||||
})
|
||||
expect(state.activeStepIndex1).toBe(3)
|
||||
})
|
||||
|
||||
it('does not advance past a pending question when a labeler is auto-selected', () => {
|
||||
let state = selectNciiOption()
|
||||
state = reducer(state, {
|
||||
type: 'selectLabeler',
|
||||
labeler: {} as AppBskyLabelerDefs.LabelerViewDetailed,
|
||||
})
|
||||
expect(state.activeStepIndex1).toBe(2)
|
||||
})
|
||||
|
||||
it('skips to step 4 when the answer resolves after labeler auto-selection', () => {
|
||||
let state = selectNciiOption()
|
||||
state = reducer(state, {
|
||||
type: 'selectLabeler',
|
||||
labeler: {} as AppBskyLabelerDefs.LabelerViewDetailed,
|
||||
})
|
||||
state = reducer(state, {
|
||||
type: 'answerNciiQuestion',
|
||||
question: 'isDepicted',
|
||||
answer: false,
|
||||
})
|
||||
expect(state.activeStepIndex1).toBe(4)
|
||||
})
|
||||
|
||||
it('clears NCII state when the reason or category is cleared', () => {
|
||||
const state = selectNciiOption()
|
||||
expect(reducer(state, {type: 'clearOption'}).ncii).toBeUndefined()
|
||||
expect(reducer(state, {type: 'clearCategory'}).ncii).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,7 @@
|
||||
import {type AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {
|
||||
type AppBskyLabelerDefs,
|
||||
ToolsOzoneReportDefs as OzoneReportDefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {OTHER_REPORT_REASONS} from '#/components/moderation/ReportDialog/const'
|
||||
import {
|
||||
@@ -6,6 +9,10 @@ import {
|
||||
type ReportOption,
|
||||
} from '#/components/moderation/ReportDialog/utils/useReportOptions'
|
||||
|
||||
export type NciiQualification = {
|
||||
isDepicted?: boolean
|
||||
}
|
||||
|
||||
export type ReportState = {
|
||||
selectedCategory?: ReportCategoryConfig
|
||||
selectedOption?: ReportOption
|
||||
@@ -14,6 +21,26 @@ export type ReportState = {
|
||||
detailsOpen: boolean
|
||||
activeStepIndex1: number
|
||||
error?: string
|
||||
/**
|
||||
* Present while the selected reason is NCII. Tracks the answer to the
|
||||
* qualifying question that determines whether the report should go through
|
||||
* the external NCII report form instead of in-app submission.
|
||||
*/
|
||||
ncii?: NciiQualification
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the NCII qualifying question into an outcome. The depicted person
|
||||
* (or their authorized representative) is directed to the external NCII
|
||||
* report form; everyone else proceeds with the normal in-app submission.
|
||||
*/
|
||||
export function getNciiQualificationOutcome(
|
||||
ncii?: NciiQualification,
|
||||
): 'pending' | 'externalForm' | 'inApp' | undefined {
|
||||
if (!ncii) return undefined
|
||||
if (ncii.isDepicted === true) return 'externalForm'
|
||||
if (ncii.isDepicted === false) return 'inApp'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
export type ReportAction =
|
||||
@@ -32,6 +59,11 @@ export type ReportAction =
|
||||
| {
|
||||
type: 'clearOption'
|
||||
}
|
||||
| {
|
||||
type: 'answerNciiQuestion'
|
||||
question: keyof NciiQualification
|
||||
answer: boolean
|
||||
}
|
||||
| {
|
||||
type: 'selectLabeler'
|
||||
labeler: AppBskyLabelerDefs.LabelerViewDetailed
|
||||
@@ -81,14 +113,19 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
|
||||
selectedLabeler: undefined,
|
||||
activeStepIndex1: 1,
|
||||
detailsOpen: false,
|
||||
ncii: undefined,
|
||||
}
|
||||
case 'selectOption':
|
||||
case 'selectOption': {
|
||||
const isNcii = action.option.reason === OzoneReportDefs.REASONSEXUALNCII
|
||||
return {
|
||||
...state,
|
||||
selectedOption: action.option,
|
||||
activeStepIndex1: 3,
|
||||
// NCII reports require answering qualifying questions before moving on
|
||||
activeStepIndex1: isNcii ? 2 : 3,
|
||||
detailsOpen: OTHER_REPORT_REASONS.has(action.option.reason),
|
||||
ncii: isNcii ? {} : undefined,
|
||||
}
|
||||
}
|
||||
case 'clearOption':
|
||||
return {
|
||||
...state,
|
||||
@@ -96,12 +133,33 @@ export function reducer(state: ReportState, action: ReportAction): ReportState {
|
||||
selectedLabeler: undefined,
|
||||
activeStepIndex1: 2,
|
||||
detailsOpen: false,
|
||||
ncii: undefined,
|
||||
}
|
||||
case 'answerNciiQuestion': {
|
||||
const ncii = {...state.ncii, [action.question]: action.answer}
|
||||
return {
|
||||
...state,
|
||||
ncii,
|
||||
activeStepIndex1:
|
||||
getNciiQualificationOutcome(ncii) === 'inApp'
|
||||
? state.selectedLabeler
|
||||
? 4
|
||||
: 3
|
||||
: 2,
|
||||
}
|
||||
}
|
||||
case 'selectLabeler':
|
||||
return {
|
||||
...state,
|
||||
selectedLabeler: action.labeler,
|
||||
activeStepIndex1: 4,
|
||||
/*
|
||||
* Labelers may be auto-selected (e.g. chat reports only go to
|
||||
* Bluesky), so don't advance past pending NCII qualifying questions.
|
||||
*/
|
||||
activeStepIndex1:
|
||||
getNciiQualificationOutcome(state.ncii) === 'inApp' || !state.ncii
|
||||
? 4
|
||||
: 2,
|
||||
detailsOpen: state.selectedOption
|
||||
? OTHER_REPORT_REASONS.has(state.selectedOption?.reason)
|
||||
: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
|
||||
|
||||
jest.mock('@bsky.app/react-native-mmkv', () => ({
|
||||
jest.mock('react-native-mmkv', () => ({
|
||||
MMKV: class MMKVMock {
|
||||
_store = new Map<string, string>()
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import Animated, {
|
||||
clamp,
|
||||
interpolate,
|
||||
interpolateColor,
|
||||
runOnJS,
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
@@ -14,6 +13,7 @@ import Animated, {
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {scheduleOnRN} from 'react-native-worklets'
|
||||
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {type GestureActions} from './GestureActionView.shared'
|
||||
@@ -74,17 +74,17 @@ export function GestureActionView({
|
||||
() => transX,
|
||||
() => {
|
||||
if (transX.get() === 0) {
|
||||
runOnJS(setActiveAction)(null)
|
||||
scheduleOnRN(setActiveAction, null)
|
||||
} else if (transX.get() < 0) {
|
||||
if (
|
||||
actions.leftSecond &&
|
||||
transX.get() <= -actions.leftSecond.threshold
|
||||
) {
|
||||
if (activeAction !== 'leftSecond') {
|
||||
runOnJS(setActiveAction)('leftSecond')
|
||||
scheduleOnRN(setActiveAction, 'leftSecond')
|
||||
}
|
||||
} else if (activeAction !== 'leftFirst') {
|
||||
runOnJS(setActiveAction)('leftFirst')
|
||||
scheduleOnRN(setActiveAction, 'leftFirst')
|
||||
}
|
||||
} else if (transX.get() > 0) {
|
||||
if (
|
||||
@@ -92,10 +92,10 @@ export function GestureActionView({
|
||||
transX.get() > actions.rightSecond.threshold
|
||||
) {
|
||||
if (activeAction !== 'rightSecond') {
|
||||
runOnJS(setActiveAction)('rightSecond')
|
||||
scheduleOnRN(setActiveAction, 'rightSecond')
|
||||
}
|
||||
} else if (activeAction !== 'rightFirst') {
|
||||
runOnJS(setActiveAction)('rightFirst')
|
||||
scheduleOnRN(setActiveAction, 'rightFirst')
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -127,7 +127,7 @@ export function GestureActionView({
|
||||
!hitSecond.get()
|
||||
) {
|
||||
runPopAnimation()
|
||||
runOnJS(haptic)()
|
||||
scheduleOnRN(haptic)
|
||||
hitSecond.set(true)
|
||||
} else if (
|
||||
hitSecond.get() &&
|
||||
@@ -144,7 +144,7 @@ export function GestureActionView({
|
||||
!hitFirst.get()
|
||||
) {
|
||||
runPopAnimation()
|
||||
runOnJS(haptic)()
|
||||
scheduleOnRN(haptic)
|
||||
hitFirst.set(true)
|
||||
} else if (
|
||||
hitFirst.get() &&
|
||||
@@ -161,7 +161,7 @@ export function GestureActionView({
|
||||
!hitSecond.get()
|
||||
) {
|
||||
runPopAnimation()
|
||||
runOnJS(haptic)()
|
||||
scheduleOnRN(haptic)
|
||||
hitSecond.set(true)
|
||||
} else if (
|
||||
hitSecond.get() &&
|
||||
@@ -178,7 +178,7 @@ export function GestureActionView({
|
||||
!hitFirst.get()
|
||||
) {
|
||||
runPopAnimation()
|
||||
runOnJS(haptic)()
|
||||
scheduleOnRN(haptic)
|
||||
hitFirst.set(true)
|
||||
} else if (
|
||||
hitFirst.get() &&
|
||||
@@ -193,15 +193,15 @@ export function GestureActionView({
|
||||
'worklet'
|
||||
if (e.translationX < 0) {
|
||||
if (hitSecond.get() && actions.leftSecond) {
|
||||
runOnJS(actions.leftSecond.action)()
|
||||
scheduleOnRN(actions.leftSecond.action)
|
||||
} else if (hitFirst.get() && actions.leftFirst) {
|
||||
runOnJS(actions.leftFirst.action)()
|
||||
scheduleOnRN(actions.leftFirst.action)
|
||||
}
|
||||
} else if (e.translationX > 0) {
|
||||
if (hitSecond.get() && actions.rightSecond) {
|
||||
runOnJS(actions.rightSecond.action)()
|
||||
scheduleOnRN(actions.rightSecond.action)
|
||||
} else if (hitSecond.get() && actions.rightFirst) {
|
||||
runOnJS(actions.rightFirst.action)()
|
||||
scheduleOnRN(actions.rightFirst.action)
|
||||
}
|
||||
}
|
||||
transX.set(() => withTiming(0, {duration: 200}))
|
||||
|
||||
@@ -107,6 +107,7 @@ export function AnimatedLikeIcon({
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: size / 2,
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
<Animated.View
|
||||
@@ -121,6 +122,7 @@ export function AnimatedLikeIcon({
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: size / 2,
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -84,11 +84,19 @@ export function useIntentHandler() {
|
||||
}
|
||||
case 'apply-ota': {
|
||||
const channel = params.get('channel')
|
||||
const releaseVersion = params.get('releaseVersion')
|
||||
const buildNumber = params.get(
|
||||
IS_IOS ? 'iosBuildNumber' : 'androidBuildNumber',
|
||||
)
|
||||
const appVersion =
|
||||
releaseVersion && buildNumber
|
||||
? `${releaseVersion}.${buildNumber}`
|
||||
: null
|
||||
if (!channel) {
|
||||
Alert.alert('Error', 'No channel provided to look for.')
|
||||
} else {
|
||||
tryApplyUpdate(channel)
|
||||
return
|
||||
}
|
||||
tryApplyUpdate(channel, appVersion)
|
||||
return
|
||||
}
|
||||
default: {
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import {Alert} from 'react-native'
|
||||
import {
|
||||
checkForUpdateAsync,
|
||||
fetchUpdateAsync,
|
||||
reloadAsync,
|
||||
setExtraParamAsync,
|
||||
UpdateCheckResultNotAvailableReason,
|
||||
useUpdates,
|
||||
} from 'expo-updates'
|
||||
import {act, renderHook, waitFor} from '@testing-library/react-native'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {APP_VERSION} from '#/env'
|
||||
import {device} from '#/storage'
|
||||
import {
|
||||
useApplyPullRequestOTAUpdate,
|
||||
useOTAUpdateRecovery,
|
||||
} from './useOTAUpdates'
|
||||
|
||||
jest.mock('expo-updates', () => ({
|
||||
checkForUpdateAsync: jest.fn(),
|
||||
fetchUpdateAsync: jest.fn(),
|
||||
isEnabled: true,
|
||||
reloadAsync: jest.fn(),
|
||||
setExtraParamAsync: jest.fn(),
|
||||
UpdateCheckResultNotAvailableReason: {
|
||||
NO_UPDATE_AVAILABLE_ON_SERVER: 'noUpdateAvailableOnServer',
|
||||
UPDATE_PREVIOUSLY_FAILED: 'updatePreviouslyFailed',
|
||||
},
|
||||
useUpdates: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('#/logger', () => ({
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('#/storage', () => ({
|
||||
device: {
|
||||
get: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
set: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('#/alf', () => ({
|
||||
useTheme: jest.fn().mockImplementation(() => ({
|
||||
scheme: 'light',
|
||||
})),
|
||||
}))
|
||||
|
||||
/**
|
||||
* `channel` here is the build-time constant baked into the native build, not the
|
||||
* channel of the running bundle. `channel` is passed as the manifest metadata
|
||||
* channel our update server stamps into every published update - omit it to
|
||||
* simulate an embedded launch, which has no server manifest.
|
||||
*/
|
||||
function mockCurrentlyRunning({
|
||||
buildChannel = 'testflight',
|
||||
channel,
|
||||
updateId = 'current-update',
|
||||
}: {
|
||||
buildChannel?: string
|
||||
channel?: string
|
||||
updateId?: string
|
||||
} = {}) {
|
||||
const currentlyRunning = {
|
||||
channel: buildChannel,
|
||||
emergencyLaunchReason: null,
|
||||
isEmbeddedLaunch: !channel,
|
||||
isEmergencyLaunch: false,
|
||||
updateId,
|
||||
manifest: channel ? {id: updateId, metadata: {channel}} : undefined,
|
||||
}
|
||||
jest.mocked(useUpdates).mockReturnValue({
|
||||
currentlyRunning,
|
||||
} as ReturnType<typeof useUpdates>)
|
||||
return currentlyRunning
|
||||
}
|
||||
|
||||
const currentUpdate = {updateId: 'current-update'}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockCurrentlyRunning()
|
||||
jest.mocked(setExtraParamAsync).mockResolvedValue(undefined)
|
||||
jest.mocked(reloadAsync).mockResolvedValue(undefined)
|
||||
jest.spyOn(Alert, 'alert').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
describe('useApplyPullRequestOTAUpdate', () => {
|
||||
it('detects a running PR deployment from the manifest metadata', () => {
|
||||
mockCurrentlyRunning({
|
||||
buildChannel: 'testflight',
|
||||
channel: 'pull-request-123',
|
||||
})
|
||||
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
expect(result.current.currentChannel).toBe('pull-request-123')
|
||||
expect(result.current.isCurrentlyRunningPullRequestDeployment).toBe(true)
|
||||
expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(true)
|
||||
})
|
||||
|
||||
it('treats a standard downloaded update as a standard channel', () => {
|
||||
mockCurrentlyRunning({buildChannel: 'testflight', channel: 'testflight'})
|
||||
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
expect(result.current.currentChannel).toBe('testflight')
|
||||
expect(result.current.isCurrentlyRunningPullRequestDeployment).toBe(false)
|
||||
expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the build channel for an embedded launch', () => {
|
||||
mockCurrentlyRunning({buildChannel: 'testflight'})
|
||||
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
expect(result.current.currentChannel).toBe('testflight')
|
||||
expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(false)
|
||||
})
|
||||
|
||||
it('reports no channel when updates are disabled', () => {
|
||||
mockCurrentlyRunning({buildChannel: ''})
|
||||
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
expect(result.current.currentChannel).toBeUndefined()
|
||||
expect(result.current.isCurrentlyRunningNonStandardChannel).toBe(false)
|
||||
})
|
||||
|
||||
it('stays quiet when already running the latest of the requested channel', async () => {
|
||||
mockCurrentlyRunning({
|
||||
buildChannel: 'testflight',
|
||||
channel: 'pull-request-123',
|
||||
})
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: false,
|
||||
reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||
|
||||
expect(Alert.alert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when no deployment is available for a different channel', async () => {
|
||||
mockCurrentlyRunning({
|
||||
buildChannel: 'testflight',
|
||||
channel: 'pull-request-123',
|
||||
})
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: false,
|
||||
reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-456'))
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'No Deployment Available',
|
||||
expect.stringContaining('pull-request-456'),
|
||||
)
|
||||
})
|
||||
|
||||
it('stays silent on a re-fired intent even when the app version differs', async () => {
|
||||
mockCurrentlyRunning({
|
||||
buildChannel: 'testflight',
|
||||
channel: 'pull-request-123',
|
||||
})
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: false,
|
||||
reason: UpdateCheckResultNotAvailableReason.NO_UPDATE_AVAILABLE_ON_SERVER,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0'))
|
||||
|
||||
expect(Alert.alert).not.toHaveBeenCalled()
|
||||
expect(fetchUpdateAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prompts to apply an available update when the app version matches', async () => {
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: true,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() =>
|
||||
result.current.tryApplyUpdate('pull-request-123', APP_VERSION),
|
||||
)
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Apply update from PR #123?',
|
||||
expect.stringContaining('relaunch'),
|
||||
expect.arrayContaining([expect.objectContaining({text: 'Apply'})]),
|
||||
)
|
||||
})
|
||||
|
||||
it('warns before applying an OTA built for a different app version', async () => {
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: true,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
jest.mocked(fetchUpdateAsync).mockResolvedValue({
|
||||
isNew: true,
|
||||
isRollBackToEmbedded: false,
|
||||
manifest: {id: 'mismatched-update'},
|
||||
} as Awaited<ReturnType<typeof fetchUpdateAsync>>)
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123', '0.0.0'))
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'App Version Mismatch',
|
||||
expect.stringContaining('Applying it anyway may cause'),
|
||||
expect.arrayContaining([expect.objectContaining({text: 'Apply Anyway'})]),
|
||||
)
|
||||
|
||||
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
|
||||
act(() => buttons?.[1].onPress?.())
|
||||
|
||||
await waitFor(() => expect(reloadAsync).toHaveBeenCalled())
|
||||
expect(device.set).toHaveBeenCalledWith(['pendingOTAUpdate'], {
|
||||
attemptedAt: expect.any(Number),
|
||||
channel: 'pull-request-123',
|
||||
updateId: 'mismatched-update',
|
||||
})
|
||||
})
|
||||
|
||||
it('informs the user when checking for an OTA fails', async () => {
|
||||
jest.mocked(checkForUpdateAsync).mockRejectedValue(new Error('offline'))
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Update Check Failed',
|
||||
expect.stringContaining('Error: offline'),
|
||||
)
|
||||
expect(result.current.pending).toBe(false)
|
||||
})
|
||||
|
||||
it('informs the user when downloading an OTA fails', async () => {
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: true,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
jest
|
||||
.mocked(fetchUpdateAsync)
|
||||
.mockRejectedValue(new Error('download failed'))
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
|
||||
|
||||
act(() => buttons?.[1].onPress?.())
|
||||
|
||||
await waitFor(() =>
|
||||
expect(Alert.alert).toHaveBeenLastCalledWith(
|
||||
'Update Failed',
|
||||
expect.stringContaining('Error: download failed'),
|
||||
),
|
||||
)
|
||||
expect(device.set).not.toHaveBeenCalled()
|
||||
expect(result.current.pending).toBe(false)
|
||||
})
|
||||
|
||||
it('clears the recovery marker and informs the user when reloading fails', async () => {
|
||||
jest.mocked(checkForUpdateAsync).mockResolvedValue({
|
||||
isAvailable: true,
|
||||
} as Awaited<ReturnType<typeof checkForUpdateAsync>>)
|
||||
jest.mocked(fetchUpdateAsync).mockResolvedValue({
|
||||
isNew: true,
|
||||
isRollBackToEmbedded: false,
|
||||
manifest: {id: 'new-update'},
|
||||
} as Awaited<ReturnType<typeof fetchUpdateAsync>>)
|
||||
jest.mocked(reloadAsync).mockRejectedValue(new Error('reload failed'))
|
||||
const {result} = renderHook(() => useApplyPullRequestOTAUpdate())
|
||||
|
||||
await act(() => result.current.tryApplyUpdate('pull-request-123'))
|
||||
const buttons = jest.mocked(Alert.alert).mock.calls[0][2]
|
||||
|
||||
act(() => buttons?.[1].onPress?.())
|
||||
|
||||
await waitFor(() =>
|
||||
expect(Alert.alert).toHaveBeenLastCalledWith(
|
||||
'Update Failed',
|
||||
expect.stringContaining('Error: reload failed'),
|
||||
),
|
||||
)
|
||||
expect(device.set).toHaveBeenCalledWith(['pendingOTAUpdate'], {
|
||||
attemptedAt: expect.any(Number),
|
||||
channel: 'pull-request-123',
|
||||
updateId: 'new-update',
|
||||
})
|
||||
expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate'])
|
||||
expect(result.current.pending).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useOTAUpdateRecovery', () => {
|
||||
it('informs the user when Expo fell back from the attempted OTA', async () => {
|
||||
jest.mocked(device.get).mockReturnValue({
|
||||
attemptedAt: Date.now(),
|
||||
channel: 'pull-request-123',
|
||||
updateId: 'failed-update',
|
||||
})
|
||||
|
||||
renderHook(() => useOTAUpdateRecovery())
|
||||
|
||||
await waitFor(() =>
|
||||
expect(Alert.alert).toHaveBeenCalledWith(
|
||||
'Update Failed',
|
||||
expect.stringContaining('PR #123 deployment could not start'),
|
||||
),
|
||||
)
|
||||
expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate'])
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Custom OTA Update Failed to Launch',
|
||||
expect.objectContaining({attemptedUpdateId: 'failed-update'}),
|
||||
)
|
||||
})
|
||||
|
||||
it('recognizes a launched OTA when the update ID casing differs', () => {
|
||||
jest.mocked(device.get).mockReturnValue({
|
||||
attemptedAt: Date.now(),
|
||||
channel: 'pull-request-123',
|
||||
updateId: currentUpdate.updateId.toUpperCase(),
|
||||
})
|
||||
|
||||
renderHook(() => useOTAUpdateRecovery())
|
||||
|
||||
expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate'])
|
||||
expect(Alert.alert).not.toHaveBeenCalled()
|
||||
expect(logger.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('silently clears a stale marker from an older OTA bundle', () => {
|
||||
jest.mocked(device.get).mockReturnValue({
|
||||
attemptedAt: Date.now() - 10 * 60e3,
|
||||
channel: 'pull-request-123',
|
||||
updateId: 'older-update',
|
||||
})
|
||||
|
||||
renderHook(() => useOTAUpdateRecovery())
|
||||
|
||||
expect(device.remove).toHaveBeenCalledWith(['pendingOTAUpdate'])
|
||||
expect(Alert.alert).not.toHaveBeenCalled()
|
||||
expect(logger.error).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
+281
-40
@@ -1,20 +1,74 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {Alert, AppState, type AppStateStatus} from 'react-native'
|
||||
import {
|
||||
Alert,
|
||||
AppState,
|
||||
type AppStateStatus,
|
||||
Image as RNImage,
|
||||
} from 'react-native'
|
||||
import {nativeBuildVersion} from 'expo-application'
|
||||
import {
|
||||
checkForUpdateAsync,
|
||||
type CurrentlyRunningInfo,
|
||||
fetchUpdateAsync,
|
||||
isEnabled,
|
||||
reloadAsync,
|
||||
type ReloadScreenOptions,
|
||||
setExtraParamAsync,
|
||||
UpdateCheckResultNotAvailableReason,
|
||||
useUpdates,
|
||||
} from 'expo-updates'
|
||||
|
||||
import {isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {IS_ANDROID, IS_IOS, IS_TESTFLIGHT} from '#/env'
|
||||
import {useTheme} from '#/alf'
|
||||
import {APP_VERSION, IS_IOS, IS_TESTFLIGHT} from '#/env'
|
||||
import {device} from '#/storage'
|
||||
|
||||
const MINIMUM_MINIMIZE_TIME = 15 * 60e3
|
||||
const OTA_RECOVERY_WINDOW = 5 * 60e3
|
||||
|
||||
/**
|
||||
* The channel this native build is expected to receive updates from. Anything
|
||||
* else is only reachable through the dev tooling in settings.
|
||||
*/
|
||||
const DEFAULT_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
|
||||
|
||||
/**
|
||||
* Channels that our native builds are configured with, see `eas.json`. An
|
||||
* update running on any other channel was applied manually.
|
||||
*/
|
||||
const STANDARD_CHANNELS = ['production', 'testflight', 'development']
|
||||
|
||||
function getDeploymentName(channel: string) {
|
||||
const pullRequestNumber = channel.match(/^pull-request-(\d+)$/)?.[1]
|
||||
return pullRequestNumber ? `PR #${pullRequestNumber}` : channel
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel of the update bundle that is actually running. The
|
||||
* `currentlyRunning.channel` constant only reflects the channel baked into the
|
||||
* native build config, so a manually applied deployment (e.g. a pull request
|
||||
* channel) must be detected from the manifest metadata our update server stamps
|
||||
* into every published update. Embedded launches have no server manifest and
|
||||
* fall back to the build constant.
|
||||
*/
|
||||
function getRunningChannel(
|
||||
currentlyRunning: CurrentlyRunningInfo | undefined,
|
||||
): string | undefined {
|
||||
/*
|
||||
* `metadata` is typed as a bare `object` by expo-manifests, and is absent
|
||||
* entirely from embedded manifests, so narrow it ourselves.
|
||||
*/
|
||||
const manifest = currentlyRunning?.manifest as
|
||||
| {metadata?: {channel?: unknown}}
|
||||
| undefined
|
||||
const channel = manifest?.metadata?.channel
|
||||
if (typeof channel === 'string' && channel) {
|
||||
return channel
|
||||
}
|
||||
// The build constant is an empty string rather than null when unconfigured.
|
||||
return currentlyRunning?.channel || undefined
|
||||
}
|
||||
|
||||
async function setExtraParams() {
|
||||
await setExtraParamAsync(
|
||||
@@ -23,10 +77,7 @@ async function setExtraParams() {
|
||||
// This just ensures it gets passed as a string
|
||||
`${nativeBuildVersion}`,
|
||||
)
|
||||
await setExtraParamAsync(
|
||||
'channel',
|
||||
IS_TESTFLIGHT ? 'testflight' : 'production',
|
||||
)
|
||||
await setExtraParamAsync('channel', DEFAULT_CHANNEL)
|
||||
}
|
||||
|
||||
async function setExtraParamsPullRequest(channel: string) {
|
||||
@@ -39,7 +90,7 @@ async function setExtraParamsPullRequest(channel: string) {
|
||||
await setExtraParamAsync('channel', channel)
|
||||
}
|
||||
|
||||
async function updateTestflight() {
|
||||
async function updateTestflight(scheme: 'light' | 'dark') {
|
||||
await setExtraParams()
|
||||
|
||||
const res = await checkForUpdateAsync()
|
||||
@@ -57,7 +108,9 @@ async function updateTestflight() {
|
||||
text: 'Relaunch',
|
||||
style: 'default',
|
||||
onPress: async () => {
|
||||
await reloadAsync()
|
||||
await reloadAsync({
|
||||
reloadScreenOptions: splash(scheme),
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -66,70 +119,241 @@ async function updateTestflight() {
|
||||
}
|
||||
|
||||
export function useApplyPullRequestOTAUpdate() {
|
||||
const t = useTheme()
|
||||
const {currentlyRunning} = useUpdates()
|
||||
const [pending, setPending] = useState(false)
|
||||
const currentChannel = currentlyRunning?.channel
|
||||
const currentChannel = getRunningChannel(currentlyRunning)
|
||||
const isCurrentlyRunningPullRequestDeployment =
|
||||
currentChannel?.startsWith('pull-request')
|
||||
/*
|
||||
* Covers pull request deployments as well as any other channel we manually
|
||||
* applied an update from. Note that the channel is undefined when updates are
|
||||
* disabled (e.g. in dev), in which case there's nothing to restore.
|
||||
*/
|
||||
const isCurrentlyRunningNonStandardChannel = Boolean(
|
||||
currentChannel && !STANDARD_CHANNELS.includes(currentChannel),
|
||||
)
|
||||
|
||||
const tryApplyUpdate = async (channel: string) => {
|
||||
const tryApplyUpdate = async (
|
||||
channel: string,
|
||||
declaredAppVersion?: string | null,
|
||||
) => {
|
||||
const deploymentName = getDeploymentName(channel)
|
||||
|
||||
const checkForDeployment = async () => {
|
||||
await setExtraParamsPullRequest(channel)
|
||||
const res = await checkForUpdateAsync()
|
||||
if (!res.isAvailable) {
|
||||
if (
|
||||
res.reason ===
|
||||
UpdateCheckResultNotAvailableReason.UPDATE_PREVIOUSLY_FAILED
|
||||
) {
|
||||
Alert.alert(
|
||||
'Deployment Blocked',
|
||||
`The ${deploymentName} deployment previously failed to start on this device, so the app will not try to apply it again.`,
|
||||
)
|
||||
} else if (currentChannel !== channel) {
|
||||
Alert.alert(
|
||||
'No Deployment Available',
|
||||
`No new deployments of ${channel} are currently available for your current native build.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return res.isAvailable
|
||||
}
|
||||
|
||||
const applyUpdate = () => {
|
||||
setPending(true)
|
||||
void (async () => {
|
||||
try {
|
||||
if (!(await checkForDeployment())) return
|
||||
const fetchedUpdate = await fetchUpdateAsync()
|
||||
if (!fetchedUpdate.isNew) {
|
||||
throw new Error('Expo did not download a new update.')
|
||||
}
|
||||
|
||||
device.set(['pendingOTAUpdate'], {
|
||||
attemptedAt: Date.now(),
|
||||
channel,
|
||||
updateId: fetchedUpdate.manifest.id,
|
||||
})
|
||||
try {
|
||||
/*
|
||||
* TODO: once expo-linking is upgraded to >= 57, enable this so the
|
||||
* re-delivered initial URL doesn't trigger a redundant silent check
|
||||
* after the reload.
|
||||
*/
|
||||
// Linking.clearInitialURL()
|
||||
await reloadAsync({
|
||||
reloadScreenOptions: splash(t.scheme),
|
||||
})
|
||||
} catch (e) {
|
||||
device.remove(['pendingOTAUpdate'])
|
||||
throw e
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const error = String(e)
|
||||
logger.error('Internal OTA Update Error', {error})
|
||||
Alert.alert(
|
||||
'Update Failed',
|
||||
`Could not apply the ${deploymentName} deployment: ${error}`,
|
||||
)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
/*
|
||||
* Check before prompting about anything, so that re-running this while
|
||||
* already on the newest update of `channel` stays silent. Reloading into an
|
||||
* update re-delivers the deep link that triggered it, and the same link may
|
||||
* also just be tapped again.
|
||||
*/
|
||||
setPending(true)
|
||||
await setExtraParamsPullRequest(channel)
|
||||
const res = await checkForUpdateAsync()
|
||||
if (res.isAvailable) {
|
||||
try {
|
||||
if (!(await checkForDeployment())) return
|
||||
|
||||
if (declaredAppVersion && declaredAppVersion !== APP_VERSION) {
|
||||
Alert.alert(
|
||||
'App Version Mismatch',
|
||||
`This OTA update was built for a different version of the app.\n\nCurrent app version: ${APP_VERSION}\nOTA app version: ${declaredAppVersion}\n\nApplying it anyway may cause the app to stop working and require a reinstall.`,
|
||||
[
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Apply Anyway',
|
||||
style: 'destructive',
|
||||
onPress: applyUpdate,
|
||||
},
|
||||
],
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'Deployment Available',
|
||||
`A deployment of ${channel} is availalble. Applying this deployment may result in a bricked installation, in which case you will need to reinstall the app and may lose local data. Are you sure you want to proceed?`,
|
||||
`Apply update from ${deploymentName}?`,
|
||||
'The app will relaunch after the update is applied.',
|
||||
[
|
||||
{
|
||||
text: 'No',
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Relaunch',
|
||||
text: 'Apply',
|
||||
style: 'default',
|
||||
onPress: async () => {
|
||||
await fetchUpdateAsync()
|
||||
await reloadAsync()
|
||||
},
|
||||
onPress: applyUpdate,
|
||||
},
|
||||
],
|
||||
)
|
||||
} else {
|
||||
} catch (e: unknown) {
|
||||
const error = String(e)
|
||||
logger.error('Internal OTA Update Error', {error})
|
||||
Alert.alert(
|
||||
'No Deployment Available',
|
||||
`No new deployments of ${channel} are currently available for your current native build.`,
|
||||
'Update Check Failed',
|
||||
`Could not check the ${deploymentName} deployment: ${error}`,
|
||||
)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
setPending(false)
|
||||
}
|
||||
|
||||
const revertToEmbedded = async () => {
|
||||
/**
|
||||
* Pulls the newest update from the channel this build ships with and relaunches
|
||||
* into it, undoing a manually applied deployment.
|
||||
*/
|
||||
const restoreDefaultChannel = async () => {
|
||||
setPending(true)
|
||||
try {
|
||||
await updateTestflight()
|
||||
await setExtraParams()
|
||||
const res = await checkForUpdateAsync()
|
||||
if (res.isAvailable) {
|
||||
await fetchUpdateAsync()
|
||||
await reloadAsync()
|
||||
} else {
|
||||
Alert.alert(
|
||||
'Nothing to Restore',
|
||||
`No deployment of ${DEFAULT_CHANNEL} is currently available for your native build. Reinstall the app to get back to a standard build.`,
|
||||
)
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error('Internal OTA Update Error', {error: `${e}`})
|
||||
Alert.alert(
|
||||
'Restore Failed',
|
||||
`Could not restore the ${DEFAULT_CHANNEL} deployment: ${e}`,
|
||||
)
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tryApplyUpdate,
|
||||
revertToEmbedded,
|
||||
restoreDefaultChannel,
|
||||
isCurrentlyRunningPullRequestDeployment,
|
||||
isCurrentlyRunningNonStandardChannel,
|
||||
currentChannel,
|
||||
defaultChannel: DEFAULT_CHANNEL,
|
||||
pending,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports when expo-updates recovered from a custom OTA that failed to launch.
|
||||
* The attempted update ID is persisted before reload so the previous bundle can
|
||||
* distinguish a successful relaunch from an automatic fallback.
|
||||
*/
|
||||
export function useOTAUpdateRecovery() {
|
||||
const {currentlyRunning} = useUpdates()
|
||||
|
||||
useEffect(() => {
|
||||
const pendingUpdate = device.get(['pendingOTAUpdate'])
|
||||
if (!pendingUpdate || !currentlyRunning) return
|
||||
|
||||
device.remove(['pendingOTAUpdate'])
|
||||
if (
|
||||
pendingUpdate.updateId.toLowerCase() ===
|
||||
currentlyRunning.updateId?.toLowerCase()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// A fallback relaunch is immediate. A stale marker can be left by a
|
||||
// successful runtime-compatible bundle that predates this hook.
|
||||
if (
|
||||
typeof pendingUpdate.attemptedAt !== 'number' ||
|
||||
Date.now() - pendingUpdate.attemptedAt >= OTA_RECOVERY_WINDOW
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const deploymentName = getDeploymentName(pendingUpdate.channel)
|
||||
logger.error('Custom OTA Update Failed to Launch', {
|
||||
channel: pendingUpdate.channel,
|
||||
attemptedUpdateId: pendingUpdate.updateId,
|
||||
currentUpdateId: currentlyRunning.updateId,
|
||||
isEmergencyLaunch: currentlyRunning.isEmergencyLaunch,
|
||||
emergencyLaunchReason: currentlyRunning.emergencyLaunchReason,
|
||||
})
|
||||
Alert.alert(
|
||||
'Update Failed',
|
||||
`The ${deploymentName} deployment could not start. The app recovered by loading a working version instead.`,
|
||||
)
|
||||
}, [currentlyRunning])
|
||||
}
|
||||
|
||||
export function useOTAUpdates() {
|
||||
const shouldReceiveUpdates = isEnabled && !__DEV__
|
||||
|
||||
const t = useTheme()
|
||||
const appState = useRef<AppStateStatus>('active')
|
||||
const lastMinimize = useRef(0)
|
||||
const ranInitialCheck = useRef(false)
|
||||
const timeout = useRef<NodeJS.Timeout>(undefined)
|
||||
const {currentlyRunning, isUpdatePending} = useUpdates()
|
||||
const currentChannel = currentlyRunning?.channel
|
||||
const currentChannel = getRunningChannel(currentlyRunning)
|
||||
|
||||
const setCheckTimeout = useCallback(() => {
|
||||
timeout.current = setTimeout(async () => {
|
||||
@@ -155,13 +379,13 @@ export function useOTAUpdates() {
|
||||
|
||||
const onIsTestFlight = useCallback(async () => {
|
||||
try {
|
||||
await updateTestflight()
|
||||
await updateTestflight(t.scheme)
|
||||
} catch (err: any) {
|
||||
if (!isNetworkError(err)) {
|
||||
logger.error('Internal OTA Update Error', {safeMessage: err})
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [t.scheme])
|
||||
|
||||
useEffect(() => {
|
||||
// We don't need to check anything if the current update is a PR update
|
||||
@@ -192,13 +416,6 @@ export function useOTAUpdates() {
|
||||
return
|
||||
}
|
||||
|
||||
// TEMP: disable wake-from-background OTA loading on Android.
|
||||
// This is causing a crash when the thread view is open due to
|
||||
// `maintainVisibleContentPosition`. See repro repo for more details:
|
||||
// https://github.com/mozzius/ota-crash-repro
|
||||
// Old Arch only - re-enable once we're on the New Archictecture! -sfn
|
||||
if (IS_ANDROID) return
|
||||
|
||||
const subscription = AppState.addEventListener(
|
||||
'change',
|
||||
async nextAppState => {
|
||||
@@ -210,7 +427,9 @@ export function useOTAUpdates() {
|
||||
// chances are that there isn't anything important going on in the current session.
|
||||
if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) {
|
||||
if (isUpdatePending) {
|
||||
await reloadAsync()
|
||||
await reloadAsync({
|
||||
reloadScreenOptions: splash(t.scheme),
|
||||
})
|
||||
} else {
|
||||
setCheckTimeout()
|
||||
}
|
||||
@@ -227,5 +446,27 @@ export function useOTAUpdates() {
|
||||
clearTimeout(timeout.current)
|
||||
subscription.remove()
|
||||
}
|
||||
}, [isUpdatePending, currentChannel, setCheckTimeout])
|
||||
}, [isUpdatePending, currentChannel, setCheckTimeout, t.scheme])
|
||||
}
|
||||
|
||||
/**
|
||||
* Splash screen for while the app is updating
|
||||
*/
|
||||
export const splash = (scheme: 'light' | 'dark') => {
|
||||
const source =
|
||||
scheme === 'light'
|
||||
? require('../../../assets/splash/splash.png')
|
||||
: require('../../../assets/splash/splash-dark.png')
|
||||
|
||||
return {
|
||||
image: RNImage.resolveAssetSource(source).uri,
|
||||
imageFullScreen: true,
|
||||
imageResizeMode: 'cover',
|
||||
backgroundColor: scheme === 'light' ? '#006AFF' : '#002861',
|
||||
spinner: {
|
||||
enabled: true,
|
||||
color: '#ffffff',
|
||||
size: 'large',
|
||||
},
|
||||
} satisfies ReloadScreenOptions
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
export function useOTAUpdates() {}
|
||||
export function useOTAUpdateRecovery() {}
|
||||
export function useApplyPullRequestOTAUpdate() {
|
||||
return {
|
||||
tryApplyUpdate: async (_channel: string) => {},
|
||||
revertToEmbedded: () => {},
|
||||
tryApplyUpdate: async (
|
||||
_channel: string,
|
||||
_declaredAppVersion?: string | null,
|
||||
) => {},
|
||||
restoreDefaultChannel: async () => {},
|
||||
isCurrentlyRunningPullRequestDeployment: false,
|
||||
isCurrentlyRunningNonStandardChannel: false,
|
||||
currentChannel: 'web-build',
|
||||
defaultChannel: 'web-build',
|
||||
pending: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,11 @@ export async function openCamera(customOpts: ImagePickerOptions) {
|
||||
}
|
||||
const res = await launchCameraAsync(opts)
|
||||
|
||||
if (!res || !res.assets) {
|
||||
throw new Error('Camera was closed before taking a photo')
|
||||
if (res.canceled) {
|
||||
return
|
||||
}
|
||||
|
||||
const asset = res?.assets[0]
|
||||
const asset = res.assets[0]
|
||||
|
||||
return {
|
||||
path: asset.uri,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {AbortError} from '#/lib/async/cancelable'
|
||||
import {type CompressedVideo} from '#/lib/media/video/types'
|
||||
import {shouldRetryError} from '#/lib/strings/errors'
|
||||
import {getServiceAuthToken} from '../upload.shared'
|
||||
import {mimeToExt} from '../util'
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {getMissingParts, planParts} from './planParts'
|
||||
import {createChunkReader} from './readChunk'
|
||||
import {createUploadPart} from './uploadPart'
|
||||
import {uploadParts} from './uploadParts'
|
||||
import {delay, isRetryableMultipartError} from './utils'
|
||||
|
||||
export class MultipartFallbackError extends Error {}
|
||||
|
||||
@@ -35,7 +37,7 @@ export async function uploadVideoMultipart({
|
||||
onStarted?: () => void
|
||||
}): Promise<AppBskyVideoDefs.JobStatus> {
|
||||
throwIfAborted(signal)
|
||||
const tokenProvider = createTokenProvider(agent)
|
||||
const tokenProvider = createTokenProvider(agent, signal)
|
||||
const token = await tokenProvider.get()
|
||||
const name = `${nanoid(12)}.${mimeToExt(video.mimeType)}`
|
||||
let session
|
||||
@@ -60,6 +62,7 @@ export async function uploadVideoMultipart({
|
||||
}
|
||||
signal.addEventListener('abort', abortOnCancel, {once: true})
|
||||
let reader: ReturnType<typeof createChunkReader> | undefined
|
||||
// Kept outside the upload try block for finish-time missing-part recovery.
|
||||
let parts: ReturnType<typeof planParts> = []
|
||||
try {
|
||||
try {
|
||||
@@ -85,9 +88,7 @@ export async function uploadVideoMultipart({
|
||||
)
|
||||
}
|
||||
|
||||
// Finish stores this credential for the later PDS blob upload, so use a
|
||||
// fresh token rather than the one that may have aged during transfer.
|
||||
await tokenProvider.get(true)
|
||||
// Preserve TypeScript's narrowing inside the recovery callback.
|
||||
const activeReader = reader
|
||||
if (!activeReader) throw new Error('Video chunk reader is unavailable')
|
||||
return await finishAndRecover({
|
||||
@@ -126,14 +127,18 @@ async function finishAndRecover({
|
||||
resendMissingParts,
|
||||
}: {
|
||||
jobId: string
|
||||
getToken: () => Promise<string>
|
||||
getToken: (forceRefresh?: boolean) => Promise<string>
|
||||
signal: AbortSignal
|
||||
resendMissingParts: (receivedPartNumbers: number[]) => Promise<boolean>
|
||||
}): Promise<AppBskyVideoDefs.JobStatus> {
|
||||
let createdFailures = 0
|
||||
let forceTokenRefresh = true
|
||||
while (true) {
|
||||
throwIfAborted(signal)
|
||||
const token = await getToken()
|
||||
// Finish stores this credential for the later PDS blob upload. Refresh it
|
||||
// once after part transfer, then reuse it while polling/recovering.
|
||||
const token = await getToken(forceTokenRefresh)
|
||||
forceTokenRefresh = false
|
||||
try {
|
||||
const result = await finishUpload(jobId, token, signal)
|
||||
return result.jobStatus
|
||||
@@ -162,8 +167,8 @@ async function finishAndRecover({
|
||||
}
|
||||
return await abortThenFallbackOrResolve(jobId, token, finishError)
|
||||
case 'finishing':
|
||||
// Finalization owns the reservation and may already have assembled
|
||||
// the object. Retrying is idempotent; legacy fallback is unsafe.
|
||||
// The service may have assembled the upload even though the finish
|
||||
// request failed. Poll and retry instead of starting a second upload.
|
||||
await delay(1000, signal)
|
||||
continue
|
||||
case 'failed':
|
||||
@@ -177,6 +182,16 @@ async function finishAndRecover({
|
||||
`Multipart upload ${status.state}`,
|
||||
status.state === 'aborted' ? 'UploadAborted' : 'UploadExpired',
|
||||
)
|
||||
case 'completed':
|
||||
throw new MultipartUploadError(
|
||||
'Multipart upload completed without a job status',
|
||||
'InvalidUploadStatus',
|
||||
)
|
||||
default:
|
||||
throw new MultipartUploadError(
|
||||
'Multipart upload returned an unknown status',
|
||||
'InvalidUploadStatus',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,7 +208,7 @@ async function getUploadStatusWithRetry(
|
||||
return await getUploadStatus(jobId, token, signal)
|
||||
} catch (err) {
|
||||
throwIfAborted(signal)
|
||||
if (!isRetryableStatusError(err)) throw err
|
||||
if (!isRetryableMultipartError(err)) throw err
|
||||
lastError = err
|
||||
if (attempt < 3) await delay(500 * 2 ** (attempt - 1), signal)
|
||||
}
|
||||
@@ -201,16 +216,6 @@ async function getUploadStatusWithRetry(
|
||||
throw lastError
|
||||
}
|
||||
|
||||
function isRetryableStatusError(err: unknown) {
|
||||
return (
|
||||
err instanceof TypeError ||
|
||||
(err instanceof MultipartUploadError &&
|
||||
(err.error === 'ServiceOverloaded' ||
|
||||
err.status === undefined ||
|
||||
err.status >= 500))
|
||||
)
|
||||
}
|
||||
|
||||
async function abortThenFallbackOrResolve(
|
||||
jobId: string,
|
||||
token: string,
|
||||
@@ -233,7 +238,7 @@ async function abortThenFallbackOrResolve(
|
||||
)
|
||||
}
|
||||
|
||||
function createTokenProvider(agent: AtpAgent) {
|
||||
function createTokenProvider(agent: AtpAgent, signal: AbortSignal) {
|
||||
let token: string | undefined
|
||||
let expiresAt = 0
|
||||
let refresh: Promise<string> | undefined
|
||||
@@ -242,11 +247,7 @@ function createTokenProvider(agent: AtpAgent) {
|
||||
if (!forceRefresh && token && Date.now() < expiresAt - 60_000) return token
|
||||
if (!refresh) {
|
||||
const exp = Math.floor(Date.now() / 1000) + 60 * 30
|
||||
refresh = getServiceAuthToken({
|
||||
agent,
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
exp,
|
||||
})
|
||||
refresh = getServiceAuthTokenWithRetry(agent, exp, signal)
|
||||
.then(nextToken => {
|
||||
token = nextToken
|
||||
expiresAt = exp * 1000
|
||||
@@ -262,20 +263,30 @@ function createTokenProvider(agent: AtpAgent) {
|
||||
return {get}
|
||||
}
|
||||
|
||||
async function getServiceAuthTokenWithRetry(
|
||||
agent: AtpAgent,
|
||||
exp: number,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
let lastError: unknown
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
throwIfAborted(signal)
|
||||
try {
|
||||
return await getServiceAuthToken({
|
||||
agent,
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
exp,
|
||||
})
|
||||
} catch (err) {
|
||||
throwIfAborted(signal)
|
||||
if (!(err instanceof TypeError) && !shouldRetryError(err)) throw err
|
||||
lastError = err
|
||||
if (attempt < 3) await delay(500 * 2 ** (attempt - 1), signal)
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal) {
|
||||
if (signal.aborted) throw new AbortError()
|
||||
}
|
||||
|
||||
function delay(ms: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
function onAbort() {
|
||||
clearTimeout(timer)
|
||||
reject(new AbortError())
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, {once: true})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {AbortError} from '#/lib/async/cancelable'
|
||||
import {MultipartUploadError} from './api'
|
||||
import {type ChunkReader, type UploadPartFn} from './types'
|
||||
import {uploadParts} from './uploadParts'
|
||||
|
||||
@@ -65,7 +67,7 @@ describe('uploadParts', () => {
|
||||
const n = (attemptsByPart.get(part.partNumber) ?? 0) + 1
|
||||
attemptsByPart.set(part.partNumber, n)
|
||||
if (part.partNumber === 2 && n === 1) {
|
||||
return Promise.reject(new Error('transient'))
|
||||
return Promise.reject(new TypeError('transient network error'))
|
||||
}
|
||||
return Promise.resolve({
|
||||
partNumber: part.partNumber,
|
||||
@@ -86,9 +88,59 @@ describe('uploadParts', () => {
|
||||
expect(results).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('retries rate-limited parts', async () => {
|
||||
let attempts = 0
|
||||
const uploadPart: UploadPartFn = ({part}) => {
|
||||
attempts++
|
||||
if (attempts === 1) {
|
||||
return Promise.reject(
|
||||
new MultipartUploadError('rate limited', 'RateLimitExceeded', 429),
|
||||
)
|
||||
}
|
||||
return Promise.resolve({
|
||||
partNumber: part.partNumber,
|
||||
sizeBytes: part.size,
|
||||
})
|
||||
}
|
||||
|
||||
await uploadParts({
|
||||
parts: parts.slice(0, 1),
|
||||
reader: fakeReader(),
|
||||
uploadPart,
|
||||
totalBytes: 10,
|
||||
setProgress: () => {},
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
|
||||
expect(attempts).toBe(2)
|
||||
})
|
||||
|
||||
it('does not retry a non-retryable response', async () => {
|
||||
const uploadPart = jest.fn<
|
||||
ReturnType<UploadPartFn>,
|
||||
Parameters<UploadPartFn>
|
||||
>(() =>
|
||||
Promise.reject(
|
||||
new MultipartUploadError('bad request', 'InvalidRequest', 400),
|
||||
),
|
||||
)
|
||||
|
||||
await expect(
|
||||
uploadParts({
|
||||
parts: parts.slice(0, 1),
|
||||
reader: fakeReader(),
|
||||
uploadPart,
|
||||
totalBytes: 10,
|
||||
setProgress: () => {},
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).rejects.toThrow('bad request')
|
||||
expect(uploadPart).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws after exhausting attempts', async () => {
|
||||
const uploadPart: UploadPartFn = () =>
|
||||
Promise.reject(new Error('always fails'))
|
||||
Promise.reject(new TypeError('always fails'))
|
||||
|
||||
await expect(
|
||||
uploadParts({
|
||||
@@ -103,6 +155,32 @@ describe('uploadParts', () => {
|
||||
).rejects.toThrow('always fails')
|
||||
})
|
||||
|
||||
it('preserves the originating error when sibling workers abort', async () => {
|
||||
const uploadPart: UploadPartFn = ({part, signal}) => {
|
||||
if (part.partNumber === 1) {
|
||||
return new Promise((_resolve, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new AbortError()), {
|
||||
once: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
return Promise.reject(new Error('part upload failed'))
|
||||
}
|
||||
|
||||
await expect(
|
||||
uploadParts({
|
||||
parts: parts.slice(0, 2),
|
||||
reader: fakeReader(),
|
||||
uploadPart,
|
||||
totalBytes: 20,
|
||||
setProgress: () => {},
|
||||
signal: new AbortController().signal,
|
||||
concurrency: 2,
|
||||
maxAttempts: 1,
|
||||
}),
|
||||
).rejects.toThrow('part upload failed')
|
||||
})
|
||||
|
||||
it('reports progress that reaches 1 when all parts complete', async () => {
|
||||
const progress: number[] = []
|
||||
const uploadPart: UploadPartFn = ({part, chunk, onProgress}) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type PartUploadResult,
|
||||
type UploadPartFn,
|
||||
} from './types'
|
||||
import {delay, isRetryableMultipartError} from './utils'
|
||||
|
||||
/**
|
||||
* Uploads every part with a concurrency cap and per-part retry, aggregating
|
||||
@@ -78,10 +79,15 @@ export async function uploadParts({
|
||||
}),
|
||||
)
|
||||
signal.removeEventListener('abort', abortWorkers)
|
||||
const failure = settled.find(
|
||||
const failures = settled.filter(
|
||||
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||
)
|
||||
if (signal.aborted) throw new AbortError()
|
||||
// A sibling worker aborted after the first failure can settle earlier in
|
||||
// array order. Preserve the originating error for fallback and telemetry.
|
||||
const failure =
|
||||
failures.find(result => !(result.reason instanceof AbortError)) ??
|
||||
failures[0]
|
||||
if (failure) throw failure.reason
|
||||
return results
|
||||
}
|
||||
@@ -113,6 +119,7 @@ async function uploadPartWithRetry({
|
||||
throw new AbortError()
|
||||
}
|
||||
lastError = err
|
||||
if (!isRetryableMultipartError(err)) throw err
|
||||
if (attempt < maxAttempts) {
|
||||
await delay(500 * 2 ** (attempt - 1), signal)
|
||||
}
|
||||
@@ -120,17 +127,3 @@ async function uploadPartWithRetry({
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
function delay(ms: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
function onAbort() {
|
||||
clearTimeout(timer)
|
||||
reject(new AbortError())
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, {once: true})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import {AbortError} from '#/lib/async/cancelable'
|
||||
import {isRetryableHttpStatus} from '#/lib/strings/errors'
|
||||
import {MultipartUploadError} from './api'
|
||||
|
||||
export function isRetryableMultipartError(err: unknown) {
|
||||
return (
|
||||
err instanceof TypeError ||
|
||||
(err instanceof MultipartUploadError &&
|
||||
(err.error === 'ServiceOverloaded' ||
|
||||
err.status === undefined ||
|
||||
isRetryableHttpStatus(err.status)))
|
||||
)
|
||||
}
|
||||
|
||||
export function delay(ms: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
function onAbort() {
|
||||
clearTimeout(timer)
|
||||
reject(new AbortError())
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, {once: true})
|
||||
})
|
||||
}
|
||||
@@ -88,6 +88,10 @@ export function isCancelledError(e: unknown) {
|
||||
|
||||
// TODO Replace this with error.shouldRetry() when available. -dsb
|
||||
const RETRYABLE_ERRORS = [408, 425, 429, 500, 502, 503, 504, 522, 524]
|
||||
export function shouldRetryError(e: unknown) {
|
||||
return e instanceof XRPCError && RETRYABLE_ERRORS.includes(e.status)
|
||||
export function isRetryableHttpStatus(status: number) {
|
||||
return RETRYABLE_ERRORS.includes(status)
|
||||
}
|
||||
|
||||
export function shouldRetryError(e: unknown) {
|
||||
return e instanceof XRPCError && isRetryableHttpStatus(e.status)
|
||||
}
|
||||
|
||||
+3
-1
@@ -69,7 +69,9 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
import('date-fns/locale/es').then(m => m.es),
|
||||
import('@formatjs/intl-pluralrules/locale-data/an.js'),
|
||||
import('@formatjs/intl-numberformat/locale-data/an.js'),
|
||||
import('@formatjs/intl-displaynames/locale-data/an.js'),
|
||||
// Aragonese locale data is missing
|
||||
// see: https://github.com/bluesky-social/social-app/pull/11327
|
||||
import('@formatjs/intl-displaynames/locale-data/es.js'),
|
||||
])
|
||||
return dateLocale
|
||||
}
|
||||
|
||||
+922
-896
File diff suppressed because it is too large
Load Diff
+933
-907
File diff suppressed because it is too large
Load Diff
+922
-896
File diff suppressed because it is too large
Load Diff
+933
-907
File diff suppressed because it is too large
Load Diff
+933
-907
File diff suppressed because it is too large
Load Diff
+933
-907
File diff suppressed because it is too large
Load Diff
+986
-960
File diff suppressed because it is too large
Load Diff
+938
-912
File diff suppressed because it is too large
Load Diff
+1022
-996
File diff suppressed because it is too large
Load Diff
+1005
-979
File diff suppressed because it is too large
Load Diff
+946
-920
File diff suppressed because it is too large
Load Diff
+921
-895
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+648
-560
File diff suppressed because one or more lines are too long
+932
-906
File diff suppressed because it is too large
Load Diff
+923
-897
File diff suppressed because it is too large
Load Diff
+939
-913
File diff suppressed because it is too large
Load Diff
+921
-895
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+922
-896
File diff suppressed because it is too large
Load Diff
+922
-896
File diff suppressed because it is too large
Load Diff
+921
-895
File diff suppressed because it is too large
Load Diff
+923
-897
File diff suppressed because it is too large
Load Diff
+922
-896
File diff suppressed because it is too large
Load Diff
+933
-907
File diff suppressed because it is too large
Load Diff
+921
-895
File diff suppressed because it is too large
Load Diff
+959
-933
File diff suppressed because it is too large
Load Diff
+922
-896
File diff suppressed because it is too large
Load Diff
+922
-896
File diff suppressed because it is too large
Load Diff
+927
-901
File diff suppressed because it is too large
Load Diff
+922
-896
File diff suppressed because it is too large
Load Diff
+933
-907
File diff suppressed because it is too large
Load Diff
+921
-895
File diff suppressed because it is too large
Load Diff
+925
-899
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user