Skip to content

Show New message marker when a user marks their own message as unread - #95217

Open
MelvinBot wants to merge 18 commits into
mainfrom
claude-selfAuthoredUnreadMarker
Open

Show New message marker when a user marks their own message as unread#95217
MelvinBot wants to merge 18 commits into
mainfrom
claude-selfAuthoredUnreadMarker

Conversation

@MelvinBot

@MelvinBot MelvinBot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

When a user marks their own message as unread, the green "New" message marker was not shown above that message, even though the conversation was correctly bolded as unread in the LHN. Marking another user's message worked fine, and the marker only appeared inconsistently (e.g. after a marker already existed) and disappeared again after navigating away and back.

The write path is correct — markCommentAsUnread sets lastReadTime to just before the marked action, so the action is genuinely unread regardless of author. The bug was purely in the display decision in shouldDisplayNewMarkerOnReportAction. For a self-authored action it hard-blocked the marker unless a marker already existed:

if (isFromCurrentUser) {
    if (prevUnreadMarkerReportActionID) {
        return !shouldIgnoreUnreadForCurrentUserMessage;
    }
    return false;
}

prevUnreadMarkerReportActionID starts as null on first open and resets to null on re-entry, so the return false suppressed the marker in exactly the reported scenarios. This guard was introduced by #91940 (issue #91443) to stop the marker from anchoring above a message the user just sent, but that case is already fully captured by shouldIgnoreUnreadForCurrentUserMessage (isNewMessage || isPreviouslyOptimistic).

This change collapses the branch to return !shouldIgnoreUnreadForCurrentUserMessage, so:

The now-unused prevUnreadMarkerReportActionID param is dropped from this function's destructuring. A unit test for the explicit self-mark-unread case was updated to assert the marker now shows.

Fixed Issues

$ #94794
PROPOSAL: #94794 (comment)

Tests

// TODO: The human co-author must fill out the tests you ran before marking this PR as "ready for review"

  1. Open a thread/chat with several messages between user A and user B.
  2. As user A, mark one of your own messages as unread.
  3. Verify the green "New" message marker appears above that message (and the chat is bolded as unread in the LHN).
  4. Navigate to another conversation, then navigate back to this thread.
  5. Verify the "New" marker is still displayed above the message.
  6. Send a new message as user A and verify no "New" marker appears above the message you just sent.
  • Verify that no errors appear in the JS console

Offline tests

Same as Tests.

QA Steps

// TODO: The human co-author must fill out the QA tests you ran before marking this PR as "ready for review".

  1. Open a thread/chat with several messages between user A and user B.
  2. As user A, mark one of your own messages as unread.
  3. Verify the green "New" message marker appears above that message.
  4. Navigate away and back; verify the marker is still displayed.
  5. Send a new message and verify no "New" marker appears above your just-sent message.
  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android / native
    • Android / Chrome
    • iOS / native
    • iOS / Safari
    • MacOS / Chrome / Safari
    • MacOS / Desktop
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that the left part of a conditional rendering a React component is a boolean and NOT a string, e.g. myBool && <MyComponent />.
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I verified the translation was requested/reviewed in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is approved by marketing by adding the Waiting for Copy label for a copy review on the original GH to get the correct copy.
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.js or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • If we are not using the full Onyx data that we loaded, I've added the proper selector in order to ensure the component only re-renders when the data it is using changes
      • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(themeColors.componentBG))
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR author checklist, including those that don't apply to this PR.

Screenshots/Videosundefined

Co-authored-by: Olly <Ollyws@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 I looked into the failing checks. There's no code issue to fix — the only failing check is PR Author Checklist / checklist, which fails because the author checklist isn't filled out:

You completed 0 out of 41 checklist items with 41 unfinished items
PR Author Checklist is not completely filled out. Please check every box to verify you've thought about the item.

This is a human-completed checklist, so I can't/shouldn't auto-tick it — Ollyws, please check the boxes in the PR description to clear it.

Every automated code check is green: typecheck, all 8 test jobs, ESLint, Prettier, Bun tests, Storybook, knip, unused styles, React Compiler, and the completed builds all pass. The only checks still running are the long-lived standalone builds and perf tests, which don't indicate any problem with the diff.

No commit was pushed since nothing in the code needs changing.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 The newly-failing check — build (New Expensify Dev, DebugDevelopment, false) — is an unrelated flaky iOS build failure, not something this PR caused. I did not push anything.

