Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 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
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
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps)
isScrolledOverThreshold: scrollingVerticalBottomOffset.current >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD,
isOffline,
isReversed: true,
manuallyMarkedUnreadReportActionID: report?.manuallyMarkedUnreadReportActionID,
hasWindowFocus: Visibility.hasFocus(),
});

Expand Down
6 changes: 6 additions & 0 deletions src/hooks/useUnreadMarker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,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 @@ -64,6 +65,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 @@ -121,6 +126,7 @@ function useUnreadMarker({
isReversed: false,
isAnonymousUser,
prevUnreadMarkerReportActionID,
manuallyMarkedUnreadReportActionID,
hasWindowFocus: Visibility.hasFocus(),
});
// Pagination is anchored to the oldest unread on first open; that anchor does not change when the user
Expand Down
2 changes: 2 additions & 0 deletions src/libs/DebugUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,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 @@ -611,6 +612,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
3 changes: 3 additions & 0 deletions src/libs/actions/Report/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2855,6 +2855,7 @@ function readNewestAction(reportID: string | undefined, isReportActionsLoaded: b
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
value: {
lastReadTime,
manuallyMarkedUnreadReportActionID: null,
},
},
];
Expand Down Expand Up @@ -2931,6 +2932,7 @@ function markCommentAsUnread(reportID: string | undefined, reportActions: OnyxEn

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

Expand All @@ -2957,6 +2959,7 @@ function markCommentAsUnread(reportID: string | undefined, reportActions: OnyxEn
value: {
lastReadTime: report?.lastReadTime ?? null,
lastActorAccountID: report?.lastActorAccountID ?? null,
manuallyMarkedUnreadReportActionID: report?.manuallyMarkedUnreadReportActionID ?? null,
},
},
];
Expand Down
35 changes: 31 additions & 4 deletions src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ type ShouldDisplayNewMarkerOnReportActionParams = {

/** 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 @@ -48,6 +51,7 @@ const shouldDisplayNewMarkerOnReportAction = ({
isScrolledOverThreshold,
isOffline,
prevUnreadMarkerReportActionID,
manuallyMarkedUnreadReportActionID,
hasWindowFocus = true,
}: ShouldDisplayNewMarkerOnReportActionParams): boolean => {
const isNextMessageUnread = !!nextMessage && isReportActionUnread(nextMessage, unreadMarkerTime);
Expand All @@ -62,6 +66,17 @@ const shouldDisplayNewMarkerOnReportAction = ({
return false;
}

// The user explicitly marked THIS action as unread. Anchor the marker here regardless of the
// timestamp-based check below: once an optimistic self-message confirms, unreadMarkerTime,
// lastReadTime, and created all converge on (or drift past) the confirmed `created`, so
// isReportActionUnread wrongly reports it as read. The stored reportActionID is the only signal
// stable across that transition. The marked action is the oldest unread by construction
// (markCommentAsUnread sets lastReadTime = its created - 1ms), so it remains the correct anchor
// even when newer messages arrive after the mark.
if (!!manuallyMarkedUnreadReportActionID && message.reportActionID === manuallyMarkedUnreadReportActionID) {
return true;
}

const isCurrentMessageUnread = isReportActionUnread(message, unreadMarkerTime);

// If the current message is read or the next message is unread, don't show the unread marker.
Expand All @@ -84,12 +99,19 @@ const shouldDisplayNewMarkerOnReportAction = ({
const isPreviouslyOptimistic =
(isPendingAdd(prevSortedVisibleReportActionsObjects[message.reportActionID]) && !isPendingAdd(message)) ||
(!!prevSortedVisibleReportActionsObjects[message.reportActionID]?.isOptimisticAction && !message.isOptimisticAction);
const shouldIgnoreUnreadForCurrentUserMessage = isNewMessage || isPreviouslyOptimistic;
// While a manual mark-as-unread is active, the marked action is the sole anchor (handled by the
// `manuallyMarkedUnreadReportActionID` check above, which returns before this branch). Ignore unread
// for every *other* self-authored message so a newer self-message sent after the mark can't steal the
// marker off the marked one. When no manual mark exists this term is false, so #91940 behavior is unchanged.
const shouldIgnoreUnreadForCurrentUserMessage = isNewMessage || isPreviouslyOptimistic || !!manuallyMarkedUnreadReportActionID;

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.
// For a self-authored action, only move/keep the "New" marker when one already exists in this session
// (`prevUnreadMarkerReportActionID` is set). The explicit mark-as-unread case is handled earlier by the
// stable `manuallyMarkedUnreadReportActionID` check, which anchors the marker on first open/re-entry
// regardless of this guard. Without this guard, a persisted self-authored action (e.g. a reimbursable
// toggle) whose timestamps have drifted past `lastReadTime` would wrongly show the marker on a cold
// open/re-entry — the regression from Expensify/App#91940.
if (prevUnreadMarkerReportActionID) {
return !shouldIgnoreUnreadForCurrentUserMessage;
}
Expand Down Expand Up @@ -131,6 +153,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 @@ -150,6 +175,7 @@ const getUnreadMarkerReportAction = ({
isReversed,
isAnonymousUser = false,
prevUnreadMarkerReportActionID,
manuallyMarkedUnreadReportActionID,
hasWindowFocus = true,
}: GetUnreadMarkerReportActionParams): [string | null, number] => {
if (isAnonymousUser) {
Expand Down Expand Up @@ -191,6 +217,7 @@ const getUnreadMarkerReportAction = ({
isScrolledOverThreshold,
isOffline,
prevUnreadMarkerReportActionID,
manuallyMarkedUnreadReportActionID,
hasWindowFocus,
});

Expand Down
1 change: 1 addition & 0 deletions src/selectors/Report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,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 @@ -133,6 +133,10 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback<
/** The time when user read the last message */
lastReadTime?: string;

/** reportActionID the user explicitly marked as unread. Stable across the optimistic→confirmed
* transition, unlike lastReadTime, 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
68 changes: 67 additions & 1 deletion tests/unit/ReportActionsUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5471,7 +5471,11 @@ describe('ReportActionsUtils', () => {
).toBe(false);
});

it('returns false when message is from current user and is already present (not new, not optimistic) and no existing marker', () => {
it('returns false for a self-authored already-present action on a cold open when no marker exists and it was not explicitly marked unread (Expensify/App#91940 guard)', () => {
// A persisted self-authored action (e.g. a reimbursable toggle) whose timestamp reads as unread must
// NOT anchor the marker on a cold open/re-entry, where prevUnreadMarkerReportActionID is null. The
// explicit mark-as-unread case is handled separately via manuallyMarkedUnreadReportActionID (covered
// by the tests below), so this guard prevents the #91940 regression without affecting it.
const message = makeAction({actorAccountID: currentUserAccountID, reportActionID: 'existing-action-id'});
const prevSortedVisibleReportActionsObjects = {
[message.reportActionID]: makeAction({actorAccountID: currentUserAccountID, reportActionID: 'existing-action-id'}),
Expand Down Expand Up @@ -5544,6 +5548,68 @@ describe('ReportActionsUtils', () => {
}),
).toBe(true);
});

it('anchors the marker on the explicitly marked-unread action even after its confirmed created drifts before unreadMarkerTime', () => {
// Simulates the offline→online case: an optimistic self-message the user marked unread confirms
// with a `created` that lands before unreadMarkerTime, so the timestamp check reads it as "read".
// The stable manuallyMarkedUnreadReportActionID must still anchor the marker here.
const message = makeAction({actorAccountID: currentUserAccountID, reportActionID: 'marked-action-id', pendingAction: null, created: '2023-01-01 09:00:00.000'});
const prevSortedVisibleReportActionsObjects = {
[message.reportActionID]: makeAction({
actorAccountID: currentUserAccountID,
reportActionID: 'marked-action-id',
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
}),
};
expect(
shouldDisplayNewMarkerOnReportAction({
...baseParams,
message,
prevSortedVisibleReportActionsObjects,
manuallyMarkedUnreadReportActionID: 'marked-action-id',
isOffline: false,
}),
).toBe(true);
});

it('does not anchor the marker on a just-sent self-message when no action is marked unread', () => {
// Same confirmed self-message, but nothing is marked unread. The stable-id override is skipped and
// the existing just-sent suppression applies, keeping the #91443 fix intact.
const message = makeAction({actorAccountID: currentUserAccountID, reportActionID: 'confirmed-action-id', pendingAction: null});
const prevSortedVisibleReportActionsObjects = {
[message.reportActionID]: makeAction({
actorAccountID: currentUserAccountID,
reportActionID: 'confirmed-action-id',
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
}),
};
expect(
shouldDisplayNewMarkerOnReportAction({
...baseParams,
message,
prevSortedVisibleReportActionsObjects,
manuallyMarkedUnreadReportActionID: null,
isOffline: false,
}),
).toBe(false);
});

it('keeps the marker on the explicitly marked-unread action even when a newer message is present', () => {
// The marked action is the oldest unread by construction (lastReadTime = its created - 1ms), so it
// stays the anchor regardless of an adjacent unread message — a newer message arriving after the mark
// must not steal the marker off the message the user deliberately marked unread.
const message = makeAction({actorAccountID: currentUserAccountID, reportActionID: 'marked-action-id', created: '2023-01-01 11:00:00.000'});
const nextMessage = makeAction({created: '2023-01-01 11:30:00.000'});
expect(
shouldDisplayNewMarkerOnReportAction({
...baseParams,
message,
nextMessage,
manuallyMarkedUnreadReportActionID: 'marked-action-id',
isOffline: false,
}),
).toBe(true);
});
});

describe('getUnreadMarkerReportAction', () => {
Expand Down
Loading