Skip to content

feat: localized unread count - #3250

Merged
arnautov-anton merged 3 commits into
masterfrom
localized-unread-count
Aug 4, 2026
Merged

feat: localized unread count#3250
arnautov-anton merged 3 commits into
masterfrom
localized-unread-count

Conversation

@arnautov-anton

@arnautov-anton arnautov-anton commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

🎯 Goal

Ref: GetStream/stream-chat-react-native#3679

Summary by CodeRabbit

  • Bug Fixes
    • Improved unread-count accuracy for channels using local unread tracking.
    • Marking messages as read now updates channel lists and delivery indicators consistently.
    • Read-state updates no longer incorrectly reset unread counts.
    • Hidden-tab unread indicators now stay synchronized with local unread activity.
    • Read actions continue to work when server read events are unavailable.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

πŸ“ Walkthrough

Walkthrough

Channel read handling now supports local unread counts. Local read events update channel and list state without server requests. Server marking remains conditional on read_events, while unread notifications and delivery status handle local read events.

Changes

Local unread count handling

Layer / File(s) Summary
Read flow and channel state
src/components/MessageList/hooks/useMarkRead.ts, src/components/Channel/Channel.tsx
Read handling now supports local unread tracking, preserves existing unread state, updates hidden-tab notifications, and ignores local read events during channel synchronization.
Unread event propagation
src/components/ChannelListItem/ChannelListItem.tsx, src/components/ChannelListItem/hooks/useMessageDeliveryStatus.ts
Channel list unread counts and message delivery status now process message.read_locally events. Mark-read events recalculate counts for the matching channel and user.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested labels: released on @latest``

Suggested reviewers: oliverlaz, martincupela

πŸš₯ Pre-merge checks | βœ… 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description states the goal and issue reference but omits the required Implementation details and UI Changes sections. Add the Implementation details and UI Changes sections, including the implementation summary and relevant screenshots or an explicit note that no UI changes apply.
βœ… Passed checks (4 passed)
Check name Status Explanation
Title check βœ… Passed The title clearly and concisely describes the localized unread count functionality added by the pull request.
Docstring Coverage βœ… Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check βœ… Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check βœ… Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
πŸ“ Generate docstrings
  • Create stacked PR
  • Commit on current branch
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch localized-unread-count

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

Size Change: +205 B (+0.02%)

Total Size: 884 kB

πŸ“¦ View Changed
Filename Size Change
dist/cjs/emojis.js 2.58 kB +2 B (+0.08%)
dist/cjs/index.js 293 kB +19 B (+0.01%)
dist/cjs/useChannelHeaderOnlineStatus.js 41.2 kB +1 B (0%)
dist/cjs/useMessageComposerController.js 1.01 kB +1 B (+0.1%)
dist/cjs/useNotificationApi.js 58 kB +86 B (+0.15%)
dist/es/channel-detail.mjs 22.7 kB +2 B (+0.01%)
dist/es/emojis.mjs 2.5 kB -1 B (-0.04%)
dist/es/index.mjs 290 kB +19 B (+0.01%)
dist/es/useChannelHeaderOnlineStatus.mjs 40.6 kB +1 B (0%)
dist/es/useMessageComposerController.mjs 936 B -1 B (-0.11%)
dist/es/useNotificationApi.mjs 56.7 kB +76 B (+0.13%)
ℹ️ View Unchanged
Filename Size
dist/cjs/audioProcessing.js 1.74 kB
dist/cjs/channel-detail.js 23.3 kB
dist/cjs/mp3-encoder.js 814 B
dist/cjs/ReactPlayerWrapper.js 546 B
dist/css/channel-detail.css 2.84 kB
dist/css/emoji-picker.css 178 B
dist/css/emoji-replacement.css 456 B
dist/css/index.css 41.4 kB
dist/es/audioProcessing.mjs 1.65 kB
dist/es/mp3-encoder.mjs 768 B
dist/es/ReactPlayerWrapper.mjs 485 B