Analysis: This PR only touches two TypeScript files — src/pages/inbox/report/shouldDisplayNewMarkerOnReportAction.ts and tests/unit/ReportActionsUtilsTest.ts. The failing job died in the native Xcode/Swift compile step with a generic infra error and no compiler error attributable to the diff:

● Remote cache failure error: NoSuchKey: The specified key does not exist.
■ Failed: Building the app
■ RockError: Failed to build the project
  [cause]: "xcodebuild -workspace NewExpensify.xcworkspace ... -scheme 'New Expensify Dev' ..."

A JS/TS-only change cannot break native iOS Swift compilation — and indeed every other build passed, including the Android build build (Expensify Dev, Debug, true). That asymmetry (iOS fails, Android + all JS bundling succeed) is the signature of a flaky iOS builder / remote-cache miss, not a code problem.

Recommendation: Re-run the failing iOS build job — it should pass on retry.

The other failing check, PR Author Checklist / checklist, is just the unchecked author checklist (0/41 items) — Ollyws, please tick the boxes in the PR description to clear it. Every automated code check (typecheck, all 8 test jobs, ESLint, Prettier, Bun tests, Storybook, knip, unused styles, React Compiler) is green.

@Ollyws

Ollyws commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot mark this as ready to review.

@MelvinBot
MelvinBot marked this pull request as ready for review July 15, 2026 16:27
@MelvinBot
MelvinBot requested a review from a team as a code owner July 15, 2026 16:27
@melvin-bot
melvin-bot Bot requested review from JmillsExpensify and Ollyws and removed request for a team July 15, 2026 16:27
@melvin-bot

melvin-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

@Ollyws Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@melvin-bot
melvin-bot Bot removed the request for review from a team July 15, 2026 16:28
@MelvinBot

Copy link
Copy Markdown
Contributor Author

🤖 Done — this PR is now marked Ready for review.

Two heads-ups before reviewers dig in:

  • The Tests and QA Steps sections still contain // TODO placeholders for the human co-author to fill in (the steps you actually ran + checking off the console-error box). Reviewers will expect these completed.
  • I could not verify the change in a browser in this run (no dev web backend available), so the "New" marker behavior described in the PR is based on the code change only, not a live check.

@JmillsExpensify
JmillsExpensify removed their request for review July 15, 2026 16:28

@JmillsExpensify JmillsExpensify left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c52f485375

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +87 to +91
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.
if (prevUnreadMarkerReportActionID) {
return !shouldIgnoreUnreadForCurrentUserMessage;
}
return false;
// Only suppress the "New" marker for a self-authored message that was just sent (newly added or still
// transitioning from an optimistic action). An existing self-authored action that the user explicitly
// marked as unread should anchor the marker even when no marker exists yet (e.g. on first open/re-entry).
return !shouldIgnoreUnreadForCurrentUserMessage;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the self-message suppression for cold opens

This now treats any persisted self-authored action as a valid unread-marker anchor, not only actions explicitly marked unread. On a cold open/re-entry after the user's just-sent message has been confirmed with a server created time later than the optimistic lastReadTime, usePrevious initializes the previous-actions map with the current actions, so isNewMessage and isPreviouslyOptimistic are both false and this return shows the green marker above the user's own message, reintroducing the self-message marker case this guard previously prevented.

Useful? React with 👍 / 👎.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

The failing build is a flaky iOS infra failure — not caused by this PR

No code fix is needed. This PR only changes two pure JS/TS files (shouldDisplayNewMarkerOnReportAction.ts and a unit test), and the failing check is the iOS native xcodebuild step, which doesn't compile that JS (it's served by Metro at runtime). The build just needs a re-run.

I attempted to re-run the failed job automatically but couldn't in this environment. Please click Re-run failed jobs on the failed build run, or push any trivial change to re-trigger it.

Evidence
  • Only iOS failed. build (New Expensify Dev, DebugDevelopment, false) failed, but all three other native builds (build (developmentDebug, false), build (Debug, true), build (Expensify Dev, Debug, true)) passed on the same commit, as did typecheck, ESLint, Prettier, Bun tests, all 8 test jobs, and Storybook tests.
  • No compile error in the log. The build ends with a generic RockError: Failed to build the project / Failed: Building the app — there is no error: diagnostic anywhere, and it got all the way through compiling Pods.
  • Infrastructure errors are present: Blacksmith cache setup failed, using standard checkout: Error: gRPC connection test failed: [unavailable] connect ETIMEDOUT and Remote cache failure error: NoSuchKey: The specified key does not exist.
  • Mechanism: the change is display logic + a Jest test. The iOS native build compiles Objective-C/Swift/C++; it does not bundle or type-check the app's JS/TS. A change to these files cannot break xcodebuild.

