diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 202d637610f3..d06fa29aa180 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2243,6 +2243,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 39124a13d9e0..c736866c60d5 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -168,6 +168,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', @@ -1448,6 +1451,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/ScrollOffsetContextProvider.tsx b/src/components/ScrollOffsetContextProvider.tsx index f051497606d4..d85691307f2e 100644 --- a/src/components/ScrollOffsetContextProvider.tsx +++ b/src/components/ScrollOffsetContextProvider.tsx @@ -61,21 +61,25 @@ function getKey(route: PlatformStackRouteProp | 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/TabLabel.tsx b/src/components/TabSelector/TabLabel.tsx index e47490b89f38..c00d939cdb44 100644 --- a/src/components/TabSelector/TabLabel.tsx +++ b/src/components/TabSelector/TabLabel.tsx @@ -5,6 +5,7 @@ import type {StyleProp, TextStyle} from 'react-native'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; +import type {TabSelectorSize} from './types'; type TabLabelProps = { /** Title of the tab */ @@ -21,16 +22,20 @@ type TabLabelProps = { /** Text style */ textStyle?: StyleProp; + + /** Size variant */ + size?: TabSelectorSize; }; -function TabLabel({title = '', activeOpacity = 0, inactiveOpacity = 1, hasIcon = false, textStyle}: TabLabelProps) { +function TabLabel({title = '', activeOpacity = 0, inactiveOpacity = 1, hasIcon = false, textStyle, size}: TabLabelProps) { const styles = useThemeStyles(); + const smallTextStyle = size === 'small' ? styles.tabTextSmall : undefined; return ( {title} @@ -38,7 +43,7 @@ function TabLabel({title = '', activeOpacity = 0, inactiveOpacity = 1, hasIcon = {title} diff --git a/src/components/TabSelector/TabSelectorBase.tsx b/src/components/TabSelector/TabSelectorBase.tsx index 89d8b543730a..03b89482707c 100644 --- a/src/components/TabSelector/TabSelectorBase.tsx +++ b/src/components/TabSelector/TabSelectorBase.tsx @@ -26,6 +26,7 @@ function TabSelectorBase({ position, shouldShowLabelWhenInactive = true, equalWidth = false, + size, shouldShowProductTrainingTooltip = false, renderProductTrainingTooltip, }: TabSelectorBaseProps) { @@ -63,7 +64,7 @@ function TabSelectorBase({ }} ref={containerRef} style={styles.scrollableTabSelector} - contentContainerStyle={styles.tabSelectorContentContainer} + contentContainerStyle={[styles.tabSelectorContentContainer, size === 'small' && styles.tabSelectorContentContainerSmall]} horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="handled" @@ -122,7 +123,10 @@ function TabSelectorBase({ shouldShowProductTrainingTooltip={shouldShowProductTrainingTooltip} renderProductTrainingTooltip={renderProductTrainingTooltip} equalWidth={equalWidth} + size={size} 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 fb5ada5f9d94..7723d8722750 100644 --- a/src/components/TabSelector/TabSelectorItem.tsx +++ b/src/components/TabSelector/TabSelectorItem.tsx @@ -34,7 +34,10 @@ function TabSelectorItem({ shouldShowProductTrainingTooltip = false, renderProductTrainingTooltip, equalWidth = false, + size, badgeText, + isBadgeCondensed = false, + badgeStyles, isDisabled = false, pendingAction, }: TabSelectorItemProps) { @@ -58,6 +61,7 @@ function TabSelectorItem({ accessibilityRole={CONST.ROLE.TAB} style={[ styles.tabSelectorButton, + size === 'small' && styles.tabSelectorButtonSmall, styles.tabBackground(isHovered, isActive, isDisabled, backgroundColor), styles.userSelectNone, isOfflineWithPendingAction ? styles.offlineFeedbackPending : undefined, @@ -89,12 +93,15 @@ function TabSelectorItem({ activeOpacity={styles.tabOpacity(isDisabled, isHovered, isActive, activeOpacity, inactiveOpacity).opacity} inactiveOpacity={styles.tabOpacity(isDisabled, isHovered, isActive, inactiveOpacity, activeOpacity).opacity} hasIcon={!!icon} + size={size} /> )} {!!badgeText && ( )} diff --git a/src/components/TabSelector/types.ts b/src/components/TabSelector/types.ts index 4d5ac355b75c..813525bbc8ce 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'; @@ -45,6 +45,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; @@ -52,6 +58,8 @@ type TabSelectorBaseItem = WithSentryLabel & { pendingAction?: PendingAction; }; +type TabSelectorSize = 'default' | 'small'; + type TabSelectorBaseProps = { /** Tabs to render. */ tabs: TabSelectorBaseItem[]; @@ -77,6 +85,9 @@ type TabSelectorBaseProps = { /** Whether tabs should have equal width. */ equalWidth?: boolean; + /** Size variant for the tabs. 'small' uses a compact 28px height. */ + size?: TabSelectorSize; + /** Determines whether the product training tooltip should be displayed to the user. */ shouldShowProductTrainingTooltip?: boolean; @@ -121,6 +132,9 @@ type TabSelectorItemProps = WithSentryLabel & { /** Whether tabs should have equal width */ equalWidth?: boolean; + /** Size variant for the tabs. */ + size?: TabSelectorSize; + /** Determines whether the product training tooltip should be displayed to the user. */ shouldShowProductTrainingTooltip?: boolean; @@ -130,6 +144,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; @@ -182,4 +202,4 @@ type BackgroundColor = Animated.AnimatedInterpolation | string; type Opacity = 1 | 0 | Animated.AnimatedInterpolation; -export type {TabSelectorProps, BackgroundColor, GetBackgroundColorConfig, Opacity, GetOpacityConfig, TabSelectorBaseProps, TabSelectorBaseItem, TabSelectorItemProps}; +export type {TabSelectorProps, BackgroundColor, GetBackgroundColorConfig, Opacity, GetOpacityConfig, TabSelectorBaseProps, TabSelectorBaseItem, TabSelectorItemProps, TabSelectorSize}; diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index bd6bb42e0257..9c9350fa19c6 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 {getTransactionThreadReportID} from '@libs/MergeTransactionUtils'; import {isOneTransactionReport} from '@libs/ReportUtils'; @@ -33,23 +35,32 @@ type SidebarOrderedReportsStateContextValue = { orderedReportIDs: string[]; currentReportID: string | undefined; chatTabBrickRoad: BrickRoad; + activeTab: ValueOf; + inboxTabCounts: Record; }; type SidebarOrderedReportsActionsContextValue = { clearLHNCache: () => void; + setActiveTab: (tab: ValueOf) => void; }; -type ReportsToDisplayInLHN = Record; +type ReportsToDisplayInLHN = Record; const SidebarOrderedReportsStateContext = createContext({ orderedReports: [], 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: () => {}, }); const policyMapper = (policy: OnyxEntry): PartialPolicyForSidebar => @@ -74,6 +85,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); @@ -286,7 +299,14 @@ function SidebarOrderedReportsContextProvider({ const orderedReportIDs = useMemo(() => getOrderedReportIDs(), [getOrderedReportIDs]); - // Get the actual reports based on the ordered IDs + // Narrow the ordered reports down to the ones belonging to the active Inbox tab. The "All" tab + // returns everything (still honoring Most Recent / Focus mode from the ordering above). + const filteredReportIDs = useMemo(() => SidebarUtils.filterReportsForInboxTab(orderedReportIDs, reportsToDisplayInLHN, activeTab), [orderedReportIDs, reportsToDisplayInLHN, activeTab]); + + // 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) { @@ -297,7 +317,7 @@ function SidebarOrderedReportsContextProvider({ [chatReports], ); - const orderedReports = useMemo(() => getOrderedReports(orderedReportIDs), [getOrderedReports, orderedReportIDs]); + const orderedReports = useMemo(() => getOrderedReports(filteredReportIDs), [getOrderedReports, filteredReportIDs]); const clearLHNCache = useCallback(() => { Log.info('[useSidebarOrderedReports] Clearing sidebar cache manually via debug modal'); @@ -305,6 +325,10 @@ function SidebarOrderedReportsContextProvider({ setClearCacheDummyCounter((current) => current + 1); }, []); + const setActiveTab = useCallback((tab: ValueOf) => { + setInboxTab(tab); + }, []); + 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 @@ -317,30 +341,47 @@ function SidebarOrderedReportsContextProvider({ // 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. if ( - (!shouldUseNarrowLayout || orderedReportIDs.length === 0) && + (!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, - orderedReportIDs: updatedReportIDs, + orderedReportIDs: updatedFilteredIDs, currentReportID: derivedCurrentReportID, chatTabBrickRoad: getChatTabBrickRoad(updatedReportIDs, reportAttributes), + activeTab, + inboxTabCounts, }; } return { orderedReports, - orderedReportIDs, + orderedReportIDs: filteredReportIDs, currentReportID: derivedCurrentReportID, chatTabBrickRoad: getChatTabBrickRoad(orderedReportIDs, reportAttributes), + activeTab, + inboxTabCounts, }; - }, [getOrderedReportIDs, orderedReportIDs, derivedCurrentReportID, shouldUseNarrowLayout, getOrderedReports, orderedReports, reportAttributes]); + }, [ + getOrderedReportIDs, + orderedReportIDs, + filteredReportIDs, + derivedCurrentReportID, + shouldUseNarrowLayout, + getOrderedReports, + orderedReports, + reportAttributes, + activeTab, + inboxTabCounts, + reportsToDisplayInLHN, + ]); - const actionsValue: SidebarOrderedReportsActionsContextValue = useMemo(() => ({clearLHNCache}), [clearLHNCache]); + const actionsValue: SidebarOrderedReportsActionsContextValue = useMemo(() => ({clearLHNCache, setActiveTab}), [clearLHNCache, setActiveTab]); useEffect(() => { const hookExecutionDuration = performance.now() - hookStartTime.current; diff --git a/src/languages/de.ts b/src/languages/de.ts index d667dfc8f8e3..a006bca102bc 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2889,6 +2889,12 @@ ${amount} für ${merchant} – ${date}`, }, }, }, + focusModeUpdateModal: { + title: 'Willkommen im #Fokusmodus!', + prompt: (priorityModePageUrl: string) => + `Behalten Sie den Überblick, indem Sie nur ungelesene Chats oder Chats anzeigen, die Ihre Aufmerksamkeit erfordern. Keine Sorge, Sie können dies jederzeit in den Einstellungen ändern.`, + }, + inboxTabs: {all: 'Alle', todo: 'To-dos', unread: 'Ungelesen'}, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, generatingPDF: 'PDF erstellen', @@ -3442,11 +3448,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 ef8b6dc4e5a5..817b74657fda 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2960,6 +2960,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', @@ -3522,11 +3532,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 124f418aa5ba..75d8450d23d7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2768,6 +2768,12 @@ ${amount} para ${merchant} - ${date}`, }, }, }, + focusModeUpdateModal: { + title: '¡Bienvenido al modo #focus!', + prompt: (priorityModePageUrl: string) => + `Mantente al tanto de todo viendo solo los chats sin leer o los chats que necesitan tu atención. No te preocupes, puedes cambiar esto en cualquier momento en los ajustes.`, + }, + inboxTabs: {all: 'Todo', todo: 'Tareas pendientes', unread: 'Sin leer'}, reportDetailsPage: { inWorkspace: (policyName) => `en ${policyName}`, generatingPDF: 'Generar PDF', @@ -3318,11 +3324,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 ad9c07769040..f193a2ddae59 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2814,9 +2814,9 @@ ${amount} pour ${merchant} - ${date}`, title: 'Modifier l’agent', agentName: 'Nom de l’agent', instructions: 'Écrire des instructions personnalisées', - chatWithAgent: 'Discuter avec l\u2019agent', + chatWithAgent: 'Discuter avec l’agent', copilotIntoAccount: 'Copilote dans le compte', - deleteAgent: 'Supprimer l\u2019agent', + deleteAgent: 'Supprimer l’agent', deleteAgentTitle: 'Supprimer l’agent ?', deleteAgentMessage: 'Voulez-vous vraiment supprimer cet agent ? Cette action est irréversible.', }, @@ -2897,6 +2897,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 à faire', unread: 'Non lu'}, reportDetailsPage: { inWorkspace: (policyName: string) => `dans ${policyName}`, generatingPDF: 'Générer le PDF', @@ -2979,7 +2985,7 @@ ${amount} pour ${merchant} - ${date}`, phoneOrEmail: 'Téléphone ou e-mail', error: { agentSignInBlocked: - 'Les comptes d\u2019agent ne permettent pas de se connecter directement. Pour utiliser un agent, connectez-vous avec votre propre compte et accédez-y via Copilot.', + 'Les comptes d’agent ne permettent pas de se connecter directement. Pour utiliser un agent, connectez-vous avec votre propre compte et accédez-y via Copilot.', invalidFormatEmailLogin: 'L’adresse e-mail saisie est invalide. Veuillez corriger le format et réessayer.', }, cannotGetAccountDetails: 'Impossible de récupérer les détails du compte. Veuillez essayer de vous reconnecter.', @@ -3453,11 +3459,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 a6b6451b56b4..a28298ae0973 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2802,8 +2802,8 @@ ${amount} per ${merchant} - ${date}`, title: 'Modifica agente', agentName: 'Nome agente', instructions: 'Scrivi istruzioni personalizzate', - chatWithAgent: 'Chatta con l\u2019agente', - copilotIntoAccount: 'Copilot nell\u2019account', + chatWithAgent: 'Chatta con l’agente', + copilotIntoAccount: 'Copilot nell’account', deleteAgent: 'Elimina agente', deleteAgentTitle: 'Eliminare agente?', deleteAgentMessage: 'Sei sicuro di voler eliminare questo agente? Questa azione non può essere annullata.', @@ -2885,6 +2885,12 @@ ${amount} per ${merchant} - ${date}`, }, }, }, + focusModeUpdateModal: { + title: 'Benvenuto nella modalità #focus!', + prompt: (priorityModePageUrl: string) => + `Tieniti sempre aggiornato 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: 'Tutto', todo: 'Attività da fare', unread: 'Non letti'}, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, generatingPDF: 'Genera PDF', @@ -3434,11 +3440,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 6f7b222188dc..dc575c058d53 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2861,6 +2861,12 @@ ${date} の ${merchant} への ${amount}`, }, }, }, + focusModeUpdateModal: { + title: '#focus モードへようこそ!', + prompt: (priorityModePageUrl: string) => + `未読のチャットや対応が必要なチャットだけを表示して、状況を常に把握しましょう。設定から、いつでもこの設定を変更できます。`, + }, + inboxTabs: {all: 'すべて', todo: 'To-do リスト', unread: '未読'}, reportDetailsPage: { inWorkspace: (policyName: string) => `${policyName} 内`, generatingPDF: 'PDFを生成', @@ -3409,11 +3415,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 8c3fcff4afbf..fb26f2494b1b 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2882,6 +2882,12 @@ ${amount} voor ${merchant} - ${date}`, }, }, }, + focusModeUpdateModal: { + title: 'Welkom in de #focus-modus!', + prompt: (priorityModePageUrl: string) => + `Blijf alles bij 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: 'To-do’s', unread: 'Ongelezen'}, reportDetailsPage: { inWorkspace: (policyName: string) => `in ${policyName}`, generatingPDF: 'PDF genereren', @@ -3429,11 +3435,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 166583676332..4d982df7ad57 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2876,6 +2876,12 @@ ${amount} dla ${merchant} - ${date}`, }, }, }, + focusModeUpdateModal: { + title: 'Witaj w trybie #focus!', + prompt: (priorityModePageUrl: string) => + `Bądź na bieżąco, pokazując tylko nieprzeczytane czaty lub czaty wymagające twojej uwagi. Spokojnie, możesz to zmienić w każdej chwili w ustawieniach.`, + }, + inboxTabs: {all: 'Wszystkie', todo: 'Zadania', unread: 'Nieprzeczytane'}, reportDetailsPage: { inWorkspace: (policyName: string) => `w ${policyName}`, generatingPDF: 'Wygeneruj PDF', @@ -3421,11 +3427,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 e99cb41e547e..a87b31515b8b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2876,6 +2876,12 @@ ${amount} para ${merchant} - ${date}`, }, }, }, + focusModeUpdateModal: { + title: 'Bem-vindo ao modo #focus!', + prompt: (priorityModePageUrl: string) => + `Fique por dentro de tudo vendo apenas os 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: 'Tudo', todo: 'Tarefas', unread: 'Não lidas'}, reportDetailsPage: { inWorkspace: (policyName: string) => `em ${policyName}`, generatingPDF: 'Gerar PDF', @@ -3422,11 +3428,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 9743c91cd4c9..ed45e36029f5 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2804,6 +2804,11 @@ ${amount},商户:${merchant} - 日期:${date}`, }, }, }, + focusModeUpdateModal: { + title: '欢迎进入 #focus 模式!', + prompt: (priorityModePageUrl: string) => `只查看未读或需要你关注的聊天,时刻掌握最新进展。别担心,你随时可以在设置中更改此项。`, + }, + inboxTabs: {all: '全部', todo: '待办事项', unread: '未读'}, reportDetailsPage: { inWorkspace: (policyName: string) => `在 ${policyName} 中`, generatingPDF: '生成 PDF', @@ -3347,10 +3352,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 9db647aaef81..f665343db310 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -393,6 +393,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, @@ -404,7 +405,7 @@ function getReportsToDisplayInLHN({ draftComment: reportDraftComment, transactions, isOffline, - isReportArchived: isArchivedReport(reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]), + isReportArchived, reportAttributes, currentUserLogin, currentUserAccountID, @@ -412,8 +413,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; } } @@ -474,6 +476,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, @@ -485,7 +488,7 @@ function updateReportsToDisplayInLHN({ draftComment: reportDraftComment, transactions, isOffline, - isReportArchived: isArchivedReport(reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`] ?? {}), + isReportArchived, reportAttributes, currentUserLogin, currentUserAccountID, @@ -493,16 +496,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; @@ -1481,6 +1487,70 @@ 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 { + return isUnread(report, undefined, isReportArchived) && 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, @@ -1497,4 +1567,6 @@ export default { getReportsToDisplayInLHN, updateReportsToDisplayInLHN, shouldDisplayReportInLHN, + filterReportsForInboxTab, + getInboxTabCounts, }; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 90a829debb56..6d30a14c4881 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -1011,6 +1011,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 @@ -1914,12 +1918,13 @@ export { isBlockedFromConcierge, subscribeToUserEvents, updatePreferredSkinTone, + setInboxTab, + updateChatPriorityMode, setShouldUseStagingServer, togglePlatformMute, joinScreenShare, clearScreenShareRequest, generateStatementPDF, - updateChatPriorityMode, setContactMethodAsDefault, updateTheme, resetContactMethodValidateCodeSentState, 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 ( + + + setActiveTab(key as ValueOf)} + size="small" + equalWidth + /> + + + ); +} + +InboxTabSelector.displayName = 'InboxTabSelector'; + +export default InboxTabSelector; diff --git a/src/styles/index.ts b/src/styles/index.ts index 79e479f6bf4f..0d8a64abf75a 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4456,6 +4456,11 @@ const staticStyles = (theme: ThemeColors) => scrollMarginInline: variables.tabSelectorScrollMarginInline, }, + tabSelectorButtonSmall: { + height: variables.componentSizeSmall, + paddingHorizontal: 12, + }, + tabSelector: { flexDirection: 'row', paddingHorizontal: 20, @@ -4468,6 +4473,23 @@ const staticStyles = (theme: ThemeColors) => paddingHorizontal: 20, }, + tabSelectorContentContainerSmall: { + paddingTop: 0, + paddingBottom: 0, + }, + + tabTextSmall: { + fontSize: variables.fontSizeSmall, + lineHeight: 16, + }, + + inboxTabBadge: { + minWidth: 18, + height: 16, + marginLeft: 4, + justifyContent: 'center', + }, + scrollableTabSelector: { flexGrow: 0, }, @@ -6341,14 +6363,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/src/types/onyx/InboxTab.ts b/src/types/onyx/InboxTab.ts new file mode 100644 index 000000000000..3fc35c106b39 --- /dev/null +++ b/src/types/onyx/InboxTab.ts @@ -0,0 +1,7 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +/** The active Inbox tab filter (All, To-do, or Unread) */ +type InboxTab = ValueOf; + +export default InboxTab; 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(() => ''),