Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
129630e
feat: implement destructive reconciliation
isekovanic Aug 11, 2026
4b34488
chore: move api to offline db api
isekovanic Aug 11, 2026
7be972e
feat: generalize merging the newest page and destructive reconciliation
isekovanic Aug 11, 2026
49509aa
fix: execute reconciliation even if not at head interval
isekovanic Aug 11, 2026
26ed33b
fix: use correct interval for candidate ids
isekovanic Aug 12, 2026
2a06363
fix: cold load reconciliation
isekovanic Aug 12, 2026
894a7cc
chore: add potential todos
isekovanic Aug 12, 2026
3a235c2
fix: remove redndant comment
isekovanic Aug 12, 2026
ad291a7
feat: expose batching and batch failed messages
isekovanic Aug 12, 2026
a93784f
chore: add test
isekovanic Aug 12, 2026
f7626b7
feat: add list level synchronous batching
isekovanic Aug 12, 2026
0595360
fix: rename
isekovanic Aug 12, 2026
fa09af4
chore: rename api
isekovanic Aug 12, 2026
269f4d6
fix: clarify docs
isekovanic Aug 12, 2026
679e8e4
fix: properly reconcile above the fold items
isekovanic Aug 13, 2026
20bb4d3
chore: add test for both removal and receival
isekovanic Aug 13, 2026
b72eeee
feat: channel state migration initial pass
isekovanic Aug 13, 2026
c5d1623
feat: extend channel state with missing properties
isekovanic Aug 13, 2026
6f4a5e6
feat: add missing membership state
isekovanic Aug 13, 2026
cef2056
chore: remove autoreads and delegate for later research
isekovanic Aug 14, 2026
aec45ca
Merge branch 'release-v10' into feat/channel-state-migration
isekovanic Aug 14, 2026
8c23dc0
feat: ai state
isekovanic Aug 17, 2026
0da6c8f
feat: introduce isDirectChannel state
isekovanic Aug 17, 2026
319f172
Merge remote-tracking branch 'origin/release-v10' into feat/channel-s…
isekovanic Aug 18, 2026
c6e4a0e
fix: dadress pr comments
isekovanic Aug 19, 2026
fbdb99c
fix: rename channel.disconnected
isekovanic Aug 19, 2026
65abeeb
Merge remote-tracking branch 'origin/release-v10' into feat/channel-s…
isekovanic Aug 20, 2026
b17ea6c
fix: post merge issue
isekovanic Aug 20, 2026
2c00558
fix: remove deprecated api
isekovanic Aug 20, 2026
ca3251e
feat: introduce watching state
isekovanic Aug 20, 2026
85c7497
fix: extend watch status
isekovanic Aug 21, 2026
b10fcdc
fix: comments and error msgs
isekovanic Aug 21, 2026
098047b
fix: lint errors
isekovanic Aug 21, 2026
c54834d
fix: bring back old comment
isekovanic Aug 21, 2026
dff31cd
Merge remote-tracking branch 'origin/release-v10' into feat/channel-s…
isekovanic Aug 21, 2026
3b720f2
Merge remote-tracking branch 'origin/release-v10' into feat/channel-s…
isekovanic Aug 21, 2026
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
299 changes: 234 additions & 65 deletions src/channel.ts

Large diffs are not rendered by default.

355 changes: 255 additions & 100 deletions src/channel_state.ts

Large diffs are not rendered by default.

76 changes: 69 additions & 7 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { AxiosInstance } from 'axios';
import axios from 'axios';

