Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
c52f485
Show New marker when user marks their own message as unread
MelvinBot Jul 2, 2026
669f3f5
Re-trigger CI (flaky iOS build)
MelvinBot Jul 15, 2026
5f489a4
Anchor New marker on self-marked-unread action via stable reportActionID
MelvinBot Jul 23, 2026
5513cf7
Fix: register manuallyMarkedUnreadReportActionID in Report key enumer…
MelvinBot Jul 23, 2026
5e78e87
Fix: add manuallyMarkedUnreadReportActionID case to validateReportDra…
MelvinBot Jul 23, 2026
a973b57
Merge remote-tracking branch 'origin/main' into claude-selfAuthoredUn…
MelvinBot Jul 24, 2026
233a43f
Restore prevUnreadMarkerReportActionID guard for self-authored actions
MelvinBot Jul 24, 2026
8c48e50
Update unit test to assert self-authored cold-open guard
MelvinBot Jul 24, 2026
4f7bc57
Only skip auto-read on reconnect when the marked action was previousl…
MelvinBot Jul 30, 2026
601ef78
Revert "Only skip auto-read on reconnect when the marked action was p…
MelvinBot Jul 30, 2026
f76655a
Clear manuallyMarkedUnreadReportActionID when the current user sends …
MelvinBot Jul 30, 2026
1b0eb1e
Skip reconnect auto-read only for an optimistic manually-unread action
MelvinBot Jul 30, 2026
efed22e
Revert "Skip reconnect auto-read only for an optimistic manually-unre…
MelvinBot Jul 30, 2026
31cacdd
Revert "Clear manuallyMarkedUnreadReportActionID when the current use…
MelvinBot Jul 30, 2026
5882567
Clear manually-unread marker by not bailing before readNewestAction
MelvinBot Jul 30, 2026
6dfac6a
Keep unread marker on the marked message when a newer self-message is…
MelvinBot Jul 30, 2026
79bdb15
Anchor manual-unread marker on the marked action regardless of adjace…
MelvinBot Jul 31, 2026
0a8dfe8
Remove manuallyMarkedUnreadReportActionID guard from handleReportChan…
MelvinBot Jul 31, 2026
ecab87e
Retain new marker through auto-read and make manual-unread the sole m…
MelvinBot Aug 18, 2026
ff1c441
Clear manuallyMarkedUnreadReportActionID on completed openReport reload
MelvinBot Aug 18, 2026
d2af5d9
Reconcile manual unread marker only on genuine report reload, not eve…
MelvinBot Aug 18, 2026
d166efe
Clear manual unread marker on the second open of the report, not the …
MelvinBot Aug 18, 2026
de87a27
Clear manual unread marker on navigate-away-and-back, not on refresh …
MelvinBot Aug 18, 2026
22e8fc7
Move manual unread marker clear from successData to optimisticData in…
MelvinBot Aug 26, 2026
4d5e09d
Clear manuallyMarkedUnreadReportActionID on every openReport
MelvinBot Aug 26, 2026
a1d047d
Revert "Clear manuallyMarkedUnreadReportActionID on every openReport"
MelvinBot Aug 26, 2026
3e501fc
Also clear manual unread marker on page refresh, not only navigate-back
MelvinBot Aug 26, 2026
bb823e6
Merge remote-tracking branch 'origin/main' into claude-selfAuthoredUn…
MelvinBot Aug 26, 2026
825fb5e
Fix failing unread-marker tests: move marker when marked action is de…
MelvinBot Aug 26, 2026
81fda36
Don't populate prevUnreadMarkerReportActionID from the manual-mark an…
MelvinBot Aug 26, 2026
3dbc70c
Move self-message marker-hop guard into the display decision
MelvinBot Aug 27, 2026
98a8e1d
Update readNewestAction comment to reflect the openReport clearing me…
MelvinBot Aug 27, 2026
2657b78
Only suppress self-marker hop while previous anchor is still present
MelvinBot Aug 27, 2026
162c775
Merge remote-tracking branch 'origin/main' into claude-selfAuthoredUn…
MelvinBot Aug 27, 2026
5e43f7d
Discard ignored navigateToConciergeChat promise in fire-and-forget ca…
MelvinBot Aug 27, 2026
01c8744
Don't reassert manuallyMarkedUnreadReportActionID in markAsUnread suc…
MelvinBot Aug 27, 2026
16ac539
Apply review suggestions: trim marker comments; clear manual unread m…
MelvinBot Sep 8, 2026
75b2104
Add readNewestAction coverage for manual unread mark clearing
MelvinBot Sep 8, 2026
b5b7c7a
Revert readNewestAction manual-unread clearing per review; keep marke…
MelvinBot Sep 8, 2026
36a44ea
Roll back to 01c874428b96bca71d9f993d8662b679d824dea6
MelvinBot Sep 8, 2026
d3fe775
Trim the self-authored marker-hop comment per review
MelvinBot Sep 8, 2026
d1a2d52
Trim the prevUnreadMarkerReportActionID tracking comment per review
MelvinBot Sep 8, 2026
f03b445
Trim the readNewestAction manual-unread comment per review
MelvinBot Sep 8, 2026
25eb162
Merge remote-tracking branch 'origin/main' into claude-selfAuthoredUn…
MelvinBot Sep 8, 2026
3b117ad
Use connectWithoutView for the RAM-only report loading state to fix E…
MelvinBot Sep 8, 2026
c4a3edf
Pass hasOnceLoadedReportActions to openReport instead of reading it f…
MelvinBot Sep 10, 2026
c4a3e80
Merge remote-tracking branch 'origin/main' into claude-selfAuthoredUn…
MelvinBot Sep 14, 2026
3e967d6
Make the unread-marker comments more concise
MelvinBot Sep 15, 2026
b62d637
Merge remote-tracking branch 'origin/main' into claude-selfAuthoredUn…
MelvinBot Sep 15, 2026
a1989f7
Merge remote-tracking branch 'origin/main' into claude-selfAuthoredUn…
MelvinBot Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/hooks/useUnreadMarker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type UseUnreadMarkerResult = {
};

