Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
87d7d71
feat: bump stream-chat and introduce new hooks
isekovanic Jul 8, 2026
3888d48
feat: include message action handlers
isekovanic Jul 8, 2026
9f10e37
feat: use stat stores
isekovanic Jul 8, 2026
0872d91
feat: reactive message list first pass
isekovanic Jul 8, 2026
8633abf
feat: add mark read/unread
isekovanic Jul 9, 2026
7550378
feat: thread state
isekovanic Jul 9, 2026
ee53121
chore: context storm cleanup
isekovanic Jul 9, 2026
685fb30
feat: use state directly instead of UI sdk state
isekovanic Jul 9, 2026
5f83608
feat: remove PaginatedMessageListContext
isekovanic Jul 9, 2026
ca2eca4
chore: add ai migration guide for state layer
isekovanic Jul 9, 2026
6773480
chore: optimistic updates for edit and delete
isekovanic Jul 9, 2026
9ea3ec5
fix: pagination past a certain page
isekovanic Jul 9, 2026
fc09564
feat: move message sending to llc state updates
isekovanic Jul 10, 2026
c45d7fa
fix: reaction optimistic updates
isekovanic Jul 10, 2026
2b5d2c1
feat: completely remove channel unread store and rely on LLC
isekovanic Jul 10, 2026
bd86382
chore: cleanup of old state
isekovanic Jul 10, 2026
718fee6
feat: highlighted messages
isekovanic Jul 10, 2026
d3f824b
feat: migrate marking as read
isekovanic Jul 10, 2026
e880cce
feat: move thread to reactive state fully
isekovanic Jul 10, 2026
484fb49
perf: do not load too many messages
isekovanic Jul 11, 2026
4afa591
fix: messagelist snapping on send
isekovanic Jul 11, 2026
469ae4d
perf: introduce delivery count hooks
isekovanic Jul 11, 2026
05142b5
feat: fully migrate thread to thread instances
isekovanic Jul 11, 2026
6236684
feat: always rely on shared thread instance
isekovanic Jul 12, 2026
77e8592
fix: also replied to channel message operations
isekovanic Jul 13, 2026
262e1c1
fix: thread connection recovery mechanism
isekovanic Jul 13, 2026
7a7ee70
fix: inline merge of message lists
isekovanic Jul 15, 2026
18905dd
fix: scrollto edge cases
isekovanic Jul 15, 2026
34549a0
fix: unread resolution
isekovanic Jul 15, 2026
6ce7903
fix: unified unread count state
isekovanic Jul 16, 2026
fa1bf4b
fix: use throttled mark read version
isekovanic Jul 17, 2026
4a34bf3
chore: renaming
isekovanic Jul 17, 2026
e0cda49
chore: cleanup of old thread apis
isekovanic Jul 17, 2026
5741b51
chore: cleanup thread composer semantics
isekovanic Jul 17, 2026
36904be
fix: rely on paginator state and not replies
isekovanic Jul 20, 2026
8e14da1
fix: remove more remnants of the old channel state/
isekovanic Jul 20, 2026
2426410
Merge branch 'develop' into feat/LLC-new-state-layer
isekovanic Jul 20, 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
193 changes: 193 additions & 0 deletions ai-docs/ai-migration-v9-to-v10.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# stream-chat-react-native v9 → v10 — Agent Migration Guide

> Machine-oriented migration reference for AI coding agents, mirroring the style
> of `ai-migration.md` (v8 → v9). **WORK IN PROGRESS.** v10 adopts the
> `stream-chat` v10 reactive state layer (instance-owned `StateStore`s +
> `messagePaginator`). This file currently documents the **context-teardown
> breaking changes** that have shipped; more v10 changes (optimistic-ops
> migration, targeting, unread-state) are not yet landed and will be appended.

## 0. For the agent (read first)

1. **Your training data predates v10.** Do not rely on memory for v10 symbols or
export paths. Verify against the installed SDK source under
`node_modules/stream-chat-react-native-core/src/`.
2. **The unifying idea:** state that used to be copied into React context is now
read reactively from `stream-chat` instance stores via the SDK hook
`useStateStore(store, selector)`. The channel exposes per-concern stores on
`channel.state.*` and a message paginator on `channel.messagePaginator`.
3. **Resolution hooks** (already present since early v10):
- `useChannelContext().channel` — the active `Channel` instance.
- `useMessagePaginator()` — `threadInstance?.messagePaginator ?? channel.messagePaginator`.
- `useStateStore(store, selector)` — subscribe to a `StateStore` with a
memo-stable selector (return a stable object; do not allocate fresh arrays
inside the selector).
4. **Detect before editing.** Run §1; skip any section with zero hits.

