Merge main into app-2670-toolbox-video-compressor

This commit is contained in:
vineyardbovines
2026-08-04 09:46:01 -04:00
265 changed files with 98501 additions and 48567 deletions
+22 -43
View File
@@ -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>
)}
</>
+18 -1
View File
@@ -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
+3 -2
View File
@@ -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,
+31
View File
@@ -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',
})
})
})
+6
View File
@@ -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,
})
}
+1 -1
View File
@@ -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'
+3 -3
View File
@@ -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({
+5
View File
@@ -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',
}
+4 -1
View File
@@ -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,
},
}
+17
View File
@@ -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'
+5 -7
View File
@@ -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])
+5 -21
View File
@@ -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)
}
}
+3
View File
@@ -165,6 +165,9 @@ export function Outer({
)
}
/**
* @deprecated use `Dialog.ScrollableInner` instead
*/
export function Inner({
children,
style,
+20 -19
View File
@@ -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,
+1 -1
View File
@@ -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>
}
+37 -32
View File
@@ -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" />
+2
View File
@@ -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
+39 -36
View File
@@ -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={[
+53 -55
View File
@@ -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 -2
View File
@@ -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}}
+12 -14
View File
@@ -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,
})
})
+6 -10
View File
@@ -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)
}
+1 -2
View File
@@ -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'
+65
View File
@@ -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>
)
}
+1
View File
@@ -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
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -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,
{
+7 -9
View File
@@ -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!`}
/>
</>
)}
+2 -2
View File
@@ -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}
+6 -10
View File
@@ -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,
+4 -3
View File
@@ -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}
+38 -9
View File
@@ -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 -1
View File
@@ -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'
+23 -20
View File
@@ -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,
+8 -10
View File
@@ -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,
{
+2 -1
View File
@@ -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()
+2 -2
View File
@@ -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])
+3 -3
View File
@@ -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 -6
View File
@@ -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>
+35 -29
View File
@@ -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>
+27 -28
View File
@@ -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,
},
}
}
+180 -22
View File
@@ -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>()
+14 -14
View File
@@ -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}))
+2
View File
@@ -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,
}}
/>
</>
+10 -2
View File
@@ -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: {
+354
View File
@@ -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
View File
@@ -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
}
+8 -2
View File
@@ -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,
}
}
+3 -3
View File
@@ -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,
+50 -39
View File
@@ -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}) => {
+8 -15
View File
@@ -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})
})
}
+27
View File
@@ -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})
})
}
+6 -2
View File
@@ -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
View File
@@ -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
}
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
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
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
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
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
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
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
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
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
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
File diff suppressed because it is too large Load Diff
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