compressed-size-action

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.92308% with 6 lines in your changes missing coverage. Please review.
βœ… Project coverage is 85.21%. Comparing base (972b68c) to head (32aac5f).

Files with missing lines Patch % Lines
src/components/Channel/Channel.tsx 66.66% 6 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3250      +/-   ##
==========================================
+ Coverage   85.18%   85.21%   +0.03%     
==========================================
  Files         507      507              
  Lines       15949    15957       +8     
  Branches     5025     5029       +4     
==========================================
+ Hits        13586    13598      +12     
+ Misses       2363     2359       -4     

β˜” View full report in Codecov by Harness.
πŸ“’ Have feedback on the report? Share it here.

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • πŸ“¦ JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@arnautov-anton
arnautov-anton marked this pull request as ready for review August 3, 2026 12:13
@arnautov-anton
arnautov-anton force-pushed the localized-unread-count branch from 06fe8a4 to 127811f Compare August 3, 2026 12:13
Comment on lines -335 to 337
} catch (e) {
console.error(t('Failed to mark channel as read'));
if (activeUnreadHandler) {
activeUnreadHandler(0, originalTitle.current);
} else if (originalTitle.current) {
document.title = originalTitle.current;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Got rid of this catch clause - I feel like this should bubble up.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/Channel/Channel.tsx (1)

321-343: 🩺 Stability & Availability | 🟠 Major | ⚑ Quick win

Handle rejected mark-read requests.

If channel.markRead() rejects, this callback exits before title restoration. If doMarkReadRequest() rejects, its unawaited promise can become unhandled. Await the custom request and restore scoped error handling for both paths.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Channel/Channel.tsx` around lines 321 - 343, Update the
mark-read callback in Channel.tsx to await doMarkReadRequest and wrap both
custom and channel.markRead paths with scoped error handling, ensuring
rejections are handled while execution still reaches the
activeUnreadHandler/document.title restoration logic. Preserve the existing
unread-state update behavior for successful responses.
🧹 Nitpick comments (1)
src/components/Channel/Channel.tsx (1)

349-356: πŸ“ Maintainability & Code Quality | πŸ”΅ Trivial | ⚑ Quick win

Use channel.cid in this dependency array.

Replace channel with channel.cid. Do not include channel directly in dependency arrays.

As per coding guidelines: β€œDo not include channel in dependency arrays; use channel.cid only.”

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Channel/Channel.tsx` around lines 349 - 356, Update the
dependency array associated with activeUnreadHandler to replace channel with
channel.cid, preserving all other dependencies unchanged and avoiding direct
channel object dependencies.

Source: Coding guidelines

πŸ€– Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/Channel/Channel.tsx`:
- Around line 305-308: Move the channel.disconnected early return from the outer
flow into the server channel.markRead() path. Keep channel.markReadLocally()
reachable while disconnected when local unread counts are enabled, while
preserving the guard before any server read operation.

In `@src/components/ChannelListItem/ChannelListItem.tsx`:
- Around line 120-133: Update the handleEvent logic in ChannelListItem’s
useEffect so it filters by channel.cid only when event.cid is present, allowing
CID-less notification.mark_read events to update the unread count while
preserving filtering for mismatched provided CIDs.

---

Outside diff comments:
In `@src/components/Channel/Channel.tsx`:
- Around line 321-343: Update the mark-read callback in Channel.tsx to await
doMarkReadRequest and wrap both custom and channel.markRead paths with scoped
error handling, ensuring rejections are handled while execution still reaches
the activeUnreadHandler/document.title restoration logic. Preserve the existing
unread-state update behavior for successful responses.

---

Nitpick comments:
In `@src/components/Channel/Channel.tsx`:
- Around line 349-356: Update the dependency array associated with
activeUnreadHandler to replace channel with channel.cid, preserving all other
dependencies unchanged and avoiding direct channel object dependencies.
πŸͺ„ Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
βš™οΈ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a276737b-533a-420b-91e6-d8020ec737f1

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 972b68c and 127811f.

πŸ“’ Files selected for processing (4)
  • src/components/Channel/Channel.tsx
  • src/components/ChannelListItem/ChannelListItem.tsx
  • src/components/ChannelListItem/hooks/useMessageDeliveryStatus.ts
  • src/components/MessageList/hooks/useMarkRead.ts

Comment on lines +305 to +308
if (channel.disconnected) return;

if (!channelConfig?.read_events && client.options.isLocalUnreadCountEnabled) {
const event = channel.markReadLocally();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## locate Channel.tsx"
fd -a 'Channel\.tsx$' . | sed 's#^\./##' | head -20

echo "## relevant lines in src/components/Channel/Channel.tsx"
wc -l src/components/Channel/Channel.tsx
sed -n '280,360p' src/components/Channel/Channel.tsx

echo "## search markReadLocally and disconnected usages"
rg -n "markReadLocally|markRead\(\)|disconnected|read_events|read_locally" src/components/Channel/Channel.tsx src -g '*.ts' -g '*.tsx' | head -120

echo "## package/import context"
fd -a 'package.json$' . | head -5
cat package.json | jq '{scripts, devDependencies, dependencies}' 2>/dev/null | head -180

Repository: GetStream/stream-chat-react

Length of output: 12876


🌐 Web query:

stream-chat-react ChannelChannel disconnected channel.markReadLocally isLocalUnreadCountEnabled

πŸ’‘ Result:

In the Stream Chat SDKs, isLocalUnreadCountEnabled and markReadLocally() are features designed primarily for channels where server-side read events are disabled, such as high-traffic livestream channels [1][2]. When isLocalUnreadCountEnabled is set to true during client initialization, the SDK maintains a client-side unread count [1]. Unlike standard unread counts managed by the backend, this count is local to the device and is not synced across other sessions [1]. To reset this local count, you must use channel.markReadLocally() [1]. This method dispatches a client-only event to update the local UI state without triggering a network request to the backend, making it appropriate for scenarios where channel.markRead() (which relies on server-side events) would be a no-op or unnecessary [1]. Regarding the "disconnected" state, recent updates to stream-chat-react have introduced guards to prevent errors when calling methods on a channel after the underlying client has been disconnected [3]. Previously, invoking channel methods (like those involved in state or read-tracking) on a disconnected client could throw errors or cause rendering crashes [4][3][5]. The SDK now includes internal checks (e.g., !channel.disconnected) in various hooks and methods to gracefully handle these situations [3]. If you are encountering issues with disconnected channels, ensure your project is updated to a recent version of the SDK that includes these fixes [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## Channel.markReadLocally definition/usages in SDK package source if present"
fd -e ts -e d.ts -e tsx | rg 'Channel.*channel|stream-chat|packages|node_modules' | head -120 || true

echo "## Check dependencies and installed stream-chat source"
node - <<'JS'
const fs = require('fs');
const path = require('path');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
console.log('stream-chat:', pkg.dependencies['stream-chat'] || pkg.devDependencies['stream-chat']);
for (const name of fs.readdirSync('node_modules', {withFileTypes: true}).filter(d => d.isDirectory())) {
  if (name.startsWith('`@stream-io`') || name === 'stream-chat') {
    const files = fs.readdirSync(`node_modules/${name}`, {withFileTypes: true});
    const interesting = files.filter(d => d.name === 'package.json');
    if (interesting.length) console.log(`node_modules/${name}`, fs.readFileSync(`node_modules/${name}/package.json`, 'utf8').slice(0,500).replace(/\n/g,' '));
  }
}
JS

echo "## Inspect PR reference and SDK type docs if network available"
python3 - <<'PY'
import json
from urllib.request import urlopen
for url in [
  "https://api.github.com/repos/GetStream/stream-chat-react/pulls/3227",
  "https://api.github.com/repos/GetStream/chat/pulls/3252", # likely related stream-chat
]:
    try:
        r=urlopen(url,timeout=15)
        data=json.load(r)
        print(f"\n===== {url} =====")
        print(data.get("title"))
        print(data.get("body","")[:2000].replace("\n"," ") if data.get("body") else "no body")
    except Exception as e:
        print(f"\n===== {url} error =====")
        print(e)
PY

Repository: GetStream/stream-chat-react

Length of output: 1245


🌐 Web query:

"markReadLocally" "Channel" "disconnected" "stream-chat" 9.50.2

πŸ’‘ Result:

In the Stream Chat SDK (including version 9.50.2), markReadLocally is a method designed to reset the current user's unread message count purely on the client side, without triggering a network request to the backend [1][2]. Key technical details regarding markReadLocally: Purpose: It is specifically intended for use in channels where read events are disabled, such as livestreams, but where the client has been configured with the isLocalUnreadCountEnabled option [1][2]. Mechanism: Instead of sending a server-side read event, markReadLocally dispatches a client-only event called message.read_locally [1][2]. This event is processed by the SDK's internal read logic, allowing the local UI unread count to update immediately without waiting for server confirmation [1][2]. Persistence: If offline support is enabled, the SDK persists the reset state to the offline database, ensuring the local unread count remains consistent across application restarts [1][2]. Usage: You call it directly on the channel instance [1]: channel.markReadLocally; Comparison with markRead: Unlike markRead, which is intended for channels with read events enabled and communicates with the Stream backend to synchronize read state across all devices, markReadLocally is a no-op regarding server synchronization [1][2]. If used on a channel that has read events enabled, it will not inform other devices or the server that the user has read the messages [1]. [1][2]

Citations:


🌐 Web query:

"channel.markReadLocally" "message.read_locally" "Channel" "disconnected" "stream-chat-react"

πŸ’‘ Result:

The channel.markReadLocally method is a client-side function used in the Stream Chat SDK to reset the local unread message count without sending a network request to the backend [1]. When called, it dispatches a client-only message.read_locally event, which updates the UI state (e.g., clearing unread indicators) locally [1]. This method is primarily intended for use in scenarios where server-side read events are disabled or not desired (e.g., to maintain a purely local unread count) [1]. It differs from the standard markRead method, which performs a server-side API call and is typically throttled by the SDK to prevent rate-limiting [2][3][4]. Regarding the context of a "Channel disconnected" state, calling SDK methods on a channel after the chat client has been disconnected can result in errors [5][6][7]. Recent versions of the stream-chat-react library have introduced guards to prevent these methods (such as lastRead and others) from crashing the render or causing unhandled exceptions when the client is disconnected [6]. If you encounter issues while the channel is disconnected, ensure your application logic checks the channel.disconnected property before invoking channel operations [6]. If you are experiencing crashes specifically, ensure you are using a version of the SDK that includes these connection guards [6].

Citations:


🌐 Web query:

github.com GetStream stream-chat-react 3227 Channel.tsx disconnected markReadLocally

πŸ’‘ Result:

Pull Request #3227 in the GetStream/stream-chat-react repository is a maintenance and bug-fix release that addresses several stability issues, including those related to the channel state when a client is disconnected [1][2]. Specifically, PR #3227 includes a fix for Issue #2393, which reported that internal methods, such as those related to reading state, could be called on a channel instance after the client had already disconnected [1][3]. Before this fix, calling methods like channel.lastRead during a component render could throw an error (e.g., "You can't use a channel after client.disconnect was called") and crash the application because there were insufficient guards against the disconnected state [1][3]. The fix involves adding checks, such as!channel.disconnected, to guard various channel methods within the SDK's internal components, including those that might trigger mark-read operations or read-state updates [1]. In the context of GetStream SDKs: - markRead is the standard, throttled method for marking messages as read on the backend [4][5][6]. - markReadLocally is a specific method used primarily for channels where read events are disabled (e.g., some livestream channels), allowing for a purely client-side reset of unread counts without triggering network requests [7]. By guarding these operations against the disconnected state, the SDK prevents runtime crashes when the WebSocket connection is severed while a component is still mounted or attempting to update its UI state [1][3]. Top results: [1], [3], [7]

Citations:


Scope the disconnected guard to server reads.

markReadLocally() processes unread state via a local client event only. Do not return for every disconnected check at line 305; move the channel.disconnected guard into the server channel.markRead() path instead.

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Channel/Channel.tsx` around lines 305 - 308, Move the
channel.disconnected early return from the outer flow into the server
channel.markRead() path. Keep channel.markReadLocally() reachable while
disconnected when local unread counts are enabled, while preserving the guard
before any server read operation.

Comment on lines 120 to +133
useEffect(() => {
const handleEvent = (event: Event) => {
if (channel.cid !== event.cid) return;
if (event.user?.id !== client.user?.id) return;
setUnread(channel.countUnread());
};

client.on('notification.mark_read', handleEvent);
channel.on('notification.mark_unread', handleEvent);
channel.on('message.read_locally', handleEvent);
return () => {
client.off('notification.mark_read', handleEvent);
channel.off('notification.mark_unread', handleEvent);
channel.off('message.read_locally', handleEvent);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚑ Quick win

Preserve CID-less notification.mark_read handling.

When event.cid is absent, Line 122 returns before the preview unread count updates. The supplied test context expects a CID-less mark-read event to clear the unread count. Filter only when event.cid is present.

Proposed fix
-      if (channel.cid !== event.cid) return;
+      if (event.cid && channel.cid !== event.cid) return;

The supplied test context in src/components/ChannelListItem/__tests__/ChannelListItem.test.tsx:521-575 covers this case.

πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
const handleEvent = (event: Event) => {
if (channel.cid !== event.cid) return;
if (event.user?.id !== client.user?.id) return;
setUnread(channel.countUnread());
};
client.on('notification.mark_read', handleEvent);
channel.on('notification.mark_unread', handleEvent);
channel.on('message.read_locally', handleEvent);
return () => {
client.off('notification.mark_read', handleEvent);
channel.off('notification.mark_unread', handleEvent);
channel.off('message.read_locally', handleEvent);
useEffect(() => {
const handleEvent = (event: Event) => {
if (event.cid && channel.cid !== event.cid) return;
if (event.user?.id !== client.user?.id) return;
setUnread(channel.countUnread());
};
client.on('notification.mark_read', handleEvent);
channel.on('notification.mark_unread', handleEvent);
channel.on('message.read_locally', handleEvent);
return () => {
client.off('notification.mark_read', handleEvent);
channel.off('notification.mark_unread', handleEvent);
channel.off('message.read_locally', handleEvent);
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ChannelListItem/ChannelListItem.tsx` around lines 120 - 133,
Update the handleEvent logic in ChannelListItem’s useEffect so it filters by
channel.cid only when event.cid is present, allowing CID-less
notification.mark_read events to update the unread count while preserving
filtering for mismatched provided CIDs.

@arnautov-anton
arnautov-anton merged commit 1b8fa34 into master Aug 4, 2026
13 checks passed
@arnautov-anton
arnautov-anton deleted the localized-unread-count branch August 4, 2026 11:27
github-actions Bot pushed a commit that referenced this pull request Aug 7, 2026
## [14.11.0](v14.10.0...v14.11.0) (2026-08-07)

### Bug Fixes

* **Channel:** guard render-phase channel.getConfig() against disconnected channels ([#3257](#3257)) ([f60273f](f60273f)), closes [#3254](#3254) [#2393](#2393) [#3249](#3249)
* **EmojiPicker:** drop @emoji-mart/react peer dependency ([#3255](#3255)) ([0820e4c](0820e4c))

### Features

* add icons to ComponentContext ([#3246](#3246)) ([972b68c](972b68c))
* localized unread count ([#3250](#3250)) ([1b8fa34](1b8fa34)), closes [GetStream/stream-chat-react-native#3679](GetStream/stream-chat-react-native#3679)
* **MessageComposer:** introduce context for custom composers ([#3249](#3249)) ([5776c16](5776c16)), closes [#3248](#3248)
@stream-ci-bot

Copy link
Copy Markdown

πŸŽ‰ This PR is included in version 14.11.0 πŸŽ‰

The release is available on:

Your semantic-release bot πŸ“¦πŸš€

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants