Skip to content

fix(core): mark only the filtered discussions as read - #5307

Open
Saksham-chourasia wants to merge 5 commits into
flarum:2.xfrom
Saksham-chourasia:fix/mark-filtered-discussions-as-read
Open

Saksham-chourasia wants to merge 5 commits into
flarum:2.xfrom
Saksham-chourasia:fix/mark-filtered-discussions-as-read

Conversation

@Saksham-chourasia

@Saksham-chourasia Saksham-chourasia commented Oct 27, 2026 •

Copy link
Copy Markdown

Fixes #5304

Problem

"Mark all as read" was never a per-discussion operation. It wrote a single
timestamp on the user, users.marked_all_as_read_at, and every unread badge is
derived from it — Discussion.unreadCount() on the frontend, UnreadFilter on
the backend. The request carried nothing about the list either: IndexPage
sent PATCH /api/users/:id { markedAllAsReadAt } and the filters the list was
fetched with were dropped. So with tag:extensions active, pressing it silenced
the whole forum.

Changes

POST /api/discussions/read takes the same filter parameter the index
endpoint takes, so the set it marks read is the set the list shows.

  • Flarum\Discussion\Command\ReadDiscussions + handler, resolved by the
    {Command}Handler convention, so no service-provider wiring.
  • The handler runs the filters back through SearchManager /
    DiscussionSearcher, which keeps whereVisibleTo() and every filter
    extensions register — the tag of a tag page, a subscription, a fulltext q —
    then upserts discussion_user rows read up to last_post_number, in batches
    of 500. Only the read columns are written, so a subscription living on the
    same row survives.
  • It narrows to what is still unread before searching: that is the only thing a
    row is needed for. The matches are then read 500 at a time rather than in one
    query, so a broad search or a busy tag never loads every discussion into
    memory at once. No sort is asked for either — flarum/sticky unions a second query to
    float unread stickies when the sort is the default one, and that ordering
    means nothing to rows being written.
  • With no filters it is still every discussion, and still the one timestamp
    rather than a row each, so All Discussions costs exactly what it did before.

On the frontend, DiscussionListState.markAllAsRead() sends the filters
requestParams() asked the API for, so extension filters come along without
core knowing about them. IndexPage delegates to it and picks the confirmation
copy to match, since "all discussions" would be a lie for the filtered case.

PATCH /api/users/:id { markedAllAsReadAt } is untouched and still marks
everything read.

Reproduction

  1. Search tag:extensions (or open a tag page).
  2. Press "Mark all read".
  3. Before: every discussion on the forum is read. After: only the matching ones.

Changes after review

Fixes made:

  • Search terms: activeFilters() now parses the query with app.search.gambits.apply(), the same way Store.find() does. author:x and is:unread reach POST /api/discussions/read as real filters, and the confirmation text in IndexPage follows the parsed filter too. tag:extensions goes through the same code, but the tag gambit only exists when flarum/tags is loaded, so core's Jest tests check author: and is:unread.
  • Memory: the handler now loads 500 discussions per query instead of all matches at once. It writes nothing until every page has been read. Writing earlier would make discussions stop matching unread and push later pages past rows that still need marking.
  • Stale items: markAllAsRead() saves the list's discussions before sending the request, so switching tags mid-request no longer marks the new list read on the client.
  • Unhandled rejection: IndexPage now ends the call with .catch(() => {}).
  • Skipped events: a comment in ReadDiscussionsHandler says the bulk path does not fire UserRead or the other read-state events. The PR description note is below.

Tests added:

  • PHP integration tests (ReadAllTest.php, 8 tests): the six cases you listed, plus a set of 501 matching discussions that needs more than one page.
  • Jest tests: search-term parsing, and the request racing a list switch.

Note for extension authors

The bulk path (POST /api/discussions/read) writes read state with one upsert per batch. It does not fire UserDataSaving, UserRead, or the other read-state events that ReadDiscussionHandler fires for a single discussion. An extension that reacts to a discussion being read won't see those events for discussions marked read this way. This is deliberate: firing events for thousands of discussions at once would defeat the point of writing in bulk.

"Mark all as read" wrote a single timestamp on the user,
users.marked_all_as_read_at, and every unread badge is derived from it, so
it silenced the whole forum no matter what the list in front of the reader
was showing. Searching for tag:extensions and pressing it marked every
discussion on the forum read, and there was nothing in the request that
could have said otherwise: the filters the list was fetched with were never
sent.