## 1. Detection (run first)

Run each ripgrep against the customer's app source root. Zero hits = skip.

```bash
# §2 — TypingContext removed
rg '\b(useTypingContext|TypingProvider|TypingContext|TypingContextValue|useCreateTypingContext)\b' src/

# §3 — ChannelContext scalar fields removed
rg 'useChannelContext\(\)' -A6 src/ | rg '\b(members|read|watchers|watcherCount)\b'

# §4 — PaginatedMessageListContext removed
rg '\b(usePaginatedMessageListContext|PaginatedMessageListProvider|PaginatedMessageListContextValue|useCreatePaginatedMessageListContext)\b' src/

# §5 — custom FooterComponent / HeaderComponent reading loadingMore from context
rg '\b(FooterComponent|HeaderComponent)\b' src/
```

## 2. `TypingContext` removed → `channel.state.typingStore`

Removed symbols: `TypingContext`, `TypingProvider`, `useTypingContext`,
`TypingContextValue`, `useCreateTypingContext`.

**Before (v9):**

```tsx
import { useTypingContext } from 'stream-chat-react-native';

const { typing } = useTypingContext();
```

**After (v10):**

```tsx
import { useChannelContext, useStateStore } from 'stream-chat-react-native';
import type { TypingUsersState } from 'stream-chat';

const typingSelector = (state: TypingUsersState) => ({ typing: state.typing });

const { channel } = useChannelContext();
const { typing } = useStateStore(channel.state.typingStore, typingSelector) ?? { typing: {} };
```

`typing` has the same shape as before (`Record<string, Event>`).

## 3. `ChannelContext` scalar state fields removed → `channel.state.*Store`

`useChannelContext()` no longer returns `members`, `read`, `watchers`, or
`watcherCount`. Read them from the per-channel stores.

| Removed context field | Replacement store | Store state key |
|---|---|---|
| `members` | `channel.state.membersStore` | `members` |
| `read` | `channel.state.readStore` | `read` |
| `watchers` | `channel.state.watcherStore` | `watchers` |
| `watcherCount` | `channel.state.watcherStore` | `watcherCount` |

**Before (v9):**

```tsx
const { members, watcherCount } = useChannelContext();
```

**After (v10):**

```tsx
import { useChannelContext, useStateStore } from 'stream-chat-react-native';
import type { MembersState, WatcherState } from 'stream-chat';

const membersSelector = (s: MembersState) => ({ members: s.members });
const watcherSelector = (s: WatcherState) => ({
watcherCount: s.watcherCount,
watchers: s.watchers,
});

const { channel } = useChannelContext();
const { members } = useStateStore(channel.state.membersStore, membersSelector) ?? { members: {} };
const { watcherCount } = useStateStore(channel.state.watcherStore, watcherSelector) ?? {};
```

Note: `members` remains available on the per-message `MessageContext`
(`useMessageContext().members`) for message-level consumers such as a custom
message footer — only the **channel-level** `ChannelContext.members` was removed.

## 4. `PaginatedMessageListContext` removed → `useMessagePaginator()` / `channel.messagePaginator`

Removed symbols: `PaginatedMessageListContext`, `PaginatedMessageListProvider`,
`usePaginatedMessageListContext`, `PaginatedMessageListContextValue`,
`useCreatePaginatedMessageListContext`.

The message-list state (messages / hasMore / loading) now lives on the paginator.
**Direction mapping** (non-obvious): the paginator is bi-directional — `tail` =
older messages, `head` = newer.

| Removed context field | Replacement |
|---|---|
| `messages` | `channel.messagePaginator.state.items` |
| `hasMore` (older) | `channel.messagePaginator.state.hasMoreTail` |
| `hasMoreNewer` | `channel.messagePaginator.state.hasMoreHead` |
| `loading` | `channel.messagePaginator.state.isLoading` |
| `loadMore()` (older) | `channel.messagePaginator.toTail()` |
| `loadMoreRecent()` (newer) | `channel.messagePaginator.toHead()` |
| `loadLatestMessages()` | `channel.messagePaginator.jumpToTheLatestMessage()` |

**Before (v9):**

```tsx
const { messages, hasMore, loadMore } = usePaginatedMessageListContext();
```

**After (v10):**

```tsx
import { useChannelContext, useStateStore } from 'stream-chat-react-native';
import type { LocalMessage } from 'stream-chat';

const selector = (s: { items?: LocalMessage[]; hasMoreTail: boolean }) => ({
messages: s.items,
hasMore: s.hasMoreTail,
});

const { channel } = useChannelContext();
const { messages, hasMore } = useStateStore(channel.messagePaginator.state, selector) ?? {};
const loadMore = () => channel.messagePaginator.toTail();
```