The two checklist failures (PR Author Checklist / PR Reviewer Checklist) are expected — they stay red until the human co-author fills out the checklist boxes and testing sections, so no action from me there.

Note: I could not verify anything in a browser in this run, but that isn't needed here — the conclusion is based on the CI logs and the PR diff.

Co-authored-by: Olly <Ollyws@users.noreply.github.com>
@MelvinBot
MelvinBot requested a review from a team as a code owner July 15, 2026 16:35
@MelvinBot

Copy link
Copy Markdown
Contributor Author

I pushed an empty commit (669f3f53) to re-trigger CI, since the failing build (New Expensify Dev, DebugDevelopment, false) check was a flaky iOS infrastructure failure, not a code defect — so there was nothing to fix in the source.

As detailed in my previous comment: this PR only changes JS/TS (a display-logic function + a unit test), the iOS native xcodebuild step doesn't compile that JS, all three other native builds passed on the same commit, and the failed log contained no compile error — only infra errors (Blacksmith cache setup failed: gRPC ... ETIMEDOUT, Remote cache failure error: NoSuchKey).

The re-run is in progress now. The two checklist failures are the standard PR Author/Reviewer Checklist checks and stay red until the human co-author fills out the checklist and testing sections.

I could not verify anything in a browser this run, but that isn't needed here — the conclusion rests on the CI logs and the PR diff.

@melvin-bot
melvin-bot Bot requested review from lakchote and removed request for a team July 15, 2026 16:36
@melvin-bot

melvin-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

@lakchote Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@MelvinBot

Copy link
Copy Markdown
Contributor Author

The flaky iOS build is resolved — only the checklist remains

The previously-failing build (New Expensify Dev, DebugDevelopment, false) was a flaky iOS infra failure (Blacksmith cache/gRPC timeout, no compile error, JS-only diff). It has been re-triggered via commit 669f3f53, and on the current HEAD the build failure is gone — every completed check passes and the remaining test/perf/typecheck jobs are green or still running.