const lastReadTimeSelector = (report: OnyxTypes.Report | undefined) => report?.lastReadTime ?? '';
const manuallyMarkedUnreadReportActionIDSelector = (report: OnyxTypes.Report | undefined) => report?.manuallyMarkedUnreadReportActionID ?? null;

function useUnreadMarker({
reportID,
Expand All @@ -74,6 +75,10 @@ function useUnreadMarker({
});
const reportLastReadTime = reportLastReadTimeValue ?? '';

const [manuallyMarkedUnreadReportActionID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {
selector: manuallyMarkedUnreadReportActionIDSelector,
});

const [unreadMarkerTime, setUnreadMarkerTime] = useState(reportLastReadTime);

useEffect(() => {
Expand Down Expand Up @@ -131,6 +136,7 @@ function useUnreadMarker({
isReversed,
isAnonymousUser,
prevUnreadMarkerReportActionID,
manuallyMarkedUnreadReportActionID,
hasWindowFocus: Visibility.hasFocus(),
newMessageBoundaryTime,
});
Expand Down
2 changes: 2 additions & 0 deletions src/libs/DebugUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ function validateReportDraftProperty(key: keyof Report | keyof ReportNameValuePa
case 'lastMessageText':
case 'lastVisibleActionCreated':
case 'lastReadTime':
case 'manuallyMarkedUnreadReportActionID':
case 'lastMentionedTime':
case 'policyAvatar':
case 'policyName':
Expand Down Expand Up @@ -639,6 +640,7 @@ function validateReportDraftProperty(key: keyof Report | keyof ReportNameValuePa
lastMessageText: CONST.RED_BRICK_ROAD_PENDING_ACTION,
lastVisibleActionCreated: CONST.RED_BRICK_ROAD_PENDING_ACTION,
lastReadTime: CONST.RED_BRICK_ROAD_PENDING_ACTION,
manuallyMarkedUnreadReportActionID: CONST.RED_BRICK_ROAD_PENDING_ACTION,
lastReadSequenceNumber: CONST.RED_BRICK_ROAD_PENDING_ACTION,
lastMentionedTime: CONST.RED_BRICK_ROAD_PENDING_ACTION,
policyAvatar: CONST.RED_BRICK_ROAD_PENDING_ACTION,
Expand Down
52 changes: 50 additions & 2 deletions src/libs/actions/Report/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,13 @@ type OpenReportActionParams = {

hasReportActions: boolean | undefined;

/**
* Whether this report's actions loaded at least once this session (RAM-only, so falsy means a page refresh /
* cold start — when a manual unread marker is cleared). Only the report screen passes it; other callers omit
* it to leave the marker alone.
*/
hasOnceLoadedReportActions?: boolean;

/** Whether opening the report should update its read state. Set to false when fetching report data without the user actually viewing the conversation */
shouldMarkAsRead?: boolean;

Expand Down Expand Up @@ -529,6 +536,19 @@ Onyx.connect({
},
});

// RAM-only set of reportIDs the user navigated away from this session, so `openReport` can clear a manual
// unread marker on the return trip only. A blur uniquely identifies that trip: it doesn't fire on the repeated
// openReport calls of a single visit, and being RAM-only it is empty after a refresh.
const reportsNavigatedAwayFrom = new Set<string>();

/** Records that the user navigated away from the report, so the next `openReport` clears its manual unread marker. */
function flagReportNavigatedAway(reportID: string | undefined) {
if (!reportID) {
return;
}
reportsNavigatedAwayFrom.add(reportID);
}

let allPersonalDetails: OnyxEntry<PersonalDetailsList> = {};
Onyx.connect({
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
Expand Down Expand Up @@ -1706,6 +1726,8 @@ function openReport(params: OpenReportActionParams) {
isSelfTourViewed,
hasCompletedGuidedSetupFlow,
hasReportActions,
// Defaults to true so only the report screen, the one caller that passes it, can clear a manual unread marker.
hasOnceLoadedReportActions = true,
shouldMarkAsRead = true,
conciergeChat,
} = params;
Expand All @@ -1717,7 +1739,21 @@ function openReport(params: OpenReportActionParams) {
const participantAccountIDList = participants.map((p) => p.accountID).filter((id): id is number => id !== undefined);
const existingReportName = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.reportName;
const isCreatingNewReport = !isEmptyObject(newReportObject);
const optimisticReport: Partial<Pick<Report, 'reportName'>> = hasReportActions || !existingReportName ? {} : {reportName: existingReportName};
// True only on a genuine return trip: `flagReportNavigatedAway` sets it on blur/unmount, so it is false on the
// first open, on the repeated openReport calls of a single visit, and after a refresh (the set is RAM-only).
const didNavigateBackToReport = reportsNavigatedAwayFrom.has(reportID);
reportsNavigatedAwayFrom.delete(reportID);
// A refresh resets the report screen's RAM-only `hasOnceLoadedReportActions`, which is how we detect one here.
// A genuine first open has no marker to clear, so this only affects a marker persisted from before the refresh.
const isFirstLoadAfterRefresh = !hasOnceLoadedReportActions;
const optimisticReport: Partial<Pick<Report, 'reportName' | 'manuallyMarkedUnreadReportActionID'>> = hasReportActions || !existingReportName ? {} : {reportName: existingReportName};

// A manual mark-as-unread keeps its marker anchored while the user stays in the report, and is cleared only on
// a return trip or a refresh. This is a client-side decision, so it goes in optimisticData to apply immediately
// and offline. It is deliberately not restored in failureData — that would resurrect a marker already moved past.
if (didNavigateBackToReport || isFirstLoadAfterRefresh) {
optimisticReport.manuallyMarkedUnreadReportActionID = null;
}

const optimisticData: Array<
OnyxUpdate<
Expand Down Expand Up @@ -3192,6 +3228,8 @@ function readNewestAction(reportID: string | undefined, isReportActionsLoaded: b

const lastReadTime = getDBTimeWithSkew();

// Deliberately leaves `manuallyMarkedUnreadReportActionID` alone so an auto-read doesn't wipe a marker the
// user created. `openReport` clears it on a return trip or a refresh.
const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
Expand Down Expand Up @@ -3274,6 +3312,7 @@ function markCommentAsUnread(reportID: string | undefined, reportActions: OnyxEn

const reportValue = {
lastReadTime,
manuallyMarkedUnreadReportActionID: reportAction?.reportActionID ?? null,
...(lastActorAccountID && {lastActorAccountID}),
};

Expand All @@ -3285,11 +3324,18 @@ function markCommentAsUnread(reportID: string | undefined, reportActions: OnyxEn
},
];

// Deliberately omits `manuallyMarkedUnreadReportActionID`. If this request is still queued when `openReport`
// clears the marker (e.g. the mark happened offline), reasserting the id on reconnect would resurrect a marker
// the user has moved past. The optimistic value above persists on its own, since the server MERGE never
// carries this client-only field.
const successData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.REPORT>> = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: reportValue,
value: {
lastReadTime,
...(lastActorAccountID && {lastActorAccountID}),
},
},
];

Expand All @@ -3300,6 +3346,7 @@ function markCommentAsUnread(reportID: string | undefined, reportActions: OnyxEn
value: {
lastReadTime: report?.lastReadTime ?? null,
lastActorAccountID: report?.lastActorAccountID ?? null,
manuallyMarkedUnreadReportActionID: report?.manuallyMarkedUnreadReportActionID ?? null,
},
},
];
Expand Down Expand Up @@ -8934,6 +8981,7 @@ export {
leaveRoom,
markAsManuallyExported,
markCommentAsUnread,
flagReportNavigatedAway,
navigateToAndOpenChildReport,
navigateToAndOpenReport,
navigateToAndOpenReportWithAccountIDs,
Expand Down
18 changes: 18 additions & 0 deletions src/pages/inbox/ReportFetchHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from
import {
clearStaleDMRecoveryTargetByTargetReportID,
createTransactionThreadReport,
flagReportNavigatedAway,
joinReportViaSecureLink,
markLocalReportActionsAsLoaded,
openReport,
Expand Down Expand Up @@ -235,6 +236,9 @@ function ReportFetchHandler() {
betas,
personalDetails,
hasReportActions,
// Falsy means a page refresh / cold start, which is when openReport clears a manual unread marker.
// This screen opens the report the user is looking at, so it is the only caller that passes it.
hasOnceLoadedReportActions: reportLoadingState.hasOnceLoadedReportActions,
currentUserAccountID,
isSelfTourViewed,
hasCompletedGuidedSetupFlow,
Expand Down Expand Up @@ -432,6 +436,20 @@ function ReportFetchHandler() {
};
}, []);

// Record navigating away so the next openReport can clear a manual unread marker on the return trip. We flag
// on blur (wide layout keeps the screen mounted) and on unmount / reportID change (narrow layout tears it
// down). Staying in the report never flags it, so the user's marker is not wiped mid-session.
useEffect(() => {
if (!prevIsFocused || isFocused) {
return;
}
flagReportNavigatedAway(reportIDFromRoute);
}, [isFocused, prevIsFocused, reportIDFromRoute]);

useEffect(() => {
return () => flagReportNavigatedAway(reportIDFromRoute);
}, [reportIDFromRoute]);

// `isLoadingInitialReportActions` is memory-only and is not reset between navigations. A prior failed
// fetch leaves a stale `false` that can make ReportNotFoundGuard show "not here" before the fetch below
// re-runs. When opening a report whose actions were never successfully loaded, mark it as loading again so
Expand Down
48 changes: 44 additions & 4 deletions src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ type ShouldDisplayNewMarkerOnReportActionParams = {

/** The reportActionID of the current unread marker, if one exists */
prevUnreadMarkerReportActionID?: string | null;

/** Whether the action `prevUnreadMarkerReportActionID` points to is still present (not deleted/hidden) */
isPrevUnreadMarkerReportActionPresent?: boolean;

/** The reportActionID the user explicitly marked as unread, if any */
manuallyMarkedUnreadReportActionID?: string | null;
/** Whether the app window is focused */
hasWindowFocus?: boolean;

Expand All @@ -47,9 +53,19 @@ const shouldDisplayNewMarkerOnReportAction = ({
isScrolledOverThreshold,
isOffline,
prevUnreadMarkerReportActionID,
isPrevUnreadMarkerReportActionPresent = false,
manuallyMarkedUnreadReportActionID,
hasWindowFocus = true,
newMessageBoundaryTime,
}: ShouldDisplayNewMarkerOnReportActionParams): boolean => {
// While a manual mark is active, the marked action is the sole anchor: every other action is suppressed.
// We anchor by reportActionID rather than timestamp because `created` shifts on the optimistic->confirmed
// transition and would wrongly read as already-read. The marked action is the oldest unread by construction
// (markCommentAsUnread sets lastReadTime = its created - 1ms), so it stays correct as newer messages arrive.
if (manuallyMarkedUnreadReportActionID) {
return message.reportActionID === manuallyMarkedUnreadReportActionID && !shouldHideNewMarker(message, isOffline);
}

const isNextMessageUnread = !!nextMessage && isReportActionUnread(nextMessage, unreadMarkerTime);

// If the current message is the earliest message received while offline, we want to display the unread marker above this message.
Expand Down Expand Up @@ -84,12 +100,16 @@ const shouldDisplayNewMarkerOnReportAction = ({
const isPreviouslyOptimistic =
(isPendingAdd(prevSortedVisibleReportActionsObjects[message.reportActionID]) && !isPendingAdd(message)) ||
(!!prevSortedVisibleReportActionsObjects[message.reportActionID]?.isOptimisticAction && !message.isOptimisticAction);
const shouldIgnoreUnreadForCurrentUserMessage = isNewMessage || isPreviouslyOptimistic;
const prevMarkedReportAction = prevUnreadMarkerReportActionID ? prevSortedVisibleReportActionsObjects[prevUnreadMarkerReportActionID] : undefined;
const isPreviouslyUnreadFromCurrentUser = currentUserAccountID === prevMarkedReportAction?.actorAccountID;
// Once a self-authored action holds the marker, don't let a different self-authored action steal it (the
// Expensify/App#91940 hop). Only while that anchor is still present — if it was deleted, the marker must relocate.
const isDifferentUnread = isPrevUnreadMarkerReportActionPresent && isPreviouslyUnreadFromCurrentUser && prevMarkedReportAction?.reportActionID !== message.reportActionID;
const shouldIgnoreUnreadForCurrentUserMessage = isNewMessage || isPreviouslyOptimistic || isDifferentUnread;

if (isFromCurrentUser) {
// When an existing marker is being relocated (e.g. after the original unread message is deleted),
// allow the marker to land on a self-authored action.
// Otherwise, never anchor the "New" marker above a self-authored action on first open/re-entry.
// Only move/keep the marker on a self-authored action when one already exists in this session.
// An explicit mark-as-unread bypasses this guard via the early return at the top of the function.
if (prevUnreadMarkerReportActionID) {
return !shouldIgnoreUnreadForCurrentUserMessage;
}
Expand Down Expand Up @@ -134,6 +154,9 @@ type GetUnreadMarkerReportActionParams = {

/** The reportActionID of the current unread marker, if one exists */
prevUnreadMarkerReportActionID?: string | null;

/** The reportActionID the user explicitly marked as unread, if any */
manuallyMarkedUnreadReportActionID?: string | null;
/** Whether the app window is focused */
hasWindowFocus?: boolean;

Expand All @@ -157,13 +180,28 @@ const getUnreadMarkerReportAction = ({
isReversed,
isAnonymousUser = false,
prevUnreadMarkerReportActionID,
manuallyMarkedUnreadReportActionID,
hasWindowFocus = true,
newMessageBoundaryTime,
}: GetUnreadMarkerReportActionParams): [string | null, number] => {
if (isAnonymousUser) {
return [null, -1];
}

// Drop the manual anchor once the marked action is deleted, otherwise no action would match it and the
// marker would vanish instead of relocating via the timestamp scan below.
const manuallyMarkedUnreadReportAction = manuallyMarkedUnreadReportActionID
? visibleReportActions.find((action) => action.reportActionID === manuallyMarkedUnreadReportActionID)
: undefined;
const activeManuallyMarkedUnreadReportActionID =
manuallyMarkedUnreadReportAction && !shouldHideNewMarker(manuallyMarkedUnreadReportAction, isOffline) ? manuallyMarkedUnreadReportActionID : null;

// Lets the caller tell "the anchor was deleted, so relocate the marker" apart from "the anchor is still
// around, so another self-authored action must not steal it".
const isPrevUnreadMarkerReportActionPresent = prevUnreadMarkerReportActionID
? visibleReportActions.some((action) => action.reportActionID === prevUnreadMarkerReportActionID && !shouldHideNewMarker(action, isOffline))
: false;

const startIndex = isReversed ? visibleReportActions.length - 1 : (earliestReceivedOfflineMessageIndex ?? 0);
const endIndex = isReversed ? (earliestReceivedOfflineMessageIndex ?? 0) : visibleReportActions.length;
const step = isReversed ? -1 : 1;
Expand Down Expand Up @@ -199,6 +237,8 @@ const getUnreadMarkerReportAction = ({
isScrolledOverThreshold,
isOffline,
prevUnreadMarkerReportActionID,
isPrevUnreadMarkerReportActionPresent,
manuallyMarkedUnreadReportActionID: activeManuallyMarkedUnreadReportActionID,
hasWindowFocus,
newMessageBoundaryTime,
});
Expand Down
1 change: 1 addition & 0 deletions src/selectors/Report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ type ExcludedFields = ValidReportKeys<
'lastMessageText',
'lastVisibleActionCreated',
'lastReadTime',
'manuallyMarkedUnreadReportActionID',
'lastReadSequenceNumber',
'lastMentionedTime',
'lastVisibleActionLastModified',
Expand Down
4 changes: 4 additions & 0 deletions src/types/onyx/Report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback<
/** The time when user read the last message */
lastReadTime?: string;

/** reportActionID the user explicitly marked as unread. Unlike lastReadTime it is stable across the
* optimistic→confirmed transition, so the "New" marker can anchor on a self-authored action. */
manuallyMarkedUnreadReportActionID?: string | null;

/** The sequence number of the last report visit */
lastReadSequenceNumber?: number;

Expand Down
1 change: 1 addition & 0 deletions src/types/utils/whitelistedReportKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type WhitelistedReport = OnyxCommon.OnyxValueWithOfflineFeedback<
lastMessageText: unknown;
lastVisibleActionCreated: unknown;
lastReadTime: unknown;
manuallyMarkedUnreadReportActionID: unknown;
lastReadSequenceNumber: unknown;
lastMentionedTime: unknown;
policyAvatar: unknown;
Expand Down
Loading
Loading