POST /api/discussions/read takes the same `filter` parameter the index
endpoint takes, so the set it marks read is the set the list shows. It runs
the filters back through the searcher, which keeps whereVisibleTo() and
every filter extensions register — the tag of a tag page, a subscription,
a fulltext query — and writes a discussion_user row per match, read up to
last_post_number. Only the read columns are written, so a subscription on
the same row survives; rows go out in chunks of 100.

The filters are narrowed to what is still unread before the search, which
is the only thing a row is needed for and bounds the work by what there is
to write. No sort is asked for either: flarum/sticky unions a second query
to float unread stickies when the sort is the default one, and none of that
ordering means anything to the rows being written.

With no filters it is still every discussion, and still the one timestamp
rather than a row each, so All Discussions costs exactly what it did
before. The frontend sends the filters `requestParams()` asked the API for,
so extension filters come along without core knowing about them, and the
confirmation names which of the two is about to happen.
@Saksham-chourasia
Saksham-chourasia requested a review from a team as a code owner October 27, 2026 08:53

@imorland imorland left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for picking this up. Sending the list's own filters to a dedicated endpoint is the right approach, and running them through SearchManager means visibility rules and extension filters apply. A few things need fixing before this can go in.

Search terms are sent unparsed, so the reproduction steps don't work

Search terms like tag:extensions, author:x and is:unread are converted into filters on the frontend, and only inside Store.find() (common/Store.ts, the app.search.gambits.apply() call). The backend never parses them. markAllAsRead() builds its own request, so it sends filter[q]=tag:extensions as-is. The server then runs a fulltext search for that literal string and marks the wrong discussions read, or none. Meanwhile, the client marks every loaded discussion read, so the UI and the server disagree until a reload. Tag pages are fine because they set filter.tag directly, but searching tag:extensions (step 1 of the reproduction) hits this bug.

Please run the filter through app.search.gambits.apply('discussions', filter) before sending it. Doing it in activeFilters() would also make the confirmation wording follow the parsed filter.

The handler loads every match into memory

With limit: null, the searcher does a single unbounded ->get() and builds a full Discussion model for every matching unread discussion. Only the writes are chunked. A new account using a broad search or a busy tag can match tens of thousands of discussions. Please page through the results, or select just id and last_post_number, instead of loading everything at once.

Tests

This adds an endpoint and a command but no tests. At minimum I'd like integration tests covering:

  • a filtered request marks matching discussions read and leaves the rest unread
  • no filter (or only empty values) sets marked_all_as_read_at and writes no discussion_user rows
  • guests get a 401
  • discussions the actor can't see aren't marked read
  • an existing discussion_user row keeps its other columns (for example subscription) after the upsert
  • discussions with a null last_post_number are skipped

Smaller things

  • DiscussionListState::markAllAsRead() reads this.getAllItems() only when the request finishes. If the reader switches to another tag while the request is in flight, the same state object now holds the new list, and those discussions get marked read on the client. Capture the items before sending the request.
  • IndexPage ignores the returned promise, so a failed request logs "Uncaught (in promise)". Core already shows the error alert, so a no-op .catch(() => {}) is enough.
  • The bulk path doesn't fire the UserDataSaving and read-state events that ReadDiscussionHandler fires. That's fine as a deliberate trade-off, but please note it in the PR description so extension authors aren't surprised.

ReadDiscussionsHandler ran one unbounded SearchManager query (limit: null), hydrating a Discussion model for every unread match. It now fetches CHUNK_SIZE (500) discussions per query, keeps only the small upsert tuples, and writes once every page is read, so writes cannot shift a later page's offset. Also documents that the bulk path does not raise UserRead or the per-discussion read-state events that ReadDiscussionHandler fires.
activeFilters() now runs q through app.search.gambits.apply(), as Store.find() does, so author:x, is:unread and similar terms reach POST /api/discussions/read as filters instead of a literal fulltext query. The confirmation wording in IndexPage follows the parsed filter too. markAllAsRead() captures the list's items before sending the request, so switching lists mid-request no longer marks the new list read on the client. IndexPage swallows the rejected promise, since core already shows the error alert.
Covers filtered marking, the unfiltered timestamp path (no filter and empty values), guests getting 401, visibility scoping, preserving other discussion_user columns on upsert, skipping null last_post_number, and a match set larger than one internal page.
Passing only q to gambits.apply() and merging the result over the filter replaced keys that a predicate gambit (flarum/tags' tag:) should append to. On a tag page with tag:x searched, Store.find() lists discussions in both tags, but mark-as-read sent only x. activeFilters() now calls app.search.gambits.apply('discussions', filter) on the whole filter, as Store.find() does.
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.

Discussion lists "Mark all read" button should take filters into account

3 participants