diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d74ddd97f5d9..2faaca9ad5f3 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2327,6 +2327,11 @@ const CONST = { GSD: 'gsd', DEFAULT: 'default', }, + INBOX_TAB: { + ALL: 'all', + TODO: 'todo', + UNREAD: 'unread', + }, THEME: { DEFAULT: 'system', FALLBACK: 'dark', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 0b43d3a7de18..5cbc9ab25bc1 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -171,6 +171,9 @@ const ONYXKEYS = { /** Contains the user preference for the LHN priority mode */ NVP_PRIORITY_MODE: 'nvp_priorityMode', + /** Contains the user preference for the active inbox tab filter */ + NVP_INBOX_TAB: 'nvp_inboxTab', + /** Contains the users's block expiration (if they have one) */ NVP_BLOCKED_FROM_CONCIERGE: 'nvp_private_blockedFromConcierge', @@ -1492,6 +1495,7 @@ type OnyxValuesMapping = { [ONYXKEYS.BETA_CONFIGURATION]: OnyxTypes.BetaConfiguration; [ONYXKEYS.NVP_MUTED_PLATFORMS]: Partial>; [ONYXKEYS.NVP_PRIORITY_MODE]: ValueOf; + [ONYXKEYS.NVP_INBOX_TAB]: ValueOf; [ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE]: OnyxTypes.BlockedFromConcierge; [ONYXKEYS.QUEUE_FLUSHED_DATA]: AnyOnyxUpdate[]; [ONYXKEYS.TRANSACTIONS_PENDING_3DS_REVIEW]: OnyxTypes.TransactionsPending3DSReview; diff --git a/src/components/LHNOptionsList/LHNEmptyState.tsx b/src/components/LHNOptionsList/LHNEmptyState.tsx index 40fed2097bc6..cb0f4552dbca 100644 --- a/src/components/LHNOptionsList/LHNEmptyState.tsx +++ b/src/components/LHNOptionsList/LHNEmptyState.tsx @@ -3,12 +3,16 @@ import {View} from 'react-native'; import type {BlockingViewProps} from '@components/BlockingViews/BlockingView'; import BlockingView from '@components/BlockingViews/BlockingView'; import Icon from '@components/Icon'; +import Text from '@components/Text'; import TextBlock from '@components/TextBlock'; +import TextLink from '@components/TextLink'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import {useSidebarOrderedReportsActions, useSidebarOrderedReportsState} from '@hooks/useSidebarOrderedReports'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import useEmptyLHNIllustration from './useEmptyLHNIllustration'; function LHNEmptyState() { @@ -16,7 +20,34 @@ function LHNEmptyState() { const styles = useThemeStyles(); const {translate} = useLocalize(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['MagnifyingGlass', 'Plus']); - const emptyLHNIllustration = useEmptyLHNIllustration(); + const emptyLHNIllustration = useEmptyLHNIllustration() as BlockingViewProps; + const {activeTab} = useSidebarOrderedReportsState(); + const {setActiveTab} = useSidebarOrderedReportsActions(); + + if (activeTab === CONST.INBOX_TAB.UNREAD || activeTab === CONST.INBOX_TAB.TODO) { + const title = activeTab === CONST.INBOX_TAB.UNREAD ? translate('common.emptyLHN.noUnreadChats') : translate('common.emptyLHN.noTodos'); + const caughtUpSubtitle = ( + + {translate('common.emptyLHN.caughtUp')} + setActiveTab(CONST.INBOX_TAB.ALL)} + style={[styles.textStrong, styles.mt5, styles.ph4, styles.textAlignCenter]} + > + {translate('common.emptyLHN.seeAllChats')} + + + ); + + return ( + + ); + } const subtitle = ( @@ -56,7 +87,7 @@ function LHNEmptyState() { return ( | NavigationPartial function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProps) { const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); + const [inboxTab] = useOnyx(ONYXKEYS.NVP_INBOX_TAB); const scrollOffsetsRef = useRef>({}); const previousPriorityMode = usePrevious(priorityMode); + const previousInboxTab = usePrevious(inboxTab); useEffect(() => { - if (previousPriorityMode === null || previousPriorityMode === priorityMode) { + const priorityModeChanged = previousPriorityMode !== null && previousPriorityMode !== priorityMode; + const inboxTabChanged = previousInboxTab !== null && previousInboxTab !== inboxTab; + if (!priorityModeChanged && !inboxTabChanged) { return; } - // If the priority mode changes, we need to clear the scroll offsets for the home and search screens because it affects the size of the elements and scroll positions wouldn't be correct. + // If the priority mode or inbox tab changes, we need to clear the scroll offsets for the home and search screens because it affects the size of the elements and scroll positions wouldn't be correct. for (const key of Object.keys(scrollOffsetsRef.current)) { if (key.includes(SCREENS.INBOX) || key.includes(SCREENS.SEARCH.ROOT)) { delete scrollOffsetsRef.current[key]; } } - }, [priorityMode, previousPriorityMode]); + }, [priorityMode, previousPriorityMode, inboxTab, previousInboxTab]); const saveScrollOffset: ScrollOffsetContextValue['saveScrollOffset'] = useCallback((route, scrollOffset) => { scrollOffsetsRef.current[getKey(route)] = scrollOffset; diff --git a/src/components/TabSelector/TabSelectorBase.tsx b/src/components/TabSelector/TabSelectorBase.tsx index 93916bfc1503..7209dc573e51 100644 --- a/src/components/TabSelector/TabSelectorBase.tsx +++ b/src/components/TabSelector/TabSelectorBase.tsx @@ -26,6 +26,7 @@ function TabSelectorBase({ position, shouldShowLabelWhenInactive = true, equalWidth = false, + contentContainerStyles, }: TabSelectorBaseProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -61,7 +62,11 @@ function TabSelectorBase({ }} ref={containerRef} style={styles.scrollableTabSelector} - contentContainerStyle={styles.tabSelectorContentContainer} + // On iOS a horizontal ScrollView lays out its content along an unbounded main axis, so flex-1 tabs + // (equalWidth) divide their intrinsic content width instead of the viewport. Giving the content + // container a definite width lets the flex children split it evenly. Scoped to equalWidth so normal + // overflowing/scrollable tab rows are not constrained. + contentContainerStyle={[styles.tabSelectorContentContainer, equalWidth && styles.w100, contentContainerStyles]} horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="handled" @@ -119,6 +124,8 @@ function TabSelectorBase({ shouldShowLabelWhenInactive={shouldShowLabelWhenInactive} equalWidth={equalWidth} badgeText={tab.badgeText} + isBadgeCondensed={tab.isBadgeCondensed} + badgeStyles={tab.badgeStyles} pendingAction={tab.pendingAction} isDisabled={tab.isDisabled} /> diff --git a/src/components/TabSelector/TabSelectorItem.tsx b/src/components/TabSelector/TabSelectorItem.tsx index 421f643601b1..0b1e4a88a563 100644 --- a/src/components/TabSelector/TabSelectorItem.tsx +++ b/src/components/TabSelector/TabSelectorItem.tsx @@ -31,6 +31,8 @@ function TabSelectorItem({ sentryLabel, equalWidth = false, badgeText, + isBadgeCondensed = false, + badgeStyles, isDisabled = false, pendingAction, }: TabSelectorItemProps) { @@ -90,6 +92,8 @@ function TabSelectorItem({ )} diff --git a/src/components/TabSelector/types.ts b/src/components/TabSelector/types.ts index b32c41e37d15..9a80f4ac3aea 100644 --- a/src/components/TabSelector/types.ts +++ b/src/components/TabSelector/types.ts @@ -1,6 +1,6 @@ import type {MaterialTopTabBarProps} from '@react-navigation/material-top-tabs'; // eslint-disable-next-line no-restricted-imports -import type {Animated} from 'react-native'; +import type {Animated, StyleProp, ViewStyle} from 'react-native'; import type {ThemeColors} from '@styles/theme/types'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -39,6 +39,12 @@ type TabSelectorBaseItem = WithSentryLabel & { /** Text to display on the badge on the tab. */ badgeText?: string; + /** Whether the tab's badge should use the condensed (smaller) style. */ + isBadgeCondensed?: boolean; + + /** Additional styles for the tab's badge. */ + badgeStyles?: StyleProp; + /** Whether this tab is disabled */ isDisabled?: boolean; @@ -70,6 +76,9 @@ type TabSelectorBaseProps = { /** Whether tabs should have equal width. */ equalWidth?: boolean; + + /** Additional styles for the tabs' scroll content container. */ + contentContainerStyles?: StyleProp; }; type TabSelectorItemProps = WithSentryLabel & { @@ -112,6 +121,12 @@ type TabSelectorItemProps = WithSentryLabel & { /** Text to display on the badge on the tab. */ badgeText?: string; + /** Whether the tab's badge should use the condensed (smaller) style. */ + isBadgeCondensed?: boolean; + + /** Additional styles for the tab's badge. */ + badgeStyles?: StyleProp; + /** Whether this tab is disabled */ isDisabled?: boolean; diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index 2e4e31f13a0d..c15e52fe29ae 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -1,5 +1,7 @@ import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import {setInboxTab} from '@libs/actions/User'; import Log from '@libs/Log'; import SidebarUtils from '@libs/SidebarUtils'; import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; @@ -27,27 +29,40 @@ type SidebarOrderedReportsContextProviderProps = { }; type SidebarOrderedReportsStateContextValue = { - orderedReports: OnyxTypes.Report[]; + /** The reports rendered in the LHN for the active Inbox tab (a filtered subset of orderedReportIDs). */ + filteredReports: OnyxTypes.Report[]; + /** All ordered LHN report IDs, unfiltered by the active Inbox tab. Used for total counts (e.g. focus-mode switch) and brick road. */ orderedReportIDs: string[]; currentReportID: string | undefined; chatTabBrickRoad: BrickRoad; + activeTab: ValueOf; + inboxTabCounts: Record; }; type SidebarOrderedReportsActionsContextValue = { clearLHNCache: () => void; + setActiveTab: (tab: ValueOf) => void; + setStickyReportID: (reportID: string) => void; }; -type ReportsToDisplayInLHN = Record; +type ReportsToDisplayInLHN = Record; const SidebarOrderedReportsStateContext = createContext({ - orderedReports: [], + filteredReports: [], orderedReportIDs: [], currentReportID: '', chatTabBrickRoad: undefined, + activeTab: CONST.INBOX_TAB.ALL, + inboxTabCounts: { + [CONST.INBOX_TAB.TODO]: 0, + [CONST.INBOX_TAB.UNREAD]: 0, + }, }); const SidebarOrderedReportsActionsContext = createContext({ clearLHNCache: () => {}, + setActiveTab: () => {}, + setStickyReportID: () => {}, }); const policyMapper = (policy: OnyxEntry): PartialPolicyForSidebar => @@ -72,6 +87,8 @@ function SidebarOrderedReportsContextProvider({ }: SidebarOrderedReportsContextProviderProps) { const {localeCompare} = useLocalize(); const [priorityMode = CONST.PRIORITY_MODE.DEFAULT] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); + const [inboxTab = CONST.INBOX_TAB.ALL] = useOnyx(ONYXKEYS.NVP_INBOX_TAB); + const activeTab = inboxTab ?? CONST.INBOX_TAB.ALL; const [chatReports, {sourceValue: reportUpdates}] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [, {sourceValue: policiesUpdates}] = useMappedPolicies(policyMapper); const [transactions, {sourceValue: transactionsUpdates}] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); @@ -278,7 +295,32 @@ function SidebarOrderedReportsContextProvider({ const orderedReportIDs = useMemo(() => getOrderedReportIDs(), [getOrderedReportIDs]); - // Get the actual reports based on the ordered IDs + // When a report is opened from the To-do/Unread tab (see setStickyReportID), we remember it so it + // stays visible after viewing it removes it from the tab (e.g. it gets read). It's only set on a + // non-All tab, so opening a chat from the All tab never makes it appear under Unread/To-do. + const [stickyReport, setStickyReport] = useState<{reportID: string; tab: ValueOf} | undefined>(undefined); + + // The reports for the active tab, plus the sticky report opened from it (kept visible even after it's read). + const stickyReportID = stickyReport?.reportID; + const stickyReportTab = stickyReport?.tab; + const filteredReportIDs = useMemo(() => { + const baseFilteredReportIDs = SidebarUtils.filterReportsForInboxTab(orderedReportIDs, reportsToDisplayInLHN, activeTab); + if (activeTab === CONST.INBOX_TAB.ALL || !stickyReportID || stickyReportTab !== activeTab || baseFilteredReportIDs.includes(stickyReportID)) { + return baseFilteredReportIDs; + } + if (!orderedReportIDs.includes(stickyReportID)) { + // While opening the report, reading it can briefly drop it from the LHN set entirely (before + // navigation marks it as the focused report). Keep it at the top so the list doesn't flash empty. + return [stickyReportID, ...baseFilteredReportIDs]; + } + const baseSet = new Set(baseFilteredReportIDs); + return orderedReportIDs.filter((reportID) => baseSet.has(reportID) || reportID === stickyReportID); + }, [orderedReportIDs, reportsToDisplayInLHN, activeTab, stickyReportTab, stickyReportID]); + + // The count shown in each tab's badge, derived from the full "All" set (not the currently filtered view). + const inboxTabCounts = useMemo(() => SidebarUtils.getInboxTabCounts(orderedReportIDs, reportsToDisplayInLHN), [orderedReportIDs, reportsToDisplayInLHN]); + + // Get the actual reports based on the filtered IDs const getOrderedReports = useCallback( (reportIDs: string[]): OnyxTypes.Report[] => { if (!chatReports) { @@ -289,7 +331,7 @@ function SidebarOrderedReportsContextProvider({ [chatReports], ); - const orderedReports = useMemo(() => getOrderedReports(orderedReportIDs), [getOrderedReports, orderedReportIDs]); + const filteredReports = useMemo(() => getOrderedReports(filteredReportIDs), [getOrderedReports, filteredReportIDs]); const clearLHNCache = useCallback(() => { Log.info('[useSidebarOrderedReports] Clearing sidebar cache manually via debug modal'); @@ -297,6 +339,25 @@ function SidebarOrderedReportsContextProvider({ setClearCacheDummyCounter((current) => current + 1); }, []); + const setActiveTab = useCallback((tab: ValueOf) => { + setInboxTab(tab); + + // The sticky report is scoped to the tab it was opened from, so reset it when switching tabs. + setStickyReport(undefined); + }, []); + + // Called when a report is opened from the LHN. On the To-do/Unread tabs we remember it so it stays + // visible after viewing it removes it from the tab. On the All tab we keep nothing sticky. + const setStickyReportID = useCallback( + (reportID: string) => { + if (activeTab === CONST.INBOX_TAB.ALL) { + return; + } + setStickyReport({reportID, tab: activeTab}); + }, + [activeTab], + ); + const stateValue: SidebarOrderedReportsStateContextValue = useMemo(() => { // We need to make sure the current report is in the list of reports, but we do not want // to have to re-generate the list every time the currentReportID changes. To do that @@ -308,31 +369,52 @@ function SidebarOrderedReportsContextProvider({ // requirement for web. Consider a case, where we have report with expenses and we click on // any expense, a new LHN item is added in the list and is visible on web. But on mobile, we // just navigate to the screen with expense details, so there seems no point to execute this logic on mobile. + // Only the "All" tab force-regenerates to surface the current report. On the To-do/Unread tabs the + // sticky-aware filteredReportIDs already keeps the opened report visible, and re-filtering here + // (without the sticky report) would briefly empty the list while opening it. if ( - (!shouldUseNarrowLayout || orderedReportIDs.length === 0) && + activeTab === CONST.INBOX_TAB.ALL && + (!shouldUseNarrowLayout || filteredReportIDs.length === 0) && derivedCurrentReportID && derivedCurrentReportID !== '-1' && - orderedReportIDs.indexOf(derivedCurrentReportID) === -1 + filteredReportIDs.indexOf(derivedCurrentReportID) === -1 ) { const updatedReportIDs = getOrderedReportIDs(); - const updatedReports = getOrderedReports(updatedReportIDs); + const updatedFilteredIDs = SidebarUtils.filterReportsForInboxTab(updatedReportIDs, reportsToDisplayInLHN, activeTab); + const updatedReports = getOrderedReports(updatedFilteredIDs); return { - orderedReports: updatedReports, + filteredReports: updatedReports, orderedReportIDs: updatedReportIDs, currentReportID: derivedCurrentReportID, chatTabBrickRoad: getChatTabBrickRoad(updatedReportIDs, reportAttributes), + activeTab, + inboxTabCounts, }; } return { - orderedReports, + filteredReports, orderedReportIDs, currentReportID: derivedCurrentReportID, chatTabBrickRoad: getChatTabBrickRoad(orderedReportIDs, reportAttributes), + activeTab, + inboxTabCounts, }; - }, [getOrderedReportIDs, orderedReportIDs, derivedCurrentReportID, shouldUseNarrowLayout, getOrderedReports, orderedReports, reportAttributes]); + }, [ + getOrderedReportIDs, + orderedReportIDs, + filteredReportIDs, + derivedCurrentReportID, + shouldUseNarrowLayout, + getOrderedReports, + filteredReports, + reportAttributes, + activeTab, + inboxTabCounts, + reportsToDisplayInLHN, + ]); - const actionsValue: SidebarOrderedReportsActionsContextValue = useMemo(() => ({clearLHNCache}), [clearLHNCache]); + const actionsValue: SidebarOrderedReportsActionsContextValue = useMemo(() => ({clearLHNCache, setActiveTab, setStickyReportID}), [clearLHNCache, setActiveTab, setStickyReportID]); useEffect(() => { const hookExecutionDuration = performance.now() - hookStartTime.current; diff --git a/src/languages/de.ts b/src/languages/de.ts index 97cb8c517b88..576d85dcfba3 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -356,6 +356,10 @@ const translations: TranslationDeepObject = { subtitleText1: 'Finde einen Chat über die', subtitleText2: 'Schaltfläche oben oder erstellen Sie etwas mit der', subtitleText3: 'Schaltfläche unten.', + noUnreadChats: 'Keine ungelesenen Chats', + noTodos: 'Keine To-dos', + caughtUp: 'Sie sind auf dem neuesten Stand. Gut gemacht!', + seeAllChats: 'Alle Chats anzeigen', }, businessName: 'Firmenname', clear: 'Löschen', @@ -2901,6 +2905,12 @@ ${amount} für ${merchant} – ${date}`, }, }, }, + focusModeUpdateModal: { + title: 'Willkommen im #Focus-Modus!', + prompt: (priorityModePageUrl: string) => + `Behalten Sie den Überblick, indem Sie nur ungelesene Chats oder Chats sehen, die Ihre Aufmerksamkeit erfordern. Keine Sorge, Sie können dies jederzeit in den Einstellungen ändern.`, + }, + inboxTabs: {all: 'Alle', todo: 'Aufgaben', unread: 'Ungelesen'}, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, generatingPDF: 'PDF erstellen', @@ -3466,11 +3476,6 @@ ${amount} für ${merchant} – ${date}`, year: 'Jahr', selectYear: 'Bitte ein Jahr auswählen', }, - focusModeUpdateModal: { - title: 'Willkommen im #Fokusmodus!', - prompt: (priorityModePageUrl: string) => - `Behalte den Überblick, indem du nur ungelesene Chats oder Chats siehst, die deine Aufmerksamkeit benötigen. Keine Sorge, du kannst das jederzeit in den Einstellungen ändern.`, - }, notFound: { chatYouLookingForCannotBeFound: 'Der Chat, den du suchst, kann nicht gefunden werden.', getMeOutOfHere: 'Hol mich hier raus', diff --git a/src/languages/en.ts b/src/languages/en.ts index 18763f33ccba..07ddcfaa1e1f 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -372,6 +372,10 @@ const translations = { subtitleText1: 'Find a chat using the', subtitleText2: 'button above, or create something using the', subtitleText3: 'button below.', + noUnreadChats: 'No unread chats', + noTodos: 'No to-dos', + caughtUp: "You're all caught up. Well done!", + seeAllChats: 'See all chats', }, businessName: 'Business name', clear: 'Clear', @@ -2981,6 +2985,16 @@ const translations = { }, }, }, + focusModeUpdateModal: { + title: 'Welcome to #focus mode!', + prompt: (priorityModePageUrl: string) => + `Stay on top of things by only seeing unread chats or chats that need your attention. Don’t worry, you can change this at any point in settings.`, + }, + inboxTabs: { + all: 'All', + todo: 'To-dos', + unread: 'Unread', + }, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, generatingPDF: 'Generate PDF', @@ -3555,11 +3569,6 @@ const translations = { month: 'Month', selectMonth: 'Please select a month', }, - focusModeUpdateModal: { - title: 'Welcome to #focus mode!', - prompt: (priorityModePageUrl: string) => - `Stay on top of things by only seeing unread chats or chats that need your attention. Don’t worry, you can change this at any point in settings.`, - }, notFound: { chatYouLookingForCannotBeFound: 'The chat you are looking for cannot be found.', getMeOutOfHere: 'Get me out of here', diff --git a/src/languages/es.ts b/src/languages/es.ts index 6f93af479e88..7fcd41c00d81 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -312,6 +312,10 @@ const translations: TranslationDeepObject = { subtitleText1: 'Encuentra un chat usando el botón', subtitleText2: 'o crea algo usando el botón', subtitleText3: '.', + noUnreadChats: 'No hay chats sin leer', + noTodos: 'No hay tareas pendientes', + caughtUp: 'Te has puesto al día. ¡Bien hecho!', + seeAllChats: 'Ver todos los chats', }, businessName: 'Nombre de la empresa', clear: 'Borrar', @@ -2779,6 +2783,12 @@ ${amount} para ${merchant} - ${date}`, }, }, }, + focusModeUpdateModal: { + title: '¡Bienvenido al modo #focus!', + prompt: (priorityModePageUrl: string) => + `Mantente al tanto de todo viendo solo los chats no leídos o los chats que necesitan tu atención. No te preocupes, puedes cambiarlo en cualquier momento en los ajustes.`, + }, + inboxTabs: {all: 'Todo', todo: 'Tareas pendientes', unread: 'No leído'}, reportDetailsPage: { inWorkspace: (policyName) => `en ${policyName}`, generatingPDF: 'Generar PDF', @@ -3342,11 +3352,6 @@ ${amount} para ${merchant} - ${date}`, month: 'Mes', selectMonth: 'Por favor, selecciona un mes', }, - focusModeUpdateModal: { - title: '¡Bienvenido al modo #concentración!', - prompt: (priorityModePageUrl) => - `Mantente al tanto de todo viendo sólo los chats no leídos o los que necesitan tu atención. No te preocupes, puedes cambiar el ajuste en cualquier momento desde la configuración.`, - }, notFound: { chatYouLookingForCannotBeFound: 'El chat que estás buscando no se pudo encontrar.', getMeOutOfHere: 'Sácame de aquí', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 6d624fc9171d..33ea55c01a96 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -355,6 +355,10 @@ const translations: TranslationDeepObject = { subtitleText1: 'Recherchez une discussion à l’aide de la', subtitleText2: 'bouton ci-dessus, ou créez quelque chose en utilisant le', subtitleText3: 'bouton ci-dessous.', + noUnreadChats: 'Aucune discussion non lue', + noTodos: 'Aucune tâche à faire', + caughtUp: 'Vous êtes à jour. Bravo !', + seeAllChats: 'Voir toutes les discussions', }, businessName: 'Nom de l’entreprise', clear: 'Effacer', @@ -2909,6 +2913,12 @@ ${amount} pour ${merchant} - ${date}`, }, }, }, + focusModeUpdateModal: { + title: 'Bienvenue dans le mode #focus !', + prompt: (priorityModePageUrl: string) => + `Gardez le contrôle en n’affichant que les discussions non lues ou celles qui nécessitent votre attention. Ne vous inquiétez pas, vous pouvez modifier ce réglage à tout moment dans les paramètres.`, + }, + inboxTabs: {all: 'Tout', todo: 'Tâches', unread: 'Non lu'}, reportDetailsPage: { inWorkspace: (policyName: string) => `dans ${policyName}`, generatingPDF: 'Générer le PDF', @@ -3478,11 +3488,6 @@ ${amount} pour ${merchant} - ${date}`, year: 'Année', selectYear: 'Veuillez sélectionner une année', }, - focusModeUpdateModal: { - title: 'Bienvenue en mode #focus !', - prompt: (priorityModePageUrl: string) => - `Gardez le contrôle en affichant uniquement les discussions non lues ou celles qui nécessitent votre attention. Ne vous inquiétez pas, vous pouvez modifier ce paramètre à tout moment dans les paramètres.`, - }, notFound: { chatYouLookingForCannotBeFound: 'La discussion que vous recherchez est introuvable.', getMeOutOfHere: 'Faites-moi sortir d’ici', diff --git a/src/languages/it.ts b/src/languages/it.ts index feb2a6d77ff2..1eaef9b1151a 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -356,6 +356,10 @@ const translations: TranslationDeepObject = { subtitleText1: 'Trova una chat usando la', subtitleText2: 'pulsante sopra oppure crea qualcosa utilizzando il', subtitleText3: 'pulsante qui sotto.', + noUnreadChats: 'Nessuna chat non letta', + noTodos: 'Nessuna attività da fare', + caughtUp: 'Hai gestito tutto. Ben fatto!', + seeAllChats: 'Vedi tutte le chat', }, businessName: 'Nome azienda', clear: 'Pulisci', @@ -2897,6 +2901,12 @@ ${amount} per ${merchant} - ${date}`, }, }, }, + focusModeUpdateModal: { + title: 'Benvenuto nella modalità #focus!', + prompt: (priorityModePageUrl: string) => + `Tieniti al passo vedendo solo le chat non lette o quelle che richiedono la tua attenzione. Non preoccuparti, puoi cambiare questa impostazione in qualsiasi momento nelle impostazioni.`, + }, + inboxTabs: {all: 'Tutti', todo: 'Attività da fare', unread: 'Non letti'}, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, generatingPDF: 'Genera PDF', @@ -3458,11 +3468,6 @@ ${amount} per ${merchant} - ${date}`, year: 'Anno', selectYear: 'Seleziona un anno', }, - focusModeUpdateModal: { - title: 'Benvenuto/a nella modalità #focus!', - prompt: (priorityModePageUrl: string) => - `Resta sempre aggiornato vedendo solo le chat non lette o quelle che richiedono la tua attenzione. Non preoccuparti, puoi modificare questa impostazione in qualsiasi momento nelle impostazioni.`, - }, notFound: { chatYouLookingForCannotBeFound: 'La chat che stai cercando non può essere trovata.', getMeOutOfHere: 'Fammi uscire di qui', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 649aff97fe24..0b50b3fc617c 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -355,6 +355,10 @@ const translations: TranslationDeepObject = { subtitleText1: 'チャットを検索するには', subtitleText2: '上のボタン、または次を使って何かを作成する', subtitleText3: '下のボタンを押してください。', + noUnreadChats: '未読のチャットはありません', + noTodos: 'To-do はありません', + caughtUp: 'すべて確認済みです。お疲れさまでした!', + seeAllChats: 'すべてのチャットを表示', }, businessName: '会社名', clear: 'クリア', @@ -2872,6 +2876,12 @@ ${date} の ${merchant} への ${amount}`, }, }, }, + focusModeUpdateModal: { + title: '#focus モードへようこそ!', + prompt: (priorityModePageUrl: string) => + `未読のチャットや対応が必要なチャットだけを表示して、状況を常に把握できるようにしましょう。いつでも設定で変更できます。`, + }, + inboxTabs: {all: 'すべて', todo: 'To-do リスト', unread: '未読'}, reportDetailsPage: { inWorkspace: (policyName: string) => `${policyName} 内`, generatingPDF: 'PDFを生成', @@ -3432,11 +3442,6 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' year: '年', selectYear: '年を選択してください', }, - focusModeUpdateModal: { - title: '#focusモードへようこそ!', - prompt: (priorityModePageUrl: string) => - `未読のチャットや対応が必要なチャットだけを表示して、常に状況を把握しましょう。いつでも設定から変更できます。`, - }, notFound: { chatYouLookingForCannotBeFound: 'お探しのチャットが見つかりません。', getMeOutOfHere: 'ここから出して', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index e63deddd0e6b..c46939c19bc0 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -355,6 +355,10 @@ const translations: TranslationDeepObject = { subtitleText1: 'Zoek een chat met de', subtitleText2: 'knop hierboven, of maak iets met de', subtitleText3: 'knop hieronder.', + noUnreadChats: 'Geen ongelezen chats', + noTodos: 'Geen taken', + caughtUp: 'Je bent helemaal bij. Goed gedaan!', + seeAllChats: 'Alle chats bekijken', }, businessName: 'Bedrijfsnaam', clear: 'Wissen', @@ -2894,6 +2898,12 @@ ${amount} voor ${merchant} - ${date}`, }, }, }, + focusModeUpdateModal: { + title: 'Welkom in #focus-modus!', + prompt: (priorityModePageUrl: string) => + `Blijf op de hoogte door alleen ongelezen chats of chats te zien die je aandacht nodig hebben. Geen zorgen, je kunt dit op elk moment wijzigen in de instellingen.`, + }, + inboxTabs: {all: 'Alles', todo: 'Te doen', unread: 'Ongelezen'}, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, generatingPDF: 'PDF genereren', @@ -3454,11 +3464,6 @@ ${amount} voor ${merchant} - ${date}`, year: 'Jaar', selectYear: 'Selecteer een jaar', }, - focusModeUpdateModal: { - title: 'Welkom bij de #focus-modus!', - prompt: (priorityModePageUrl: string) => - `Houd het overzicht door alleen ongelezen chats of chats die je aandacht nodig hebben te zien. Geen zorgen, je kunt dit op elk moment wijzigen in de instellingen.`, - }, notFound: { chatYouLookingForCannotBeFound: 'De chat die je zoekt, kan niet worden gevonden.', getMeOutOfHere: 'Haal me hier weg', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 27229922c876..36ec734fd64a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -355,6 +355,10 @@ const translations: TranslationDeepObject = { subtitleText1: 'Znajdź czat za pomocą', subtitleText2: 'przycisk powyżej lub utwórz coś za pomocą', subtitleText3: 'przycisk poniżej.', + noUnreadChats: 'Brak nieprzeczytanych czatów', + noTodos: 'Brak zadań', + caughtUp: 'Ze wszystkim już się uporałeś. Dobra robota!', + seeAllChats: 'Zobacz wszystkie czaty', }, businessName: 'Nazwa firmy', clear: 'Wyczyść', @@ -2888,6 +2892,12 @@ ${amount} dla ${merchant} - ${date}`, }, }, }, + focusModeUpdateModal: { + title: 'Witaj w trybie #focus!', + prompt: (priorityModePageUrl: string) => + `Bądź na bieżąco, widząc tylko nieprzeczytane czaty lub czaty wymagające twojej uwagi. Spokojnie, możesz to zmienić w dowolnym momencie w ustawieniach.`, + }, + inboxTabs: {all: 'Wszystko', todo: 'Zadania do wykonania', unread: 'Nieprzeczytane'}, reportDetailsPage: { inWorkspace: (policyName: string) => `w ${policyName}`, generatingPDF: 'Wygeneruj PDF', @@ -3445,11 +3455,6 @@ ${amount} dla ${merchant} - ${date}`, year: 'Rok', selectYear: 'Wybierz rok', }, - focusModeUpdateModal: { - title: 'Witamy w trybie #focus!', - prompt: (priorityModePageUrl: string) => - `Miej wszystko pod kontrolą, wyświetlając tylko nieprzeczytane czaty lub czaty wymagające Twojej uwagi. Nie martw się, możesz to zmienić w dowolnym momencie w ustawieniach.`, - }, notFound: { chatYouLookingForCannotBeFound: 'Nie można znaleźć czatu, którego szukasz.', getMeOutOfHere: 'Wyprowadź mnie stąd', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 23e7f33c3ec4..bf94ea74c331 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -355,6 +355,10 @@ const translations: TranslationDeepObject = { subtitleText1: 'Encontre um chat usando o', subtitleText2: 'botão acima ou crie algo usando o', subtitleText3: 'botão abaixo.', + noUnreadChats: 'Nenhum chat não lido', + noTodos: 'Nenhuma tarefa pendente', + caughtUp: 'Você está em dia. Muito bem!', + seeAllChats: 'Ver todas as conversas', }, businessName: 'Nome da empresa', clear: 'Limpar', @@ -2888,6 +2892,12 @@ ${amount} para ${merchant} - ${date}`, }, }, }, + focusModeUpdateModal: { + title: 'Bem-vindo ao modo #focus!', + prompt: (priorityModePageUrl: string) => + `Fique no controle vendo apenas chats não lidos ou que precisam da sua atenção. Não se preocupe, você pode mudar isso a qualquer momento em configurações.`, + }, + inboxTabs: {all: 'Todos', todo: 'Pendências', unread: 'Não lidas'}, reportDetailsPage: { inWorkspace: (policyName: string) => `em ${policyName}`, generatingPDF: 'Gerar PDF', @@ -3446,11 +3456,6 @@ ${amount} para ${merchant} - ${date}`, year: 'Ano', selectYear: 'Selecione um ano', }, - focusModeUpdateModal: { - title: 'Bem-vindo ao modo #focus!', - prompt: (priorityModePageUrl: string) => - `Mantenha tudo sob controle vendo apenas os chats não lidos ou que precisam da sua atenção. Não se preocupe, você pode alterar isso a qualquer momento em configurações.`, - }, notFound: { chatYouLookingForCannotBeFound: 'O chat que você está procurando não foi encontrado.', getMeOutOfHere: 'Me tire daqui', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2e6c1ae370f6..cfe7e1209612 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -355,6 +355,10 @@ const translations: TranslationDeepObject = { subtitleText1: '使用以下方式查找聊天', subtitleText2: '上方的按钮,或使用以下内容创建', subtitleText3: '下方按钮。', + noUnreadChats: '没有未读聊天', + noTodos: '没有待办事项', + caughtUp: '你已经全部处理完了。干得好!', + seeAllChats: '查看所有聊天', }, businessName: '公司名称', clear: '清除', @@ -2815,6 +2819,11 @@ ${amount},商户:${merchant} - 日期:${date}`, }, }, }, + focusModeUpdateModal: { + title: '欢迎使用 #focus 模式!', + prompt: (priorityModePageUrl: string) => `通过只查看未读聊天或需要你关注的聊天,随时掌握最新进展。别担心,你可以随时在设置中更改此项。`, + }, + inboxTabs: {all: '全部', todo: '待办事项', unread: '未读'}, reportDetailsPage: { inWorkspace: (policyName: string) => `在 ${policyName} 中`, generatingPDF: '生成 PDF', @@ -3370,10 +3379,6 @@ ${amount},商户:${merchant} - 日期:${date}`, year: '年份', selectYear: '请选择年份', }, - focusModeUpdateModal: { - title: '欢迎进入 #focus 模式!', - prompt: (priorityModePageUrl: string) => `通过仅查看未读聊天或需要你关注的聊天来随时掌握进展。别担心,你可以随时在设置中更改此项。`, - }, notFound: { chatYouLookingForCannotBeFound: '找不到您要查找的聊天。', getMeOutOfHere: '带我离开这里', diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 5cab9b06fe37..f7c45e88d58a 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -395,6 +395,7 @@ function getReportsToDisplayInLHN({ } const reportDraftComment = draftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${report.reportID}`]; + const isReportArchived = isArchivedReport(reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]); const {shouldDisplay, hasErrorsOtherThanFailedReceipt} = shouldDisplayReportInLHN({ report, @@ -406,7 +407,7 @@ function getReportsToDisplayInLHN({ draftComment: reportDraftComment, transactions, isOffline, - isReportArchived: isArchivedReport(reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]), + isReportArchived, reportAttributes, currentUserLogin, currentUserAccountID, @@ -414,8 +415,9 @@ function getReportsToDisplayInLHN({ if (shouldDisplay) { const requiresAttention = reportAttributes?.[report?.reportID]?.requiresAttention ?? false; - const hasAttentionOrError = requiresAttention || hasErrorsOtherThanFailedReceipt; - reportsToDisplay[reportID] = hasAttentionOrError ? {...report, requiresAttention, hasErrorsOtherThanFailedReceipt} : report; + const isUnreadReport = getIsUnreadReportForInboxTab(report, isReportArchived); + reportsToDisplay[reportID] = + requiresAttention || hasErrorsOtherThanFailedReceipt || isUnreadReport ? {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport} : report; } } @@ -476,6 +478,7 @@ function updateReportsToDisplayInLHN({ // Get the specific draft comment for this report instead of using a single draft comment for all reports // This fixes the issue where the current report's draft comment was incorrectly used to filter all reports const reportDraftComment = draftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${report.reportID}`]; + const isReportArchived = isArchivedReport(reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`] ?? {}); const {shouldDisplay, hasErrorsOtherThanFailedReceipt} = shouldDisplayReportInLHN({ report, @@ -487,7 +490,7 @@ function updateReportsToDisplayInLHN({ draftComment: reportDraftComment, transactions, isOffline, - isReportArchived: isArchivedReport(reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`] ?? {}), + isReportArchived, reportAttributes, currentUserLogin, currentUserAccountID, @@ -495,16 +498,19 @@ function updateReportsToDisplayInLHN({ if (shouldDisplay) { const requiresAttention = reportAttributes?.[report?.reportID]?.requiresAttention ?? false; - const hasAttentionOrError = requiresAttention || hasErrorsOtherThanFailedReceipt; + const isUnreadReport = getIsUnreadReportForInboxTab(report, isReportArchived); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const hasFlags = requiresAttention || hasErrorsOtherThanFailedReceipt || isUnreadReport; const existingEntry = displayedReports[reportID]; - if (hasAttentionOrError) { + if (hasFlags) { if ( existingEntry !== report || existingEntry?.requiresAttention !== requiresAttention || - existingEntry?.hasErrorsOtherThanFailedReceipt !== hasErrorsOtherThanFailedReceipt + existingEntry?.hasErrorsOtherThanFailedReceipt !== hasErrorsOtherThanFailedReceipt || + existingEntry?.isUnreadReport !== isUnreadReport ) { - getMutableCopy()[reportID] = {...report, requiresAttention, hasErrorsOtherThanFailedReceipt}; + getMutableCopy()[reportID] = {...report, requiresAttention, hasErrorsOtherThanFailedReceipt, isUnreadReport}; } } else if (existingEntry !== report) { getMutableCopy()[reportID] = report; @@ -1491,6 +1497,72 @@ function getRoomWelcomeMessage( return welcomeMessage; } +/** + * Whether a report should appear in the "Unread" Inbox tab: it has unread messages and is not muted. + * Computed once while building the LHN report set (which is cached/incremental) so the tab filter only reads a flag. + */ +function getIsUnreadReportForInboxTab(report: Report, isReportArchived: boolean): boolean { + // The `lastActorAccountID` guard matches getOptionData: it keeps chats whose only visible message was + // deleted out of the Unread tab even though isUnread() can still be true (lastVisibleActionCreated isn't reset). + return isUnread(report, undefined, isReportArchived) && !!report.lastActorAccountID && getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; +} + +/** Whether a report belongs in the "To-do" Inbox tab: it has an outstanding GBR (requiresAttention) or RBR (errors). */ +function getIsTodoReportForInboxTab(report: ReportsToDisplayInLHN[string]): boolean { + return !!report.requiresAttention || !!report.hasErrorsOtherThanFailedReceipt; +} + +/** + * Filters the already-ordered LHN report IDs down to the ones that belong to the active Inbox tab. + * The "All" tab returns everything (and still honors Most Recent / Focus mode upstream); the other + * tabs narrow that same set to reports requiring action (To-do) or with unread messages (Unread). + */ +function filterReportsForInboxTab(reportIDs: string[], reportsToDisplay: ReportsToDisplayInLHN, activeTab: ValueOf): string[] { + if (activeTab === CONST.INBOX_TAB.ALL) { + return reportIDs; + } + + return reportIDs.filter((reportID) => { + const report = reportsToDisplay[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + if (!report) { + return false; + } + + switch (activeTab) { + case CONST.INBOX_TAB.TODO: + return getIsTodoReportForInboxTab(report); + case CONST.INBOX_TAB.UNREAD: + return !!report.isUnreadReport; + default: + return true; + } + }); +} + +/** Counts how many of the ordered reports fall into the To-do and Unread Inbox tabs, for the count badge shown on each. */ +function getInboxTabCounts(reportIDs: string[], reportsToDisplay: ReportsToDisplayInLHN): Record { + let todoCount = 0; + let unreadCount = 0; + + for (const reportID of reportIDs) { + const report = reportsToDisplay[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + if (!report) { + continue; + } + if (getIsTodoReportForInboxTab(report)) { + todoCount++; + } + if (report.isUnreadReport) { + unreadCount++; + } + } + + return { + [CONST.INBOX_TAB.TODO]: todoCount, + [CONST.INBOX_TAB.UNREAD]: unreadCount, + }; +} + // Exported for unit testing only. Do not use directly in production code. export { categorizeReportsForLHN as _categorizeReportsForLHN, @@ -1507,4 +1579,6 @@ export default { getReportsToDisplayInLHN, updateReportsToDisplayInLHN, shouldDisplayReportInLHN, + filterReportsForInboxTab, + getInboxTabCounts, }; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 42d1aadf8c1c..d0656f9b1518 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -1020,6 +1020,10 @@ function updatePreferredSkinTone(skinTone: number) { API.write(WRITE_COMMANDS.UPDATE_PREFERRED_EMOJI_SKIN_TONE, parameters, {optimisticData}); } +function setInboxTab(tab: ValueOf) { + Onyx.merge(ONYXKEYS.NVP_INBOX_TAB, tab); +} + /** * Sync user chat priority mode with Onyx and Server * @param mode @@ -1925,12 +1929,13 @@ export { isBlockedFromConcierge, subscribeToUserEvents, updatePreferredSkinTone, + setInboxTab, + updateChatPriorityMode, setShouldUseStagingServer, togglePlatformMute, joinScreenShare, clearScreenShareRequest, generateStatementPDF, - updateChatPriorityMode, setContactMethodAsDefault, updateTheme, setHighContrastIntent, diff --git a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx index 1c26ec9a423a..916ff22e97b7 100644 --- a/src/pages/inbox/sidebar/BaseSidebarScreen.tsx +++ b/src/pages/inbox/sidebar/BaseSidebarScreen.tsx @@ -14,6 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isMobile} from '@libs/Browser'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import ONYXKEYS from '@src/ONYXKEYS'; +import InboxTabSelector from './InboxTabSelector'; import SidebarLinksData from './SidebarLinksData'; // Once the app finishes loading for the first time, we never show the skeleton again @@ -57,6 +58,7 @@ function BaseSidebarScreen() { shouldDisplaySearch={shouldUseNarrowLayout} shouldDisplayHelpButton={shouldUseNarrowLayout} /> + {!shouldShowSkeleton && } {shouldShowSkeleton ? ( (count > 0 ? count.toString() : undefined); + + const tabs: TabSelectorBaseItem[] = [ + { + key: CONST.INBOX_TAB.ALL, + title: translate('inboxTabs.all'), + }, + { + key: CONST.INBOX_TAB.UNREAD, + title: translate('inboxTabs.unread'), + badgeText: getBadgeText(inboxTabCounts[CONST.INBOX_TAB.UNREAD]), + isBadgeCondensed: true, + badgeStyles: styles.inboxTabBadge, + }, + { + key: CONST.INBOX_TAB.TODO, + title: translate('inboxTabs.todo'), + badgeText: getBadgeText(inboxTabCounts[CONST.INBOX_TAB.TODO]), + isBadgeCondensed: true, + badgeStyles: styles.inboxTabBadge, + }, + ]; + + return ( + + + { + if (key !== CONST.INBOX_TAB.ALL && key !== CONST.INBOX_TAB.UNREAD && key !== CONST.INBOX_TAB.TODO) { + return; + } + setActiveTab(key); + }} + equalWidth + contentContainerStyles={styles.pb1} + /> + + + ); +} + +InboxTabSelector.displayName = 'InboxTabSelector'; + +export default InboxTabSelector; diff --git a/src/pages/inbox/sidebar/SidebarLinks.tsx b/src/pages/inbox/sidebar/SidebarLinks.tsx index a367a114ca02..46e379148e9d 100644 --- a/src/pages/inbox/sidebar/SidebarLinks.tsx +++ b/src/pages/inbox/sidebar/SidebarLinks.tsx @@ -8,6 +8,7 @@ import LHNOptionsList from '@components/LHNOptionsList/LHNOptionsList'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import {useSidebarOrderedReportsActions} from '@hooks/useSidebarOrderedReports'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {setSidebarLoaded} from '@libs/actions/App'; @@ -39,6 +40,7 @@ function SidebarLinks({insets, optionListItems, priorityMode = CONST.PRIORITY_MO const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {setStickyReportID} = useSidebarOrderedReportsActions(); const [isLoadingReportData = true] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); useEffect(() => { @@ -69,9 +71,11 @@ function SidebarLinks({insets, optionListItems, priorityMode = CONST.PRIORITY_MO cancelSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${option.reportID}`); return; } + // Keep this report visible in the active To-do/Unread tab even after opening it marks it read. + setStickyReportID(option.reportID); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(option.reportID, actionTargetReportActionID)); }, - [shouldUseNarrowLayout, isActiveReport], + [shouldUseNarrowLayout, isActiveReport, setStickyReportID], ); const viewMode = priorityMode === CONST.PRIORITY_MODE.GSD ? CONST.OPTION_MODE.COMPACT : CONST.OPTION_MODE.DEFAULT; diff --git a/src/pages/inbox/sidebar/SidebarLinksData.tsx b/src/pages/inbox/sidebar/SidebarLinksData.tsx index 1c4879c15884..f6cac99bfaae 100644 --- a/src/pages/inbox/sidebar/SidebarLinksData.tsx +++ b/src/pages/inbox/sidebar/SidebarLinksData.tsx @@ -23,7 +23,7 @@ function SidebarLinksData({insets}: SidebarLinksDataProps) { const {translate} = useLocalize(); const [priorityMode = CONST.PRIORITY_MODE.DEFAULT] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); - const {orderedReports, currentReportID} = useSidebarOrderedReportsState('SidebarLinksData'); + const {filteredReports, currentReportID} = useSidebarOrderedReportsState('SidebarLinksData'); const currentReportIDRef = useRef(currentReportID); currentReportIDRef.current = currentReportID; @@ -80,7 +80,7 @@ function SidebarLinksData({insets}: SidebarLinksDataProps) { priorityMode={priorityMode ?? CONST.PRIORITY_MODE.DEFAULT} // Data props: isActiveReport={isActiveReport} - optionListItems={orderedReports} + optionListItems={filteredReports} /> ); diff --git a/src/styles/index.ts b/src/styles/index.ts index a9bf2f0a0511..c8240850d632 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4471,6 +4471,13 @@ const staticStyles = (theme: ThemeColors) => paddingHorizontal: 20, }, + inboxTabBadge: { + minWidth: 18, + height: 16, + marginLeft: 8, + justifyContent: 'center', + }, + scrollableTabSelector: { flexGrow: 0, }, @@ -6413,14 +6420,13 @@ const dynamicStyles = (theme: ThemeColors) => top: fileTopPosition, }) satisfies ViewStyle, - tabText: (isSelected: boolean, hasIcon = false) => - ({ - marginLeft: hasIcon ? 8 : 0, - ...FontUtils.fontFamily.platform.EXP_NEUE_BOLD, - color: isSelected ? theme.text : theme.textSupporting, - lineHeight: variables.lineHeightLarge, - fontSize: variables.fontSizeLabel, - }) satisfies TextStyle, + tabText: (isSelected: boolean, hasIcon = false): TextStyle => ({ + marginLeft: hasIcon ? 8 : 0, + ...FontUtils.fontFamily.platform.EXP_NEUE_BOLD, + color: isSelected ? theme.text : theme.textSupporting, + lineHeight: variables.lineHeightLarge, + fontSize: variables.fontSizeLabel, + }), tabBackground: (hovered: boolean, isFocused: boolean, isDisabled: boolean, background: string | Animated.AnimatedInterpolation) => { if (isDisabled) { diff --git a/tests/unit/useSidebarOrderedReportsTest.tsx b/tests/unit/useSidebarOrderedReportsTest.tsx index 442bd868d9d9..aa8d3bf916da 100644 --- a/tests/unit/useSidebarOrderedReportsTest.tsx +++ b/tests/unit/useSidebarOrderedReportsTest.tsx @@ -16,6 +16,8 @@ jest.mock('@libs/SidebarUtils', () => ({ sortReportsToDisplayInLHN: jest.fn(), getReportsToDisplayInLHN: jest.fn(), updateReportsToDisplayInLHN: jest.fn(), + filterReportsForInboxTab: jest.fn((reportIDs: string[]) => reportIDs), + getInboxTabCounts: jest.fn(() => ({})), })); jest.mock('@libs/Navigation/Navigation', () => ({ getActiveRouteWithoutParams: jest.fn(() => ''),