Use `channel.messagePaginator` for the channel message list. For thread replies,
use `useMessagePaginator()` (thread-aware) — but note the **main** channel list
must read `channel.messagePaginator` directly so it keeps showing channel
messages while a thread is open.

## 5. Custom `FooterComponent` / `HeaderComponent` receive `loadingMore` as a prop

The inline loading indicators no longer read `loadingMore` /
`loadingMoreRecent` from `PaginatedMessageListContext`; the list owns those flags
and passes them as props. If you override the message list's loading indicator:

- **`MessageList`** (inverted): custom `FooterComponent` now receives
`{ loadingMore?: boolean }` as a prop.
- **`MessageFlashList`**: custom `HeaderComponent` now receives
`{ loadingMore?: boolean }` as a prop.

**Before (v9):**

```tsx
const MyFooter = () => {
const { loadingMore } = usePaginatedMessageListContext();
return loadingMore ? <Spinner /> : null;
};
```

**After (v10):**

```tsx
const MyFooter = ({ loadingMore }: { loadingMore?: boolean }) =>
loadingMore ? <Spinner /> : null;
```

No-prop override components remain assignable (the prop is optional), so
overrides that don't read the flag need no change.

## 6. Verify

- Typecheck the customer app; the removed symbols surface as
"Property does not exist" / "Cannot find name" errors — fix each per §2–§5.
- Run the app: typing indicator, member/watcher counts, message-list scroll
pagination (both directions), and any custom loading-indicator override.
3 changes: 1 addition & 2 deletions examples/ExpoMessaging/app/channel/[cid]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
ChannelAvatar,
MessageComposer,
MessageList,
ThreadContextValue,
useChannelPreviewDisplayName,
useChatContext,
} from 'stream-chat-expo';
Expand Down Expand Up @@ -110,7 +109,7 @@ export default function ChannelScreen() {
messageId={messageId}
>
<MessageList
onThreadSelect={(thread: ThreadContextValue['thread']) => {
onThreadSelect={(thread) => {
setThread(thread);
router.push(`/channel/${channel.cid}/thread/${thread?.cid ?? ''}`);
}}
Expand Down
9 changes: 4 additions & 5 deletions examples/ExpoMessaging/context/AppContext.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import React, { PropsWithChildren, createContext, useState } from 'react';

import { Channel as ChannelType } from 'stream-chat';
import { ThreadContextValue } from 'stream-chat-expo';
import { Channel as ChannelType, LocalMessage } from 'stream-chat';

export type AppContextType = {
channel: ChannelType | undefined;
setChannel: React.Dispatch<React.SetStateAction<ChannelType | undefined>>;
setThread: React.Dispatch<React.SetStateAction<ThreadContextValue['thread'] | undefined>>;
thread: ThreadContextValue['thread'] | undefined;
setThread: React.Dispatch<React.SetStateAction<LocalMessage | null | undefined>>;
thread: LocalMessage | null | undefined;
};

export const AppContext = createContext<AppContextType>({
Expand All @@ -19,7 +18,7 @@ export const AppContext = createContext<AppContextType>({

export const AppProvider = ({ children }: PropsWithChildren) => {
const [channel, setChannel] = useState<ChannelType | undefined>(undefined);
const [thread, setThread] = useState<ThreadContextValue['thread'] | undefined>(undefined);
const [thread, setThread] = useState<LocalMessage | null | undefined>(undefined);

return (
<AppContext.Provider value={{ channel, setChannel, thread, setThread }}>
Expand Down
16 changes: 11 additions & 5 deletions examples/SampleApp/metro.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const metroExclusionList = require(
const exclusionList = metroExclusionList.default || metroExclusionList;
const packageDirPath = PATH.resolve(__dirname, '../../package');
const nativePackageDirPath = PATH.resolve(__dirname, '../../package/native-package');
// Local checkout of stream-chat-js (portaled via root package.json `resolutions`).
// Metro needs the path explicitly — it does not honor Yarn's portal symlink for native bundling.
const streamChatLocalPath = '/Users/isekovanic/Projects/stream-chat-js';

const symlinked = {
'stream-chat-react-native': nativePackageDirPath,
Expand Down Expand Up @@ -65,17 +68,20 @@ const uniqueModules = dependencyPackageNames.map((packageName) => {
const blockList = uniqueModules.map(({ blockPattern }) => blockPattern);

// provide the path for the unique modules
const extraNodeModules = uniqueModules.reduce((acc, item) => {
acc[item.packageName] = item.modulePath;
return acc;
}, {});
const extraNodeModules = uniqueModules.reduce(
(acc, item) => {
acc[item.packageName] = item.modulePath;
return acc;
},
{ 'stream-chat': streamChatLocalPath },
);

config.resolver.blockList = exclusionList(blockList);
config.resolver.extraNodeModules = extraNodeModules;

config.resolver.nodeModulesPaths = [PATH.resolve(__dirname, 'node_modules')];

// add the package dir for metro to access the package folder
config.watchFolders = [packageDirPath];
config.watchFolders = [packageDirPath, streamChatLocalPath];

module.exports = config;
37 changes: 20 additions & 17 deletions examples/SampleApp/src/components/UnreadCountBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,31 @@ export const ChannelsUnreadCountBadge: React.FC = () => {
const { chatClient } = useAppContext();
const [unreadCount, setUnreadCount] = useState<number>(0);
/**
* Listen to changes in unread counts and update the badge count
* Listen to changes in unread counts and update the badge count.
*
* We deliberately derive the total from each channel's local `countUnread()` (a sum over the
* loaded/active channels) rather than the server-sent `event.total_unread_count`. The local count
* respects the "viewing live" gate — the channel the user is currently reading at the bottom stays
* at 0 — so the badge does NOT flicker when a message arrives in the active channel. The server
* total, by contrast, briefly counts that message until the mark-read round-trips.
*/
useEffect(() => {
const listener = chatClient?.on((e) => {
const event = e.me ?? e;
if (event.total_unread_count !== undefined) {
setUnreadCount(event.total_unread_count);
} else {
if (Object.keys(chatClient?.activeChannels).length > 0) {
const countUnread = Object.values(chatClient.activeChannels).reduce(
(count, channel) => count + channel.countUnread(),
0,
);
setUnreadCount(countUnread);
}
}
if (!chatClient) {
return;
}
const computeUnreadCount = () =>
Object.values(chatClient.activeChannels).reduce(
(count, channel) => count + (channel?.countUnread() ?? 0),
0,
);

setUnreadCount(computeUnreadCount());
const listener = chatClient.on(() => {
setUnreadCount(computeUnreadCount());
});

return () => {
if (listener) {
listener.unsubscribe();
}
listener.unsubscribe();
};
}, [chatClient]);

Expand Down
9 changes: 4 additions & 5 deletions examples/SampleApp/src/context/StreamChatContext.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import React from 'react';
import { PropsWithChildren, createContext, useState } from 'react';

import { Channel as ChannelType } from 'stream-chat';
import { ThreadContextValue } from 'stream-chat-react-native';
import { Channel as ChannelType, LocalMessage } from 'stream-chat';

export type StreamChatContextType = {
channel: ChannelType | undefined;
setChannel: React.Dispatch<React.SetStateAction<ChannelType | undefined>>;
setThread: React.Dispatch<React.SetStateAction<ThreadContextValue['thread'] | undefined>>;
thread: ThreadContextValue['thread'] | undefined;
setThread: React.Dispatch<React.SetStateAction<LocalMessage | null | undefined>>;
thread: LocalMessage | null | undefined;
};

export const StreamChatContext = createContext<StreamChatContextType>({
Expand All @@ -20,7 +19,7 @@ export const StreamChatContext = createContext<StreamChatContextType>({

export const StreamChatProvider = ({ children }: PropsWithChildren) => {
const [channel, setChannel] = useState<ChannelType | undefined>(undefined);
const [thread, setThread] = useState<ThreadContextValue['thread'] | undefined>(undefined);
const [thread, setThread] = useState<LocalMessage | null | undefined>(undefined);

return (
<StreamChatContext.Provider value={{ channel, setChannel, thread, setThread }}>
Expand Down
3 changes: 1 addition & 2 deletions examples/SampleApp/src/screens/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
MessageComposer,
MessageList,
MessageFlashList,
ThreadContextValue,
useAttachmentPickerContext,
useChannelPreviewDisplayName,
useChatContext,
Expand Down Expand Up @@ -130,7 +129,7 @@ export const ChannelScreen: React.FC<ChannelScreenProps> = ({ navigation, route

const [channel, setChannel] = useState<StreamChatChannel | undefined>(channelFromProp);

const [selectedThread, setSelectedThread] = useState<ThreadContextValue['thread']>();
const [selectedThread, setSelectedThread] = useState<LocalMessage | null>();

useEffect(() => {
const initChannel = async () => {
Expand Down
Loading
Loading