Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 32 additions & 17 deletions src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,21 +302,30 @@ const ChannelInner = (
throttle(
async (options?: MarkReadWrapperOptions) => {
const { updateChannelUiUnreadState = true } = options ?? {};
if (channel.disconnected || !channelConfig?.read_events) {
return;
}

lastRead.current = new Date();
if (channel.disconnected) return;

if (!channelConfig?.read_events && client.options.isLocalUnreadCountEnabled) {
const event = channel.markReadLocally();
Comment on lines +305 to +308

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.


if (updateChannelUiUnreadState && event) {
lastRead.current = new Date();
_setChannelUnreadUiState({
last_read: lastRead.current,
last_read_message_id: event.last_read_message_id,
unread_messages: 0,
});
}
} else if (channelConfig?.read_events) {
lastRead.current = new Date();

try {
if (doMarkReadRequest) {
doMarkReadRequest(
channel,
updateChannelUiUnreadState ? setChannelUnreadUiState : undefined,
);
} else {
const markReadResponse = await channel.markRead();
// markReadResponse.event can be null in case of a user that is not a member of a channel being marked read
// markReadResponse.event can be null in case of a user that is not a member of a channel being marked read
// in that case event is null and we should not set unread UI
if (updateChannelUiUnreadState && markReadResponse?.event) {
_setChannelUnreadUiState({
Expand All @@ -326,14 +335,12 @@ const ChannelInner = (
});
}
}
}

if (activeUnreadHandler) {
activeUnreadHandler(0, originalTitle.current);
} else if (originalTitle.current) {
document.title = originalTitle.current;
}
} 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;
}
Comment on lines -335 to 337

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.

},
500,
Expand All @@ -343,9 +350,9 @@ const ChannelInner = (
activeUnreadHandler,
channel,
channelConfig,
client,
doMarkReadRequest,
setChannelUnreadUiState,
t,
],
);

Expand Down Expand Up @@ -381,7 +388,7 @@ const ChannelInner = (
if (mainChannelUpdated) {
if (
document.hidden &&
channelConfig?.read_events &&
(channelConfig?.read_events || client.options.isLocalUnreadCountEnabled) &&
!channel.muteStatus().muted
) {
const unread = channel.countUnread(lastRead.current);
Expand Down Expand Up @@ -424,6 +431,10 @@ const ChannelInner = (
});
}

if (event.type === 'message.read_locally') {
return;
}

if (event.type === 'notification.mark_unread')
_setChannelUnreadUiState((prev) => {
if (!(event.last_read_at && event.user)) return prev;
Expand Down Expand Up @@ -490,7 +501,11 @@ const ChannelInner = (
if (client.user?.id && channel.state.read[client.user.id]) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { user, ...ownReadState } = channel.state.read[client.user.id];
_setChannelUnreadUiState(ownReadState);
_setChannelUnreadUiState((existingState) => {
// only set the initial state here, do not override existing
if (existingState) return existingState;
return ownReadState;
});
}
/**
* TODO: maybe pass last_read to the countUnread method to get proper value
Expand Down
15 changes: 5 additions & 10 deletions src/components/ChannelListItem/ChannelListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,25 +117,20 @@ export const ChannelListItem = (props: ChannelListItemProps) => {
typeof active === 'undefined' ? activeChannel?.cid === channel.cid : active;
const { muted } = useIsChannelMuted(channel);

useEffect(() => {
const handleEvent = (event: Event) => {
if (!event.cid) return setUnread(0);
if (channel.cid === event.cid) setUnread(0);
};

client.on('notification.mark_read', handleEvent);
return () => client.off('notification.mark_read', handleEvent);
}, [channel, client]);

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);
Comment on lines 120 to +133

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.

};
}, [channel, client]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,12 @@ export const useMessageDeliveryStatus = ({

channel.on('message.delivered', handleMessageDelivered);
channel.on('message.read', handleMarkRead);
channel.on('message.read_locally', handleMarkRead);

return () => {
channel.off('message.delivered', handleMessageDelivered);
channel.off('message.read', handleMarkRead);
channel.off('message.read_locally', handleMarkRead);
};
}, [channel, client, isOwnMessage, lastMessage]);

Expand Down
6 changes: 5 additions & 1 deletion src/components/MessageList/hooks/useMarkRead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ export const useMarkRead = ({
const { channel } = useChannelStateContext('useMarkRead');

useEffect(() => {
if (!channel.getConfig()?.read_events) return;
const unreadNotificationSupported =
channel.getConfig()?.read_events || client.options.isLocalUnreadCountEnabled;

if (!unreadNotificationSupported) return;

const shouldMarkRead = () =>
!document.hidden &&
!wasMarkedUnread &&
Expand Down