Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
328c7c2
feat: support multiple channel lists with ChannelPaginatorsOrchestrator
MartinCupela Sep 3, 2025
65fd6cd
feat: allow to boost paginator items and lock item order
MartinCupela Sep 15, 2025
69b421f
feat: support missing ChannelManager features in BasePaginator
MartinCupela Nov 7, 2025
f4892c6
feat: add filter resolvers for channel filters archived, app_banned, …
MartinCupela Nov 10, 2025
aeaa92d
feat: allow to keep channels in certain matching paginators and not i…
MartinCupela Nov 10, 2025
8938419
feat: allow to keep channels in certain matching paginators and not i…
MartinCupela Nov 11, 2025
03614c7
fix: do not remove channel from paginator on channel.hidden
MartinCupela Nov 11, 2025
0187696
chore: move paginators to a dedicated folder
MartinCupela Nov 18, 2025
53941ef
feat: introduce intervals to BasePaginator
MartinCupela Nov 21, 2025
7aa4f91
feat: add MessagePaginator
MartinCupela Jan 12, 2026
98c4d1b
chore(Thread): add initial spec for Thread Constructor Minimal Init
MartinCupela Feb 27, 2026
1ff2271
feat(Thread): implement Add Optional `threadData` Constructor Branch …
MartinCupela Feb 27, 2026
9650862
feat(Thread): implement Complete Hydration + Pagination Bootstrap for…
MartinCupela Feb 27, 2026
2ab25b9
feat: add reactive states to ChannelState
MartinCupela Mar 3, 2026
97c2650
Merge remote-tracking branch 'wt/agent/feat/init-empty-thread' into f…
MartinCupela Mar 3, 2026
892f6e6
test: fix message composer tests
MartinCupela Mar 3, 2026
3a7c938
feat: add message send retry cache
MartinCupela Mar 3, 2026
9172f11
feat: query replies with MessagePaginator
MartinCupela Mar 4, 2026
a703f7c
feat: support delete operation on MessageOperations
MartinCupela Mar 4, 2026
314ea2c
feat: allow to define custom mark-read request function for Thread an…
MartinCupela Mar 4, 2026
fe7a68d
feat: add messageFocusSignal state to MessagePaginator
MartinCupela Mar 4, 2026
cf4252e
refactor: remove thread from Channel's CustomMarkReadRequestFn
MartinCupela Mar 4, 2026
aa706de
test: fix failing tests
MartinCupela Mar 5, 2026
1fbf420
feat: export configuration service
MartinCupela Mar 5, 2026
a3f4bd7
feat: add backwards compatible APIs
MartinCupela Mar 5, 2026
ed0ce71
fix: nullify first_unread_message_id on message.read event
MartinCupela Mar 5, 2026
c972ee9
fix: update thread participant counts and reply counts on message.new…
MartinCupela Mar 5, 2026
43d365f
feat: decouple request sort from in-memory item order via BasePaginat…
MartinCupela Mar 5, 2026
2444199
fix: make Channel's MessagePaginator ingest message on message.new
MartinCupela Mar 5, 2026
a971056
fix: make Channel's MessagePaginator react to channel.truncated, user…
MartinCupela Mar 5, 2026
08d2073
fix: reflect quoted message update among MessagePaginator items and r…
MartinCupela Mar 5, 2026
31eedab
fix: emit new paginator state always when jumping to a message
MartinCupela Mar 6, 2026
b58912f
merge: integrate PR #1674 (paginator stack) into release-v10
MartinCupela Jul 3, 2026
02608e3
test/fix: green up PR #1674 merge (types, lint, 3467 tests pass)
MartinCupela Jul 3, 2026
19489c0
feat: add UserGroupPaginator.ts
MartinCupela Jul 8, 2026
fe9b398
Merge remote-tracking branch 'origin/master' into feat/message-pagina…
isekovanic Jul 8, 2026
70d34ab
fix: pagination cursors for offline support
isekovanic Jul 9, 2026
9f4d3f1
feat(ChannelPaginatorOrchestrator): add ingestChannel method
MartinCupela Jul 13, 2026
634a90c
fix(filterCompiler): prevent short-circuiting logical operators
MartinCupela Jul 13, 2026
3f12e0c
feat(ChannelPaginator): add mutedFilterResolver
MartinCupela Jul 13, 2026
fc273e0
feat(MessagePaginator): add scheduleMessageFocusSignalClear method
MartinCupela Jul 13, 2026
c46639d
Merge remote-tracking branch 'origin/feat/message-paginator-master-me…
MartinCupela Jul 14, 2026
179944b
fix: message paginator fixes and extra features (#1802)
isekovanic Jul 16, 2026
720519d
fix: mark read live state freezing (#1803)
isekovanic Jul 17, 2026
7f0506c
fix: clean up thread state remnants (#1804)
isekovanic Jul 20, 2026
3d2a56a
refactor: replace legacy ChannelState message/thread/pinned storage w…
MartinCupela Jul 22, 2026
9721c3d
feat: optimistic reactions
isekovanic Jul 23, 2026
5bf5191
fix: revert concurrency
isekovanic Jul 23, 2026
a8880c7
fix: pass entire reaction
isekovanic Jul 23, 2026
b94724b
fix: generalize db writes and make optimistic rollback durable
isekovanic Jul 24, 2026
896f499
feat(pagination): add intervalViews store (#1807)
MartinCupela Jul 24, 2026
cc0fa1a
fix: reactivity between shared messages in thread
isekovanic Jul 24, 2026
018b186
chore: tests
isekovanic Jul 24, 2026
cb4e25c
Merge branch 'feat/message-paginator-master-merge' into feat/optimist…
isekovanic Jul 24, 2026
1acbefb
refactor: merge OpenAPI changes (#1809)
MartinCupela Jul 29, 2026
a7f0ce9
Merge branch 'release-v10' of github.com:GetStream/stream-chat-js int…
arnautov-anton Jul 30, 2026
d00e67f
Post-merge fixes
arnautov-anton Jul 30, 2026
a6d8a7f
Some more test fixes
arnautov-anton Jul 31, 2026
b1f924c
feat: reactive collections (#1811)
isekovanic Jul 31, 2026
31229e6
Merge branch 'feat/message-paginator-master-merge' into feat/optimist…
isekovanic Jul 31, 2026
3e35183
Merge branch 'release-v10' into feat/optimistic-reactions
isekovanic Aug 4, 2026
5b8cd3f
chore: remove unused incrementReplyCountLocally from Thread
isekovanic Aug 4, 2026
bab3c02
chore: renaming
isekovanic Aug 4, 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
113 changes: 95 additions & 18 deletions src/channel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { AxiosRequestConfig } from 'axios';
import { ChannelState } from './channel_state';
import { CooldownTimer } from './CooldownTimer';
import { isEphemeral } from './errors';
import { applyReactionLocally } from './messageStore';
import { MessageComposer } from './messageComposer';
import { MessageReceiptsTracker } from './messageDelivery';
import type { ReadStoreReconcileMeta } from './messageDelivery';
Expand Down Expand Up @@ -46,9 +48,10 @@ import type {
PinnedMessagesSort,
QueryMembersPayload,
ReactionAPIResponse,
ReactionResponse,
ReactionRequest,
SearchPayload,
SendMessageOptions,
SendReactionRequest,
SharedLocation,
UnBanUserOptions,
UpdateChannelPartialRequest,
Expand Down Expand Up @@ -240,7 +243,10 @@ export class Channel extends ChannelApi {
this.cooldownTimer = new CooldownTimer({ channel: this });

this.messageOperations = new MessageOperations({
ingest: (m) => this.messagePaginator.ingestItem(m),
ingest: (m) => {
this.messagePaginator.ingestItem(m);
this.getClient().messageStore.flushSubscribers(m.id);
},
get: (id) => this.messagePaginator.getItem(id),
handlers: () => {
const { requestHandlers } = this.configState.getLatestValue();
Expand Down Expand Up @@ -416,6 +422,76 @@ export class Channel extends ChannelApi {
);
}

/**
* Adds a reaction with an optimistic local state update: the reaction is applied to the cached
* message immediately ({@link applyReactionLocally}), then the request is
* fired via {@link Channel.sendReaction} (which owns the offline-DB write + queue). The
* server-authoritative counts reconcile on the response; the message is rolled back on failure.
*/
async addReactionWithLocalUpdate({
messageId,
reaction,
options,
}: {
messageId: string;
reaction: ReactionRequest;
options?: Pick<SendReactionRequest, 'enforce_unique' | 'skip_push'>;
}) {
const client = this.getClient();
const undo = applyReactionLocally(client, {
enforceUnique: options?.enforce_unique ?? false,
messageId,
reaction,
});

try {
const response = await this.sendReaction({ id: messageId, reaction, ...options });
// reconcile the server copy only if we still hold it — a bare upsert of an unheld id would
// orphan it (the store's refcount GC only reclaims held ids).
if (response?.message && client.messageStore.has(response.message.id)) {
client.messageStore.upsert(formatMessage(response.message));
}
} catch (error) {
if (undo && (!client.offlineDb || !isEphemeral(error as Error))) {
undo();
}
throw error;
}
}

/**
* Removes the current user's reaction with an optimistic local state update, mirroring
* {@link Channel.addReactionWithLocalUpdate}.
*/
async deleteReactionWithLocalUpdate({
messageId,
type,
}: {
messageId: string;
type: string;
}) {
const client = this.getClient();
const undo = applyReactionLocally(client, {
messageId,
reaction: { type },
removed: true,
});

try {
const response = await this.deleteReaction({ id: messageId, type });
// reconcile the server copy only if we still hold it — a bare upsert of an unheld id would
// orphan it (the store's refcount GC only reclaims held ids).
if (response?.message && client.messageStore.has(response.message.id)) {
client.messageStore.upsert(formatMessage(response.message));
}
} catch (error) {
if (undo && (!client.offlineDb || !isEphemeral(error as Error))) {
undo();
}
throw error;
}
}

/**
* Upload a file to this channel’s file endpoint (multipart). Forwards to the client’s `sendFile` implementation.
*
Expand Down Expand Up @@ -549,6 +625,8 @@ export class Channel extends ChannelApi {
try {
const offlineDb = this.getClient().offlineDb;
if (offlineDb) {
// The optimistic reaction row is written by the local-update layer
// (`applyReactionLocally`); here we only queue the request for replay.
return await offlineDb.queueTask<ReactionAPIResponse>({
task: {
channelId: this.id as string,
Expand Down Expand Up @@ -578,19 +656,8 @@ export class Channel extends ChannelApi {
try {
const offlineDb = this.getClient().offlineDb;
if (offlineDb) {
const message = this.messagePaginator.getItem(request.id);
const reaction = {
message_id: request.id,
type: request.type,
} as ReactionResponse;

if (message) {
await offlineDb.deleteReaction({
message,
reaction,
});
}

// The optimistic reaction-row removal is handled by the local-update layer
// (`applyReactionLocally`); here we only queue the request for replay.
return await offlineDb.queueTask<ReactionAPIResponse>({
task: {
channelId: this.id as string,
Expand Down Expand Up @@ -2228,7 +2295,9 @@ export class Channel extends ChannelApi {
case 'reaction.new':
if (event.message && event.reaction) {
const { reaction } = event;
if (!event.message?.parent_id) {
// Reflect main messages AND show_in_channel replies (both live in these paginators);
// pure replies are handled by the thread's own reaction subscription.
if (!event.message?.parent_id || event.message.show_in_channel) {
this.messagePaginator.reflectReaction({ message: event.message, reaction });
this.pinnedMessagesPaginator.reflectReaction({
message: event.message,
Expand All @@ -2240,7 +2309,10 @@ export class Channel extends ChannelApi {
case 'reaction.deleted':
if (event.message && event.reaction) {
const { reaction } = event;
if (event.message && !event.message.parent_id) {
if (
event.message &&
(!event.message.parent_id || event.message.show_in_channel)
) {
this.messagePaginator.reflectReaction({
message: event.message,
reaction,
Expand All @@ -2258,7 +2330,7 @@ export class Channel extends ChannelApi {
if (event.message && event.reaction) {
const { reaction } = event;
// assuming reaction.updated is only called if enforce_unique is true
if (!event.message?.parent_id) {
if (!event.message?.parent_id || event.message.show_in_channel) {
this.messagePaginator.reflectReaction({
enforceUnique: true,
message: event.message,
Expand Down Expand Up @@ -2521,5 +2593,10 @@ export class Channel extends ChannelApi {
this.disconnected = true;
this.messageReceiptsTracker.unregisterSubscriptions();
this.cooldownTimer.clearTimeout();
// Release the store-backed paginators so the message store no longer pins this removed channel
// (and its whole message graph) through its subscriber registry. The channel is being discarded
// here (disconnected + deleted from activeChannels, never reused), mirroring Thread teardown.
this.messagePaginator.dispose();
this.pinnedMessagesPaginator.dispose();
}
}
8 changes: 8 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import { Moderation } from './moderation';
import { ThreadManager } from './thread_manager';
import { DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE } from './constants';
import { PollManager } from './poll_manager';
import { MessageStore } from './messageStore/MessageStore';
import type {
ChannelManagerEventHandlerOverrides,
ChannelManagerOptions,
Expand Down Expand Up @@ -138,6 +139,12 @@ export class StreamChat extends ChatApi {
};
threads: ThreadManager;
polls: PollManager;
/**
* Client-global, normalized store holding one canonical copy of each message. The channel main
* list and thread reply paginators read/write message content through it, so a message held in
* more than one of them stays consistent without copy-to-copy fan-out.
*/
messageStore: MessageStore;
offlineDb?: AbstractOfflineDB;
notifications: NotificationManager;
reminders: ReminderManager;
Expand Down Expand Up @@ -314,6 +321,7 @@ export class StreamChat extends ChatApi {
this.defaultWSTimeout = 15 * 1000;

this.recoverStateOnReconnect = this.options.recoverStateOnReconnect;
this.messageStore = new MessageStore();
this.threads = new ThreadManager({ client: this });
this.polls = new PollManager({ client: this });
this.reminders = new ReminderManager({ client: this });
Expand Down
12 changes: 12 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ export function isErrorRetryable(error: APIError) {
return err.retryable;
}

/**
* Whether an error is EPHEMERAL — a transient failure worth queueing/retrying rather than a
* definitive rejection. True when the server never responded (connection/network/offline error - no
* `response`, i.e an axios network error or an `OfflineError`) and when the server responded with a
* retryable code (see {@link APIErrorCodes}); false only when the server responded with a
* non-retryable code (InputError 4, DoesNotExist 16, NotAllowed 17, …).
*/
export function isEphemeral(error: Error): boolean {
if (!(error as { response?: unknown }).response) return true;
return isErrorRetryable(error as APIError);
}

export function isConnectionIDError(error: APIError) {
return error.code === 46; // ConnectionIDNotFoundError
}
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export * from './insights';
export * from './logger';
export * from './messageComposer';
export * from './messageDelivery';
export { MessageStore } from './messageStore/MessageStore';
export type {
MessageStoreChangeBatch,
MessageStoreSubscriber,
} from './messageStore/MessageStore';
export * from './middleware';
export * from './moderation';
export * from './notifications';
Expand Down
2 changes: 1 addition & 1 deletion src/messageDelivery/MessageDeliveryReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,5 +359,5 @@ export class MessageDeliveryReporter {
leading: true,
trailing: true,
},
);
).throttledFn;
}
Loading