diff --git a/assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg b/assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg new file mode 100644 index 000000000000..9c0711fcaedc --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + diff --git a/src/CONST.ts b/src/CONST.ts index e2a1e79ccbb3..86c5bb8d2a0c 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1262,6 +1262,7 @@ const CONST = { ATTACHMENT_TYPE: { REPORT: 'r', NOTE: 'n', + SEARCH: 's', }, IMAGE_HIGH_RESOLUTION_THRESHOLD: 7000, @@ -5379,6 +5380,13 @@ const CONST = { APPROVED: 'approved', PAID: 'paid', }, + CHAT: { + ALL: 'all', + UNREAD: 'unread', + SENT: 'sent', + ATTACHMENTS: 'attachments', + LINKS: 'links', + }, }, CHAT_TYPES: { LINK: 'link', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f67298828d7d..7801f5cf6053 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -57,8 +57,13 @@ const ROUTES = { SEARCH_ADVANCED_FILTERS_IS: 'search/filters/is', SEARCH_REPORT: { - route: 'search/view/:reportID', - getRoute: (reportID: string) => `search/view/${reportID}` as const, + route: 'search/view/:reportID/:reportActionID?', + getRoute: (reportID: string, reportActionID?: string) => { + if (reportActionID) { + return `search/view/${reportID}/${reportActionID}` as const; + } + return `search/view/${reportID}` as const; + }, }, TRANSACTION_HOLD_REASON_RHP: 'search/hold', diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 14b7ac6f2313..c327d7fa6093 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -246,13 +246,14 @@ function AttachmentModal({ } if (typeof sourceURL === 'string') { - fileDownload(sourceURL, file?.name ?? ''); + const fileName = type === CONST.ATTACHMENT_TYPE.SEARCH ? FileUtils.getFileName(`${sourceURL}`) : file?.name; + fileDownload(sourceURL, fileName ?? ''); } // At ios, if the keyboard is open while opening the attachment, then after downloading // the attachment keyboard will show up. So, to fix it we need to dismiss the keyboard. Keyboard.dismiss(); - }, [isAuthTokenRequiredState, sourceState, file]); + }, [isAuthTokenRequiredState, sourceState, file, type]); /** * Execute the onConfirm callback and close the modal. @@ -460,7 +461,7 @@ function AttachmentModal({ let headerTitleNew = headerTitle; let shouldShowDownloadButton = false; let shouldShowThreeDotsButton = false; - if (!isEmptyObject(report)) { + if (!isEmptyObject(report) || type === CONST.ATTACHMENT_TYPE.SEARCH) { headerTitleNew = translate(isReceiptAttachment ? 'common.receipt' : 'common.attachment'); shouldShowDownloadButton = allowDownload && isDownloadButtonReadyToBeShown && !shouldShowNotFoundPage && !isReceiptAttachment && !isOffline && !isLocalSource; shouldShowThreeDotsButton = isReceiptAttachment && isModalOpen && threeDotsMenuItems.length !== 0; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index 771d2631379e..99699b9ef3c6 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -90,10 +90,8 @@ function ImageRenderer({tnode}: ImageRendererProps) { return; } - if (reportID) { - const route = ROUTES.ATTACHMENTS?.getRoute(reportID, type, source, accountID); - Navigation.navigate(route); - } + const route = ROUTES.ATTACHMENTS?.getRoute(reportID ?? '-1', type, source, accountID); + Navigation.navigate(route); }} onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs)) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx index e0df7e7081c5..ce822af14cb8 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type {CustomRendererProps, TBlock} from 'react-native-render-html'; +import {AttachmentContext} from '@components/AttachmentContext'; import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; import VideoPlayerPreview from '@components/VideoPlayerPreview'; import useCurrentReportID from '@hooks/useCurrentReportID'; @@ -28,19 +29,26 @@ function VideoRenderer({tnode, key}: VideoRendererProps) { return ( {({report}) => ( - { - const route = ROUTES.ATTACHMENTS.getRoute(report?.reportID ?? '-1', CONST.ATTACHMENT_TYPE.REPORT, sourceURL); - Navigation.navigate(route); - }} - /> + + {({accountID, type}) => ( + { + if (!sourceURL || !type) { + return; + } + const route = ROUTES.ATTACHMENTS.getRoute(report?.reportID ?? '-1', type, sourceURL, accountID); + Navigation.navigate(route); + }} + /> + )} + )} ); diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 0616794a8e3a..bc12bc6c135b 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -56,6 +56,7 @@ import CheckmarkCircle from '@assets/images/simple-illustrations/simple-illustra import CoffeeMug from '@assets/images/simple-illustrations/simple-illustration__coffeemug.svg'; import Coins from '@assets/images/simple-illustrations/simple-illustration__coins.svg'; import CommentBubbles from '@assets/images/simple-illustrations/simple-illustration__commentbubbles.svg'; +import CommentBubblesBlue from '@assets/images/simple-illustrations/simple-illustration__commentbubbles_blue.svg'; import ConciergeBubble from '@assets/images/simple-illustrations/simple-illustration__concierge-bubble.svg'; import ConciergeNew from '@assets/images/simple-illustrations/simple-illustration__concierge.svg'; import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg'; @@ -182,6 +183,7 @@ export { SmartScan, Hourglass, CommentBubbles, + CommentBubblesBlue, TrashCan, TeleScope, Profile, diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 76fd7d3524cb..f336740a8558 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -10,7 +10,7 @@ import type HeaderWithBackButtonProps from '@components/HeaderWithBackButton/typ import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; -import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import Text from '@components/Text'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; @@ -95,7 +95,7 @@ type SearchPageHeaderProps = { onSelectDeleteOption?: (itemsToDelete: string[]) => void; setOfflineModalOpen?: () => void; setDownloadErrorModalOpen?: () => void; - data?: TransactionListItemType[] | ReportListItemType[]; + data?: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[]; }; type SearchHeaderOptionValue = DeepValueOf | undefined; @@ -111,6 +111,8 @@ function getHeaderContent(type: SearchDataTypes): HeaderContent { return {icon: Illustrations.EnvelopeReceipt, titleText: 'workspace.common.invoices'}; case CONST.SEARCH.DATA_TYPES.TRIP: return {icon: Illustrations.Luggage, titleText: 'travel.trips'}; + case CONST.SEARCH.DATA_TYPES.CHAT: + return {icon: Illustrations.CommentBubblesBlue, titleText: 'common.chats'}; case CONST.SEARCH.DATA_TYPES.EXPENSE: default: return {icon: Illustrations.MoneyReceipts, titleText: 'common.expenses'}; @@ -135,6 +137,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa .filter( (item) => !SearchUtils.isTransactionListItemType(item) && + !SearchUtils.isReportActionListItemType(item) && item.reportID && item.transactions.every((transaction: {keyForList: string | number}) => selectedTransactions[transaction.keyForList]?.isSelected), ) diff --git a/src/components/Search/SearchStatusBar.tsx b/src/components/Search/SearchStatusBar.tsx index 7c1ffeff1818..0d2b8120609f 100644 --- a/src/components/Search/SearchStatusBar.tsx +++ b/src/components/Search/SearchStatusBar.tsx @@ -13,11 +13,12 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type IconAsset from '@src/types/utils/IconAsset'; -import type {ExpenseSearchStatus, InvoiceSearchStatus, SearchQueryString, SearchStatus, TripSearchStatus} from './types'; +import type {ChatSearchStatus, ExpenseSearchStatus, InvoiceSearchStatus, SearchQueryString, SearchStatus, TripSearchStatus} from './types'; type SearchStatusBarProps = { type: SearchDataTypes; status: SearchStatus; + resetOffset: () => void; }; const expenseOptions: Array<{key: ExpenseSearchStatus; icon: IconAsset; text: TranslationPaths; query: SearchQueryString}> = [ @@ -107,19 +108,54 @@ const tripOptions: Array<{key: TripSearchStatus; icon: IconAsset; text: Translat }, ]; +const chatOptions: Array<{key: ChatSearchStatus; icon: IconAsset; text: TranslationPaths; query: SearchQueryString}> = [ + { + key: CONST.SEARCH.STATUS.CHAT.ALL, + icon: Expensicons.All, + text: 'common.all', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.ALL), + }, + { + key: CONST.SEARCH.STATUS.CHAT.UNREAD, + icon: Expensicons.ChatBubbleUnread, + text: 'common.unread', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.UNREAD), + }, + { + key: CONST.SEARCH.STATUS.CHAT.SENT, + icon: Expensicons.Send, + text: 'common.sent', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.SENT), + }, + { + key: CONST.SEARCH.STATUS.CHAT.ATTACHMENTS, + icon: Expensicons.Document, + text: 'common.attachments', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.ATTACHMENTS), + }, + { + key: CONST.SEARCH.STATUS.CHAT.LINKS, + icon: Expensicons.Paperclip, + text: 'common.links', + query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.CHAT.LINKS), + }, +]; + function getOptions(type: SearchDataTypes) { switch (type) { case CONST.SEARCH.DATA_TYPES.INVOICE: return invoiceOptions; case CONST.SEARCH.DATA_TYPES.TRIP: return tripOptions; + case CONST.SEARCH.DATA_TYPES.CHAT: + return chatOptions; case CONST.SEARCH.DATA_TYPES.EXPENSE: default: return expenseOptions; } } -function SearchStatusBar({type, status}: SearchStatusBarProps) { +function SearchStatusBar({type, status, resetOffset}: SearchStatusBarProps) { const {singleExecution} = useSingleExecution(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -134,7 +170,10 @@ function SearchStatusBar({type, status}: SearchStatusBarProps) { showsHorizontalScrollIndicator={false} > {options.map((item, index) => { - const onPress = singleExecution(() => Navigation.setParams({q: item.query})); + const onPress = singleExecution(() => { + resetOffset(); + Navigation.setParams({q: item.query}); + }); const isActive = status === item.key; const isFirstItem = index === 0; const isLastItem = index === options.length - 1; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 8c4530b08b64..4a98d651e2b2 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -6,7 +6,7 @@ import {useOnyx} from 'react-native-onyx'; import ConfirmModal from '@components/ConfirmModal'; import DecisionModal from '@components/DecisionModal'; import SearchTableHeader from '@components/SelectionList/SearchTableHeader'; -import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import SearchStatusSkeleton from '@components/Skeletons/SearchStatusSkeleton'; @@ -54,7 +54,10 @@ function mapToTransactionItemWithSelectionInfo(item: TransactionListItemType, se return {...item, isSelected: selectedTransactions[item.keyForList]?.isSelected && canSelectMultiple}; } -function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListItemType, selectedTransactions: SelectedTransactions, canSelectMultiple: boolean) { +function mapToItemWithSelectionInfo(item: TransactionListItemType | ReportListItemType | ReportActionListItemType, selectedTransactions: SelectedTransactions, canSelectMultiple: boolean) { + if (SearchUtils.isReportActionListItemType(item)) { + return item; + } return SearchUtils.isTransactionListItemType(item) ? mapToTransactionItemWithSelectionInfo(item, selectedTransactions, canSelectMultiple) : { @@ -142,8 +145,8 @@ function Search({queryJSON}: SearchProps) { }; const getItemHeight = useCallback( - (item: TransactionListItemType | ReportListItemType) => { - if (SearchUtils.isTransactionListItemType(item)) { + (item: TransactionListItemType | ReportListItemType | ReportActionListItemType) => { + if (SearchUtils.isTransactionListItemType(item) || SearchUtils.isReportActionListItemType(item)) { return isLargeScreenWidth ? variables.optionRowHeight + listItemPadding : transactionItemMobileHeight + listItemPadding; } @@ -161,6 +164,8 @@ function Search({queryJSON}: SearchProps) { [isLargeScreenWidth], ); + const resetOffset = () => setOffset(0); + const getItemHeightMemoized = memoize(getItemHeight, { transformKey: ([item]) => { // List items are displayed differently on "L"arge and "N"arrow screens so the height will differ @@ -205,6 +210,7 @@ function Search({queryJSON}: SearchProps) { ) : ( @@ -219,9 +225,9 @@ function Search({queryJSON}: SearchProps) { return null; } - const ListItem = SearchUtils.getListItem(status); - const data = SearchUtils.getSections(status, searchResults.data, searchResults.search); - const sortedData = SearchUtils.getSortedSections(status, data, sortBy, sortOrder); + const ListItem = SearchUtils.getListItem(type, status); + const data = SearchUtils.getSections(type, status, searchResults.data, searchResults.search); + const sortedData = SearchUtils.getSortedSections(type, status, data, sortBy, sortOrder); const sortedSelectedData = sortedData.map((item) => mapToItemWithSelectionInfo(item, selectedTransactions, canSelectMultiple)); const shouldShowEmptyState = !isDataLoaded || data.length === 0; @@ -236,13 +242,17 @@ function Search({queryJSON}: SearchProps) { ); } - const toggleTransaction = (item: TransactionListItemType | ReportListItemType) => { + const toggleTransaction = (item: TransactionListItemType | ReportListItemType | ReportActionListItemType) => { + if (SearchUtils.isReportActionListItemType(item)) { + return; + } if (SearchUtils.isTransactionListItemType(item)) { if (!item.keyForList) { return; @@ -269,7 +279,7 @@ function Search({queryJSON}: SearchProps) { }); }; - const openReport = (item: TransactionListItemType | ReportListItemType) => { + const openReport = (item: TransactionListItemType | ReportListItemType | ReportActionListItemType) => { let reportID = SearchUtils.isTransactionListItemType(item) && !item.isFromOneTransactionReport ? item.transactionThreadReportID : item.reportID; if (!reportID) { @@ -282,6 +292,12 @@ function Search({queryJSON}: SearchProps) { SearchActions.createTransactionThread(hash, item.transactionID, reportID, item.moneyRequestReportActionID); } + if (SearchUtils.isReportActionListItemType(item)) { + const reportActionID = item.reportActionID; + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(reportID, reportActionID)); + return; + } + Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute(reportID)); }; @@ -332,10 +348,11 @@ function Search({queryJSON}: SearchProps) { - + sections={[{data: sortedSelectedData, isDisabled: false}]} - turnOnSelectionModeOnLongPress + turnOnSelectionModeOnLongPress={type !== CONST.SEARCH.DATA_TYPES.CHAT} onTurnOnSelectionMode={(item) => item && toggleTransaction(item)} onCheckboxPress={toggleTransaction} onSelectAll={toggleAllTransactions} @@ -352,7 +369,7 @@ function Search({queryJSON}: SearchProps) { /> ) } - canSelectMultiple={canSelectMultiple} + canSelectMultiple={type !== CONST.SEARCH.DATA_TYPES.CHAT && canSelectMultiple} customListHeaderHeight={searchHeaderHeight} // To enhance the smoothness of scrolling and minimize the risk of encountering blank spaces during scrolling, // we have configured a larger windowSize and a longer delay between batch renders. diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 9f2aca1ff957..b48afb75d0de 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -28,7 +28,8 @@ type SearchColumnType = ValueOf; type ExpenseSearchStatus = ValueOf; type InvoiceSearchStatus = ValueOf; type TripSearchStatus = ValueOf; -type SearchStatus = ExpenseSearchStatus | InvoiceSearchStatus | TripSearchStatus; +type ChatSearchStatus = ValueOf; +type SearchStatus = ExpenseSearchStatus | InvoiceSearchStatus | TripSearchStatus | ChatSearchStatus; type SearchContext = { currentSearchHash: number; @@ -88,4 +89,5 @@ export type { ExpenseSearchStatus, InvoiceSearchStatus, TripSearchStatus, + ChatSearchStatus, }; diff --git a/src/components/SelectionList/ChatListItem.tsx b/src/components/SelectionList/ChatListItem.tsx new file mode 100644 index 000000000000..1a27e0ecbfcf --- /dev/null +++ b/src/components/SelectionList/ChatListItem.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import {View} from 'react-native'; +import {AttachmentContext} from '@components/AttachmentContext'; +import MultipleAvatars from '@components/MultipleAvatars'; +import TextWithTooltip from '@components/TextWithTooltip'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import ReportActionItemDate from '@pages/home/report/ReportActionItemDate'; +import ReportActionItemFragment from '@pages/home/report/ReportActionItemFragment'; +import CONST from '@src/CONST'; +import BaseListItem from './BaseListItem'; +import type {ChatListItemProps, ListItem, ReportActionListItemType} from './types'; + +function ChatListItem({ + item, + isFocused, + showTooltip, + isDisabled, + canSelectMultiple, + onSelectRow, + onDismissError, + onFocus, + onLongPressRow, + shouldSyncFocus, +}: ChatListItemProps) { + const reportActionItem = item as unknown as ReportActionListItemType; + const from = reportActionItem.from; + const icons = [ + { + type: CONST.ICON_TYPE_AVATAR, + source: from.avatar, + name: reportActionItem.formattedFrom, + id: from.accountID, + }, + ]; + const styles = useThemeStyles(); + const theme = useTheme(); + const StyleUtils = useStyleUtils(); + + const attachmentContextValue = {type: CONST.ATTACHMENT_TYPE.SEARCH}; + + const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; + const hoveredBackgroundColor = styles.sidebarLinkHover?.backgroundColor ? styles.sidebarLinkHover.backgroundColor : theme.sidebar; + + return ( + + {(hovered) => ( + + + + + + + + + + + {reportActionItem.message.map((fragment, index) => ( + + ))} + + + + )} + + ); +} + +ChatListItem.displayName = 'ChatListItem'; + +export default ChatListItem; diff --git a/src/components/SelectionList/SearchTableHeader.tsx b/src/components/SelectionList/SearchTableHeader.tsx index cb1914824a20..f54532a7f318 100644 --- a/src/components/SelectionList/SearchTableHeader.tsx +++ b/src/components/SelectionList/SearchTableHeader.tsx @@ -85,6 +85,13 @@ const expenseHeaders: SearchColumnConfig[] = [ }, ]; +const SearchColumns = { + [CONST.SEARCH.DATA_TYPES.EXPENSE]: expenseHeaders, + [CONST.SEARCH.DATA_TYPES.INVOICE]: expenseHeaders, + [CONST.SEARCH.DATA_TYPES.TRIP]: expenseHeaders, + [CONST.SEARCH.DATA_TYPES.CHAT]: null, +}; + type SearchTableHeaderProps = { data: OnyxTypes.SearchResults['data']; metadata: OnyxTypes.SearchResults['search']; @@ -102,6 +109,10 @@ function SearchTableHeader({data, metadata, sortBy, sortOrder, onSortPress, shou const {translate} = useLocalize(); const displayNarrowVersion = isMediumScreenWidth || isSmallScreenWidth; + if (SearchColumns[metadata.type] === null) { + return; + } + if (displayNarrowVersion) { return; } @@ -109,7 +120,7 @@ function SearchTableHeader({data, metadata, sortBy, sortOrder, onSortPress, shou return ( - {expenseHeaders.map(({columnName, translationKey, shouldShow, isColumnSortable}) => { + {SearchColumns[metadata.type]?.map(({columnName, translationKey, shouldShow, isColumnSortable}) => { if (!shouldShow(data, metadata)) { return null; } diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index c484a59fee78..ea0fb35932dd 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -5,10 +5,11 @@ import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; import type CursorStyles from '@styles/utils/cursor/types'; import type CONST from '@src/CONST'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {SearchPersonalDetails, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults'; +import type {SearchPersonalDetails, SearchReport, SearchReportAction, SearchTransaction} from '@src/types/onyx/SearchResults'; import type {ReceiptErrors} from '@src/types/onyx/Transaction'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type IconAsset from '@src/types/utils/IconAsset'; +import type ChatListItem from './ChatListItem'; import type InviteMemberListItem from './InviteMemberListItem'; import type RadioListItem from './RadioListItem'; import type ReportListItem from './Search/ReportListItem'; @@ -206,6 +207,21 @@ type TransactionListItemType = ListItem & keyForList: string; }; +type ReportActionListItemType = ListItem & + SearchReportAction & { + /** The personal details of the user posting comment */ + from: SearchPersonalDetails; + + /** final and formatted "from" value used for displaying and sorting */ + formattedFrom: string; + + /** final "date" value used for sorting */ + date: string; + + /** Key used internally by React */ + keyForList: string; + }; + type ReportListItemType = ListItem & SearchReport & { /** The personal details of the user requesting money */ @@ -277,7 +293,16 @@ type TransactionListItemProps = ListItemProps; type ReportListItemProps = ListItemProps; -type ValidListItem = typeof RadioListItem | typeof UserListItem | typeof TableListItem | typeof InviteMemberListItem | typeof TransactionListItem | typeof ReportListItem; +type ChatListItemProps = ListItemProps; + +type ValidListItem = + | typeof RadioListItem + | typeof UserListItem + | typeof TableListItem + | typeof InviteMemberListItem + | typeof TransactionListItem + | typeof ReportListItem + | typeof ChatListItem; type Section = { /** Title of the section */ @@ -556,4 +581,6 @@ export type { TransactionListItemType, UserListItemProps, ValidListItem, + ReportActionListItemType, + ChatListItemProps, }; diff --git a/src/languages/en.ts b/src/languages/en.ts index c2902dc3d8a5..2c163aeee794 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -147,6 +147,7 @@ export default { buttonConfirm: 'Got it', name: 'Name', attachment: 'Attachment', + attachments: 'Attachments', center: 'Center', from: 'From', to: 'To', @@ -388,6 +389,10 @@ export default { importSpreadsheet: 'Import spreadsheet', offlinePrompt: "You can't take this action right now.", outstanding: 'Outstanding', + chats: 'Chats', + unread: 'Unread', + sent: 'Sent', + links: 'Links', days: 'days', }, location: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 9ce98e12b135..c2b0ae8a041f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -137,6 +137,7 @@ export default { buttonConfirm: 'Ok, entendido', name: 'Nombre', attachment: 'Archivo adjunto', + attachments: 'Archivos adjuntos', from: 'De', to: 'A', in: 'En', @@ -378,6 +379,10 @@ export default { import: 'Importar', offlinePrompt: 'No puedes realizar esta acción ahora mismo.', outstanding: 'Pendiente', + chats: 'Chats', + unread: 'No leído', + sent: 'Enviado', + links: 'Enlaces', days: 'días', }, connectionComplete: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index c8c2c0f0e41d..a9258029cba7 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1332,6 +1332,7 @@ type AuthScreensParamList = CentralPaneScreensParamList & type SearchReportParamList = { [SCREENS.SEARCH.REPORT_RHP]: { reportID: string; + reportActionID?: string; }; }; diff --git a/src/libs/SearchUtils.ts b/src/libs/SearchUtils.ts index e0a12e967bfa..e0832b6a1512 100644 --- a/src/libs/SearchUtils.ts +++ b/src/libs/SearchUtils.ts @@ -1,8 +1,9 @@ import type {ValueOf} from 'type-fest'; import type {ASTNode, QueryFilter, QueryFilters, SearchColumnType, SearchQueryJSON, SearchQueryString, SearchStatus, SortOrder} from '@components/Search/types'; +import ChatListItem from '@components/SelectionList/ChatListItem'; import ReportListItem from '@components/SelectionList/Search/ReportListItem'; import TransactionListItem from '@components/SelectionList/Search/TransactionListItem'; -import type {ListItem, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import type {ListItem, ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -76,10 +77,32 @@ function getTransactionItemCommonFormattedProperties( }; } +type ReportKey = `${typeof ONYXKEYS.COLLECTION.REPORT}${string}`; + +type TransactionKey = `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}`; + +type ReportActionKey = `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS}${string}`; + +function isReportEntry(key: string): key is ReportKey { + return key.startsWith(ONYXKEYS.COLLECTION.REPORT); +} + +function isTransactionEntry(key: string): key is TransactionKey { + return key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION); +} + +function isReportActionEntry(key: string): key is ReportActionKey { + return key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS); +} + function getShouldShowMerchant(data: OnyxTypes.SearchResults['data']): boolean { - return Object.values(data).some((item) => { - const merchant = item.modifiedMerchant ? item.modifiedMerchant : item.merchant ?? ''; - return merchant !== '' && merchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && merchant !== CONST.TRANSACTION.DEFAULT_MERCHANT; + return Object.keys(data).some((key) => { + if (isTransactionEntry(key)) { + const item = data[key]; + const merchant = item.modifiedMerchant ? item.modifiedMerchant : item.merchant ?? ''; + return merchant !== '' && merchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && merchant !== CONST.TRANSACTION.DEFAULT_MERCHANT; + } + return false; }); } @@ -89,11 +112,16 @@ function isReportListItemType(item: ListItem): item is ReportListItemType { return 'transactions' in item; } -function isTransactionListItemType(item: TransactionListItemType | ReportListItemType): item is TransactionListItemType { +function isTransactionListItemType(item: TransactionListItemType | ReportListItemType | ReportActionListItemType): item is TransactionListItemType { const transactionListItem = item as TransactionListItemType; return transactionListItem.transactionID !== undefined; } +function isReportActionListItemType(item: TransactionListItemType | ReportListItemType | ReportActionListItemType): item is ReportActionListItemType { + const reportActionListItem = item as ReportActionListItemType; + return reportActionListItem.reportActionID !== undefined; +} + function shouldShowYear(data: TransactionListItemType[] | ReportListItemType[] | OnyxTypes.SearchResults['data']): boolean { if (Array.isArray(data)) { return data.some((item: TransactionListItemType | ReportListItemType) => { @@ -110,14 +138,23 @@ function shouldShowYear(data: TransactionListItemType[] | ReportListItemType[] | }); } - for (const [key, transactionItem] of Object.entries(data)) { - if (key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { - const item = transactionItem as SearchTransaction; + for (const key in data) { + if (isTransactionEntry(key)) { + const item = data[key]; const date = TransactionUtils.getCreated(item); if (DateUtils.doesDateBelongToAPastYear(date)) { return true; } + } else if (isReportActionEntry(key)) { + const item = data[key]; + for (const action of Object.values(item)) { + const date = action.created; + + if (DateUtils.doesDateBelongToAPastYear(date)) { + return true; + } + } } } return false; @@ -128,9 +165,10 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata const doesDataContainAPastYearTransaction = shouldShowYear(data); - return Object.entries(data) - .filter(([key]) => key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) - .map(([, transactionItem]) => { + return Object.keys(data) + .filter(isTransactionEntry) + .map((key) => { + const transactionItem = data[key]; const from = data.personalDetailsList?.[transactionItem.accountID]; const to = data.personalDetailsList?.[transactionItem.managerID]; @@ -155,6 +193,26 @@ function getTransactionsSections(data: OnyxTypes.SearchResults['data'], metadata }); } +function getReportActionsSections(data: OnyxTypes.SearchResults['data']): ReportActionListItemType[] { + const reportActionItems: ReportActionListItemType[] = []; + for (const key in data) { + if (isReportActionEntry(key)) { + const reportActions = data[key]; + for (const reportAction of Object.values(reportActions)) { + const from = data.personalDetailsList?.[reportAction.accountID]; + reportActionItems.push({ + ...reportAction, + from, + formattedFrom: from?.displayName ?? from?.login ?? '', + date: reportAction.created, + keyForList: reportAction.reportActionID, + }); + } + } + } + return reportActionItems; +} + function getIOUReportName(data: OnyxTypes.SearchResults['data'], reportItem: SearchReport) { const payerPersonalDetails = data.personalDetailsList?.[reportItem.managerID ?? 0]; const payerName = payerPersonalDetails?.displayName ?? payerPersonalDetails?.login ?? translateLocal('common.hidden'); @@ -183,7 +241,7 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx const reportIDToTransactions: Record = {}; for (const key in data) { - if (key.startsWith(ONYXKEYS.COLLECTION.REPORT)) { + if (isReportEntry(key)) { const reportItem = {...data[key]}; const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportItem.reportID}`; const transactions = reportIDToTransactions[reportKey]?.transactions ?? []; @@ -192,12 +250,12 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx reportIDToTransactions[reportKey] = { ...reportItem, keyForList: reportItem.reportID, - from: data.personalDetailsList?.[reportItem.accountID], - to: data.personalDetailsList?.[reportItem.managerID], + from: data.personalDetailsList?.[reportItem.accountID ?? -1], + to: data.personalDetailsList?.[reportItem.managerID ?? -1], transactions, reportName: isIOUReport ? getIOUReportName(data, reportItem) : reportItem.reportName, }; - } else if (key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { + } else if (isTransactionEntry(key)) { const transactionItem = {...data[key]}; const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${transactionItem.reportID}`; @@ -233,15 +291,24 @@ function getReportSections(data: OnyxTypes.SearchResults['data'], metadata: Onyx return Object.values(reportIDToTransactions); } -function getListItem(status: SearchStatus): ListItemType { +function getListItem(type: SearchDataTypes, status: SearchStatus): ListItemType { + if (type === CONST.SEARCH.DATA_TYPES.CHAT) { + return ChatListItem; + } return status === CONST.SEARCH.STATUS.EXPENSE.ALL ? TransactionListItem : ReportListItem; } -function getSections(status: SearchStatus, data: OnyxTypes.SearchResults['data'], metadata: OnyxTypes.SearchResults['search']) { +function getSections(type: SearchDataTypes, status: SearchStatus, data: OnyxTypes.SearchResults['data'], metadata: OnyxTypes.SearchResults['search']) { + if (type === CONST.SEARCH.DATA_TYPES.CHAT) { + return getReportActionsSections(data); + } return status === CONST.SEARCH.STATUS.EXPENSE.ALL ? getTransactionsSections(data, metadata) : getReportSections(data, metadata); } -function getSortedSections(status: SearchStatus, data: ListItemDataType, sortBy?: SearchColumnType, sortOrder?: SortOrder) { +function getSortedSections(type: SearchDataTypes, status: SearchStatus, data: ListItemDataType, sortBy?: SearchColumnType, sortOrder?: SortOrder) { + if (type === CONST.SEARCH.DATA_TYPES.CHAT) { + return data; + } return status === CONST.SEARCH.STATUS.EXPENSE.ALL ? getSortedTransactionData(data as TransactionListItemType[], sortBy, sortOrder) : getSortedReportData(data as ReportListItemType[]); } @@ -612,6 +679,7 @@ export { isReportListItemType, isSearchResultsEmpty, isTransactionListItemType, + isReportActionListItemType, normalizeQuery, shouldShowYear, buildCannedSearchQuery, diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index 88b2a38b3603..1f259c96d625 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -32,6 +32,7 @@ function EmptySearchView({type}: EmptySearchViewProps) { buttonText: translate('search.searchResults.emptyTripResults.buttonText'), buttonAction: () => Navigation.navigate(ROUTES.TRAVEL_MY_TRIPS), }; + case CONST.SEARCH.DATA_TYPES.CHAT: case CONST.SEARCH.DATA_TYPES.EXPENSE: case CONST.SEARCH.DATA_TYPES.INVOICE: default: diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 810e7140060f..a83fd364fa4a 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -42,6 +42,12 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { icon: Expensicons.Receipt, route: ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: SearchUtils.buildCannedSearchQuery()}), }, + { + title: translate('common.chats'), + type: CONST.SEARCH.DATA_TYPES.CHAT, + icon: Expensicons.ChatBubbles, + route: ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: SearchUtils.buildCannedSearchQuery(CONST.SEARCH.DATA_TYPES.CHAT, CONST.SEARCH.STATUS.TRIP.ALL)}), + }, { title: translate('workspace.common.invoices'), type: CONST.SEARCH.DATA_TYPES.INVOICE, diff --git a/src/pages/home/report/ReportAttachments.tsx b/src/pages/home/report/ReportAttachments.tsx index 7140cd2d45c4..369d5cef6ee4 100644 --- a/src/pages/home/report/ReportAttachments.tsx +++ b/src/pages/home/report/ReportAttachments.tsx @@ -6,6 +6,7 @@ import type {Attachment} from '@components/Attachments/types'; import ComposerFocusManager from '@libs/ComposerFocusManager'; import Navigation from '@libs/Navigation/Navigation'; import type {AuthScreensParamList} from '@libs/Navigation/types'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -55,7 +56,8 @@ function ReportAttachments({route}: ReportAttachmentsProps) { ComposerFocusManager.setReadyToFocus(); }} onCarouselAttachmentChange={onCarouselAttachmentChange} - shouldShowNotFoundPage={!isLoadingApp && !report?.reportID} + shouldShowNotFoundPage={!isLoadingApp && type !== CONST.ATTACHMENT_TYPE.SEARCH && !report?.reportID} + isAuthTokenRequired={type === CONST.ATTACHMENT_TYPE.SEARCH} /> ); } diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index e67b5b30f1d6..98e3aa66fa00 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -1,18 +1,29 @@ import type {ValueOf} from 'type-fest'; import type {SearchStatus} from '@components/Search/types'; +import type ChatListItem from '@components/SelectionList/ChatListItem'; import type ReportListItem from '@components/SelectionList/Search/ReportListItem'; import type TransactionListItem from '@components/SelectionList/Search/TransactionListItem'; -import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import type CONST from '@src/CONST'; +import type ONYXKEYS from '@src/ONYXKEYS'; +import type ReportActionName from './ReportActionName'; /** Types of search data */ type SearchDataTypes = ValueOf; /** Model of search result list item */ -type ListItemType = T extends typeof CONST.SEARCH.STATUS.EXPENSE.ALL ? typeof TransactionListItem : typeof ReportListItem; +type ListItemType = C extends typeof CONST.SEARCH.DATA_TYPES.CHAT + ? typeof ChatListItem + : T extends typeof CONST.SEARCH.STATUS.EXPENSE.ALL + ? typeof TransactionListItem + : typeof ReportListItem; /** Model of search list item data type */ -type ListItemDataType = T extends typeof CONST.SEARCH.STATUS.EXPENSE.ALL ? TransactionListItemType[] : ReportListItemType[]; +type ListItemDataType = C extends typeof CONST.SEARCH.DATA_TYPES.CHAT + ? ReportActionListItemType[] + : T extends typeof CONST.SEARCH.STATUS.EXPENSE.ALL + ? TransactionListItemType[] + : ReportListItemType[]; /** Model of columns to show for search results */ type ColumnsToShow = { @@ -98,6 +109,39 @@ type SearchReport = { action?: SearchTransactionAction; }; +/** Model of report action search result */ +type SearchReportAction = { + /** The report action sender ID */ + accountID: number; + + /** The name (or type) of the action */ + actionName: ReportActionName; + + /** The report action created date */ + created: string; + + /** report action message */ + message: Array<{ + /** The type of the action item fragment. Used to render a corresponding component */ + type: string; + + /** The text content of the fragment. */ + text: string; + + /** The html content of the fragment. */ + html: string; + + /** Collection of accountIDs of users mentioned in message */ + whisperedTo?: number[]; + }>; + + /** The ID of the report action */ + reportActionID: string; + + /** The ID of the report */ + reportID: string; +}; + /** Model of transaction search result */ type SearchTransaction = { /** The ID of the transaction */ @@ -212,13 +256,23 @@ type SearchTransaction = { /** Types of searchable transactions */ type SearchTransactionType = ValueOf; +/** + * A utility type that creates a record where all keys are strings that start with a specified prefix. + */ +type PrefixedRecord = { + [Key in `${Prefix}${string}`]: ValueType; +}; + /** Model of search results */ type SearchResults = { /** Current search results state */ search: SearchResultsInfo; /** Search results data */ - data: Record> & Record; + data: PrefixedRecord & + Record> & + PrefixedRecord> & + PrefixedRecord; /** Whether search data is being fetched from server */ isLoading?: boolean; @@ -226,4 +280,4 @@ type SearchResults = { export default SearchResults; -export type {ListItemType, ListItemDataType, SearchTransaction, SearchTransactionType, SearchTransactionAction, SearchPersonalDetails, SearchDataTypes, SearchReport}; +export type {ListItemType, ListItemDataType, SearchTransaction, SearchTransactionType, SearchTransactionAction, SearchPersonalDetails, SearchDataTypes, SearchReport, SearchReportAction};