The only remaining red check is PR Author Checklist, which stays failing until the human co-author fills out the checklist boxes and the Tests / QA Steps sections in the PR description (they're currently // TODO placeholders). That's not something I can or should fix — it needs the author to complete it.

So there's no code fix required. Once the checklist is completed and the in-progress test jobs finish, the PR should be clean.

Note: I could not verify in a browser in this run, but that isn't needed here — this is based on the CI status and the PR diff.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

The flaky iOS build is resolved — nothing left to fix in code

The re-trigger commit (669f3f53) cleared the previously-failing iOS build. On the current head:

  • iOS build — no longer failing (it wasn't caused by this PR; it was a flaky infra failure).
  • typecheck — passing (this is the check directly relevant to the TS change in this PR).
  • ✅ All functional checks passing: test (jobs 1–8), Bun tests, ESLint, Oxfmt, Storybook tests, Check for unused styles, React Compiler Compliance, spellcheck, validate, dryRun, etc.
  • baseline-perf-tests / branch-perf-tests — still running at the time of writing. They passed on the prior commit (which has identical app JS, since the re-trigger commit added no code), so they're expected to pass again.
  • checklist — still red, but this is expected. The PR Author Checklist / PR Reviewer Checklist checks stay red until the human co-author fills out the checklist boxes and the Tests / QA Steps sections. No action from me there.

So there's no code fix required — the only real failure was the flaky iOS build, which is now cleared. Just complete the checklist/testing sections when the PR is ready for review.

Note: I could not verify anything in a browser in this run, but that isn't needed here — this is based on the CI check results and the PR diff.

@lakchote

Copy link
Copy Markdown
Contributor

@Ollyws can you add a video test? The test steps are still the placeholders.

@Ollyws

Ollyws commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp
01_Android_Native.mp4
Android: mWeb Chrome
02_Android_Chrome.mp4
iOS: HybridApp
03_iOS_Native.mp4
iOS: mWeb Safari
05_MacOS_Chrome.mp4
MacOS: Chrome / Safari
MacOS_Safari.mp4

@Ollyws

Ollyws commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot if you mark an optimistic message as unread while offline, the unread marker will disappear when you come back online.

Marking an optimistic self-message unread while offline dropped the New marker on
reconnect: the optimistic->confirmed merge shifts the action's created, which re-runs
handleReportChangeMarkAsRead, and on native (Visibility.hasFocus() is always true) that
fires readNewestAction, nulling manuallyMarkedUnreadReportActionID.

Instead of bailing whenever the field is set (which would suppress auto-read for
genuinely newer messages too), latch whether the marked action was ever seen optimistic
- the optimism flag is cleared on the same key at confirm time, so it must be captured
beforehand - and skip the reconnect auto-read only in that case. Applied to both the
useMarkAsRead hook and the money-request report list's inline copy of the logic.

Co-authored-by: Olly <Ollyws@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Done — pushed 1b0eb1e.

Instead of bailing whenever manuallyMarkedUnreadReportActionID is set (which would also suppress auto-read for a genuinely newer message), it latches whether the marked action was ever seen optimistic and skips the reconnect auto-read only in that case. The optimism flag can't be read live at the guard — the optimistic→confirmed merge clears isOptimisticAction/pendingAction on the same action key (Report/index.ts:1052) and that confirm is what re-fires the effect — so it's captured beforehand, while the action is still pending.

What changed (2 files):

  • useMarkAsRead.ts — a latch effect records markedActionWasOptimisticRef while the marked action is pending, and the guard in handleReportChangeMarkAsRead bails only when the field is set and that ref is true.
  • MoneyRequestReportActionsList.tsx — same latch + guard on its inline copy of the read-on-report-change logic, so the marker survives reconnect in money-request reports too.

Both are keyed by reportActionID (find(... === markedID)), so it works for any marked message, not just the newest, and the latch clears when the field is nulled on read.

Verification
  • Typechecktsgo reports no errors in either changed file. (The only errors it surfaces are the pre-existing local react-native-onyx ResultMetadata mismatch in unrelated files, same as prior runs — not from this change.)
  • ESLint — clean on both files (0 errors; the 7 warnings are pre-existing seatbelt-allowed set-state-in-effect at unrelated lines).
  • React Compiler — no regression. MoneyRequestReportActionsList.tsx already fails the compiler with 3 Cannot access refs during render errors on the pre-existing render-body scrollingVerticalBottomOffset.current reads; the count is unchanged (3→3) — my additions only shifted the line numbers, and ref writes inside a useEffect don't count.
  • ⚠️ Unit tests not run — the Jest runner is blocked in this environment (same as earlier runs). These are effect guards without direct unit coverage, so please run your native offline-mark → reconnect repro to confirm the marker now persists, and a quick check that a genuinely newer message from another user still auto-reads a normally-read report.

@Ollyws

Ollyws commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot revert that.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Reverted — pushed efed22e, which backs out 1b0eb1e (the optimistic-latch skip in useMarkAsRead and the money-request list copy). Both files are now byte-identical to their state before that commit, so the branch is back to f76655a's behavior. Let me know where you'd like to take it from here.

@Ollyws

Ollyws commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot revert f76655a

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Reverted f76655a ("Clear manuallyMarkedUnreadReportActionID when the current user sends a message") and pushed it as 31cacdd. It applied cleanly with no conflicts — 11 deletions across src/libs/actions/Report/index.ts (removed the manuallyMarkedUnreadReportActionID: null clear in addActions) and tests/actions/ReportTest.ts (removed its test).

@Ollyws

Ollyws commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot in useMarkAsRead.ts, add a check for manuallyMarkedUnreadReportActionID == null in this condition if (!isUnread(report, transactionThreadReport, isReportArchived) && !isLastActionUnread) { so the unread marker actually dissapears.

Co-authored-by: Olly <Ollyws@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Done — pushed 5882567.

Added the manuallyMarkedUnreadReportActionID == null guard to the early-return in useMarkAsRead.ts:111:

if (report?.manuallyMarkedUnreadReportActionID == null && !isUnread(report, transactionThreadReport, isReportArchived) && !isLastActionUnread) {
    return;
}

Now when a manual-unread marker is set (where isUnread/isLastActionUnread can both be false for a self-authored action), the effect no longer bails early — it falls through so readNewestAction can run and clear manuallyMarkedUnreadReportActionID, making the marker actually disappear.

Checks: oxfmt, ESLint, and React Compiler compliance clean on the file; typecheck-tsgo reports no errors in useMarkAsRead.ts (the ResultMetadata errors it prints are a pre-existing local react-native-onyx version mismatch, not from this change and not seen by CI). Unit tests green: useMarkAsReadTest (6), useUnreadMarkerTest + ReportActionsUtilsTest (364).

One thing worth confirming on your native repro: with this, once you're actively viewing (visible + focused + scrolled to end) the read path fires on the next report/visibility change — so the marker clears as soon as you're looking at it. If you intended it to persist until you leave and only clear on the next open, let me know and I'll gate it differently.

@Ollyws

Ollyws commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot const shouldIgnoreUnreadForCurrentUserMessage = isNewMessage || isPreviouslyOptimistic; in shouldDisplayNewMarkerOnReportAction should be updated to check for manuallyMarkedUnreadReportActionID == null otherwise if we send a new message after setting a previous message unread the unread marker will move down to the next message.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Agreed — good catch. When a manual mark is active, the manuallyMarkedUnreadReportActionID override at :74 is meant to be the sole anchor, but the isFromCurrentUser fallback below it can steal the marker onto a newer self-message. Gating shouldIgnoreUnreadForCurrentUserMessage on the mark is the right call.

Why it moves down. After you mark M unread, the marker anchors on M and prevUnreadMarkerReportActionID becomes M. You then send N (newer, self). Sending advances the read pointer past M, so unreadMarkerTime ≥ M.created. Once N settles (no longer isNewMessage, no longer isPreviouslyOptimistic), N reaches :109-110 with prevUnreadMarkerReportActionID set and returns !shouldIgnoreUnreadForCurrentUserMessage!falsetrue. The scan is newest-first and returns the first match, so N (index 0) is evaluated before M and wins — the marker slides down to N, and M's override at :74 never gets a turn.

FixshouldDisplayNewMarkerOnReportAction.ts:100:

const shouldIgnoreUnreadForCurrentUserMessage = isNewMessage || isPreviouslyOptimistic || !!manuallyMarkedUnreadReportActionID;

Now while a mark is active, every self-message except the marked one yields, the scan falls through to M, and M keeps the marker via the :74 override.

Why it's safe:

  • The marked action M returns at :74 before reaching :100, so this guard never touches the marked message itself — it only stops other self-messages from grabbing the marker.
  • When no manual mark exists, !!manuallyMarkedUnreadReportActionID is false, so the fix: report unread #91940 self-message behavior (reimbursable toggle, etc.) is completely unchanged.
  • The MoneyRequestReportActionsList marker path shares this function, so it's covered automatically.

Want me to push this? (Static trace only — worth confirming with your send-after-mark repro that the marker now stays put on the marked message.)

@Ollyws

Ollyws commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot ok implement it

… sent

Co-authored-by: Olly <Ollyws@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Done — pushed 6dfac6a.

Gated shouldIgnoreUnreadForCurrentUserMessage on the active mark at shouldDisplayNewMarkerOnReportAction.ts:101:

const shouldIgnoreUnreadForCurrentUserMessage = isNewMessage || isPreviouslyOptimistic || !!manuallyMarkedUnreadReportActionID;

So while a manual mark is active, every self-message except the marked one yields — the scan falls through to the marked action, which keeps the marker via the manuallyMarkedUnreadReportActionID override at :74. Sending a newer message after marking no longer slides the marker down onto it. When no mark exists the new term is false, so #91940 behavior is untouched, and the MoneyRequestReportActionsList path shares this function so it's covered too.

Checks: oxfmt, ESLint, and React Compiler compliance clean on the file; typecheck-tsgo reports no errors in shouldDisplayNewMarkerOnReportAction.ts (the OnyxDerived/useSidebarOrderedReports errors it prints are the pre-existing local react-native-onyx version mismatch, not from this change and not seen by CI). Unit tests green: ReportActionsUtilsTest (358), useUnreadMarkerTest (6), useMarkAsReadTest (6).

Worth a quick confirm on your repro: mark an older message unread → send a new message → the marker stays on the marked one and doesn't jump to the just-sent message.

@Ollyws

Ollyws commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot instead of in the shouldIgnoreUnreadForCurrentUserMessage condition, !manuallyMarkedUnreadReportActionID should be below like if (prevUnreadMarkerReportActionID && !manuallyMarkedUnreadReportActionID) {

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Agreed — the two are behaviorally identical, and your placement reads better. I'd make the swap.

Right now the manual-mark term is folded into shouldIgnoreUnreadForCurrentUserMessage at line 104, then consumed at line 114. Your version moves it up to the branch gate:

const shouldIgnoreUnreadForCurrentUserMessage = isNewMessage || isPreviouslyOptimistic;

if (isFromCurrentUser) {
    if (prevUnreadMarkerReportActionID && !manuallyMarkedUnreadReportActionID) {
        return !shouldIgnoreUnreadForCurrentUserMessage;
    }
    return false;
}

Both collapse to the same output — with P = prevUnreadMarkerReportActionID, M = !!manuallyMarkedUnreadReportActionID, S = isNewMessage || isPreviouslyOptimistic:

  • current: P ? !(S || M) : false
  • yours: (P && !M) ? !S : false

P false → both false. P true + M true → current !(S||true)=false, yours gate fails → false. P true + M false → both !S. Identical in every case.

And yours is the better expression of intent: shouldIgnoreUnreadForCurrentUserMessage goes back to meaning only "new / previously-optimistic self message" (the #91940 concern), and the "a manual mark is active, so no other self-message anchors" rule lives visibly on the branch gate instead of being hidden inside that boolean. The marked action itself still short-circuits earlier at line 74, so it never reaches this branch either way.

Want me to push this refactor?

@Ollyws

Ollyws commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot we should remove !isNextMessageUnread from if (!!manuallyMarkedUnreadReportActionID && message.reportActionID === manuallyMarkedUnreadReportActionID && !isNextMessageUnread) { so that the manually read marker persists when we recieve a new message.

…nt unread state

Co-authored-by: Olly <Ollyws@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Done — dropped !isNextMessageUnread from the override and pushed (79bdb15), plus updated the one unit test that asserted the old "yield" behavior.

One thing worth flagging so you know exactly what this did and didn't change: in both real callers this was already a no-op — the marker already anchors on the marked action when a newer message arrives. The removal just makes that intent explicit (and drops a comment that was actively misleading).

Why it was already a no-op — the direction of nextMessage

Both scans run newest→oldest, and nextMessage is the action at index ± 1 in scan order — i.e. the older neighbor, not a newer one:

markCommentAsUnread sets lastReadTime = markedAction.created - 1ms, so the marked action is the oldest unread by construction and its older neighbor is always read → isNextMessageUnread was already false there → !isNextMessageUnread was already true. So the guard never blocked the marked action in the running app; the old comment ("Still yield to a more-recent unread message") described the opposite of what the code did, because it never saw a newer nextMessage. The only place that scenario existed was the direct unit test, which fed a synthetic newer nextMessage — that's the test I flipped from falsetrue.

So if you're seeing the marker actually disappear when a new message arrives, this change won't fix it — the cause is elsewhere. The two likely culprits, both discussed earlier in this PR:

  1. The auto-read path clearing manuallyMarkedUnreadReportActionID on reconnect (handleReportChangeMarkAsReadreadNewestAction) — the native repro we were chasing.
  2. The unreadMarkerTime forward-bump: if the scan returns null for even one render, unreadMarkerTime latches forward to the newest action and the marker is gone for good.

If you have a repro where the marker vanishes specifically on a new incoming message (not the offline→reconnect flow), tell me which surface (chat vs money-request), whether you're online, and whether the new message is yours or the other user's, and I'll trace that exact path.

Heads up: local typecheck/jest/prettier are blocked in my run environment, so I couldn't execute them here. The change is a pure single-term removal (isNextMessageUnread is still used at lines 60 and 83, so no unused-var), and I updated the only test that depended on it — but please run npm test -- ReportActionsUtilsTest locally to confirm.

@Ollyws

Ollyws commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot remove report?.manuallyMarkedUnreadReportActionID == null && from handleReportChangeMarkAsRead

…geMarkAsRead early return

Co-authored-by: Olly <Ollyws@users.noreply.github.com>
@MelvinBot

Copy link
Copy Markdown
Contributor Author

Done — pushed 0a8dfe8 removing report?.manuallyMarkedUnreadReportActionID == null && from the early-return guard in handleReportChangeMarkAsRead. I also dropped the comment directly above it, since it only existed to explain that clause.

The guard is now back to:

if (!isUnread(report, transactionThreadReport, isReportArchived) && !isLastActionUnread) {
    return;
}

src/hooks/useMarkAsRead.ts:107-110

Net effect: when a self-authored action is manually marked unread (so isUnread and isLastActionUnread are both false), this now early-returns instead of falling through to shouldReadOnReportChangereadNewestAction no longer fires on this path, so manuallyMarkedUnreadReportActionID and the marker are preserved.

oxfmt and eslint are clean on the file. Typecheck errors in this run are all pre-existing (ResultMetadata not generic — the local react-native-onyx version mismatch we hit earlier), none in useMarkAsRead.ts. I couldn't drive a native build here, so please re-run your offline mark-unread → reconnect repro to confirm the marker now sticks.

@Ollyws

Ollyws commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot how can we prevent readNewestAction in handleReportChangeMarkAsRead from immediately resetting the manuallyMarkedUnreadReportActionID when a new message is received?

@MelvinBot

Copy link
Copy Markdown
Contributor Author

Prevent it at the call site, not inside readNewestAction: every readNewestAction call unconditionally merges manuallyMarkedUnreadReportActionID: null (Report/index.ts:2858), so the only way to stop the reset is to not let handleReportChangeMarkAsRead reach readNewestAction while the marker is live. Add an early return right after the reportID !== prevReportID guard:

// A report-data change (new message arriving, or the user's offline message confirming on reconnect)
// re-runs this effect via the lastVisibleActionCreated dep. Don't auto-read it away — readNewestAction
// would null manuallyMarkedUnreadReportActionID and drop the marker the user explicitly set.
if (report?.manuallyMarkedUnreadReportActionID) {
    return;
}

It has to be a full return, not just skipping the readNewestAction at :114. If you fall through, the else-branch sets readActionSkippedRef.current = true, and completeSkippedMarkAsRead will later fire readNewestAction anyway and clear the field — just deferred, not prevented.

Why this path is the right layer. handleReportChangeMarkAsRead runs off the report?.lastVisibleActionCreated dep — i.e. a data change (message received / optimistic confirm), not a fresh user-read gesture. Auto-reading in response to data arriving is exactly what shouldn't override an explicit manual-unread. And gating on manuallyMarkedUnreadReportActionID is safe because that field is only ever set by an explicit markCommentAsUnread — no other flow writes it, so the guard can't suppress a legitimate auto-read.

The one product call to make — how broadly to scope it

The presence guard above keeps the report unread through any incoming message until the user reads it themselves. That's stronger than just fixing your native reconnect repro — with it, a genuinely-new message from someone else arriving while you're viewing the report also won't auto-read. I think that's the correct read of "the user deliberately marked this unread," but it is a behavior change worth a conscious decision.

If you want to protect only the optimistic→confirmed false trigger (your offline-mark → reconnect repro) and let genuinely-new incoming messages auto-read as they do today, scope the guard to that transition instead — latch whether the marked action was optimistic before it confirms (you can't read isOptimisticAction live at this point; the confirm's successData clears it on the same key at Report/index.ts:1052), then return only when that latch is set. That's the ref-latch from my earlier comment.

Either way, the MoneyRequestReportActionsList.tsx copy of this read logic needs the same guard if the marker is expected in money-request reports.

Which behavior do you want — honor the manual-unread through all incoming messages (simple presence guard), or only survive the optimistic-confirm (scoped latch)? I can push whichever. (Static analysis only — I couldn't drive a native build this run, so please confirm against your reconnect repro.)

@Ollyws

Ollyws commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot The problem with preventing readNewestAction is that it also prevents new messages from being read as they come in, so when you re-open the report they will be marked as unread.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

You're right — bailing whenever manuallyMarkedUnreadReportActionID is set (and even the optimistic-latch variant) is too blunt, because handleReportChangeMarkAsRead is also the path that reads genuinely-new incoming messages while you're viewing the report. Freeze it and new messages from others stop getting read → they're unread on re-open. Bad.

But the trigger we actually need to neutralize is narrow: on reconnect the effect re-fires only because the marked message's own optimistic→confirmed created shift moves report.lastVisibleActionCreated. Nothing genuinely new arrived — the sole "unread" content is the message you deliberately marked (plus any messages you sent after it). So the guard shouldn't be "marker is set → bail"; it should be "bail only when there's nothing from another user to read."

That distinction is already expressed in this same file — handleAppVisibilityMarkAsRead filters unread actions to other authors at useMarkAsRead.ts:154-159. Reuse it in handleReportChangeMarkAsRead, right after the reportID !== prevReportID guard:

// The user deliberately marked an action unread. On reconnect its optimistic→confirmed created shift
// re-fires this effect, but nothing new arrived — don't let readNewestAction wipe the mark. Still allow
// the read when a genuinely-new message from someone else exists, so incoming messages are read as normal.
if (report?.manuallyMarkedUnreadReportActionID) {
    const hasUnreadFromOthers = sortedVisibleReportActions.some(
        (action) =>
            report.lastReadTime &&
            report.lastReadTime < action.created &&
            (isReportPreviewAction(action) ? action.childLastActorAccountID : action.actorAccountID) !== currentUserAccountID,
    );
    if (!hasUnreadFromOthers) {
        return;
    }
}

This resolves all three cases you've raised:

  • Offline mark → reconnect (your native repro): the only unread action is yours, hasUnreadFromOthers is false → bail → marker survives. ✅
  • Marking an older message, or sending more messages after it offline: keyed off lastReadTime (= marked.created − 1ms) and authorship, not lastAction === marked, so it works no matter where the marked action sits. ✅ (this was what killed the earlier narrow check)
  • New message from someone else arrives while viewing: hasUnreadFromOthers is true → guard falls through → readNewestAction runs and reads it as it does today. ✅

It also drops the optimistic-latch idea entirely — "no unread from others" subsumes it and is simpler.

One product call to confirm when you test: reading that genuinely-new other-user message still goes through readNewestAction, which clears manuallyMarkedUnreadReportActionID — so your deliberate mark is lost once a newer message from someone else is read. I think that's acceptable (read state legitimately advances), but if you want the mark to survive even that, it'd mean decoupling the marker-clear from readNewestAction — a bigger change worth its own decision. And the MoneyRequestReportActionsList.tsx:378-390 copy of this logic needs the same guard.

Want me to push this version? (Static analysis only — I couldn't drive a native build this run, so please confirm against your offline-mark → reconnect and new-message-arrives repros.)

@Ollyws

Ollyws commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

@MelvinBot NO the point is that if we mark as unread, then recieve new messages from another user, if readNewestAction is not executed after recieving new messages they will show up as unread when we re-open the report, if we DO execute it, the optimistic unread marker will be removed and it will immediately dissapear. NOTE: THIS HAS NOTHING TO DO WITH GOING OFFLINE.

@MelvinBot

Copy link
Copy Markdown
Contributor Author

You're right — I anchored on the offline/optimistic path and that was wrong. The "was previously optimistic" check doesn't help here: in your scenario the marked action was never optimistic, and the effect re-fires because another user's message bumps report.lastVisibleActionCreated (an effect dep) — no offline, no confirm, any platform.

The real problem is that readNewestAction is all-or-nothing, and the marker and "everything is read" are the same piece of state. When a newer message arrives while you're viewing scrolled-to-end, shouldReadOnReportChange is true and readNewestAction fires, which both advances lastReadTime and nulls manuallyMarkedUnreadReportActionID → marker gone. Skip it (not focused / not scrolled to end → readActionSkippedRef) and everything below the mark stays unread on reopen. You can't get "marker stays, new messages read" because reading is what clears the marker.

So there's no timing trick that resolves it — it's a product call, and I think honoring the manual-unread is the right one: don't auto-read while manuallyMarkedUnreadReportActionID is set. That's the presence guard, and the "new messages show as unread on reopen" outcome is actually correct semantics — you deliberately declared the report unread from that point, so everything after it should stay unread until you read it yourself. In other words the downside I flagged on the presence guard last time isn't a regression; it's the intended behavior of mark-as-unread.

The one case worth an explicit decision: a newer message arriving while you're actively watching the report scrolled to the bottom. Today that auto-reads; with the guard it stays unread with the marker in place. Keeping it unread is consistent ("manual unread wins until I read it"), but it's a real behavior change from today, so it's your call to confirm.

Why decoupling (advance lastReadTime but keep the id) isn't a clean third option

The marker anchors purely on message.reportActionID === manuallyMarkedUnreadReportActionID at shouldDisplayNewMarkerOnReportAction.ts:74, independent of lastReadTime. So you could have readNewestAction advance lastReadTime but leave the id set — the green marker would survive while the LHN shows the report as read. That's an internally inconsistent state (a "New" divider inside a read report), which is exactly why the id is cleared on read in the first place. I don't think it's worth introducing; the presence guard is the coherent fix.

If you're aligned on honoring manual-unread, the change is if (report?.manuallyMarkedUnreadReportActionID) { return; } right after the reportID !== prevReportID guard, plus the same in the MoneyRequestReportActionsList copy. Want me to push that?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants