diff --git a/src/hooks/useNetworkWithOfflineStatus.ts b/src/hooks/useNetworkWithOfflineStatus.ts new file mode 100644 index 000000000000..9167b0cae969 --- /dev/null +++ b/src/hooks/useNetworkWithOfflineStatus.ts @@ -0,0 +1,28 @@ +import type {MutableRefObject} from 'react'; +import {useEffect, useRef} from 'react'; +import DateUtils from '@libs/DateUtils'; +import useLocalize from './useLocalize'; +import useNetwork from './useNetwork'; + +type UseNetworkWithOfflineStatus = {isOffline: boolean; lastOfflineAt: MutableRefObject; lastOnlineAt: MutableRefObject}; + +export default function useNetworkWithOfflineStatus(): UseNetworkWithOfflineStatus { + const {isOffline} = useNetwork(); + const {preferredLocale} = useLocalize(); + + // The last time/date the user went/was offline. If the user was never offline, it is set to undefined. + const lastOfflineAt = useRef(isOffline ? DateUtils.getLocalDateFromDatetime(preferredLocale) : undefined); + + // The last time/date the user went/was online. If the user was never online, it is set to undefined. + const lastOnlineAt = useRef(isOffline ? undefined : DateUtils.getLocalDateFromDatetime(preferredLocale)); + + useEffect(() => { + if (isOffline) { + lastOfflineAt.current = DateUtils.getLocalDateFromDatetime(preferredLocale); + } else { + lastOnlineAt.current = DateUtils.getLocalDateFromDatetime(preferredLocale); + } + }, [isOffline, preferredLocale]); + + return {isOffline, lastOfflineAt, lastOnlineAt}; +} diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index dba40f820a75..a354ea3d5444 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -9,7 +9,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {OnyxInputOrEntry, PrivatePersonalDetails} from '@src/types/onyx'; +import type {Locale, OnyxInputOrEntry, PrivatePersonalDetails} from '@src/types/onyx'; import type {JoinWorkspaceResolution, OriginalMessageChangeLog, OriginalMessageExportIntegration} from '@src/types/onyx/OriginalMessage'; import type Report from '@src/types/onyx/Report'; import type ReportAction from '@src/types/onyx/ReportAction'; @@ -126,7 +126,7 @@ function isDeletedAction(reportAction: OnyxInputOrEntry, lastReadTime: string) { +function isReportActionUnread(reportAction: OnyxEntry, lastReadTime?: string) { if (!lastReadTime) { return !isCreatedAction(reportAction); } @@ -1806,10 +1807,38 @@ function getReportActionsLength() { return Object.keys(allReportActions ?? {}).length; } +function wasActionCreatedWhileOffline(action: ReportAction, isOffline: boolean, lastOfflineAt: Date | undefined, lastOnlineAt: Date | undefined, locale: Locale): boolean { + // The user was never online. + if (!lastOnlineAt) { + return true; + } + + // The user never was never offline. + if (!lastOfflineAt) { + return false; + } + + const actionCreatedAt = DateUtils.getLocalDateFromDatetime(locale, action.created); + + // The action was created before the user went offline. + if (actionCreatedAt <= lastOfflineAt) { + return false; + } + + // The action was created while the user was offline. + if (isOffline || actionCreatedAt < lastOnlineAt) { + return true; + } + + // The action was created after the user went back online. + return false; +} + export { doesReportHaveVisibleActions, extractLinksFromMessageHtml, formatLastMessageText, + isReportActionUnread, getActionableMentionWhisperMessage, getAllReportActions, getCombinedReportActions, @@ -1917,6 +1946,7 @@ export { getRemovedConnectionMessage, getActionableJoinRequestPendingReportAction, getReportActionsLength, + wasActionCreatedWhileOffline, }; export type {LastVisibleMessage}; diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 541c5d44c6b2..58e7fe319359 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -13,7 +13,7 @@ import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/InvertedFlatList/BaseInve import {usePersonalDetails} from '@components/OnyxProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; +import useNetworkWithOfflineStatus from '@hooks/useNetworkWithOfflineStatus'; import usePrevious from '@hooks/usePrevious'; import useReportScrollManager from '@hooks/useReportScrollManager'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -123,14 +123,6 @@ function keyExtractor(item: OnyxTypes.ReportAction): string { return item.reportActionID; } -function isMessageUnread(message: OnyxTypes.ReportAction, lastReadTime?: string): boolean { - if (!lastReadTime) { - return !ReportActionsUtils.isCreatedAction(message); - } - - return !!(message && lastReadTime && message.created && lastReadTime < message.created); -} - const onScrollToIndexFailed = () => {}; function ReportActionsList({ @@ -162,7 +154,8 @@ function ReportActionsList({ const {windowHeight} = useWindowDimensions(); const {isInNarrowPaneModal, shouldUseNarrowLayout} = useResponsiveLayout(); - const {isOffline} = useNetwork(); + const {preferredLocale} = useLocalize(); + const {isOffline, lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus(); const route = useRoute>(); const reportScrollManager = useReportScrollManager(); const userActiveSince = useRef(DateUtils.getDBTime()); @@ -226,32 +219,81 @@ function ReportActionsList({ }, [report.reportID]); const prevUnreadMarkerReportActionID = useRef(null); + /** + * Whether a message is NOT from the active user and it was received while the user was offline. + */ + const wasMessageReceivedWhileOffline = useCallback( + (message: OnyxTypes.ReportAction) => + !ReportActionsUtils.wasActionTakenByCurrentUser(message) && + ReportActionsUtils.wasActionCreatedWhileOffline(message, isOffline, lastOfflineAt.current, lastOnlineAt.current, preferredLocale), + [isOffline, lastOfflineAt, lastOnlineAt, preferredLocale], + ); + + /** + * The index of the earliest message that was received while offline + */ + const earliestReceivedOfflineMessageIndex = useMemo(() => { + // Create a list of (sorted) indices of message that were received while offline + const receviedOfflineMessages = sortedReportActions.reduce((acc, message, index) => { + if (wasMessageReceivedWhileOffline(message)) { + acc[index] = index; + } + + return acc; + }, []); + + // The last index in the list is the earliest message that was received while offline + return receviedOfflineMessages.at(-1); + }, [sortedReportActions, wasMessageReceivedWhileOffline]); + /** * The reportActionID the unread marker should display above */ const unreadMarkerReportActionID = useMemo(() => { - const shouldDisplayNewMarker = (reportAction: OnyxTypes.ReportAction, index: number): boolean => { + const shouldDisplayNewMarker = (message: OnyxTypes.ReportAction, index: number): boolean => { const nextMessage = sortedVisibleReportActions.at(index + 1); - const isCurrentMessageUnread = isMessageUnread(reportAction, unreadMarkerTime); - const isNextMessageRead = !nextMessage || !isMessageUnread(nextMessage, unreadMarkerTime); - const shouldDisplay = isCurrentMessageUnread && isNextMessageRead && !ReportActionsUtils.shouldHideNewMarker(reportAction); - const isWithinVisibleThreshold = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? reportAction.created < (userActiveSince.current ?? '') : true; + const isNextMessageUnread = !!nextMessage && ReportActionsUtils.isReportActionUnread(nextMessage, unreadMarkerTime); + + // If the current message is the earliest message received while offline, we want to display the unread marker above this message. + const isEarliestReceivedOfflineMessage = index === earliestReceivedOfflineMessageIndex; + if (isEarliestReceivedOfflineMessage && !isNextMessageUnread) { + return true; + } + + const isWithinVisibleThreshold = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? message.created < (userActiveSince.current ?? '') : true; + + // If the unread marker should be hidden or is not within the visible area, don't show the unread marker. + if (ReportActionsUtils.shouldHideNewMarker(message) || !isWithinVisibleThreshold) { + return false; + } + + const isCurrentMessageUnread = ReportActionsUtils.isReportActionUnread(message, unreadMarkerTime); + + // If the current message is read or the next message is unread, don't show the unread marker. + if (!isCurrentMessageUnread || isNextMessageUnread) { + return false; + } // If no unread marker exists, don't set an unread marker for newly added messages from the current user. - const isFromCurrentUser = accountID === (ReportActionsUtils.isReportPreviewAction(reportAction) ? !reportAction.childLastActorAccountID : reportAction.actorAccountID); - const isNewMessage = !prevSortedVisibleReportActionsObjects[reportAction.reportActionID]; + const isFromCurrentUser = accountID === (ReportActionsUtils.isReportPreviewAction(message) ? !message.childLastActorAccountID : message.actorAccountID); + const isNewMessage = !prevSortedVisibleReportActionsObjects[message.reportActionID]; + // The unread marker will show if the action's `created` time is later than `unreadMarkerTime`. // The `unreadMarkerTime` has already been updated to match the optimistic action created time, // but once the new action is saved on the backend, the actual created time will be later than the optimistic one. // Therefore, we also need to prevent the unread marker from appearing for previously optimistic actions. - const isPreviouslyOptimistic = !!prevSortedVisibleReportActionsObjects[reportAction.reportActionID]?.isOptimisticAction && !reportAction.isOptimisticAction; + const isPreviouslyOptimistic = !!prevSortedVisibleReportActionsObjects[message.reportActionID]?.isOptimisticAction && !message.isOptimisticAction; const shouldIgnoreUnreadForCurrentUserMessage = !prevUnreadMarkerReportActionID.current && isFromCurrentUser && (isNewMessage || isPreviouslyOptimistic); - return shouldDisplay && isWithinVisibleThreshold && !shouldIgnoreUnreadForCurrentUserMessage; + return !shouldIgnoreUnreadForCurrentUserMessage; }; + // If there are message that were recevied while offline, + // we can skip checking all messages later than the earliest recevied offline message. + const startIndex = earliestReceivedOfflineMessageIndex ?? 0; + // Scan through each visible report action until we find the appropriate action to show the unread marker - for (let index = 0; index < sortedVisibleReportActions.length; index++) { + for (let index = startIndex; index < sortedVisibleReportActions.length; index++) { const reportAction = sortedVisibleReportActions.at(index); // eslint-disable-next-line react-compiler/react-compiler @@ -261,7 +303,7 @@ function ReportActionsList({ } return null; - }, [sortedVisibleReportActions, unreadMarkerTime, accountID, prevSortedVisibleReportActionsObjects]); + }, [accountID, earliestReceivedOfflineMessageIndex, prevSortedVisibleReportActionsObjects, sortedVisibleReportActions, unreadMarkerTime]); prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; /**