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}
>