Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
88ea425
feat: handle predefined filters with ChannelPaginator
MartinCupela Aug 5, 2026
3f305d8
feat(ChannelPaginator): adjust offline DB parity
MartinCupela Aug 5, 2026
0084956
feat(ChannelPaginatorsOrchestrator): add `channel.hidden` routing and…
MartinCupela Aug 5, 2026
ab83f55
feat(ChannelPaginator): add configurable default retry count
MartinCupela Aug 5, 2026
49547a3
refactor: rename to ChannelPaginatorsOrchestrator to `ChannelManager`…
MartinCupela Aug 5, 2026
0a1f8c2
test: consolidate test suite
MartinCupela Aug 5, 2026
000982b
docs: remove dead-code and adjust docs
MartinCupela Aug 5, 2026
c4153ab
Merge branch 'release-v10' into feat/channel-orchestrator-to-channel-…
MartinCupela Aug 5, 2026
22d28b4
test: fix failing test
MartinCupela Aug 5, 2026
d05d545
Merge branch 'release-v10' into feat/llc-instance-configuration
MartinCupela Aug 11, 2026
7b1929c
feat: add instance configuration service
MartinCupela Aug 17, 2026
0d9c7ee
feat: introduce ConfigController configuration for a specific entity …
MartinCupela Aug 17, 2026
0489989
refactor: move config utility functions into a dedicated folder
MartinCupela Aug 17, 2026
9294037
feat: reflect server-side configuration in file upload and poll creation
MartinCupela Aug 18, 2026
b139c3f
feat(configuration)!: resolve server flags into instance configuration
MartinCupela Aug 18, 2026
3b618fc
Merge branch 'release-v10' into feat/llc-instance-configuration
MartinCupela Aug 18, 2026
363adef
fix(configuration)!: key channel server configs by cid, not channel type
MartinCupela Aug 21, 2026
84bb900
fix(LiveLocationManager): keep config subscription active until the l…
MartinCupela Aug 21, 2026
00a6f70
fix: freeze Channel config object
MartinCupela Aug 21, 2026
cb9f320
fix: apply authority when ConfigController.patch is applied
MartinCupela Aug 21, 2026
46e9c5e
fix(copyConfigPatch): prevent infinite recursion
MartinCupela Aug 21, 2026
147174a
fix: use ConfigurationController for Thread config state management
MartinCupela Aug 21, 2026
2641855
fix: do not allow to add custom keys to entity configuration service
MartinCupela Aug 21, 2026
62f7152
refactor: do not export ConfigController
MartinCupela Aug 21, 2026
6458aa0
Merge branch 'release-v10' into feat/llc-instance-configuration
MartinCupela Aug 21, 2026
67ee9cf
fix: subscribe CooldownTimer to Channel state changes
MartinCupela 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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth
- **`insights.ts` — `InsightMetrics` + `postInsights`.** WS-health telemetry sent to `https://chat-insights.getstream.io`. This is internal; do not call from end-user code paths. The fields captured by `buildWsBaseInsight` include token and connection metadata — treat changes here as security-sensitive.
- **`uploadManager.ts` / `LiveLocationManager.ts` / `CooldownTimer.ts`** — feature controllers, each owns its own `StateStore` slice.
- **Domain subsystems** (each a folder with its own `index.ts` barrel):
- `messageComposer/` — biggest subsystem (≈3.5k lines). Composer + sub-composers (text, attachment, link previews, poll, location, custom-data) wired together by `MessageComposer` and driven by the middleware executor. Composition can target a `Channel`, `Thread`, or an existing local message (edit flow). Server-side composer config from `getConfig()` is merged on top of `DEFAULT_COMPOSER_CONFIG` with a customizer that prevents enabling features the server has disabled.
- `messageComposer/` — biggest subsystem (≈3.5k lines). Composer + sub-composers (text, attachment, link previews, poll, location, custom-data) wired together by `MessageComposer` and driven by the middleware executor. Composition can target a `Channel`, `Thread`, or an existing local message (edit flow). Server-side composer config from `getConfig()` is merged on top of `DEFAULT_COMPOSER_CONFIG` via `mergeServerRestrictions` (`src/configuration/serverAuthority.ts`), which prevents enabling features the server has disabled and is re-applied on every route that resolves configuration, not only at construction.
- `messageDelivery/` — `MessageDeliveryReporter` (instance on the client) and `MessageReceiptsTracker` (per-channel sorted-by-timestamp tracker for delivered/read receipts; uses binary search over twin sorted arrays).
- `notifications/` — toast-style `NotificationManager` (severities `error`/`warning`/`info`/`success`, configurable durations and sort comparator). Default instance is created by the client; pass `options.notifications` to provide your own.
- `offline-support/` — `AbstractOfflineDB` is **abstract**. Mobile/RN SDKs inject a concrete implementation via `client.setOfflineDBApi(...)`. The `OfflineDBSyncManager` reconciles pending tasks on reconnect. Don't take it as a built-in feature of this package — it's an injection point with no default impl here.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ yarn start

