diff --git a/app.config.js b/app.config.js index 14c844027e..38f0cf21d9 100644 --- a/app.config.js +++ b/app.config.js @@ -95,28 +95,34 @@ module.exports = function (config) { CFBundleSpokenName: 'Blue Sky', CFBundleLocalizations: [ 'en', + 'an', + 'ast', 'ca', 'de', 'es', 'fi', 'fr', 'ga', + 'gl', 'hi', 'hu', 'id', 'it', 'ja', 'ko', + 'nl', 'pl', 'pt', + 'pt-BR', 'ro', 'ru', 'th', 'tr', 'uk', - 'zh_CN', - 'zh_HK', - 'zh_TW', + 'vi', + 'yue-Hant', + 'zh-Hans', + 'zh-Hant', ], }, associatedDomains: ASSOCIATED_DOMAINS, diff --git a/lingui.config.js b/lingui.config.js index f3c49ffa3a..9ed4264942 100644 --- a/lingui.config.js +++ b/lingui.config.js @@ -2,8 +2,8 @@ module.exports = { locales: [ 'en', - 'ast', 'an', + 'ast', 'ca', 'de', 'en-GB', diff --git a/package.json b/package.json index ddb90c4f65..6372dd5f5b 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,6 @@ "@radix-ui/react-focus-guards": "^1.1.1", "@radix-ui/react-focus-scope": "^1.1.0", "@react-native-async-storage/async-storage": "1.23.1", - "@react-native-masked-view/masked-view": "0.3.0", "@react-native-menu/menu": "^1.1.0", "@react-native-picker/picker": "2.6.1", "@react-navigation/bottom-tabs": "^6.5.20", @@ -170,7 +169,7 @@ "react-keyed-flatten-children": "^3.0.0", "react-native": "0.74.1", "react-native-compressor": "^1.8.24", - "react-native-date-picker": "^4.4.2", + "react-native-date-picker": "^5.0.7", "react-native-drawer-layout": "^4.0.1", "react-native-gesture-handler": "2.20.0", "react-native-get-random-values": "~1.11.0", diff --git a/src/Splash.tsx b/src/Splash.tsx index a52b8837d1..c31723aaa1 100644 --- a/src/Splash.tsx +++ b/src/Splash.tsx @@ -18,9 +18,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context' import Svg, {Path, SvgProps} from 'react-native-svg' import {Image} from 'expo-image' import * as SplashScreen from 'expo-splash-screen' -import MaskedView from '@react-native-masked-view/masked-view' -import {isAndroid} from '#/platform/detection' import {Logotype} from '#/view/icons/Logotype' // @ts-ignore import splashImagePointer from '../assets/splash.png' @@ -53,8 +51,6 @@ type Props = { isReady: boolean } -const AnimatedLogo = Animated.createAnimatedComponent(Logo) - export function Splash(props: React.PropsWithChildren) { 'use no memo' const insets = useSafeAreaInsets() @@ -152,8 +148,6 @@ export function Splash(props: React.PropsWithChildren) { {duration: 400, easing: Easing.out(Easing.cubic)}, async () => { // set these values to check animation at specific point - // outroLogo.set(0.1) - // outroApp.set(0.1) outroLogo.set(() => withTiming( 1, @@ -221,66 +215,31 @@ export function Splash(props: React.PropsWithChildren) { )} - {isReady && - (isAndroid || reduceMotion === true ? ( - // Use a simple fade on older versions of android (work around a bug) - <> - - {props.children} - + {isReady && ( + <> + + {props.children} + - {!isAnimationComplete && ( - - + {!isAnimationComplete && ( + + + - )} - - ) : ( - - - - }> - {!isAnimationComplete && ( - - )} - - {props.children} - - ))} + )} + + )} ) } diff --git a/src/components/forms/DateField/index.android.tsx b/src/components/forms/DateField/index.android.tsx index 1830ca4bfd..0fd5bca012 100644 --- a/src/components/forms/DateField/index.android.tsx +++ b/src/components/forms/DateField/index.android.tsx @@ -50,11 +50,14 @@ export function DateField({ /> {open && ( + // Android implementation of DatePicker currently does not change default button colors according to theme and only takes hex values for buttonColor + // Can remove the buttonColor setting if/when this PR is merged: https://github.com/henninghall/react-native-date-picker/pull/871 = { en: undefined, - ast: undefined, an: undefined, + ast: undefined, ca, de, ['en-GB']: enGB, diff --git a/src/components/icons/VideoClip.tsx b/src/components/icons/VideoClip.tsx index c2c13c4913..0a541a418f 100644 --- a/src/components/icons/VideoClip.tsx +++ b/src/components/icons/VideoClip.tsx @@ -1,5 +1,5 @@ import {createSinglePathSVG} from './TEMPLATE' export const VideoClip_Stroke2_Corner0_Rounded = createSinglePathSVG({ - path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2.444h2V13Zm0 4.444h-2V19h2v-1.556ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z', + path: 'M3 4a1 1 0 011-1h16a1 1 0 011 1v16a1 1 0 01-1 1H4a1 1 0 01-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2h2V13Zm0 4h-2V19h2ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z', }) diff --git a/src/lib/hooks/useOpenLink.ts b/src/lib/hooks/useOpenLink.ts index 5b75695b8e..0629656ac6 100644 --- a/src/lib/hooks/useOpenLink.ts +++ b/src/lib/hooks/useOpenLink.ts @@ -2,10 +2,13 @@ import {useCallback} from 'react' import {Linking} from 'react-native' import * as WebBrowser from 'expo-web-browser' +import {logEvent} from '#/lib/statsig/statsig' import { createBskyAppAbsoluteUrl, + isBskyAppUrl, isBskyRSSUrl, isRelativeUrl, + toNiceDomain, } from '#/lib/strings/url-helpers' import {isNative} from '#/platform/detection' import {useModalControls} from '#/state/modals' @@ -25,6 +28,13 @@ export function useOpenLink() { url = createBskyAppAbsoluteUrl(url) } + if (!isBskyAppUrl(url)) { + logEvent('link:clicked', { + domain: toNiceDomain(url), + url, + }) + } + if (isNative && !url.startsWith('mailto:')) { if (override === undefined && enabled === undefined) { openModal({ diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index f8c6d181c4..e6c9c5d135 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -80,12 +80,6 @@ export type LogEvents = { feedUrl: string feedType: string index: number - reason: - | 'focus' - | 'tabbar-click' - | 'pager-swipe' - | 'desktop-sidebar-click' - | 'starter-pack-initial-feed' } 'feed:endReached': { feedUrl: string @@ -217,6 +211,10 @@ export type LogEvents = { 'starterPack:opened': { starterPack: string } + 'link:clicked': { + url: string + domain: string + } 'feed:interstitial:profileCard:press': {} 'feed:interstitial:feedCard:press': {} diff --git a/src/locale/languages.ts b/src/locale/languages.ts index a345aeb6d3..ad20b16eb1 100644 --- a/src/locale/languages.ts +++ b/src/locale/languages.ts @@ -44,7 +44,7 @@ interface AppLanguageConfig { export const APP_LANGUAGES: AppLanguageConfig[] = [ {code2: AppLanguage.en, name: 'English'}, {code2: AppLanguage.an, name: 'Aragonés – Aragonese'}, - {code2: AppLanguage.ast, name: 'Asturianu - Asturian'}, + {code2: AppLanguage.ast, name: 'Asturianu – Asturian'}, {code2: AppLanguage.ca, name: 'Català – Catalan'}, {code2: AppLanguage.de, name: 'Deutsch – German'}, {code2: AppLanguage.en_GB, name: 'English (UK)'}, @@ -52,7 +52,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [ {code2: AppLanguage.fi, name: 'Suomi – Finnish'}, {code2: AppLanguage.fr, name: 'Français – French'}, {code2: AppLanguage.ga, name: 'Gaeilge – Irish'}, - {code2: AppLanguage.gl, name: 'Galego - Galician'}, + {code2: AppLanguage.gl, name: 'Galego – Galician'}, {code2: AppLanguage.hi, name: 'हिंदी – Hindi'}, {code2: AppLanguage.hu, name: 'magyar – Hungarian'}, {code2: AppLanguage.id, name: 'Bahasa Indonesia – Indonesian'}, diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 1028d7e641..44e90a5519 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -32,6 +32,7 @@ const POLL_FREQ = 60e3 // 60sec export function FeedPage({ testID, isPageFocused, + isPageAdjacent, feed, feedParams, renderEmptyState, @@ -42,6 +43,7 @@ export function FeedPage({ feed: FeedDescriptor feedParams?: FeedParams isPageFocused: boolean + isPageAdjacent: boolean renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element savedFeedConfig?: AppBskyActorDefs.SavedFeed @@ -111,11 +113,11 @@ export function FeedPage({ () - const pal = usePalette('default') const hasPinnedCustom = React.useMemo(() => { if (!hasSession) return false @@ -61,7 +59,8 @@ export function HomeHeader( onSelect={onSelect} testID={props.testID} items={items} - indicatorColor={pal.colors.link} + dragProgress={props.dragProgress} + dragState={props.dragState} /> ) diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index de04099917..da7fd1e936 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -1,27 +1,33 @@ import React, {forwardRef} from 'react' import {View} from 'react-native' import PagerView, { - PagerViewOnPageScrollEvent, + PagerViewOnPageScrollEventData, PagerViewOnPageSelectedEvent, - PageScrollStateChangedNativeEvent, + PagerViewOnPageSelectedEventData, + PageScrollStateChangedNativeEventData, } from 'react-native-pager-view' +import Animated, { + runOnJS, + SharedValue, + useEvent, + useHandler, + useSharedValue, +} from 'react-native-reanimated' -import {LogEvents} from '#/lib/statsig/events' import {atoms as a, native} from '#/alf' export type PageSelectedEvent = PagerViewOnPageSelectedEvent export interface PagerRef { - setPage: ( - index: number, - reason: LogEvents['home:feedDisplayed']['reason'], - ) => void + setPage: (index: number) => void } export interface RenderTabBarFnProps { selectedPage: number onSelect?: (index: number) => void tabBarAnchor?: JSX.Element | null | undefined // Ignored on native. + dragProgress: SharedValue // Ignored on web. + dragState: SharedValue<'idle' | 'dragging' | 'settling'> // Ignored on web. } export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element @@ -29,106 +35,82 @@ interface Props { initialPage?: number renderTabBar: RenderTabBarFn onPageSelected?: (index: number) => void - onPageSelecting?: ( - index: number, - reason: LogEvents['home:feedDisplayed']['reason'], - ) => void onPageScrollStateChanged?: ( scrollState: 'idle' | 'dragging' | 'settling', ) => void testID?: string } + +const AnimatedPagerView = Animated.createAnimatedComponent(PagerView) + export const Pager = forwardRef>( function PagerImpl( { children, initialPage = 0, renderTabBar, - onPageScrollStateChanged, - onPageSelected, - onPageSelecting, + onPageScrollStateChanged: parentOnPageScrollStateChanged, + onPageSelected: parentOnPageSelected, testID, }: React.PropsWithChildren, ref, ) { - const [selectedPage, setSelectedPage] = React.useState(0) - const lastOffset = React.useRef(0) - const lastDirection = React.useRef(0) - const scrollState = React.useRef('') + const [selectedPage, setSelectedPage] = React.useState(initialPage) const pagerView = React.useRef(null) React.useImperativeHandle(ref, () => ({ - setPage: ( - index: number, - reason: LogEvents['home:feedDisplayed']['reason'], - ) => { + setPage: (index: number) => { pagerView.current?.setPage(index) - onPageSelecting?.(index, reason) }, })) - const onPageSelectedInner = React.useCallback( - (e: PageSelectedEvent) => { - setSelectedPage(e.nativeEvent.position) - onPageSelected?.(e.nativeEvent.position) - }, - [setSelectedPage, onPageSelected], - ) - - const onPageScroll = React.useCallback( - (e: PagerViewOnPageScrollEvent) => { - const {position, offset} = e.nativeEvent - if (offset === 0) { - // offset hits 0 in some awkward spots so we ignore it - return - } - // NOTE - // we want to call `onPageSelecting` as soon as the scroll-gesture - // enters the "settling" phase, which means the user has released it - // we can't infer directionality from the scroll information, so we - // track the offset changes. if the offset delta is consistent with - // the existing direction during the settling phase, we can say for - // certain where it's going and can fire - // -prf - if (scrollState.current === 'settling') { - if (lastDirection.current === -1 && offset < lastOffset.current) { - onPageSelecting?.(position, 'pager-swipe') - setSelectedPage(position) - lastDirection.current = 0 - } else if ( - lastDirection.current === 1 && - offset > lastOffset.current - ) { - onPageSelecting?.(position + 1, 'pager-swipe') - setSelectedPage(position + 1) - lastDirection.current = 0 - } - } else { - if (offset < lastOffset.current) { - lastDirection.current = -1 - } else if (offset > lastOffset.current) { - lastDirection.current = 1 - } - } - lastOffset.current = offset - }, - [lastOffset, lastDirection, onPageSelecting], - ) - - const handlePageScrollStateChanged = React.useCallback( - (e: PageScrollStateChangedNativeEvent) => { - scrollState.current = e.nativeEvent.pageScrollState - onPageScrollStateChanged?.(e.nativeEvent.pageScrollState) + const onPageSelectedJSThread = React.useCallback( + (nextPosition: number) => { + setSelectedPage(nextPosition) + parentOnPageSelected?.(nextPosition) }, - [scrollState, onPageScrollStateChanged], + [setSelectedPage, parentOnPageSelected], ) const onTabBarSelect = React.useCallback( (index: number) => { pagerView.current?.setPage(index) - onPageSelecting?.(index, 'tabbar-click') }, - [pagerView, onPageSelecting], + [pagerView], + ) + + const dragState = useSharedValue<'idle' | 'settling' | 'dragging'>('idle') + const dragProgress = useSharedValue(selectedPage) + const didInit = useSharedValue(false) + const handlePageScroll = usePagerHandlers( + { + onPageScroll(e: PagerViewOnPageScrollEventData) { + 'worklet' + if (didInit.get() === false) { + // On iOS, there's a spurious scroll event with 0 position + // even if a different page was supplied as the initial page. + // Ignore it and wait for the first confirmed selection instead. + return + } + dragProgress.set(e.offset + e.position) + }, + onPageScrollStateChanged(e: PageScrollStateChangedNativeEventData) { + 'worklet' + if (dragState.get() === 'idle' && e.pageScrollState === 'settling') { + // This is a programmatic scroll on Android. + // Stay "idle" to match iOS and avoid confusing downstream code. + return + } + dragState.set(e.pageScrollState) + parentOnPageScrollStateChanged?.(e.pageScrollState) + }, + onPageSelected(e: PagerViewOnPageSelectedEventData) { + 'worklet' + didInit.set(true) + runOnJS(onPageSelectedJSThread)(e.position) + }, + }, + [parentOnPageScrollStateChanged], ) return ( @@ -136,17 +118,50 @@ export const Pager = forwardRef>( {renderTabBar({ selectedPage, onSelect: onTabBarSelect, + dragProgress, + dragState, })} - + onPageScroll={handlePageScroll}> {children} - + ) }, ) + +function usePagerHandlers( + handlers: { + onPageScroll: (e: PagerViewOnPageScrollEventData) => void + onPageScrollStateChanged: (e: PageScrollStateChangedNativeEventData) => void + onPageSelected: (e: PagerViewOnPageSelectedEventData) => void + }, + dependencies: unknown[], +) { + const {doDependenciesDiffer} = useHandler(handlers as any, dependencies) + const subscribeForEvents = [ + 'onPageScroll', + 'onPageScrollStateChanged', + 'onPageSelected', + ] + return useEvent( + event => { + 'worklet' + const {onPageScroll, onPageScrollStateChanged, onPageSelected} = handlers + if (event.eventName.endsWith('onPageScroll')) { + onPageScroll(event as any as PagerViewOnPageScrollEventData) + } else if (event.eventName.endsWith('onPageScrollStateChanged')) { + onPageScrollStateChanged( + event as any as PageScrollStateChangedNativeEventData, + ) + } else if (event.eventName.endsWith('onPageSelected')) { + onPageSelected(event as any as PagerViewOnPageSelectedEventData) + } + }, + subscribeForEvents, + doDependenciesDiffer, + ) +} diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx index e6909fe10f..c620e73e33 100644 --- a/src/view/com/pager/Pager.web.tsx +++ b/src/view/com/pager/Pager.web.tsx @@ -2,7 +2,6 @@ import React from 'react' import {View} from 'react-native' import {flushSync} from 'react-dom' -import {LogEvents} from '#/lib/statsig/events' import {s} from '#/lib/styles' export interface RenderTabBarFnProps { @@ -16,10 +15,6 @@ interface Props { initialPage?: number renderTabBar: RenderTabBarFn onPageSelected?: (index: number) => void - onPageSelecting?: ( - index: number, - reason: LogEvents['home:feedDisplayed']['reason'], - ) => void } export const Pager = React.forwardRef(function PagerImpl( { @@ -27,7 +22,6 @@ export const Pager = React.forwardRef(function PagerImpl( initialPage = 0, renderTabBar, onPageSelected, - onPageSelecting, }: React.PropsWithChildren, ref, ) { @@ -36,16 +30,13 @@ export const Pager = React.forwardRef(function PagerImpl( const anchorRef = React.useRef(null) React.useImperativeHandle(ref, () => ({ - setPage: ( - index: number, - reason: LogEvents['home:feedDisplayed']['reason'], - ) => { - onTabBarSelect(index, reason) + setPage: (index: number) => { + onTabBarSelect(index) }, })) const onTabBarSelect = React.useCallback( - (index: number, reason: LogEvents['home:feedDisplayed']['reason']) => { + (index: number) => { const scrollY = window.scrollY // We want to determine if the tabbar is already "sticking" at the top (in which // case we should preserve and restore scroll), or if it is somewhere below in the @@ -64,7 +55,6 @@ export const Pager = React.forwardRef(function PagerImpl( flushSync(() => { setSelectedPage(index) onPageSelected?.(index) - onPageSelecting?.(index, reason) }) if (isSticking) { const restoredScrollY = scrollYs.current[index] @@ -75,7 +65,7 @@ export const Pager = React.forwardRef(function PagerImpl( } } }, - [selectedPage, setSelectedPage, onPageSelected, onPageSelecting], + [selectedPage, setSelectedPage, onPageSelected], ) return ( @@ -83,7 +73,7 @@ export const Pager = React.forwardRef(function PagerImpl( {renderTabBar({ selectedPage, tabBarAnchor: , - onSelect: e => onTabBarSelect(e, 'tabbar-click'), + onSelect: e => onTabBarSelect(e), })} {React.Children.map(children, (child, i) => ( diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 92b98dc2e6..6174459647 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -97,6 +97,8 @@ export const PagerWithHeader = React.forwardRef( scrollY={scrollY} testID={testID} allowHeaderOverScroll={allowHeaderOverScroll} + dragProgress={props.dragProgress} + dragState={props.dragState} /> ) @@ -182,17 +184,12 @@ export const PagerWithHeader = React.forwardRef( [onPageSelected, setCurrentPage], ) - const onPageSelecting = React.useCallback((index: number) => { - setCurrentPage(index) - }, []) - return ( {toArray(children) .filter(Boolean) @@ -231,6 +228,8 @@ let PagerTabBar = ({ onCurrentPageSelected, onSelect, allowHeaderOverScroll, + dragProgress, + dragState, }: { currentPage: number headerOnlyHeight: number @@ -244,6 +243,8 @@ let PagerTabBar = ({ onCurrentPageSelected?: (index: number) => void onSelect?: (index: number) => void allowHeaderOverScroll?: boolean + dragProgress: SharedValue + dragState: SharedValue<'idle' | 'dragging' | 'settling'> }): React.ReactNode => { const headerTransform = useAnimatedStyle(() => { const translateY = Math.min(scrollY.get(), headerOnlyHeight) * -1 @@ -302,6 +303,8 @@ let PagerTabBar = ({ selectedPage={currentPage} onSelect={onSelect} onPressSelected={onCurrentPageSelected} + dragProgress={dragProgress} + dragState={dragState} /> diff --git a/src/view/com/pager/PagerWithHeader.web.tsx b/src/view/com/pager/PagerWithHeader.web.tsx index e72c1f3cc9..13c723f471 100644 --- a/src/view/com/pager/PagerWithHeader.web.tsx +++ b/src/view/com/pager/PagerWithHeader.web.tsx @@ -75,17 +75,12 @@ export const PagerWithHeader = React.forwardRef( [onPageSelected, setCurrentPage], ) - const onPageSelecting = React.useCallback((index: number) => { - setCurrentPage(index) - }, []) - return ( {toArray(children) .filter(Boolean) @@ -156,6 +151,8 @@ let PagerTabBar = ({ selectedPage={currentPage} onSelect={onSelect} onPressSelected={onCurrentPageSelected} + dragProgress={undefined as any /* native-only */} + dragState={undefined as any /* native-only */} /> diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 4e8646c605..b08b364c52 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -1,205 +1,392 @@ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {useCallback} from 'react' import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native' +import Animated, { + interpolate, + runOnJS, + runOnUI, + scrollTo, + SharedValue, + useAnimatedReaction, + useAnimatedRef, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {isNative} from '#/platform/detection' -import {PressableWithHover} from '../util/PressableWithHover' -import {Text} from '../util/text/Text' -import {DraggableScrollView} from './DraggableScrollView' +import {PressableWithHover} from '#/view/com/util/PressableWithHover' +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' export interface TabBarProps { testID?: string selectedPage: number items: string[] - indicatorColor?: string onSelect?: (index: number) => void onPressSelected?: (index: number) => void + dragProgress: SharedValue + dragState: SharedValue<'idle' | 'dragging' | 'settling'> } -// How much of the previous/next item we're showing -// to give the user a hint there's more to scroll. +const ITEM_PADDING = 10 +const CONTENT_PADDING = 6 +// How much of the previous/next item we're requiring +// when deciding whether to scroll into view on tap. const OFFSCREEN_ITEM_WIDTH = 20 export function TabBar({ testID, selectedPage, items, - indicatorColor, onSelect, onPressSelected, + dragProgress, + dragState, }: TabBarProps) { - const pal = usePalette('default') - const scrollElRef = useRef(null) - const itemRefs = useRef>([]) - const [itemXs, setItemXs] = useState([]) - const indicatorStyle = useMemo( - () => ({borderBottomColor: indicatorColor || pal.colors.link}), - [indicatorColor, pal], + const t = useTheme() + const scrollElRef = useAnimatedRef() + const syncScrollState = useSharedValue<'synced' | 'unsynced' | 'needs-sync'>( + 'synced', ) - const {isDesktop, isTablet} = useWebMediaQueries() - const styles = isDesktop || isTablet ? desktopStyles : mobileStyles - - useEffect(() => { - if (isNative) { - // On native, the primary interaction is swiping. - // We adjust the scroll little by little on every tab change. - // Scroll into view but keep the end of the previous item visible. - let x = itemXs[selectedPage] || 0 - x = Math.max(0, x - OFFSCREEN_ITEM_WIDTH) - scrollElRef.current?.scrollTo({x}) - } else { - // On the web, the primary interaction is tapping. - // Scrolling under tap feels disorienting so only adjust the scroll offset - // when tapping on an item out of view--and we adjust by almost an entire page. - const parent = scrollElRef?.current?.getScrollableNode?.() - if (!parent) { - return + const didInitialScroll = useSharedValue(false) + const contentSize = useSharedValue(0) + const containerSize = useSharedValue(0) + const scrollX = useSharedValue(0) + const layouts = useSharedValue<{x: number; width: number}[]>([]) + const itemsLength = items.length + + const scrollToOffsetJS = useCallback( + (x: number) => { + scrollElRef.current?.scrollTo({ + x, + y: 0, + animated: true, + }) + }, + [scrollElRef], + ) + + const indexToOffset = useCallback( + (index: number) => { + 'worklet' + const layout = layouts.get()[index] + const availableSize = containerSize.get() - 2 * CONTENT_PADDING + if (!layout) { + // Should not happen, but fall back to equal sizes. + const offsetPerPage = contentSize.get() - availableSize + return (index / (itemsLength - 1)) * offsetPerPage } - const parentRect = parent.getBoundingClientRect() - if (!parentRect) { - return + const freeSpace = availableSize - layout.width + const accumulatingOffset = interpolate( + index, + // Gradually shift every next item to the left so that the first item + // is positioned like "left: 0" but the last item is like "right: 0". + [0, itemsLength - 1], + [0, freeSpace], + 'clamp', + ) + return layout.x - accumulatingOffset + }, + [itemsLength, contentSize, containerSize, layouts], + ) + + const progressToOffset = useCallback( + (progress: number) => { + 'worklet' + return interpolate( + progress, + [Math.floor(progress), Math.ceil(progress)], + [ + indexToOffset(Math.floor(progress)), + indexToOffset(Math.ceil(progress)), + ], + 'clamp', + ) + }, + [indexToOffset], + ) + + // When we know the entire layout for the first time, scroll selection into view. + useAnimatedReaction( + () => layouts.get().length, + (nextLayoutsLength, prevLayoutsLength) => { + if (nextLayoutsLength !== prevLayoutsLength) { + if ( + nextLayoutsLength === itemsLength && + didInitialScroll.get() === false + ) { + didInitialScroll.set(true) + const progress = dragProgress.get() + const offset = progressToOffset(progress) + // It's unclear why we need to go back to JS here. It seems iOS-specific. + runOnJS(scrollToOffsetJS)(offset) + } } - const { - left: parentLeft, - right: parentRight, - width: parentWidth, - } = parentRect - const child = itemRefs.current[selectedPage] - if (!child) { - return + }, + ) + + // When you swipe the pager, the tabbar should scroll automatically + // as you're dragging the page and then even during deceleration. + useAnimatedReaction( + () => dragProgress.get(), + (nextProgress, prevProgress) => { + if ( + nextProgress !== prevProgress && + dragState.value !== 'idle' && + // This is only OK to do when we're 100% sure we're synced. + // Otherwise, there would be a jump at the beginning of the swipe. + syncScrollState.get() === 'synced' + ) { + const offset = progressToOffset(nextProgress) + scrollTo(scrollElRef, offset, 0, false) } - const childRect = child.getBoundingClientRect?.() - if (!childRect) { + }, + ) + + // If the syncing is currently off but you've just finished swiping, + // it's an opportunity to resync. It won't feel disruptive because + // you're not directly interacting with the tabbar at the moment. + useAnimatedReaction( + () => dragState.value, + (nextDragState, prevDragState) => { + if ( + nextDragState !== prevDragState && + nextDragState === 'idle' && + (syncScrollState.get() === 'unsynced' || + syncScrollState.get() === 'needs-sync') + ) { + const progress = dragProgress.get() + const offset = progressToOffset(progress) + scrollTo(scrollElRef, offset, 0, true) + syncScrollState.set('synced') + } + }, + ) + + // When you press on the item, we'll scroll into view -- unless you previously + // have scrolled the tabbar manually, in which case it'll re-sync on next press. + const onPressUIThread = useCallback( + (index: number) => { + 'worklet' + const itemLayout = layouts.get()[index] + if (!itemLayout) { + // Should not happen. return } - const {left: childLeft, right: childRight, width: childWidth} = childRect - let dx = 0 - if (childRight >= parentRight) { - dx += childRight - parentRight - dx += parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH - } else if (childLeft <= parentLeft) { - dx -= parentLeft - childLeft - dx -= parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH + const leftEdge = itemLayout.x - OFFSCREEN_ITEM_WIDTH + const rightEdge = itemLayout.x + itemLayout.width + OFFSCREEN_ITEM_WIDTH + const scrollLeft = scrollX.get() + const scrollRight = scrollLeft + containerSize.get() + const scrollIntoView = leftEdge < scrollLeft || rightEdge > scrollRight + if ( + syncScrollState.get() === 'synced' || + syncScrollState.get() === 'needs-sync' || + scrollIntoView + ) { + const offset = progressToOffset(index) + scrollTo(scrollElRef, offset, 0, true) + syncScrollState.set('synced') + } else { + // The item is already in view so it's disruptive to + // scroll right now. Do it on the next opportunity. + syncScrollState.set('needs-sync') } - let x = parent.scrollLeft + dx - x = Math.max(0, x) - x = Math.min(x, parent.scrollWidth - parentWidth) - if (dx !== 0) { - parent.scroll({ - left: x, - behavior: 'smooth', - }) + }, + [ + syncScrollState, + scrollElRef, + scrollX, + progressToOffset, + containerSize, + layouts, + ], + ) + + const onItemLayout = useCallback( + (i: number, layout: {x: number; width: number}) => { + 'worklet' + layouts.modify(ls => { + ls[i] = layout + return ls + }) + }, + [layouts], + ) + + const indicatorStyle = useAnimatedStyle(() => { + if (!_WORKLET) { + return {opacity: 0} + } + const layoutsValue = layouts.get() + if ( + layoutsValue.length !== itemsLength || + layoutsValue.some(l => l === undefined) + ) { + return { + opacity: 0, } } - }, [scrollElRef, itemXs, selectedPage, styles]) + if (layoutsValue.length === 1) { + return {opacity: 1} + } + return { + opacity: 1, + transform: [ + { + translateX: interpolate( + dragProgress.get(), + layoutsValue.map((l, i) => i), + layoutsValue.map(l => l.x + l.width / 2 - contentSize.get() / 2), + ), + }, + { + scaleX: interpolate( + dragProgress.get(), + layoutsValue.map((l, i) => i), + layoutsValue.map( + l => (l.width - ITEM_PADDING * 2) / contentSize.get(), + ), + ), + }, + ], + } + }) const onPressItem = useCallback( (index: number) => { + runOnUI(onPressUIThread)(index) onSelect?.(index) if (index === selectedPage) { onPressSelected?.(index) } }, - [onSelect, selectedPage, onPressSelected], - ) - - // calculates the x position of each item on mount and on layout change - const onItemLayout = React.useCallback( - (e: LayoutChangeEvent, index: number) => { - const x = e.nativeEvent.layout.x - setItemXs(prev => { - const Xs = [...prev] - Xs[index] = x - return Xs - }) - }, - [], + [onSelect, selectedPage, onPressSelected, onPressUIThread], ) return ( - - {items.map((item, i) => { - const selected = i === selectedPage - return ( - (itemRefs.current[i] = node as any)} - onLayout={e => onItemLayout(e, i)} - style={styles.item} - hoverStyle={pal.viewLight} - onPress={() => onPressItem(i)} - accessibilityRole="tab"> - - - {item} - - - - ) - })} - - + contentContainerStyle={styles.contentContainer} + onLayout={e => { + containerSize.set(e.nativeEvent.layout.width) + }} + onScrollBeginDrag={() => { + // Remember that you've manually messed with the tabbar scroll. + // This will disable auto-adjustment until after next pager swipe or item tap. + syncScrollState.set('unsynced') + }} + onScroll={e => { + scrollX.value = Math.round(e.nativeEvent.contentOffset.x) + }}> + { + contentSize.set(e.nativeEvent.layout.width) + }} + style={{flexDirection: 'row'}}> + {items.map((item, i) => { + return ( + + ) + })} + + + + ) } -const desktopStyles = StyleSheet.create({ - outer: { - flexDirection: 'row', - width: 598, - }, - contentContainer: { - paddingHorizontal: 0, - backgroundColor: 'transparent', - }, - item: { - paddingTop: 14, - paddingHorizontal: 14, - justifyContent: 'center', - }, - itemInner: { - paddingBottom: 12, - borderBottomWidth: 3, - borderBottomColor: 'transparent', - }, - outerBottomBorder: { - position: 'absolute', - left: 0, - right: 0, - top: '100%', - borderBottomWidth: StyleSheet.hairlineWidth, - }, -}) +function TabBarItem({ + index, + testID, + dragProgress, + item, + onPressItem, + onItemLayout, +}: { + index: number + testID: string | undefined + dragProgress: SharedValue + item: string + onPressItem: (index: number) => void + onItemLayout: (index: number, layout: {x: number; width: number}) => void +}) { + const t = useTheme() + const style = useAnimatedStyle(() => { + if (!_WORKLET) { + return {opacity: 0.7} + } + return { + opacity: interpolate( + dragProgress.get(), + [index - 1, index, index + 1], + [0.7, 1, 0.7], + 'clamp', + ), + } + }) -const mobileStyles = StyleSheet.create({ - outer: { - flexDirection: 'row', - }, + const handleLayout = useCallback( + (e: LayoutChangeEvent) => { + runOnUI(onItemLayout)(index, e.nativeEvent.layout) + }, + [index, onItemLayout], + ) + + return ( + + onPressItem(index)} + accessibilityRole="tab"> + + + {item} + + + + + ) +} + +const styles = StyleSheet.create({ contentContainer: { backgroundColor: 'transparent', - paddingHorizontal: 6, + paddingHorizontal: CONTENT_PADDING, }, item: { paddingTop: 10, - paddingHorizontal: 10, + paddingHorizontal: ITEM_PADDING, justifyContent: 'center', }, itemInner: { diff --git a/src/view/com/pager/TabBar.web.tsx b/src/view/com/pager/TabBar.web.tsx new file mode 100644 index 0000000000..789f88e753 --- /dev/null +++ b/src/view/com/pager/TabBar.web.tsx @@ -0,0 +1,193 @@ +import {useCallback, useEffect, useRef} from 'react' +import {ScrollView, StyleSheet, View} from 'react-native' + +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import {PressableWithHover} from '../util/PressableWithHover' +import {DraggableScrollView} from './DraggableScrollView' + +export interface TabBarProps { + testID?: string + selectedPage: number + items: string[] + indicatorColor?: string + backgroundColor?: string + + onSelect?: (index: number) => void + onPressSelected?: (index: number) => void +} + +// How much of the previous/next item we're showing +// to give the user a hint there's more to scroll. +const OFFSCREEN_ITEM_WIDTH = 20 + +export function TabBar({ + testID, + selectedPage, + items, + indicatorColor, + onSelect, + onPressSelected, +}: TabBarProps) { + const t = useTheme() + const scrollElRef = useRef(null) + const itemRefs = useRef>([]) + const indicatorStyle = { + borderBottomColor: indicatorColor || t.palette.primary_500, + } + const {gtMobile} = useBreakpoints() + const styles = gtMobile ? desktopStyles : mobileStyles + + useEffect(() => { + // On the web, the primary interaction is tapping. + // Scrolling under tap feels disorienting so only adjust the scroll offset + // when tapping on an item out of view--and we adjust by almost an entire page. + const parent = scrollElRef?.current?.getScrollableNode?.() + if (!parent) { + return + } + const parentRect = parent.getBoundingClientRect() + if (!parentRect) { + return + } + const { + left: parentLeft, + right: parentRight, + width: parentWidth, + } = parentRect + const child = itemRefs.current[selectedPage] + if (!child) { + return + } + const childRect = child.getBoundingClientRect?.() + if (!childRect) { + return + } + const {left: childLeft, right: childRight, width: childWidth} = childRect + let dx = 0 + if (childRight >= parentRight) { + dx += childRight - parentRight + dx += parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH + } else if (childLeft <= parentLeft) { + dx -= parentLeft - childLeft + dx -= parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH + } + let x = parent.scrollLeft + dx + x = Math.max(0, x) + x = Math.min(x, parent.scrollWidth - parentWidth) + if (dx !== 0) { + parent.scroll({ + left: x, + behavior: 'smooth', + }) + } + }, [scrollElRef, selectedPage, styles]) + + const onPressItem = useCallback( + (index: number) => { + onSelect?.(index) + if (index === selectedPage) { + onPressSelected?.(index) + } + }, + [onSelect, selectedPage, onPressSelected], + ) + + return ( + + + {items.map((item, i) => { + const selected = i === selectedPage + return ( + (itemRefs.current[i] = node as any)} + style={styles.item} + hoverStyle={t.atoms.bg_contrast_25} + onPress={() => onPressItem(i)} + accessibilityRole="tab"> + + + {item} + + + + ) + })} + + + + ) +} + +const desktopStyles = StyleSheet.create({ + outer: { + flexDirection: 'row', + width: 598, + }, + contentContainer: { + paddingHorizontal: 0, + backgroundColor: 'transparent', + }, + item: { + paddingTop: 14, + paddingHorizontal: 14, + justifyContent: 'center', + }, + itemInner: { + paddingBottom: 12, + borderBottomWidth: 3, + borderBottomColor: 'transparent', + }, + outerBottomBorder: { + position: 'absolute', + left: 0, + right: 0, + top: '100%', + borderBottomWidth: StyleSheet.hairlineWidth, + }, +}) + +const mobileStyles = StyleSheet.create({ + outer: { + flexDirection: 'row', + }, + contentContainer: { + backgroundColor: 'transparent', + paddingHorizontal: 6, + }, + item: { + paddingTop: 10, + paddingHorizontal: 10, + justifyContent: 'center', + }, + itemInner: { + paddingBottom: 10, + borderBottomWidth: 2, + borderBottomColor: 'transparent', + }, + outerBottomBorder: { + position: 'absolute', + left: 0, + right: 0, + top: '100%', + borderBottomWidth: StyleSheet.hairlineWidth, + }, +}) diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index c623234b87..fb54849198 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -216,7 +216,7 @@ let Feed = ({ checkForNewRef.current = checkForNew }, [checkForNew]) React.useEffect(() => { - if (enabled) { + if (enabled && !disablePoll) { const timeSinceFirstLoad = Date.now() - lastFetchRef.current // DISABLED need to check if this is causing random feed refreshes -prf /*if (timeSinceFirstLoad > REFRESH_AFTER) { @@ -231,7 +231,7 @@ let Feed = ({ checkForNewRef.current() } } - }, [enabled, feed, queryClient, scrollElRef]) + }, [enabled, disablePoll, feed, queryClient, scrollElRef]) React.useEffect(() => { let cleanup1: () => void | undefined, cleanup2: () => void | undefined const subscription = AppState.addEventListener('change', nextAppState => { diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index 42b2e12df6..c1293a83c0 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -60,15 +60,16 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { const snapToClosestState = useCallback( (e: NativeScrollEvent) => { 'worklet' + const offsetY = Math.max(0, e.contentOffset.y) if (isNative) { const startDragOffsetValue = startDragOffset.get() if (startDragOffsetValue === null) { return } - const didScrollDown = e.contentOffset.y > startDragOffsetValue + const didScrollDown = offsetY > startDragOffsetValue startDragOffset.set(null) startMode.set(null) - if (e.contentOffset.y < headerHeight.get()) { + if (offsetY < headerHeight.get()) { // If we're close to the top, show the shell. setMode(false) } else if (didScrollDown) { @@ -86,8 +87,9 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { const onBeginDrag = useCallback( (e: NativeScrollEvent) => { 'worklet' + const offsetY = Math.max(0, e.contentOffset.y) if (isNative) { - startDragOffset.set(e.contentOffset.y) + startDragOffset.set(offsetY) startMode.set(headerMode.get()) } }, @@ -121,14 +123,12 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { const onScroll = useCallback( (e: NativeScrollEvent) => { 'worklet' + const offsetY = Math.max(0, e.contentOffset.y) if (isNative) { const startDragOffsetValue = startDragOffset.get() const startModeValue = startMode.get() if (startDragOffsetValue === null || startModeValue === null) { - if ( - headerMode.get() !== 0 && - e.contentOffset.y < headerHeight.get() - ) { + if (headerMode.get() !== 0 && offsetY < headerHeight.get()) { // If we're close enough to the top, always show the shell. // Even if we're not dragging. setMode(false) @@ -138,7 +138,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { // The "mode" value is always between 0 and 1. // Figure out how much to move it based on the current dragged distance. - const dy = e.contentOffset.y - startDragOffsetValue + const dy = offsetY - startDragOffsetValue const dProgress = interpolate( dy, [-headerHeight.get(), headerHeight.get()], @@ -157,10 +157,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { } // On the web, we don't try to follow the drag because we don't know when it ends. // Instead, show/hide immediately based on whether we're scrolling up or down. - const dy = e.contentOffset.y - (startDragOffset.get() ?? 0) - startDragOffset.set(e.contentOffset.y) + const dy = offsetY - (startDragOffset.get() ?? 0) + startDragOffset.set(offsetY) - if (dy < 0 || e.contentOffset.y < WEB_HIDE_SHELL_THRESHOLD) { + if (dy < 0 || offsetY < WEB_HIDE_SHELL_THRESHOLD) { setMode(false) } else if (dy > 0) { setMode(true) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index cadfb48903..1218a5ba00 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -11,7 +11,7 @@ import { HomeTabNavigatorParams, NativeStackScreenProps, } from '#/lib/routes/types' -import {logEvent, LogEvents} from '#/lib/statsig/statsig' +import {logEvent} from '#/lib/statsig/statsig' import {isWeb} from '#/platform/detection' import {emitSoftReset} from '#/state/events' import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed' @@ -121,7 +121,7 @@ function HomeScreenReady({ // This is supposed to only happen on the web when you use the right nav. if (selectedIndex !== lastPagerReportedIndexRef.current) { lastPagerReportedIndexRef.current = selectedIndex - pagerRef.current?.setPage(selectedIndex, 'desktop-sidebar-click') + pagerRef.current?.setPage(selectedIndex) } }, [selectedIndex]) @@ -156,23 +156,17 @@ function HomeScreenReady({ setMinimalShellMode(false) setDrawerSwipeDisabled(index > 0) const feed = allFeeds[index] - setSelectedFeed(feed) + // Mutate the ref before setting state to avoid the imperative syncing effect + // above from starting a loop on Android when swiping back and forth. lastPagerReportedIndexRef.current = index - }, - [setDrawerSwipeDisabled, setSelectedFeed, setMinimalShellMode, allFeeds], - ) - - const onPageSelecting = React.useCallback( - (index: number, reason: LogEvents['home:feedDisplayed']['reason']) => { - const feed = allFeeds[index] + setSelectedFeed(feed) logEvent('home:feedDisplayed', { index, feedType: feed.split('|')[0], feedUrl: feed, - reason, }) }, - [allFeeds], + [setDrawerSwipeDisabled, setSelectedFeed, setMinimalShellMode, allFeeds], ) const onPressSelected = React.useCallback(() => { @@ -181,6 +175,7 @@ function HomeScreenReady({ const onPageScrollStateChanged = React.useCallback( (state: 'idle' | 'dragging' | 'settling') => { + 'worklet' if (state === 'dragging') { setMinimalShellMode(false) } @@ -228,12 +223,11 @@ function HomeScreenReady({ ref={pagerRef} testID="homeScreen" initialPage={selectedIndex} - onPageSelecting={onPageSelecting} onPageSelected={onPageSelected} onPageScrollStateChanged={onPageScrollStateChanged} renderTabBar={renderTabBar}> {pinnedFeedInfos.length ? ( - pinnedFeedInfos.map(feedInfo => { + pinnedFeedInfos.map((feedInfo, index) => { const feed = feedInfo.feedDescriptor if (feed === 'following') { return ( @@ -241,6 +235,7 @@ function HomeScreenReady({ key={feed} testID="followingFeedPage" isPageFocused={selectedFeed === feed} + isPageAdjacent={Math.abs(selectedIndex - index) === 1} feed={feed} feedParams={homeFeedParams} renderEmptyState={renderFollowingEmptyState} @@ -254,6 +249,7 @@ function HomeScreenReady({ key={feed} testID="customFeedPage" isPageFocused={selectedFeed === feed} + isPageAdjacent={Math.abs(selectedIndex - index) === 1} feed={feed} renderEmptyState={renderCustomFeedEmptyState} savedFeedConfig={savedFeedConfig} @@ -273,6 +269,7 @@ function HomeScreenReady({ diff --git a/yarn.lock b/yarn.lock index f70489e179..474ef2f8ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5655,11 +5655,6 @@ prompts "^2.4.2" semver "^7.5.2" -"@react-native-masked-view/masked-view@0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@react-native-masked-view/masked-view/-/masked-view-0.3.0.tgz#bd29fae18d148a685331910a3c7b766ce87eafcc" - integrity sha512-qLyoObcjzrkpNcoJjXquUePXfL1dXjHtuv+yX0zZ0Q4kG5yvVqd620+tSh7WbRoHkjpXhFBfLwvGhcWB2I0Lpw== - "@react-native-menu/menu@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@react-native-menu/menu/-/menu-1.1.0.tgz#e89c0850f7e5aa4c671c44a9c10edafadb23c35a" @@ -16110,10 +16105,10 @@ react-native-compressor@^1.8.24: resolved "https://registry.yarnpkg.com/react-native-compressor/-/react-native-compressor-1.8.24.tgz#3cc481ad6dfe2787ec4385275dd24791f04d9e71" integrity sha512-PdwOBdnyBnpOag1FRX9ks4cb0GiMLKFU9HSaFTHdb/uw6fVIrnCHpELASeliOxlabWb5rOyVPbc58QpGIfZQIQ== -react-native-date-picker@^4.4.2: - version "4.4.2" - resolved "https://registry.yarnpkg.com/react-native-date-picker/-/react-native-date-picker-4.4.2.tgz#f7bb9daa8559237e08bd30f907ee8487a6e2a6ec" - integrity sha512-wYKN8nYWhETVHJV/+Im30JOdzkFRwYRrDlEOyyYesOjt+1JTFJh9M7K5CqePLOIB4Nxlf2f2lRSI0VoUyFIovA== +react-native-date-picker@^5.0.7: + version "5.0.7" + resolved "https://registry.yarnpkg.com/react-native-date-picker/-/react-native-date-picker-5.0.7.tgz#24161d30c6dca8627afe1aa5a55a389421fdfba4" + integrity sha512-/RodyCZWjb+f3f4YHqKbWFYczGm+tNngwbVSB6MLGgt5Kl7LQXpv4QE6ybnHW+DM4LteTP8A6lj8LEkQ7+usLQ== react-native-dotenv@^3.3.1: version "3.4.9"