fix: do not reset channel unread count on thread read - #1835
Open
mrpmohiburrahman wants to merge 1 commit into
Open
fix: do not reset channel unread count on thread read#1835mrpmohiburrahman wants to merge 1 commit into
mrpmohiburrahman wants to merge 1 commit into
Conversation
A `message.read` carrying `event.thread` is a thread read. It now early-outs of the channel-scoped read branch in `Channel._handleChannelEvent` instead of advancing the channel read cursor, clearing `unread_messages` and zeroing `unreadCount`. The offline DB gets the matching carve-out so the wrong 0 is not persisted and re-hydrated. Fixes GetStream#1676
mrpmohiburrahman
requested review from
MartinCupela,
isekovanic,
oliverlaz,
santhoshvai,
szuperaz and
vishalnarkhede
as code owners
August 19, 2026 10:14
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
CLA
Description of the changes, What, Why and How?
Fixes #1676.
What
channel.markRead({ thread_id })marks a thread read, but themessage.readevent theserver echoes back was zeroing the channel unread count. Reading a thread reply made
still-unread channel messages disappear from the badge.
A
message.read/message.read_locallycarryingevent.threadnow early-outs of thechannel-scoped read branch in
Channel._handleChannelEventbefore any of it runs. Theoffline DB gets the matching carve-out.
Why
The
case 'message.read_locally': case 'message.read':branch is entirely channel-scopedand ran unconditionally, including for thread-scoped read events. Nothing in it looked at
event.thread, so five channel-scoped writes fired for an event that only concerns athread:
read[user].last_read = event.created_atread[user].last_read_message_idread[user].unread_messages = 0messageReceiptsTracker.onMessageRead(...)channelState.unreadCount = 0The issue suggests gating the last of those on
isOwnEvent && !isThreadRead. That fixesthe visible number but leaves
read[user].last_readadvanced, and everything thatre-derives from that cursor reproduces the same wrong
0:countUnread(lastRead)(src/channel.ts:1491) counts messages newer thanlast_read;with the cursor past the unread message it returns
0regardless of the guard.channel.truncatedrecomputesunreadCount = this.countUnread(...).state.unreadCount = read[user].unread_messages, so a re-watchor reconnect restores the wrong
0.So the guard belongs at the top of the case, not on one line inside it — one early-out
covers all five writes and is a smaller diff than the per-line version.
event.threadas the discriminator is what the SDK already uses:Thread.subscribeRepliesRead(
src/thread.ts:418) returns early unlessevent.threadis present andevent.thread.parent_message_id === this.id. Thread read state is applied there, off theclient event bus, so nothing is lost by the channel ignoring the event.
How
src/channel.ts— one early-out at the top of the case:breakexits the switch, not the method, so the post-switchwatcher_countupdate stillruns.
_callChannelListenersis dispatched separately by the client (src/client.ts:1419vs
1425), sochannel.on('message.read', …)subscribers still receive the event — onlychannel state mutation is skipped.
markReadLocally()never setsthread, so themessage.read_locallyarm is inert.src/offline-support/offline_support_api.ts— the same carve-out,message.readonly:handleReadis cid-keyed and channel-scoped, so without this a thread read persistedunread_messages: 0for the whole channel and hydration restored that0on restart —the in-memory fix alone would not have survived an app restart with offline support on.
Scoped to
message.readdeliberately:notification.mark_readhas noevent.threadcheck in its in-memory counterpart (
StreamChat._handleClientEvent), so guarding it onlyin the DB layer would let the two disagree across a restart.
Behaviour change worth flagging
The guard is not scoped to the connected user, so another user's thread read no longer
advances their channel read marker either. That is deliberate —
read[userId].last_readisthe channel read cursor, and advancing it because someone read a thread reply would make
"seen by" claim they had seen channel messages they never opened. A wrong receipt seemed
worse than a missing one, but it does affect what downstream SDKs render, so flagging it
rather than burying it. Pinned by a test; happy to narrow it to the own-user path if you
would rather keep the previous behaviour there.
client.syncDeliveredCandidates([this])also no longer fires on a thread read. It stillruns on
message.new,message.deliveredand after a channel query, and the deliveryreport is a latest-delivered high-water mark, so the following one supersedes it.
Two assumptions, stated rather than buried
of correct behaviour — no maintainer has commented on the issue, so it is not a ruling.
If the intended semantics are different, say so and I will close this or rework it.
event.threadon achannel-level read.
src/thread.ts:418already relies on it andtest/unit/threads.test.ts:767encodes it, but I could not verify it against the backend.Out of scope
src/channel.ts:2238(notification.mark_unread) looks thread-blind in the same way, butit keys off
event.thread_idrather thanevent.thread(src/thread.ts:336), so thisguard would not catch it. Unreported and a different code path — left alone to keep the
diff to what closes #1676. Happy to file a follow-up.
Tests
4 new tests, all watched failing with the guard removed and passing with it restored:
message.readcarryingthreadleavesunreadCount,last_read,last_read_message_idand
unread_messagesuntouched, and does not callmessageReceiptsTracker.onMessageRead.message.readcarryingthreaddoes not reachhandleRead.notification.mark_readcarryingthreadstill does, pinning the narrow scope.The pre-existing "should update channel read state produced for current user" test covers
the other direction — a
message.readwith nothreadstill zeroesunreadCount— andstays green, so the guard does not over-fire.
yarn lintandyarn typesboth clean.yarn test-typesnot run locally — it needs liveAPI credentials; leaving it to CI.
Changelog
channel.state.unreadCountbeing reset to0when marking a thread as read.