## 📚 More Code Examples

Read up more on [Logging](./docs/logging.md), [User Token](./docs/userToken.md), and [Webhooks](./docs/webhooks.md) (including compressed payloads and SQS / SNS delivery) or visit our [documentation](https://getstream.io/chat/docs/) for more examples.
Read up more on [Instance configuration](./docs/instance-configuration.md), [Logging](./docs/logging.md), [User Token](./docs/userToken.md), and [Webhooks](./docs/webhooks.md) (including compressed payloads and SQS / SNS delivery) or visit our [documentation](https://getstream.io/chat/docs/) for more examples.

## ✍️ Contributing

Expand Down
1,129 changes: 1,129 additions & 0 deletions docs/instance-configuration.md

Large diffs are not rendered by default.

35 changes: 24 additions & 11 deletions src/CooldownTimer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,26 +64,39 @@ export class CooldownTimer extends WithSubscriptions {
return this.state.getLatestValue().ownLatestMessageDate;
}

/**
* Subscribes the timer to the two stores it derives from — `channel.state` for `cooldown` and
* `ownCapabilities`, the message paginator's store for the current user's latest message.
*
* `Channel` calls this right after constructing the timer and unregisters it in `_disconnect`, the same
* way it drives `messageReceiptsTracker`. That replaces four imperative `cooldownTimer.refresh()` calls
* in `Channel`, and the three WS-event handlers that used to live here — which duplicated those calls
* and never ran, because nothing registered them. Between them the two arrangements still missed every
* `query()`, and any `updatePartial` that changed `cooldown` without changing capabilities.
*/
public registerSubscriptions = () => {
this.incrementRefCount();
if (this.hasSubscriptions) return;

this.addUnsubscribeFunction(
this.channel.on('message.new', (event) => {
const isOwnMessage =
event.message?.user?.id && event.message.user.id === this.getOwnUserId();
if (!isOwnMessage) return;
this.setOwnLatestMessageDate(toDateOrUndefined(event.message?.created_at));
}).unsubscribe,
this.channel.state.subscribeWithSelector(
({ data, ownCapabilities }) => ({ cooldown: data?.cooldown, ownCapabilities }),
() => this.refresh(),
),
);

// `ownLatestMessageDate` comes from the paginator's head interval. Selected on `items` rather than on
// the derived date: any ingest can change which message is the own-latest, and `refresh` already
// declines to publish unless one of its inputs actually moved.
this.addUnsubscribeFunction(
this.channel.on('channel.updated', (event) => {
const cooldownChanged = event.channel?.cooldown !== this.cooldownConfigSeconds;
if (!cooldownChanged) return;
this.refresh();
}).unsubscribe,
this.channel.messagePaginator.state.subscribeWithSelector(
({ items }) => ({ items }),
() => this.refresh(),
),
);

// The countdown has no reason to keep running once the timer stops deriving.
this.addUnsubscribeFunction(() => this.clearTimeout());
};

public setCooldownRemaining = (cooldownRemaining: number) => {
Expand Down
112 changes: 110 additions & 2 deletions src/LiveLocationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
*/

import { withCancellation } from './utils/concurrency';
import { deepFreezeConfig } from './configuration/utils/deepFreezeConfig';
import { StateStore } from './store';
import { ConfigController } from './configuration/ConfigController';
import { applyInstanceConfiguration } from './configuration/utils/applyInstanceConfiguration';
import { WithSubscriptions } from './utils/WithSubscriptions';
import type { StreamChat } from './client';
import type { Unsubscribe } from './store';
Expand Down Expand Up @@ -60,13 +63,42 @@ export type LiveLocationManagerConstructorParameters = {
// Hard-coded minimal throttle timeout
export const UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT = 3000;

export type LiveLocationManagerConfig = {
/**
* Shortest gap between live-location update requests (defaults to 3000ms).
*
* A failsafe against rate limiting, not a protocol limit: integrators already control the update
* cadence through a custom `watchLocation`, and this floor stops a chatty one from flooding the API.
* Raising it is always safe; lowering it risks 429s, so only do so against a known quota.
*/
minUpdateThrottleMs: number;
};

export const DEFAULT_LIVE_LOCATION_MANAGER_CONFIG: LiveLocationManagerConfig =
deepFreezeConfig({
minUpdateThrottleMs: UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT,
});

export class LiveLocationManager extends WithSubscriptions {
public state: StateStore<LiveLocationManagerState>;
private client: StreamChat;
private getDeviceId: DeviceIdGenerator;
private _deviceId: string;
private watchLocation: WatchLocation;

/** The shared configuration machinery — see {@link ConfigController}. */
private readonly configController: ConfigController<LiveLocationManagerConfig>;
/** Teardown for this manager's configuration subscription, released by {@link dispose}. */
private unsubscribeConfiguration?: Unsubscribe;

/**
* Resolved configuration, as a store — the shape every configurable class exposes
* (`configState` / `config` / `updateConfig`).
*/
get configState(): StateStore<LiveLocationManagerConfig> {
return this.configController.state;
}

static symbol = Symbol(LiveLocationManager.name);

constructor({
Expand All @@ -88,6 +120,51 @@ export class LiveLocationManager extends WithSubscriptions {
this._deviceId = getDeviceId();
this.getDeviceId = getDeviceId;
this.watchLocation = watchLocation;
this.configController = new ConfigController<LiveLocationManagerConfig>({
defaults: DEFAULT_LIVE_LOCATION_MANAGER_CONFIG,
});

// Last statement of the constructor, so a setup function sees a whole object. Registered here rather
// than only in `registerSubscriptions` — this manager is constructed by whoever needs it and `init()`
// is async, so gating configuration on registration would leave a window where a registered value did
// not apply.
this.subscribeConfiguration();
}

/**
* Subscribes this instance to the `'liveLocationManager'` configuration key, if it is not subscribed
* already. Idempotent, which is what lets both the constructor and {@link registerSubscriptions} call
* it: the first gives a value registered before `init()` resolves somewhere to land, the second brings
* a manager back after {@link dispose}.
*/
private subscribeConfiguration = () => {
if (this.unsubscribeConfiguration) return;

this.unsubscribeConfiguration = applyInstanceConfiguration({
args: { liveLocationManager: this },
config: this.client.config,
key: 'liveLocationManager',
applyConfig: (config) => this.initializeConfig(config),
reinitializeConfig: () =>
this.initializeConfig(
this.client.config.getConfig('liveLocationManager') ?? undefined,
),
});
};

/** The current resolved configuration. `Readonly` — change it through {@link updateConfig}. */
get config(): Readonly<LiveLocationManagerConfig> {
return this.configState.getLatestValue();
}

/** Merges a partial configuration into the resolved config and notifies subscribers. */
updateConfig(config: Partial<LiveLocationManagerConfig>) {
this.configController.patch(config);
}

/** Rebuilds the resolved configuration from package defaults plus the declarative slice. */
initializeConfig(config?: Partial<LiveLocationManagerConfig>) {
this.configController.initialize(config);
}

public async init() {
Expand All @@ -97,14 +174,46 @@ export class LiveLocationManager extends WithSubscriptions {

public registerSubscriptions = () => {
this.incrementRefCount();
// Restores configuration after a {@link dispose}, so a manager that is torn down and then used again
// is configurable again — React StrictMode's mount/cleanup/mount runs exactly that sequence against
// one instance. A no-op in the ordinary case: the constructor already subscribed.
this.subscribeConfiguration();

if (this.hasSubscriptions) return;

this.addUnsubscribeFunction(this.subscribeLiveLocationSharingUpdates());
this.addUnsubscribeFunction(this.subscribeTargetMessagesChange());
};

/**
* Ref-counted, and deliberately does **not** touch the configuration subscription: several callers can
* share one manager, so an early caller leaving must not take anything the remaining ones still need.
* Use {@link dispose} for the instance-level teardown.
*/
public unregisterSubscriptions = () => super.unregisterSubscriptions();

/**
* Releases the configuration subscription, running the `'liveLocationManager'` setup function's
* teardown. Call it when you are finished with the manager.
*
* Separate from {@link unregisterSubscriptions} because the two have different lifetimes. Event
* subscriptions are shared and ref-counted; configuration is registered once, by the constructor, for
* the life of the instance. Releasing it from the ref-counted call meant the first of two callers to
* leave silently stopped a still-live manager from tracking `client.config` — permanently, since
* nothing but the constructor registers it. Mirrors `SearchController.dispose` and the configuration
* half of `Channel._disconnect`.
*
* Until this is called, the client's configuration registry holds a handle to this manager, so a
* long-lived client and many short-lived managers need it to be called.
*
* Recoverable: a later {@link registerSubscriptions} re-subscribes, so disposing a manager that is
* then reused costs a re-run of the setup function rather than silence.
*/
public dispose = () => {
this.unsubscribeConfiguration?.();
Comment thread
isekovanic marked this conversation as resolved.
this.unsubscribeConfiguration = undefined;
};

get messages() {
return this.state.getLatestValue().messages;
}
Expand Down Expand Up @@ -175,8 +284,7 @@ export class LiveLocationManager extends WithSubscriptions {
// but the minimal timeout still has to be set as a failsafe (to prevent rate-limitting)
if (Date.now() < nextAllowedUpdateCallTimestamp) return;

nextAllowedUpdateCallTimestamp =
Date.now() + UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT;
nextAllowedUpdateCallTimestamp = Date.now() + this.config.minUpdateThrottleMs;

withCancellation(LiveLocationManager.symbol, async () => {
const promises: Promise<SharedLocationResponseData>[] = [];
Expand Down
Loading