diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx index eb7d9324d2ab..ac15e900dcbd 100644 --- a/src/components/LocaleContextProvider.tsx +++ b/src/components/LocaleContextProvider.tsx @@ -38,7 +38,10 @@ type LocaleContextProps = { datetimeToRelative: (datetime: string) => string; /** Formats a datetime to local date and time string */ - datetimeToCalendarTime: (datetime: string, includeTimezone: boolean, isLowercase?: boolean) => string; + timestampToCalendarTime: (timestamp: number, isLowercase?: boolean) => string; + + /** Formats a datetime to local time string */ + datetimeToLocalString: (datetime: string, includeTimezone: boolean) => string; /** Updates date-fns internal locale */ updateLocale: () => void; @@ -64,7 +67,8 @@ const LocaleContext = createContext({ translate: () => '', numberFormat: () => '', datetimeToRelative: () => '', - datetimeToCalendarTime: () => '', + timestampToCalendarTime: () => '', + datetimeToLocalString: () => '', updateLocale: () => '', formatPhoneNumber: () => '', toLocaleDigit: () => '', @@ -89,10 +93,15 @@ function LocaleContextProvider({preferredLocale, currentUserPersonalDetails = {} const datetimeToRelative = useMemo(() => (datetime) => DateUtils.datetimeToRelative(locale, datetime), [locale]); - const datetimeToCalendarTime = useMemo( + const timestampToCalendarTime = useMemo( () => - (datetime, includeTimezone, isLowercase = false) => - DateUtils.datetimeToCalendarTime(locale, datetime, includeTimezone, selectedTimezone, isLowercase), + (timestamp, isLowercase = false) => + DateUtils.timestampToCalendarTime(locale, timestamp, selectedTimezone, isLowercase), + [locale, selectedTimezone], + ); + + const datetimeToLocalString = useMemo( + () => (datetime, includedTimezone) => DateUtils.datetimeToLocalString(locale, datetime, selectedTimezone, includedTimezone), [locale, selectedTimezone], ); @@ -111,7 +120,8 @@ function LocaleContextProvider({preferredLocale, currentUserPersonalDetails = {} translate, numberFormat, datetimeToRelative, - datetimeToCalendarTime, + timestampToCalendarTime, + datetimeToLocalString, updateLocale, formatPhoneNumber, toLocaleDigit, @@ -119,7 +129,19 @@ function LocaleContextProvider({preferredLocale, currentUserPersonalDetails = {} fromLocaleDigit, preferredLocale: locale, }), - [translate, numberFormat, datetimeToRelative, datetimeToCalendarTime, updateLocale, formatPhoneNumber, toLocaleDigit, toLocaleOrdinal, fromLocaleDigit, locale], + [ + translate, + numberFormat, + datetimeToRelative, + timestampToCalendarTime, + datetimeToLocalString, + updateLocale, + formatPhoneNumber, + toLocaleDigit, + toLocaleOrdinal, + fromLocaleDigit, + locale, + ], ); return {children}; diff --git a/src/components/withLocalize.tsx b/src/components/withLocalize.tsx index f198ed9156e1..04307e57ca6b 100755 --- a/src/components/withLocalize.tsx +++ b/src/components/withLocalize.tsx @@ -15,8 +15,11 @@ const withLocalizePropTypes = { /** Converts a datetime into a localized string representation that's relative to current moment in time */ datetimeToRelative: PropTypes.func.isRequired, - /** Formats a datetime to local date and time string */ - datetimeToCalendarTime: PropTypes.func.isRequired, + /** Formats a datetime to local date */ + timestampToCalendarTime: PropTypes.func.isRequired, + + /** Formats a datetime to local time string */ + datetimeToLocalString: PropTypes.func.isRequired, /** Updates date-fns internal locale */ updateLocale: PropTypes.func.isRequired, diff --git a/src/languages/en.ts b/src/languages/en.ts index b5902b243d38..868b43228951 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -220,9 +220,9 @@ export default { recentDestinations: 'Recent destinations', timePrefix: "It's", conjunctionFor: 'for', - todayAt: 'Today at', - tomorrowAt: 'Tomorrow at', - yesterdayAt: 'Yesterday at', + today: 'Today', + tomorrow: 'Tomorrow', + yesterday: 'Yesterday', conjunctionAt: 'at', genericErrorMessage: 'Oops... something went wrong and your request could not be completed. Please try again later.', error: { diff --git a/src/languages/es.ts b/src/languages/es.ts index d71a95329928..5b4dfa47055a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -210,9 +210,9 @@ export default { recentDestinations: 'Destinos recientes', timePrefix: 'Son las', conjunctionFor: 'para', - todayAt: 'Hoy a las', - tomorrowAt: 'Mañana a las', - yesterdayAt: 'Ayer a las', + today: 'Hoy', + tomorrow: 'Mañana', + yesterday: 'Ayer', conjunctionAt: 'a', genericErrorMessage: 'Ups... algo no ha ido bien y la acción no se ha podido completar. Por favor, inténtalo más tarde.', error: { diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 8bd37ddd698d..a4ac80860df5 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -8,6 +8,7 @@ import { endOfDay, endOfMonth, endOfWeek, + endOfYear, format, formatDistanceToNow, getDate, @@ -19,10 +20,12 @@ import { isSameYear, isValid, parse, + parseISO, set, setDefaultOptions, startOfDay, startOfWeek, + startOfYear, subDays, subMilliseconds, subMinutes, @@ -177,44 +180,74 @@ function isYesterday(date: Date, timeZone: SelectedTimezone): boolean { } /** - * Formats an ISO-formatted datetime string to local date and time string + * Formats timestamp to selected format type + */ +function formatTimestampToDate(timestamp: number, formatString: string = CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT) { + const date = new Date(timestamp); + return tzFormat(date, formatString); +} + +/** + * Get timestamp from datetime string + */ +function getTimestampFromDatetime(datetime: string): number { + const parsedISODate = parseISO(`${datetime}Z`); + return parsedISODate.getTime(); +} + +/** + * Formats timestamp to local date * * e.g. * - * Jan 20 at 5:30 PM within the past year - * Jan 20, 2019 at 5:30 PM anything over 1 year ago + * Jan 20 within current year + * Jan 20, 2019 anything before current year */ -function datetimeToCalendarTime(locale: Locale, datetime: string, includeTimeZone = false, currentSelectedTimezone: SelectedTimezone = timezone.selected, isLowercase = false): string { - const date = getLocalDateFromDatetime(locale, datetime, currentSelectedTimezone); - const tz = includeTimeZone ? ' [UTC]Z' : ''; - let todayAt = Localize.translate(locale, 'common.todayAt'); - let tomorrowAt = Localize.translate(locale, 'common.tomorrowAt'); - let yesterdayAt = Localize.translate(locale, 'common.yesterdayAt'); - const at = Localize.translate(locale, 'common.conjunctionAt'); - const weekStartsOn = getWeekStartsOn(); +function timestampToCalendarTime(locale: Locale, timestamp: number, currentSelectedTimezone: SelectedTimezone = timezone.selected, isLowercase = false): string { + const timestampToDate = formatTimestampToDate(timestamp, CONST.DATE.FNS_DATE_TIME_FORMAT_STRING); + const date = getLocalDateFromDatetime(locale, timestampToDate, currentSelectedTimezone); - const startOfCurrentWeek = startOfWeek(new Date(), {weekStartsOn}); - const endOfCurrentWeek = endOfWeek(new Date(), {weekStartsOn}); + let today = Localize.translate(locale, 'common.today'); + let tomorrow = Localize.translate(locale, 'common.tomorrow'); + let yesterday = Localize.translate(locale, 'common.yesterday'); + + const startOfCurrentYear = startOfYear(new Date()); + const endOfCurrentYear = endOfYear(new Date()); if (isLowercase) { - todayAt = todayAt.toLowerCase(); - tomorrowAt = tomorrowAt.toLowerCase(); - yesterdayAt = yesterdayAt.toLowerCase(); + today = today.toLowerCase(); + tomorrow = tomorrow.toLowerCase(); + yesterday = yesterday.toLowerCase(); } if (isToday(date, currentSelectedTimezone)) { - return `${todayAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`; + return today; } if (isTomorrow(date, currentSelectedTimezone)) { - return `${tomorrowAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`; + return tomorrow; } if (isYesterday(date, currentSelectedTimezone)) { - return `${yesterdayAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`; + return yesterday; } - if (date >= startOfCurrentWeek && date <= endOfCurrentWeek) { - return `${format(date, CONST.DATE.MONTH_DAY_ABBR_FORMAT)} ${at} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`; + if (date >= startOfCurrentYear && date <= endOfCurrentYear) { + return format(date, CONST.DATE.MONTH_DAY_ABBR_FORMAT); } - return `${format(date, CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT)} ${at} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`; + return format(date, CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT); +} + +/** + * Formats an ISO-formatted datetime string to time string + * + * e.g. + * + * 5:30 PM + */ + +function datetimeToLocalString(locale: Locale, datetime: string, currentSelectedTimezone: SelectedTimezone = timezone.selected, includeTimeZone = false): string { + const date = getLocalDateFromDatetime(locale, datetime, currentSelectedTimezone); + const tz = includeTimeZone ? ' [UTC]Z' : ''; + + return `${format(date, CONST.DATE.LOCAL_TIME_FORMAT)}${tz}`; } /** @@ -734,7 +767,8 @@ const DateUtils = { formatToLocalTime, getZoneAbbreviation, datetimeToRelative, - datetimeToCalendarTime, + timestampToCalendarTime, + datetimeToLocalString, startCurrentDateUpdater, getLocalDateFromDatetime, getCurrentTimezone, @@ -768,6 +802,8 @@ const DateUtils = { formatToSupportedTimezone, enrichMoneyRequestTimestamp, getLastBusinessDayOfMonth, + formatTimestampToDate, + getTimestampFromDatetime, }; export default DateUtils; diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index fddbbadde33c..e7658efb6673 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -40,6 +40,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import ControlSelection from '@libs/ControlSelection'; +import DateUtils from '@libs/DateUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as ErrorUtils from '@libs/ErrorUtils'; import focusTextInputAfterAnimation from '@libs/focusTextInputAfterAnimation'; @@ -80,6 +81,7 @@ import ReportActionItemMessageEdit from './ReportActionItemMessageEdit'; import ReportActionItemSingle from './ReportActionItemSingle'; import ReportActionItemThread from './ReportActionItemThread'; import ReportAttachmentsContext from './ReportAttachmentsContext'; +import ReportDateIndicator from './ReportDateIndicator'; const getDraftMessage = (drafts: OnyxCollection, reportID: string, action: OnyxTypes.ReportAction): string | undefined => { const originalReportID = ReportUtils.getOriginalReportID(reportID, action); @@ -141,6 +143,9 @@ type ReportActionItemProps = { /** Determines if the avatar is displayed as a subscript (positioned lower than normal) */ shouldShowSubscriptAvatar?: boolean; + /** Should we show the date indicator? */ + showDateIndicator: boolean; + /** Position index of the report action in the overall report FlatList view */ index: number; @@ -178,6 +183,7 @@ function ReportActionItem({ userWallet, shouldHideThreadDividerLine = false, shouldShowSubscriptAvatar = false, + showDateIndicator, policy, transaction, onPress = undefined, @@ -926,6 +932,7 @@ function ReportActionItem({ ? (Object.values(personalDetails ?? {}).filter((details) => whisperedToAccountIDs.includes(details?.accountID ?? -1)) as OnyxTypes.PersonalDetails[]) : []; const displayNamesWithTooltips = isWhisper ? ReportUtils.getDisplayNamesWithTooltips(whisperedToPersonalDetails, isMultipleParticipant) : []; + const indicatorTimestamp = action.reportActionTimestamp ?? DateUtils.getTimestampFromDatetime(action.created) ?? 0; return ( + {showDateIndicator && } {datetimeToCalendarTime(created, false, false)}; + return {datetimeToLocalString(created, false)}; } ReportActionItemDate.displayName = 'ReportActionItemDate'; diff --git a/src/pages/home/report/ReportActionItemParentAction.tsx b/src/pages/home/report/ReportActionItemParentAction.tsx index a32900fdd502..1640ef851c2d 100644 --- a/src/pages/home/report/ReportActionItemParentAction.tsx +++ b/src/pages/home/report/ReportActionItemParentAction.tsx @@ -141,6 +141,7 @@ function ReportActionItemParentAction({ index={index} isFirstVisibleReportAction={isFirstVisibleReportAction} shouldUseThreadDividerLine={shouldUseThreadDividerLine} + showDateIndicator={false} /> ))} diff --git a/src/pages/home/report/ReportActionItemThread.tsx b/src/pages/home/report/ReportActionItemThread.tsx index c0dbe2a3825d..ab8211a2909e 100644 --- a/src/pages/home/report/ReportActionItemThread.tsx +++ b/src/pages/home/report/ReportActionItemThread.tsx @@ -33,12 +33,12 @@ type ReportActionItemThreadProps = { function ReportActionItemThread({numberOfReplies, icons, mostRecentReply, childReportID, isHovered, onSecondaryInteraction}: ReportActionItemThreadProps) { const styles = useThemeStyles(); - const {translate, datetimeToCalendarTime} = useLocalize(); + const {translate, datetimeToLocalString} = useLocalize(); const numberOfRepliesText = numberOfReplies > CONST.MAX_THREAD_REPLIES_PREVIEW ? `${CONST.MAX_THREAD_REPLIES_PREVIEW}+` : `${numberOfReplies}`; const replyText = numberOfReplies === 1 ? translate('threads.reply') : translate('threads.replies'); - const timeStamp = datetimeToCalendarTime(mostRecentReply, false); + const timeStamp = datetimeToLocalString(mostRecentReply, false); return ( diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index c826e15985f5..1cb6d15d836f 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -216,6 +216,33 @@ function ReportActionsList({ opacity: opacity.value, })); + /** + * Determines whether we should display the date indicator label in chat messages. When message is freshly created + * it doesn't have reportActionTimestamp, so we need to check the created date instead. + * @return {Boolean} + */ + const shouldShowStaticDateIndicator = useCallback( + (index: number) => { + if (index === sortedReportActions.length - 1 || index === sortedReportActions.length - 2) { + return true; + } + + const currentItem = sortedReportActions[index]; + const nextItem = sortedReportActions[index + 1]; + + if (nextItem.reportActionTimestamp && currentItem.reportActionTimestamp) { + return DateUtils.formatTimestampToDate(currentItem.reportActionTimestamp) !== DateUtils.formatTimestampToDate(nextItem.reportActionTimestamp); + } + + if (nextItem.reportActionTimestamp && !currentItem.reportActionTimestamp) { + return DateUtils.formatTimestampToDate(DateUtils.getTimestampFromDatetime(currentItem.created)) !== DateUtils.formatTimestampToDate(nextItem.reportActionTimestamp); + } + + return false; + }, + [sortedReportActions], + ); + useEffect(() => { opacity.value = withTiming(1, {duration: 100}); }, [opacity]); @@ -558,18 +585,20 @@ function ReportActionsList({ shouldDisplayReplyDivider={sortedVisibleReportActions.length > 1} isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} shouldUseThreadDividerLine={shouldUseThreadDividerLine} + showDateIndicator={shouldShowStaticDateIndicator(index)} /> ), [ + reportActions, + parentReportAction, report, + transactionThreadReport, linkedReportActionID, sortedVisibleReportActions, mostRecentIOUReportActionID, shouldHideThreadDividerLine, shouldDisplayNewMarker, - parentReportAction, - reportActions, - transactionThreadReport, + shouldShowStaticDateIndicator, parentReportActionForTransactionThread, shouldUseThreadDividerLine, firstVisibleReportActionID, diff --git a/src/pages/home/report/ReportActionsListItemRenderer.tsx b/src/pages/home/report/ReportActionsListItemRenderer.tsx index 8782d6dbce55..2e4903e41674 100644 --- a/src/pages/home/report/ReportActionsListItemRenderer.tsx +++ b/src/pages/home/report/ReportActionsListItemRenderer.tsx @@ -41,7 +41,9 @@ type ReportActionsListItemRendererProps = { /** Should we display the new marker on top of the comment? */ shouldDisplayNewMarker: boolean; - /** Linked report action ID */ + /** should we display date indicator? */ + showDateIndicator: boolean; + linkedReportActionID?: string; /** Whether we should display "Replies" divider */ @@ -70,6 +72,7 @@ function ReportActionsListItemRenderer({ isFirstVisibleReportAction = false, shouldUseThreadDividerLine = false, parentReportActionForTransactionThread, + showDateIndicator, }: ReportActionsListItemRendererProps) { const shouldDisplayParentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED && ReportUtils.isChatThread(report) && !ReportActionsUtils.isTransactionThread(parentReportAction); @@ -109,6 +112,7 @@ function ReportActionsListItemRenderer({ childReportName: reportAction.childReportName, childManagerAccountID: reportAction.childManagerAccountID, childMoneyRequestCount: reportAction.childMoneyRequestCount, + reportActionTimestamp: reportAction.reportActionTimestamp, } as ReportAction), [ reportAction.reportActionID, @@ -139,6 +143,7 @@ function ReportActionsListItemRenderer({ reportAction.childReportName, reportAction.childManagerAccountID, reportAction.childMoneyRequestCount, + reportAction.reportActionTimestamp, ], ); @@ -177,6 +182,7 @@ function ReportActionsListItemRenderer({ index={index} isFirstVisibleReportAction={isFirstVisibleReportAction} shouldUseThreadDividerLine={shouldUseThreadDividerLine} + showDateIndicator={showDateIndicator} /> ); } diff --git a/src/pages/home/report/ReportDateIndicator.tsx b/src/pages/home/report/ReportDateIndicator.tsx new file mode 100644 index 000000000000..4ba7ca5554df --- /dev/null +++ b/src/pages/home/report/ReportDateIndicator.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import {View} from 'react-native'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {Styles} from '@src/styles'; + +type ReportDateIndicatorProps = { + style?: Styles; + created: number; +}; + +function ReportDateIndicator({style = {}, created}: ReportDateIndicatorProps) { + const {timestampToCalendarTime} = useLocalize(); + const styles = useThemeStyles(); + + return ( + + + {timestampToCalendarTime(created, false)} + + + ); +} + +ReportDateIndicator.displayName = 'ReportDateIndicator'; + +export default ReportDateIndicator; diff --git a/src/styles/index.ts b/src/styles/index.ts index 4555e2e1001b..30565135ecd9 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4624,6 +4624,25 @@ const styles = (theme: ThemeColors) => borderColor: theme.icon, }, + chatItemDateIndicatorWrapper: { + zIndex: 1, + }, + + chatItemDateIndicator: { + borderWidth: 1, + borderRadius: variables.componentBorderRadiusSmall, + borderColor: theme.border, + backgroundColor: theme.componentBG, + paddingHorizontal: 12, + }, + + chatItemDateIndicatorText: { + fontSize: variables.fontSizeLabel, + lineHeight: 14, + fontWeight: FontUtils.fontWeight.bold, + color: theme.text, + }, + headerProgressBarContainer: { position: 'absolute', width: '100%', diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index 9df0113168e4..04686732aac6 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -73,20 +73,20 @@ describe('DateUtils', () => { }); it('should return the date in calendar time when calling datetimeToCalendarTime', () => { - const today = setMinutes(setHours(new Date(), 14), 32).toString(); - expect(DateUtils.datetimeToCalendarTime(LOCALE, today)).toBe('Today at 2:32 PM'); + const today = setMinutes(setHours(new Date(), 14), 32).getTime(); + expect(DateUtils.timestampToCalendarTime(LOCALE, today)).toBe('Today'); - const tomorrow = addDays(setMinutes(setHours(new Date(), 14), 32), 1).toString(); - expect(DateUtils.datetimeToCalendarTime(LOCALE, tomorrow)).toBe('Tomorrow at 2:32 PM'); + const tomorrow = addDays(setMinutes(setHours(new Date(), 14), 32), 1).getTime(); + expect(DateUtils.timestampToCalendarTime(LOCALE, tomorrow)).toBe('Tomorrow'); - const yesterday = setMinutes(setHours(subDays(new Date(), 1), 7), 43).toString(); - expect(DateUtils.datetimeToCalendarTime(LOCALE, yesterday)).toBe('Yesterday at 7:43 AM'); + const yesterday = setMinutes(setHours(subDays(new Date(), 1), 7), 43).getTime(); + expect(DateUtils.timestampToCalendarTime(LOCALE, yesterday)).toBe('Yesterday'); - const date = setMinutes(setHours(new Date('2022-11-05'), 10), 17).toString(); - expect(DateUtils.datetimeToCalendarTime(LOCALE, date)).toBe('Nov 5, 2022 at 10:17 AM'); + const date = setMinutes(setHours(new Date('2022-11-05'), 10), 17).getTime(); + expect(DateUtils.timestampToCalendarTime(LOCALE, date)).toBe('Nov 5, 2022'); - const todayLowercaseDate = setMinutes(setHours(new Date(), 14), 32).toString(); - expect(DateUtils.datetimeToCalendarTime(LOCALE, todayLowercaseDate, false, undefined, true)).toBe('today at 2:32 PM'); + const todayLowercaseDate = setMinutes(setHours(new Date(), 14), 32).getTime(); + expect(DateUtils.timestampToCalendarTime(LOCALE, todayLowercaseDate, undefined, true)).toBe('today'); }); it('should update timezone if automatic and selected timezone do not match', () => {