diff --git a/package.json b/package.json index 93439acccce3..50d6e40f2d1f 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "perf-test": "NODE_OPTIONS=--experimental-vm-modules npx reassure", "typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc", "typecheck-tsgo": "tsgo --project tsconfig.tsgo.json", - "lint": "NODE_OPTIONS=--max_old_space_size=8192 eslint . --max-warnings=381 --cache --cache-location=node_modules/.cache/eslint --cache-strategy content --concurrency=auto", + "lint": "NODE_OPTIONS=--max_old_space_size=8192 eslint . --max-warnings=335 --cache --cache-location=node_modules/.cache/eslint --cache-strategy content --concurrency=auto", "lint-changed": "NODE_OPTIONS=--max_old_space_size=8192 ./scripts/lintChanged.sh", "check-lazy-loading": "ts-node scripts/checkLazyLoading.ts", "lint-watch": "npx eslint-watch --watch --changed", diff --git a/src/components/AnimatedCollapsible/index.tsx b/src/components/AnimatedCollapsible/index.tsx index 805922b0db28..4e5a4f9cb03d 100644 --- a/src/components/AnimatedCollapsible/index.tsx +++ b/src/components/AnimatedCollapsible/index.tsx @@ -1,4 +1,4 @@ -import React, {useEffect} from 'react'; +import React, {useState} from 'react'; import type {ReactNode} from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; @@ -76,13 +76,14 @@ function AnimatedCollapsible({ const contentHeight = useSharedValue(0); const descriptionHeight = useSharedValue(0); const hasExpanded = useSharedValue(isExpanded); - const [isRendered, setIsRendered] = React.useState(isExpanded); - useEffect(() => { - hasExpanded.set(isExpanded); - if (isExpanded) { - setIsRendered(true); - } - }, [isExpanded, hasExpanded]); + const [isRendered, setIsRendered] = useState(isExpanded); + + // Keep Reanimated shared value in sync with prop (idempotent when unchanged) + hasExpanded.set(isExpanded); + // Mount content for collapse animation once expanded; unmount after animation via scheduleOnRN + if (isExpanded && !isRendered) { + setIsRendered(true); + } const animatedHeight = useDerivedValue(() => { if (!contentHeight.get()) { diff --git a/src/components/AutoUpdateTime.tsx b/src/components/AutoUpdateTime.tsx index 64331226f63c..c89442a90706 100644 --- a/src/components/AutoUpdateTime.tsx +++ b/src/components/AutoUpdateTime.tsx @@ -2,7 +2,7 @@ * Displays the user's local time and updates it every minute. * The time auto-update logic is extracted to this component to avoid re-rendering a more complex component, e.g. DetailsPage. */ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -18,33 +18,22 @@ type AutoUpdateTimeProps = { function AutoUpdateTime({timezone}: AutoUpdateTimeProps) { const {translate, getLocalDateFromDatetime} = useLocalize(); const styles = useThemeStyles(); - /** @returns Returns the locale Date object */ - const getCurrentUserLocalTime = useCallback(() => getLocalDateFromDatetime(undefined, timezone.selected), [getLocalDateFromDatetime, timezone.selected]); - const [currentUserLocalTime, setCurrentUserLocalTime] = useState(getCurrentUserLocalTime); + const [, setTick] = useState(0); const minuteRef = useRef(new Date().getMinutes()); - const timezoneName = useMemo(() => { - if (timezone.selected) { - return DateUtils.getZoneAbbreviation(currentUserLocalTime, timezone.selected); - } - return ''; - }, [currentUserLocalTime, timezone.selected]); + const currentUserLocalTime = getLocalDateFromDatetime(undefined, timezone.selected); + const timezoneName = timezone.selected ? DateUtils.getZoneAbbreviation(currentUserLocalTime, timezone.selected) : ''; useEffect(() => { - // If any of the props that getCurrentUserLocalTime depends on change, we want to update the displayed time immediately - setCurrentUserLocalTime(getCurrentUserLocalTime()); - - // Also, if the user leaves this page open, we want to make sure the displayed time is updated every minute when the clock changes - // To do this we create an interval to check if the minute has changed every second and update the displayed time if it has const interval = setInterval(() => { - const currentMinute = new Date().getMinutes(); - if (currentMinute !== minuteRef.current) { - setCurrentUserLocalTime(getCurrentUserLocalTime()); - minuteRef.current = currentMinute; + if (new Date().getMinutes() === minuteRef.current) { + return; } + setTick((t) => t + 1); + minuteRef.current = new Date().getMinutes(); }, 1000); return () => clearInterval(interval); - }, [getCurrentUserLocalTime]); + }, []); return ( diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 5b10a2b1e36c..42e76e9c1c40 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useState} from 'react'; import type {ImageStyle, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useDefaultAvatars from '@hooks/useDefaultAvatars'; @@ -77,13 +77,10 @@ function Avatar({ const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const [imageError, setImageError] = useState(false); + const [errorSource, setErrorSource] = useState(undefined); + const imageError = errorSource !== undefined && errorSource === originalSource; - useNetwork({onReconnect: () => setImageError(false)}); - - useEffect(() => { - setImageError(false); - }, [originalSource]); + useNetwork({onReconnect: () => setErrorSource(undefined)}); const isWorkspace = type === CONST.ICON_TYPE_WORKSPACE; const userAccountID = isWorkspace ? undefined : (avatarID as number); @@ -124,7 +121,7 @@ function Avatar({ setImageError(true)} + onError={() => setErrorSource(typeof originalSource === 'string' ? originalSource : undefined)} cachePolicy="memory-disk" /> diff --git a/src/components/ImageView/index.native.tsx b/src/components/ImageView/index.native.tsx index 6ce40051d9c8..06402b645424 100644 --- a/src/components/ImageView/index.native.tsx +++ b/src/components/ImageView/index.native.tsx @@ -6,6 +6,7 @@ import type ImageViewProps from './types'; function ImageView({attachmentID, isAuthTokenRequired = false, url, style, zoomRange = DEFAULT_ZOOM_RANGE, onError}: ImageViewProps) { return ( {}, + onScaleChanged: () => {}, + onSwipeDown: () => {}, + pagerRef: undefined, + externalGestureHandler: undefined, + }; + } else { + const identifier = attachmentID ?? uri; + const foundPage = state.pagerItems.findIndex((item) => (item.attachmentID ?? item.source) === identifier); + carouselContext = { + ...state, + ...actions, + isUsedInCarousel: !!state.pagerRef, + isSingleCarouselItem: state.pagerItems.length === 1, + page: foundPage === -1 ? 0 : foundPage, + }; + } const { isUsedInCarousel, isSingleCarouselItem, @@ -85,33 +110,7 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange pagerRef, isScrollEnabled, externalGestureHandler, - } = useMemo(() => { - if (state === null || actions === null) { - return { - isUsedInCarousel: false, - isSingleCarouselItem: true, - isPagerScrolling: isPagerScrollingFallback, - isScrollEnabled: isScrollingEnabledFallback, - page: 0, - activePage: 0, - onTap: () => {}, - onScaleChanged: () => {}, - onSwipeDown: () => {}, - pagerRef: undefined, - externalGestureHandler: undefined, - }; - } - - const identifier = attachmentID ?? uri; - const foundPage = state.pagerItems.findIndex((item) => (item.attachmentID ?? item.source) === identifier); - return { - ...state, - ...actions, - isUsedInCarousel: !!state.pagerRef, - isSingleCarouselItem: state.pagerItems.length === 1, - page: foundPage === -1 ? 0 : foundPage, - }; - }, [attachmentID, uri, state, actions, isPagerScrollingFallback, isScrollingEnabledFallback]); + } = carouselContext; /** Whether the Lightbox is used within an attachment carousel and there are more than one page in the carousel */ const hasSiblingCarouselItems = isUsedInCarousel && !isSingleCarouselItem; @@ -119,88 +118,47 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange const [canvasSize, setCanvasSize] = useState(); const isCanvasLoading = canvasSize === undefined; - const updateCanvasSize = useCallback( - ({ - nativeEvent: { - layout: {width, height}, - }, - }: LayoutChangeEvent) => setCanvasSize({width: PixelRatio.roundToNearestPixel(width), height: PixelRatio.roundToNearestPixel(height)}), - [], - ); - - const [contentSize, setInternalContentSize] = useState(() => cachedImageDimensions.get(uri)); - const setContentSize = useCallback( - (newDimensions: Dimensions | undefined) => { - setInternalContentSize(newDimensions); - cachedImageDimensions.set(uri, newDimensions); + const updateCanvasSize = ({ + nativeEvent: { + layout: {width, height}, }, - [uri], - ); - const updateContentSize = useCallback( - ({nativeEvent: {width, height}}: ImageOnLoadEvent) => { - if (contentSize !== undefined) { - return; - } + }: LayoutChangeEvent) => setCanvasSize({width: PixelRatio.roundToNearestPixel(width), height: PixelRatio.roundToNearestPixel(height)}); - setContentSize({width, height}); - }, - [contentSize, setContentSize], - ); - - // Enables/disables the lightbox based on the number of concurrent lightboxes - // On higher-end devices, we can show render lightboxes at the same time, - // while on lower-end devices we want to only render the active carousel item as a lightbox - // to avoid performance issues. - const isLightboxVisible = useMemo(() => { - if (!hasSiblingCarouselItems || NUMBER_OF_CONCURRENT_LIGHTBOXES === 'UNLIMITED') { - return true; + const [contentSize, setInternalContentSize] = useState(() => cachedImageDimensions.get(uri)); + const setContentSize = (newDimensions: Dimensions | undefined) => { + setInternalContentSize(newDimensions); + cachedImageDimensions.set(uri, newDimensions); + }; + const updateContentSize = ({nativeEvent: {width, height}}: ImageOnLoadEvent) => { + if (contentSize !== undefined) { + return; } - const indexCanvasOffset = Math.floor((NUMBER_OF_CONCURRENT_LIGHTBOXES - 1) / 2) || 0; - const indexOutOfRange = page > activePage + indexCanvasOffset || page < activePage - indexCanvasOffset; - return !indexOutOfRange; - }, [activePage, hasSiblingCarouselItems, page]); + setContentSize({width, height}); + }; + + const indexCanvasOffset = Math.floor((NUMBER_OF_CONCURRENT_LIGHTBOXES - 1) / 2) || 0; + const isPageInRange = page >= activePage - indexCanvasOffset && page <= activePage + indexCanvasOffset; + const isLightboxVisible = !hasSiblingCarouselItems || isPageInRange; - // Limits fallback image rendering to only a few pages around the active page. - // This prevents distant carousel items from queuing unnecessary image downloads, - // which would starve the active image of network bandwidth. const isFallbackInRange = !hasSiblingCarouselItems || Math.abs(page - activePage) <= FALLBACK_OFFSET; const [isLightboxImageLoaded, setLightboxImageLoaded] = useState(false); const [isLoading, setIsLoading] = useState(true); - const [isFallbackVisible, setFallbackVisible] = useState(!isLightboxVisible && isFallbackInRange); + const isFallbackVisible = !hasSiblingCarouselItems ? !isLightboxVisible : !(isActive && isLightboxVisible && isLightboxImageLoaded); const [isFallbackImageLoaded, setFallbackImageLoaded] = useState(false); - const previousUri = usePrevious(uri); - - // Clear cached dimensions and reset loading states when URI changes to ensure the new image get fresh dimensions - useEffect(() => { - if (previousUri === uri || !previousUri || !uri) { - return; - } - // Clear the content size state to force recalculation of dimensions - // This ensures that when an image is rotated and gets a new URI, - // we don't use stale cached dimensions from the previous image - setInternalContentSize(undefined); - setLightboxImageLoaded(false); - setFallbackImageLoaded(false); - setIsLoading(true); - // Don't delete from cache here as other components might still need it - // The new URI will get its own cache entry when loaded - }, [uri, previousUri]); - - const fallbackSize = useMemo(() => { - if (!hasSiblingCarouselItems || !contentSize || isCanvasLoading) { - return undefined; - } + let fallbackSize: Dimensions | undefined; + if (!hasSiblingCarouselItems || !contentSize || isCanvasLoading) { + fallbackSize = undefined; + } else { const {minScale} = getCanvasFitScale({canvasSize, contentSize}); - - return { + fallbackSize = { width: PixelRatio.roundToNearestPixel(contentSize.width * minScale), height: PixelRatio.roundToNearestPixel(contentSize.height * minScale), }; - }, [hasSiblingCarouselItems, contentSize, isCanvasLoading, canvasSize]); + } // If the fallback image is currently visible, we want to hide the Lightbox by setting the opacity to 0, // until the fallback gets hidden so that we don't see two overlapping images at the same time. @@ -212,60 +170,43 @@ function Lightbox({attachmentID, isAuthTokenRequired = false, uri, onScaleChange const isLightboxStillLoading = isLightboxVisible && !isLightboxImageLoaded; const isImageLoaded = !(isActive && (isCanvasLoading || isFallbackStillLoading || isLightboxStillLoading)); - // Resets the lightbox when it becomes inactive - useEffect(() => { - if (isLightboxVisible) { - return; - } - setLightboxImageLoaded(false); - setContentSize(undefined); - }, [isLightboxVisible, setContentSize]); - - // Enables and disables the fallback image when the carousel item is active or not - useEffect(() => { - // When there are no other carousel items, we don't need to show the fallback image - if (!hasSiblingCarouselItems) { - return; + const [prevLightboxVisible, setPrevLightboxVisible] = useState(isLightboxVisible); + if (prevLightboxVisible !== isLightboxVisible) { + setPrevLightboxVisible(isLightboxVisible); + if (!isLightboxVisible) { + setLightboxImageLoaded(false); + setInternalContentSize(undefined); + cachedImageDimensions.set(uri, undefined); } + } - // When the carousel item is active and the lightbox has finished loading, we want to hide the fallback image - if (isActive && isFallbackVisible && isLightboxVisible && isLightboxImageLoaded) { - setFallbackVisible(false); + // Reset isFallbackImageLoaded when fallback becomes invisible (so it's false when we show fallback again) + const [prevFallbackVisible, setPrevFallbackVisible] = useState(isFallbackVisible); + if (prevFallbackVisible !== isFallbackVisible) { + setPrevFallbackVisible(isFallbackVisible); + if (!isFallbackVisible) { setFallbackImageLoaded(false); - return; } + } - // If the carousel item has become inactive and the lightbox is not continued to be rendered, we want to show the fallback image - // but only if the page is within the fallback range to avoid unnecessary image downloads - if (!isActive && !isLightboxVisible) { - setFallbackVisible(isFallbackInRange); - } - }, [hasSiblingCarouselItems, isActive, isFallbackInRange, isFallbackVisible, isLightboxImageLoaded, isLightboxVisible]); - - const scaleChange = useCallback( - (scale: number) => { - onScaleChangedProp?.(scale); - onScaleChangedContext?.(scale); - }, - [onScaleChangedContext, onScaleChangedProp], - ); + const scaleChange = (scale: number) => { + onScaleChangedProp?.(scale); + onScaleChangedContext?.(scale); + }; const imagePriority = getImagePriority(isActive, isLightboxVisible); const isALocalFile = isLocalFile(uri); const shouldShowOfflineIndicator = isOffline && !isLoading && !isALocalFile; - const reasonAttributes = useMemo( - () => ({ - context: 'Lightbox', - isImageLoaded, - isLoadingPreviousUri: previousUri !== uri, - isOffline, - isLoading, - isALocalFile, - }), - [isImageLoaded, previousUri, uri, isOffline, isLoading, isALocalFile], - ); + const reasonAttributes: SkeletonSpanReasonAttributes = { + context: 'Lightbox', + isImageLoaded, + isLoadingPreviousUri: false, + isOffline, + isLoading, + isALocalFile, + }; return ( (() => IntlStore.getCurrentLocale()); - const localeToApply = useMemo(() => { - if (isLoadingOnyxValue(nvpPreferredLocaleMetadata)) { - return undefined; - } - + let localeToApply: Locale | undefined; + if (!isLoadingOnyxValue(nvpPreferredLocaleMetadata)) { if (nvpPreferredLocale && isSupportedLocale(nvpPreferredLocale)) { - return nvpPreferredLocale; + localeToApply = nvpPreferredLocale; + } else { + const deviceLocale = getDevicePreferredLocale(); + localeToApply = isSupportedLocale(deviceLocale) ? deviceLocale : CONST.LOCALES.DEFAULT; } - - const deviceLocale = getDevicePreferredLocale(); - return isSupportedLocale(deviceLocale) ? deviceLocale : CONST.LOCALES.DEFAULT; - }, [nvpPreferredLocale, nvpPreferredLocaleMetadata]); + } useEffect(() => { if (!localeToApply) { @@ -122,11 +119,13 @@ function LocaleContextProvider({children}: LocaleContextProviderProps) { importEmojiLocale(normalizedLocale).then(() => { endSpan(CONST.TELEMETRY.SPAN_LOCALE.EMOJI_IMPORT); - buildEmojisTrie(normalizedLocale); }); }, [localeToApply, nvpPreferredLocale]); + // Sync currentLocale from IntlStore after translations finish loading. + // IntlStore.currentLocale is external mutable state that React can't track, + // so we use this effect to explicitly update React state when it changes. useEffect(() => { if (areTranslationsLoading) { return; @@ -140,93 +139,54 @@ function LocaleContextProvider({children}: LocaleContextProviderProps) { setCurrentLocale(locale); }, [areTranslationsLoading]); - const selectedTimezone = useMemo(() => currentUserPersonalDetails?.timezone?.selected, [currentUserPersonalDetails?.timezone?.selected]); - - const collator = useMemo(() => new Intl.Collator(currentLocale, COLLATOR_OPTIONS), [currentLocale]); - - const translate = useMemo( - () => - (path, ...parameters) => - translateLocalize(currentLocale, path, ...parameters), - [currentLocale], - ); - - const numberFormat = useMemo(() => (number, options) => format(currentLocale, number, options), [currentLocale]); - - const getLocalDateFromDatetime = useMemo( - () => (datetime, currentSelectedTimezone) => - DateUtils.getLocalDateFromDatetime(currentLocale, currentSelectedTimezone ?? selectedTimezone ?? CONST.DEFAULT_TIME_ZONE.selected, datetime), - [currentLocale, selectedTimezone], - ); - - const datetimeToRelative = useMemo( - () => (datetime) => DateUtils.datetimeToRelative(currentLocale, datetime, selectedTimezone ?? CONST.DEFAULT_TIME_ZONE.selected), - [currentLocale, selectedTimezone], - ); - - const datetimeToCalendarTime = useMemo( - () => - (datetime, includeTimezone, isLowercase = false) => - DateUtils.datetimeToCalendarTime(currentLocale, datetime, selectedTimezone ?? CONST.DEFAULT_TIME_ZONE.selected, includeTimezone, isLowercase), - [currentLocale, selectedTimezone], - ); - - const formatPhoneNumber = useMemo(() => (phoneNumber) => formatPhoneNumberWithCountryCode(phoneNumber, countryCodeByIP), [countryCodeByIP]); - - const toLocaleDigit = useMemo(() => (digit) => toLocaleDigitLocaleDigitUtils(currentLocale, digit), [currentLocale]); - - const toLocaleOrdinal = useMemo( - () => - (number, writtenOrdinals = false) => - toLocaleOrdinalLocaleDigitUtils(currentLocale, number, writtenOrdinals), - [currentLocale], - ); - - const fromLocaleDigit = useMemo(() => (localeDigit) => fromLocaleDigitLocaleDigitUtils(currentLocale, localeDigit), [currentLocale]); - - const localeCompare = useMemo(() => (a, b) => collator.compare(a, b), [collator]); - - const formatTravelDate = useMemo( - () => (datetime) => { - const date = new Date(datetime); - const formattedDate = formatDate(date, CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT); - const formattedHour = formatDate(date, CONST.DATE.LOCAL_TIME_FORMAT); - const at = translateLocalize(currentLocale, 'common.conjunctionAt'); - return `${formattedDate} ${at} ${formattedHour}`; - }, - [currentLocale], - ); - - const contextValue = useMemo( - () => ({ - translate, - numberFormat, - getLocalDateFromDatetime, - datetimeToRelative, - datetimeToCalendarTime, - formatPhoneNumber, - toLocaleDigit, - toLocaleOrdinal, - fromLocaleDigit, - localeCompare, - formatTravelDate, - preferredLocale: currentLocale, - }), - [ - translate, - numberFormat, - getLocalDateFromDatetime, - datetimeToRelative, - datetimeToCalendarTime, - formatPhoneNumber, - toLocaleDigit, - toLocaleOrdinal, - fromLocaleDigit, - localeCompare, - formatTravelDate, - currentLocale, - ], - ); + const selectedTimezone = currentUserPersonalDetails?.timezone?.selected; + const effectiveTimezone = selectedTimezone ?? CONST.DEFAULT_TIME_ZONE.selected; + const collator = new Intl.Collator(currentLocale, COLLATOR_OPTIONS); + + const translate: LocaleContextProps['translate'] = (path, ...parameters) => translateLocalize(currentLocale, path, ...parameters); + + const numberFormat: LocaleContextProps['numberFormat'] = (number, options) => format(currentLocale, number, options); + + const getLocalDateFromDatetime: LocaleContextProps['getLocalDateFromDatetime'] = (datetime, currentSelectedTimezone) => + DateUtils.getLocalDateFromDatetime(currentLocale, currentSelectedTimezone ?? effectiveTimezone, datetime); + + const datetimeToRelative: LocaleContextProps['datetimeToRelative'] = (datetime) => DateUtils.datetimeToRelative(currentLocale, datetime, effectiveTimezone); + + const datetimeToCalendarTime: LocaleContextProps['datetimeToCalendarTime'] = (datetime, includeTimezone, isLowercase = false) => + DateUtils.datetimeToCalendarTime(currentLocale, datetime, effectiveTimezone, includeTimezone, isLowercase); + + const formatPhoneNumber: LocaleContextProps['formatPhoneNumber'] = (phoneNumber) => formatPhoneNumberWithCountryCode(phoneNumber, countryCodeByIP); + + const toLocaleDigit: LocaleContextProps['toLocaleDigit'] = (digit) => toLocaleDigitLocaleDigitUtils(currentLocale, digit); + + const toLocaleOrdinal: LocaleContextProps['toLocaleOrdinal'] = (number, writtenOrdinals = false) => toLocaleOrdinalLocaleDigitUtils(currentLocale, number, writtenOrdinals); + + const fromLocaleDigit: LocaleContextProps['fromLocaleDigit'] = (localeDigit) => fromLocaleDigitLocaleDigitUtils(currentLocale, localeDigit); + + const localeCompare: LocaleContextProps['localeCompare'] = (a, b) => collator.compare(a, b); + + const formatTravelDate: LocaleContextProps['formatTravelDate'] = (datetime) => { + const date = new Date(datetime); + const formattedDate = formatDate(date, CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT); + const formattedHour = formatDate(date, CONST.DATE.LOCAL_TIME_FORMAT); + const at = translateLocalize(currentLocale, 'common.conjunctionAt'); + return `${formattedDate} ${at} ${formattedHour}`; + }; + + const contextValue: LocaleContextProps = { + translate, + numberFormat, + getLocalDateFromDatetime, + datetimeToRelative, + datetimeToCalendarTime, + formatPhoneNumber, + toLocaleDigit, + toLocaleOrdinal, + fromLocaleDigit, + localeCompare, + formatTravelDate, + preferredLocale: currentLocale, + }; return {children}; } diff --git a/src/components/PlaidCardFeedIcon.tsx b/src/components/PlaidCardFeedIcon.tsx index 9732e2fa601a..97113fd2c8fb 100644 --- a/src/components/PlaidCardFeedIcon.tsx +++ b/src/components/PlaidCardFeedIcon.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; import {useCompanyCardBankIcons, useCompanyCardFeedIcons} from '@hooks/useCompanyCardIcons'; @@ -20,34 +20,26 @@ type PlaidCardFeedIconProps = { }; function PlaidCardFeedIcon({plaidUrl, style, isLarge, isSmall, useSkeletonLoader = false}: PlaidCardFeedIconProps) { - const [isBrokenImage, setIsBrokenImage] = useState(false); + const [brokenUrl, setBrokenUrl] = useState(null); + const [loadedUrl, setLoadedUrl] = useState(null); + const isBrokenImage = brokenUrl === plaidUrl; + const loading = loadedUrl !== plaidUrl && !isBrokenImage; const styles = useThemeStyles(); const illustrations = useThemeIllustrations(); const companyCardFeedIcons = useCompanyCardFeedIcons(); const companyCardBankIcons = useCompanyCardBankIcons(); const width = isLarge ? variables.cardPreviewWidth : variables.cardIconWidth; const height = isLarge ? variables.cardPreviewHeight : variables.cardIconHeight; - const [loading, setLoading] = useState(true); + const plaidImageStyle = isLarge ? styles.plaidIcon : styles.plaidIconSmall; const iconWidth = isSmall ? variables.cardMiniatureWidth : width; const iconHeight = isSmall ? variables.cardMiniatureHeight : height; const plaidLoadedStyle = isSmall ? styles.plaidIconExtraSmall : plaidImageStyle; - useEffect(() => { - if (!plaidUrl) { - return; - } - setIsBrokenImage(false); - setLoading(true); - }, [plaidUrl]); - - const reasonAttributes = useMemo( - () => ({ - context: 'PlaidCardFeedIcon', - loading, - }), - [loading], - ); + const reasonAttributes: SkeletonSpanReasonAttributes = { + context: 'PlaidCardFeedIcon', + loading, + }; return ( @@ -64,8 +56,8 @@ function PlaidCardFeedIcon({plaidUrl, style, isLarge, isSmall, useSkeletonLoader source={{uri: plaidUrl}} style={plaidLoadedStyle} cachePolicy="memory-disk" - onError={() => setIsBrokenImage(true)} - onLoadEnd={() => setLoading(false)} + onError={() => setBrokenUrl(plaidUrl)} + onLoadEnd={() => setLoadedUrl(plaidUrl)} /> {loading && useSkeletonLoader && ( {}, value, forwardedFSClass, ref}: RadioButtonsProps) { const styles = useThemeStyles(); - const [checkedValue, setCheckedValue] = useState(defaultCheckedValue); - - useEffect(() => { - if (value === checkedValue || value === undefined) { - return; - } - setCheckedValue(value ?? ''); - }, [checkedValue, value]); + const [localValue, setLocalValue] = useState(defaultCheckedValue); + const checkedValue = value !== undefined ? value : localValue; return ( <> @@ -62,7 +56,7 @@ function RadioButtons({items, onPress, defaultCheckedValue = '', radioButtonStyl isChecked={item.value === checkedValue} style={[styles.mb4, radioButtonStyle]} onPress={() => { - setCheckedValue(item.value); + setLocalValue(item.value); onInputChange(item.value); return onPress(item.value); }} diff --git a/src/components/Search/SearchFiltersCurrencyBase.tsx b/src/components/Search/SearchFiltersCurrencyBase.tsx index 26cacc2685e3..ad20de6c5dfb 100644 --- a/src/components/Search/SearchFiltersCurrencyBase.tsx +++ b/src/components/Search/SearchFiltersCurrencyBase.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react'; +import React from 'react'; import {View} from 'react-native'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -12,6 +12,7 @@ import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import SearchMultipleSelectionPicker from './SearchMultipleSelectionPicker'; import type {SearchSingleSelectionPickerItem} from './SearchSingleSelectionPicker'; import SearchSingleSelectionPicker from './SearchSingleSelectionPicker'; @@ -28,33 +29,29 @@ function SearchFiltersCurrencyBase({title, filterKey, multiselect = false}: Sear const {translate} = useLocalize(); const {currencyList} = useCurrencyListState(); const {getCurrencySymbol} = useCurrencyListActions(); - const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + const [searchAdvancedFiltersForm, searchAdvancedFiltersFormResult] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); const selectedCurrencyData = searchAdvancedFiltersForm?.[filterKey]; - const {selectedCurrenciesItems, currencyItems} = useMemo(() => { - const currencies: SearchSingleSelectionPickerItem[] = []; - const selectedCurrencies: SearchSingleSelectionPickerItem[] = []; + const currencies: SearchSingleSelectionPickerItem[] = []; + const selectedCurrencies: SearchSingleSelectionPickerItem[] = []; - for (const currencyCode of Object.keys(currencyList)) { - if (currencyList[currencyCode]?.retired) { - continue; - } - - if (Array.isArray(selectedCurrencyData) && selectedCurrencyData?.includes(currencyCode) && !selectedCurrencies.some((currencyItem) => currencyItem.value === currencyCode)) { - selectedCurrencies.push({name: `${currencyCode} - ${getCurrencySymbol(currencyCode)}`, value: currencyCode}); - } + for (const currencyCode of Object.keys(currencyList)) { + if (currencyList[currencyCode]?.retired) { + continue; + } - if (!Array.isArray(selectedCurrencyData) && selectedCurrencyData === currencyCode) { - selectedCurrencies.push({name: `${currencyCode} - ${getCurrencySymbol(currencyCode)}`, value: currencyCode}); - } + if (Array.isArray(selectedCurrencyData) && selectedCurrencyData?.includes(currencyCode) && !selectedCurrencies.some((currencyItem) => currencyItem.value === currencyCode)) { + selectedCurrencies.push({name: `${currencyCode} - ${getCurrencySymbol(currencyCode)}`, value: currencyCode}); + } - if (!currencies.some((item) => item.value === currencyCode)) { - currencies.push({name: `${currencyCode} - ${getCurrencySymbol(currencyCode)}`, value: currencyCode}); - } + if (!Array.isArray(selectedCurrencyData) && selectedCurrencyData === currencyCode) { + selectedCurrencies.push({name: `${currencyCode} - ${getCurrencySymbol(currencyCode)}`, value: currencyCode}); } - return {selectedCurrenciesItems: selectedCurrencies, currencyItems: currencies}; - }, [currencyList, selectedCurrencyData, getCurrencySymbol]); + if (!currencies.some((item) => item.value === currencyCode)) { + currencies.push({name: `${currencyCode} - ${getCurrencySymbol(currencyCode)}`, value: currencyCode}); + } + } const handleOnSubmit = (values: string[] | string | undefined) => { updateAdvancedFilters({[filterKey]: values ?? null} as Partial); @@ -75,17 +72,17 @@ function SearchFiltersCurrencyBase({title, filterKey, multiselect = false}: Sear }} /> - {multiselect && ( + {multiselect && !isLoadingOnyxValue(searchAdvancedFiltersFormResult) && ( )} {!multiselect && ( )} diff --git a/src/components/Search/SearchMultipleSelectionPicker.tsx b/src/components/Search/SearchMultipleSelectionPicker.tsx index 9a41c4e32fc3..7c232f345313 100644 --- a/src/components/Search/SearchMultipleSelectionPicker.tsx +++ b/src/components/Search/SearchMultipleSelectionPicker.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useState} from 'react'; import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; import useDebouncedState from '@hooks/useDebouncedState'; @@ -25,51 +25,37 @@ type SearchMultipleSelectionPickerProps = { function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTitle, onSaveSelection, shouldShowTextInput = true}: SearchMultipleSelectionPickerProps) { const {translate, localeCompare} = useLocalize(); - const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); - const [selectedItems, setSelectedItems] = useState(initiallySelectedItems ?? []); - useEffect(() => { - setSelectedItems(initiallySelectedItems ?? []); - }, [initiallySelectedItems]); + const [selectedItemIDs, setSelectedItemIDs] = useState(() => new Set((initiallySelectedItems ?? []).map((item) => item.value.toString()))); - const selectedItemsSection = selectedItems - .filter((item) => item?.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase())) - .sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare)) - .map((item) => ({ - text: item.name, - keyForList: item.name, - isSelected: true, - value: item.value, - leftElement: item.leftElement, - })); + const searchLower = debouncedSearchTerm.toLowerCase(); + const selectedSectionData: Array<{text: string; keyForList: string; isSelected: boolean; value: string | string[]; leftElement?: React.ReactNode}> = []; + const remainingSectionData: typeof selectedSectionData = []; + for (const item of items) { + if (!item.name.toLowerCase().includes(searchLower)) { + continue; + } + const isSelected = selectedItemIDs.has(item.value.toString()); + (isSelected ? selectedSectionData : remainingSectionData).push({text: item.name, keyForList: item.name, isSelected, value: item.value, leftElement: item.leftElement}); + } - const remainingItemsSection = items - .filter( - (item) => - !selectedItems.some((selectedItem) => selectedItem.value.toString() === item.value.toString()) && item?.name?.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()), - ) - .sort((a, b) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare)) - .map((item) => ({ - text: item.name, - keyForList: item.name, - isSelected: false, - value: item.value, - leftElement: item.leftElement, - })); + const sortByValue = (a: {value: string | string[]}, b: {value: string | string[]}) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare); + selectedSectionData.sort(sortByValue); + remainingSectionData.sort(sortByValue); - const noResultsFound = !selectedItemsSection.length && !remainingItemsSection.length; + const noResultsFound = !selectedSectionData.length && !remainingSectionData.length; const sections = noResultsFound ? [] : [ { title: undefined, - data: selectedItemsSection, + data: selectedSectionData, sectionIndex: 0, }, { title: pickerTitle, - data: remainingItemsSection, + data: remainingSectionData, sectionIndex: 1, }, ]; @@ -78,19 +64,24 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit if (!item.text || !item.keyForList || !item.value) { return; } - if (item.isSelected) { - setSelectedItems(selectedItems?.filter((selectedItem) => selectedItem.name !== item.keyForList)); - } else { - setSelectedItems([...(selectedItems ?? []), {name: item.text, value: item.value, leftElement: item.leftElement}]); - } + const id = item.value.toString(); + setSelectedItemIDs((prev) => { + const next = new Set(prev); + if (item.isSelected) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); }; const resetChanges = () => { - setSelectedItems([]); + setSelectedItemIDs(new Set()); }; const applyChanges = () => { - onSaveSelection(selectedItems.map((item) => item.value).flat()); + onSaveSelection(items.filter((item) => selectedItemIDs.has(item.value.toString())).flatMap((item) => item.value)); Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); }; diff --git a/src/components/SettlementButton/AnimatedSettlementButton.tsx b/src/components/SettlementButton/AnimatedSettlementButton.tsx index 0b7f3a11fcef..9a3b5efceeda 100644 --- a/src/components/SettlementButton/AnimatedSettlementButton.tsx +++ b/src/components/SettlementButton/AnimatedSettlementButton.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useEffect, useState} from 'react'; import type {View} from 'react-native'; import Animated, {Keyframe, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import {scheduleOnRN} from 'react-native-worklets'; @@ -39,9 +39,8 @@ function AnimatedSettlementButton({ const gap = styles.expenseAndReportPreviewTextButtonContainer.gap; const buttonMarginTop = useSharedValue(gap); const height = useSharedValue(variables.componentSizeNormal); - const [canShow, setCanShow] = React.useState(true); - const [minWidth, setMinWidth] = React.useState(0); - const viewRef = useRef(null); + const [canShow, setCanShow] = useState(true); + const [minWidth, setMinWidth] = useState(0); const containerStyles = useAnimatedStyle(() => ({ height: height.get(), @@ -50,36 +49,41 @@ function AnimatedSettlementButton({ })); const willShowPaymentButton = canIOUBePaid && isApprovedAnimationRunning; - const stretchOutY = useCallback(() => { + + const finishAnimationAndReset = () => { + setMinWidth(0); + setCanShow(true); + height.set(variables.componentSizeNormal); + buttonMarginTop.set(shouldAddTopMargin ? gap : 0); + onAnimationFinish(); + }; + + const onButtonExitComplete: () => void = () => { 'worklet'; if (shouldAddTopMargin) { buttonMarginTop.set(withTiming(willShowPaymentButton ? gap : 0, {duration: buttonDuration})); } if (willShowPaymentButton) { - scheduleOnRN(onAnimationFinish); + scheduleOnRN(finishAnimationAndReset); return; } - height.set(withTiming(0, {duration: buttonDuration}, () => scheduleOnRN(onAnimationFinish))); - }, [buttonDuration, buttonMarginTop, gap, height, onAnimationFinish, shouldAddTopMargin, willShowPaymentButton]); + height.set(withTiming(0, {duration: buttonDuration}, () => scheduleOnRN(finishAnimationAndReset))); + }; - const buttonAnimation = useMemo( - () => - new Keyframe({ - from: { - opacity: 1, - transform: [{scale: 1}], - }, - to: { - opacity: 0, - transform: [{scale: 0}], - }, - }) - .delay(buttonDelay) - .duration(buttonDuration) - .withCallback(stretchOutY), - [buttonDelay, buttonDuration, stretchOutY], - ); + const buttonAnimation = new Keyframe({ + from: { + opacity: 1, + transform: [{scale: 1}], + }, + to: { + opacity: 0, + transform: [{scale: 0}], + }, + }) + .delay(buttonDelay) + .duration(buttonDuration) + .withCallback(onButtonExitComplete); let icon; if (isApprovedAnimationRunning) { @@ -88,26 +92,26 @@ function AnimatedSettlementButton({ icon = expensifyIcons.Checkmark; } + const animatedViewRef = (el: View | null) => { + if (!el || !isAnimationRunning) { + return; + } + setMinWidth((el as unknown as HTMLElement).getBoundingClientRect?.().width ?? 0); + }; + useEffect(() => { if (!isAnimationRunning) { - setMinWidth(0); - setCanShow(true); - height.set(variables.componentSizeNormal); - buttonMarginTop.set(shouldAddTopMargin ? gap : 0); return; } - setMinWidth(viewRef.current?.getBoundingClientRect?.().width ?? 0); const timer = setTimeout(() => setCanShow(false), CONST.ANIMATION_PAID_BUTTON_HIDE_DELAY); return () => clearTimeout(timer); - }, [buttonMarginTop, gap, height, isAnimationRunning, shouldAddTopMargin]); + }, [isAnimationRunning]); return ( {isAnimationRunning && canShow && ( { - viewRef.current = el as HTMLElement | null; - }} + ref={animatedViewRef} exiting={buttonAnimation} >