Merge remote-tracking branch 'origin/main' into app-2670

This commit is contained in:
vineyardbovines
2026-07-28 12:54:32 -04:00
229 changed files with 95958 additions and 47836 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'
+1 -1
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'
+1
View File
@@ -21,6 +21,7 @@ export enum Features {
CustomLogoJapanEnable = 'custom_logo:japan:enable',
VideoMultipartUploadEnable = 'video:multipart_upload:enable',
SearchStarterPacksV2Enable = 'search_starter_packs_v2:enable',
FollowSortEnable = 'follow_sort:enable',
AATest = 'aa-test',
}
+13
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,6 +753,10 @@ export type Events = {
'trendingTopics:hide': {
context: 'settings' | 'sidebar' | 'interstitial' | 'explore:trending'
}
'trendingTopic:seen': {
context: 'sidebar' | 'interstitial' | 'explore'
recId?: string
}
'trendingTopic:click': {
context: 'sidebar' | 'interstitial' | 'explore'
recId?: string
+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}
+29 -9
View File
@@ -1,22 +1,28 @@
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,
recId,
children,
...rest
}: {
topic: AppBskyUnspeccedDefs.TrendView
metricContext: Metrics['trendingTopic:seen']['context']
recId?: string
} & Omit<LinkProps, 'to' | 'label'>) {
const topic = useTopic(raw)
useTrendingTopicSeen(metricContext, recId)
return (
<InternalLink
@@ -29,6 +35,20 @@ export function TrendingTopicLink({
)
}
export function useTrendingTopicSeen(
context: Metrics['trendingTopic:seen']['context'],
recId?: string,
) {
const ax = useAnalytics()
const trackSeen = useCallOnce(() => {
ax.metric('trendingTopic:seen', {context, recId})
})
useEffect(() => {
trackSeen()
}, [trackSeen])
}
type ParsedTrendingTopic =
| {
type: 'topic' | 'tag' | 'starter-pack' | 'unknown'
@@ -48,14 +68,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 +83,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 +92,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 +129,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(() => {
+2 -5
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
@@ -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,11 +19,14 @@ 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'
@@ -41,6 +45,8 @@ function Inner() {
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 +55,123 @@ 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) => (
<TrendRow
key={trend.link}
trend={trend}
rank={index + 1}
recId={trending.recId}
onPress={() => {
ax.metric('trendingTopic:click', {
context: 'interstitial',
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,
recId,
onPress,
}: ViewStyleProp & {
trend: AppBskyUnspeccedDefs.TrendView
rank: number
recId?: string
children?: React.ReactNode
onPress?: () => void
}) {
@@ -163,6 +179,8 @@ function TrendRow({
const {t: l, i18n} = useLingui()
const actors = useModerateTrendingActors(trend.actors)
const formattedPostCount = formatCount(i18n, trend.postCount)
useTrendingTopicSeen('interstitial', recId)
return (
<Link
@@ -170,12 +188,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}) => (
<>
@@ -215,14 +228,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>
+8 -7
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()
@@ -103,6 +102,8 @@ export function Inner() {
<TrendingTopicLink
key={topic.link}
topic={topic}
metricContext="interstitial"
recId={trending.recId}
onPress={() => {
ax.metric('trendingTopic:click', {
context: 'interstitial',
@@ -122,7 +123,7 @@ export function Inner() {
</TrendingTopicLink>
))}
<Button
label={_(msg`Hide trending topics`)}
label={l`Hide trending topics`}
size="tiny"
variant="ghost"
color="secondary"
@@ -138,9 +139,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>
@@ -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,
}}
/>
</>
+48 -15
View File
@@ -12,10 +12,22 @@ import {
import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {IS_ANDROID, IS_IOS, IS_TESTFLIGHT} from '#/env'
import {IS_IOS, IS_TESTFLIGHT} from '#/env'
const MINIMUM_MINIMIZE_TIME = 15 * 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']
async function setExtraParams() {
await setExtraParamAsync(
IS_IOS ? 'ios-build-number' : 'android-build-number',
@@ -23,10 +35,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) {
@@ -71,6 +80,14 @@ export function useApplyPullRequestOTAUpdate() {
const currentChannel = currentlyRunning?.channel
const isCurrentlyRunningPullRequestDeployment =
currentChannel?.startsWith('pull-request')
/*
* Covers pull request deployments as well as any other channel we manually
* applied an update from. Note that `channel` is null 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) => {
setPending(true)
@@ -104,19 +121,42 @@ export function useApplyPullRequestOTAUpdate() {
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,
}
}
@@ -192,13 +232,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 => {
+3 -1
View File
@@ -2,9 +2,11 @@ export function useOTAUpdates() {}
export function useApplyPullRequestOTAUpdate() {
return {
tryApplyUpdate: async (_channel: string) => {},
revertToEmbedded: () => {},
restoreDefaultChannel: async () => {},
isCurrentlyRunningPullRequestDeployment: false,
isCurrentlyRunningNonStandardChannel: false,
currentChannel: 'web-build',
defaultChannel: 'web-build',
pending: false,
}
}
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
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