import { Channel } from './channel';
import { ChannelWatchStatus } from './channel_state';
import { ClientState } from './client_state';
import { StableWSConnection } from './connection';
import { UploadManager } from './uploadManager';
Expand Down Expand Up @@ -508,6 +509,9 @@ export class StreamChat extends ChatApi {
* successful disconnection. See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent (optional).
*/
closeConnection = async (timeout?: number) => {
this._resetAIStateOnActiveChannels();
this._markActiveChannelsWatchInterrupted();

if (this.cleaningIntervalRef != null) {
clearInterval(this.cleaningIntervalRef);
this.cleaningIntervalRef = undefined;
Expand Down Expand Up @@ -1014,6 +1018,7 @@ export class StreamChat extends ChatApi {
client.user = event.me;
client.state.updateUser(event.me);
client.mutedChannels = event.me.channel_mutes;
client._reflectMutedChannelsToActiveChannels();
client.mutedUsers = event.me.mutes;
client.blockedUsers.partialNext({ userIds: event.me.blocked_user_ids ?? [] });
}
Expand All @@ -1025,6 +1030,7 @@ export class StreamChat extends ChatApi {

if (event.type === 'notification.channel_mutes_updated' && event.me?.channel_mutes) {
this.mutedChannels = event.me.channel_mutes;
this._reflectMutedChannelsToActiveChannels();
}

if (event.type === 'notification.mutes_updated' && event.me?.mutes) {
Expand All @@ -1033,9 +1039,10 @@ export class StreamChat extends ChatApi {

if (event.type === 'notification.mark_read' && event.unread_channels === 0) {
const activeChannelKeys = Object.keys(this.activeChannels);
activeChannelKeys.forEach(
(activeChannelKey) =>
(this.activeChannels[activeChannelKey].state.unreadCount = 0),
activeChannelKeys.forEach((activeChannelKey) =>
// resets `read[userId].unread_messages`, which is what the unread badge reads, so it does
// not stay stale.
this.activeChannels[activeChannelKey]._setOwnUnreadCount(0),
);
}

Expand Down Expand Up @@ -1071,6 +1078,50 @@ export class StreamChat extends ChatApi {
return postListenerCallbacks;
}

/**
* Fans the client-owned `mutedChannels` out to every active channel's reactive `state.muteStatus`.
* Each channel republishes only when its own mute status actually changed, so this stays cheap on
* the frequent `health.check` path.
*/
_reflectMutedChannelsToActiveChannels() {
for (const cid in this.activeChannels) {
this.activeChannels[cid]?._syncMuteStatus();
}
}

/**
* Resets the AI indicator state to `Idle` on every active channel. Invoked from `closeConnection`
* as it's a deliberate shutdown and will not natively trigger a WS event.
*/
_resetAIStateOnActiveChannels() {
for (const cid in this.activeChannels) {
this.activeChannels[cid]?.state.resetAIState();
}
}

/**
* Demotes every actively-watched channel to `WasWatching`. The server keys watches by connection
* ID, so losing the socket ends every watch this client held — a reconnect issues a NEW id and the
* channels have to be re-queried to watch again. `WasWatching` is what records that they should be.
*
* Only `Watching` is demoted: a channel the consumer stopped on purpose, or one that was torn
* down, stays `NotWatching` and must not be resurrected by a reconnect.
*
* Invoked from two places, because neither covers the other: `StableWSConnection._setHealth(false)`
* for an abnormal close/error (immediately — NOT via the `connection.changed` event, which is
* 5s-debounced when going offline and is skipped entirely on a quick flap, both of which would
* leave the status lying), and `closeConnection()` for a deliberate shutdown (e.g. mobile
* backgrounding), which sets `isHealthy` directly and so never reaches `_setHealth`.
*/
_markActiveChannelsWatchInterrupted() {
for (const cid in this.activeChannels) {
const channel = this.activeChannels[cid];
if (channel?.watchStatus === ChannelWatchStatus.Watching) {
channel.watchStatus = ChannelWatchStatus.WasWatching;
}
}
}

_muteStatus(cid: string) {
let muteStatus;
for (let i = 0; i < this.mutedChannels.length; i++) {
Expand Down Expand Up @@ -1410,9 +1461,16 @@ export class StreamChat extends ChatApi {
const c = this.channel(channelState.channel.type, channelState.channel.id);
const previousData = c.data;
c.data = channelState.channel;
c._syncStateFromChannelData(c.data, previousData);
c.state.syncStateFromChannelData(c.data, previousData);
c.offlineMode = offlineMode;
c.initialized = !offlineMode;
// Same precedence `queryChannels` applies to the request: an explicit caller choice wins,
// otherwise we watch only if there is a connection to watch on. Offline hydration populates
// state without a live watch, so it never counts - and a query that did not watch leaves the
// status untouched (it neither starts nor ends a watch).
if (!offlineMode && (queryChannelsOptions?.watch ?? this._hasConnectionID())) {
c.watchStatus = ChannelWatchStatus.Watching;
}
c.push_preferences = channelState.push_preferences;

const willInitialize =
Expand All @@ -1432,8 +1490,12 @@ export class StreamChat extends ChatApi {
// newest page to merge into that jumped interval across the gap (missing messages in the
// middle). A cold paginator, or one still at the head (offline/at-latest), re-seeds normally so
// cursors/hasMoreTail get (re)derived and pagination keeps working.
// Also skip the re-seed for an ACTIVE (on-screen) channel: its own `channel.reload()` owns the
// loaded window (up to 100 msgs), so a 25-msg list re-seed here is a redundant second update
// that could perturb the fuller window. (Inert until a UI SDK calls `channel.activate()`.)
if (
willInitialize &&
!c.active &&
(!c.messagePaginator.isInitialized || c.messagePaginator.isActiveIntervalAtHead)
) {
c.messagePaginator.seedFirstPageSync(
Expand Down Expand Up @@ -1580,7 +1642,7 @@ export class StreamChat extends ChatApi {
// we will replace it with `cid`
for (const key in this.activeChannels) {
const channel = this.activeChannels[key];
if (channel.disconnected) {
if (channel.pendingDisposal) {
continue;
}

Expand Down Expand Up @@ -1634,7 +1696,7 @@ export class StreamChat extends ChatApi {
if (
cid in this.activeChannels &&
this.activeChannels[cid] &&
!this.activeChannels[cid].disconnected
!this.activeChannels[cid].pendingDisposal
) {
const channel = this.activeChannels[cid];
// Only overwrite the existing channel's custom data when the caller actually provided some.
Expand All @@ -1646,7 +1708,7 @@ export class StreamChat extends ChatApi {
if (custom.custom !== undefined) {
const previousData = channel.data;
channel.data = { ...channel.data, custom: custom.custom };
channel._syncStateFromChannelData(channel.data, previousData);
channel.state.syncStateFromChannelData(channel.data, previousData);
channel._data = { ...channel._data, custom: custom.custom };
}
return channel;
Expand Down
4 changes: 4 additions & 0 deletions src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,10 @@ export class StableWSConnection {
return;
}

// The server keys channel watches by connection ID, so they are gone the moment the socket is.
// Done here rather than off the `connection.changed` event below, which is debounced by 5s.
this.client._markActiveChannelsWatchInterrupted();

// we're offline, wait few seconds and fire and event if still offline
setTimeout(() => {
if (this.isHealthy) return;
Expand Down
21 changes: 13 additions & 8 deletions src/messageDelivery/MessageReceiptsTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,14 +196,19 @@ export class MessageReceiptsTracker extends WithSubscriptions {
if (this.hasSubscriptions) return;

this.addUnsubscribeFunction(
this.channel.state.readStore.subscribe((next, prev) => {
this.reconcileFromReadStore({
previousReadState: prev?.read,
nextReadState: next.read,
meta: this.pendingReadStoreReconcileMeta,
});
this.pendingReadStoreReconcileMeta = undefined;
}),
// Subscribe to only the `read` slice of the unified channel state so this reconcile fires on
// read changes, not on every unrelated channel-state write (typing/members/…).
this.channel.state.subscribeWithSelector(
(currentState) => ({ read: currentState.read }),
(next, prev) => {
this.reconcileFromReadStore({
previousReadState: prev?.read,
nextReadState: next.read,
meta: this.pendingReadStoreReconcileMeta,
});
this.pendingReadStoreReconcileMeta = undefined;
},
),
);
};

Expand Down
2 changes: 1 addition & 1 deletion src/offline-support/offline_support_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -587,7 +587,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi {
event_.channel_type,
event_.channel_id,
);
if (channelFromState.initialized && !channelFromState.disconnected) {
if (channelFromState.initialized && !channelFromState.pendingDisposal) {
channelData = channelFromState.data as unknown as ChannelResponse;
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/pagination/paginators/MessagePaginator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,9 +328,9 @@ export class MessagePaginator extends MessageIntervalPaginator {
seedUnreadSnapshot = () => {
// A paginator query (BasePaginator.executeQuery) awaits the network before running its
// synchronous postQueryReconcile, which calls this on the first page. If the channel was
// disconnected while that request was in flight, reading the client below throws ("You can't
// use a channel after client.disconnect()"), so guard against that.
if (this.channel.disconnected) return;
// torn down while that request was in flight, reading the client below throws (the channel is
// pending disposal), so guard against that.
if (this.channel.pendingDisposal) return;
const ownUserId = this.channel.getClient().user?.id;
const ownReadState = ownUserId ? this.channel.state.read[ownUserId] : undefined;
if (!ownReadState) return;
Expand Down
13 changes: 12 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,12 +438,23 @@ export type ModerationFlagOptions = Omit<
>;

export type AIState =
| 'AI_STATE_IDLE'
| 'AI_STATE_ERROR'
| 'AI_STATE_CHECKING_SOURCES'
| 'AI_STATE_EXTERNAL_SOURCES'
| 'AI_STATE_THINKING'
| 'AI_STATE_GENERATING'
| 'AI_STATE_STOP'
| (string & {});

export const AIStates = {
Error: 'AI_STATE_ERROR',
ExternalSources: 'AI_STATE_EXTERNAL_SOURCES',
Generating: 'AI_STATE_GENERATING',
Idle: 'AI_STATE_IDLE',
Stop: 'AI_STATE_STOP',
Thinking: 'AI_STATE_THINKING',
} as const satisfies Record<string, AIState>;

/**
* An identifier containing information about the downstream SDK using stream-chat. It
* is used to resolve the user agent.
Expand Down
Loading