Repository navigation
fix(core): mark only the filtered discussions as read - #5307
Saksham-chourasia wants to merge 5 commits into
Conversation
"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.
imorland
left a comment
There was a problem hiding this comment.
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_atand writes nodiscussion_userrows - guests get a 401
- discussions the actor can't see aren't marked read
- an existing
discussion_userrow keeps its other columns (for examplesubscription) after the upsert - discussions with a null
last_post_numberare skipped
Smaller things
DiscussionListState::markAllAsRead()readsthis.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.IndexPageignores 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
UserDataSavingand read-state events thatReadDiscussionHandlerfires. 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.
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 isderived from it —
Discussion.unreadCount()on the frontend,UnreadFilteronthe backend. The request carried nothing about the list either:
IndexPagesent
PATCH /api/users/:id { markedAllAsReadAt }and the filters the list wasfetched with were dropped. So with
tag:extensionsactive, pressing it silencedthe whole forum.
Changes
POST /api/discussions/readtakes the samefilterparameter the indexendpoint takes, so the set it marks read is the set the list shows.
Flarum\Discussion\Command\ReadDiscussions+ handler, resolved by the{Command}Handlerconvention, so no service-provider wiring.SearchManager/DiscussionSearcher, which keepswhereVisibleTo()and every filterextensions register — the tag of a tag page, a subscription, a fulltext
q—then upserts
discussion_userrows read up tolast_post_number, in batchesof 500. Only the read columns are written, so a subscription living on the
same row survives.
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.
rather than a row each, so All Discussions costs exactly what it did before.
On the frontend,
DiscussionListState.markAllAsRead()sends the filtersrequestParams()asked the API for, so extension filters come along withoutcore knowing about them.
IndexPagedelegates to it and picks the confirmationcopy to match, since "all discussions" would be a lie for the filtered case.
PATCH /api/users/:id { markedAllAsReadAt }is untouched and still markseverything read.
Reproduction
tag:extensions(or open a tag page).Changes after review
Fixes made:
activeFilters()now parses the query withapp.search.gambits.apply(), the same wayStore.find()does.author:xandis:unreadreachPOST /api/discussions/readas real filters, and the confirmation text inIndexPagefollows the parsed filter too.tag:extensionsgoes through the same code, but the tag gambit only exists when flarum/tags is loaded, so core's Jest tests checkauthor:andis:unread.unreadand push later pages past rows that still need marking.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.IndexPagenow ends the call with.catch(() => {}).ReadDiscussionsHandlersays the bulk path does not fireUserReador the other read-state events. The PR description note is below.Tests added:
ReadAllTest.php, 8 tests): the six cases you listed, plus a set of 501 matching discussions that needs more than one page.Note for extension authors
The bulk path (
POST /api/discussions/read) writes read state with one upsert per batch. It does not fireUserDataSaving,UserRead, or the other read-state events thatReadDiscussionHandlerfires 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.