diff --git a/ai-docs/ai-migration-v9-to-v10.md b/ai-docs/ai-migration-v9-to-v10.md
index 8770dd440b..98fae08a22 100644
--- a/ai-docs/ai-migration-v9-to-v10.md
+++ b/ai-docs/ai-migration-v9-to-v10.md
@@ -23,7 +23,7 @@
one `channel.state` store — see §K.)
3. **Resolution hooks** (new in v10, additive — use these as the entry points):
- `useChannelContext().channel` — the active `Channel` instance.
- - `useChannel()` — `threadInstance?.channel ?? channel` (thread-aware).
+ - `useChannel()` — `thread?.channel ?? channel` (thread-aware).
- `useMessagePaginator()` — the open thread's `messagePaginator` when the subtree renders a thread
list (`threadList`), `channel.messagePaginator` otherwise.
- `useStateStore(store, selector)` — subscribe to a `StateStore` with a
@@ -161,15 +161,16 @@ means changed. Details in the linked section.
| app-wide unread from `event.total_unread_count` | sum `channel.countUnread()` over `client.activeChannels` | §7.1 |
| `useMessagesContext()` → `sendReaction` / `deleteReaction` / `deleteMessage` / `removeMessage` / `retrySendMessage` / `updateMessage` | `useMessageOperations()` (same names) | §9 |
| `useMessagesContext().targetedMessage` | `useIsTargetedMessage(messageId)` (per row) | §6 |
-| `useThreadContext().thread` (parent message) | `useStateStore(threadInstance.state, s => ({ parentMessage: s.parentMessage }))` | §10 |
-| `useThreadContext().threadMessages` | `threadInstance.messagePaginator.state.items` | §10 |
-| `useThreadContext().loadMoreThread()` / `loadMoreRecentThread()` | `threadInstance.messagePaginator.toTail()` / `.toHead()` | §10 |
-| `useThreadContext().threadHasMore` / `threadLoadingMore` | `threadInstance.messagePaginator.state.hasMoreTail` / `.isLoading` | §10 |
-| `useThreadContext().reloadThread()` | `threadInstance.reload()` | §10 |
-| `openThread(msg)` / `closeThread()` | lift `onThreadSelect` → `` | §10.1 |
-| `useMessageComposerContext().thread` | `threadInstance` | §10.2 |
+| `useThreadContext().thread` (parent message) | `thread` is now the `Thread`; parent via `useStateStore(thread.state, s => ({ parentMessage: s.parentMessage }))` | §10 |
+| `useThreadContext().threadMessages` | `thread.messagePaginator.state.items` | §10 |
+| `useThreadContext().loadMoreThread()` / `loadMoreRecentThread()` | `thread.messagePaginator.toTail()` / `.toHead()` | §10 |
+| `useThreadContext().threadHasMore` / `threadLoadingMore` | `thread.messagePaginator.state.hasMoreTail` / `.isLoading` | §10 |
+| `useThreadContext().reloadThread()` | `thread.reload()` | §10 |
+| `openThread(msg)` / `closeThread()` | lift `onThreadSelect` → `` | §10.1 |
+| `ThreadType` / `` | a `Thread` instance; `client.threads.ensure({ channel, parentMessage })` builds one from a message | §10.1 |
+| `useMessageComposerContext().thread` (parent message) | `thread` is now the `Thread`; parent on `thread.state` | §10.2 |
| `editMessage()` on the message input context | removed: `useMessageInputContext().sendMessage()` saves an edit; intercept with `updateMessageRequest` | P.17 |
-| `` props | removed — list reads `threadInstance` + paginator internally | §12 |
+| `` props | removed — list reads the `Thread` from `ThreadContext` + paginator internally | §12 |
| custom `FooterComponent` / `HeaderComponent` reading `loadingMore` from context | receive `{ loadingMore?: boolean }` as a prop | §12.1 |
| `` props | removed — state lives in the paginator | §13 |
| `useMutedUsers(channel)` | `useMutedUsers()` | §14 |
@@ -183,7 +184,7 @@ means changed. Details in the linked section.
| `loadNextPage(filters, sort, options)` | `loadNextPage()` (no args) | §18.3 |
| `` / `useChatContext().channelManager` | `client.channelManager` | §18.4 |
| `client.recoverState()` | `client.connectionRecovery.recover()` | §L.6 |
-| `useChannelContext().error` | `useStateStore((threadInstance ?? channel).messagePaginator.state, sel).lastQueryError` | §L.7 |
+| `useChannelContext().error` | `useStateStore((thread ?? channel).messagePaginator.state, sel).lastQueryError` | §L.7 |
| `` refreshing the channel / open thread on reconnect | `client.connectionRecovery`; `` only marks them active | §L.4 |
| a custom channel/thread view built on the contexts | call `channel.activate()` / `thread.activate()`, balanced with `deactivate()`, or recovery cannot see them (`` and `` do it for you) | §L.4 |
| `new StreamChat(key, { recoverStateOnReconnect: false })` | `client.config.set({ client: { connectionRecovery: { enabled: false } } })` | §L.1 |
@@ -613,34 +614,41 @@ will double-apply or conflict.
## 10. Thread API: instance-based + prop-driven
This is the largest behavioral shift. `ThreadContextValue` is reduced to **four
-fields** — everything else is derived from the `threadInstance` (a `stream-chat`
-`Thread`), and opening/closing a thread is now **prop-driven** (no imperative
+fields** — everything else is derived from `thread` (a `stream-chat` `Thread`),
+and opening/closing a thread is now **prop-driven** (no imperative
`openThread` / `closeThread`).
-**`ThreadContextValue` now = `{ threadInstance?, allowThreadMessagesInChannel,
+**`ThreadContextValue` now = `{ thread?, allowThreadMessagesInChannel,
parentMessagePreventPress?, onAlsoSentToChannelHeaderPress? }`.**
-Removed fields (all derivable from `threadInstance`):
+> **`thread` keeps its name but changes type.** In v9 `useThreadContext().thread` was the parent
+> `LocalMessage`. In v10 it is the `Thread` instance. `thread.id` is the same value in both, since a
+> thread's id is its parent message's id. Message fields such as `thread.text` or `thread.user` are
+> now on `thread.state.getLatestValue().parentMessage`. TypeScript reports those reads as errors,
+> while plain JavaScript gets `undefined`. The same applies to `useMessageComposerContext().thread`
+> and the `thread` prop of ``.
-| Removed `ThreadContext` field | Replacement (off `threadInstance`) |
+Removed or retyped fields (all derivable from `thread`):
+
+| v9 `ThreadContext` field | v10 (off `thread`) |
|---|---|
-| `thread` (parent message) | `threadInstance.state.getLatestValue().parentMessage` (reactive via `useStateStore`) |
-| `threadMessages` | `threadInstance.messagePaginator.state.items` |
-| `loadMoreThread` | `threadInstance.messagePaginator.toTail()` |
-| `loadMoreRecentThread` | `threadInstance.messagePaginator.toHead()` |
-| `threadHasMore` | `threadInstance.messagePaginator.state.hasMoreTail` |
-| `threadLoadingMore` / `threadLoadingMoreRecent` | `threadInstance.messagePaginator.state.isLoading` |
-| `reloadThread` | `threadInstance.reload()` |
+| `thread` (parent message) | `thread.state.getLatestValue().parentMessage` (reactive via `useStateStore`) |
+| `threadMessages` | `thread.messagePaginator.state.items` |
+| `loadMoreThread` | `thread.messagePaginator.toTail()` |
+| `loadMoreRecentThread` | `thread.messagePaginator.toHead()` |
+| `threadHasMore` | `thread.messagePaginator.state.hasMoreTail` |
+| `threadLoadingMore` / `threadLoadingMoreRecent` | `thread.messagePaginator.state.isLoading` |
+| `reloadThread` | `thread.reload()` |
| `openThread` / `closeThread` / `setThreadLoadingMore` | removed — see prop-driven flow below |
```tsx
import { useThreadContext, useStateStore } from 'stream-chat-react-native';
-const { threadInstance } = useThreadContext();
-const { parentMessage } = useStateStore(threadInstance.state, (s) => ({
+const { thread } = useThreadContext();
+const { parentMessage } = useStateStore(thread.state, (s) => ({
parentMessage: s.parentMessage,
}));
-const { replies } = useStateStore(threadInstance.messagePaginator.state, (s) => ({
+const { replies } = useStateStore(thread.messagePaginator.state, (s) => ({
replies: s.items,
}));
```
@@ -652,33 +660,55 @@ into your own state from a list's `onThreadSelect`, then render
``; set it back to `null` to close.
```tsx
-const [thread, setThread] = useState(null);
+const [thread, setThread] = useState(null);
-
+{thread ? (
+
+
+
+) : null}
```
-``'s `thread` prop accepts `LocalMessage | ThreadType | null` (where
-`ThreadType = { thread: LocalMessage; threadInstance: Thread }`).
+Every thread API takes and passes a `stream-chat` `Thread` instance. ``'s `thread` prop is
+`Thread | null`, and `onThreadSelect` is `(thread: Thread) => void` on `MessageList`,
+`MessageFlashList`, `Message` and `ThreadList`. The parent message is on
+`thread.state.getLatestValue().parentMessage` and the channel on `thread.channel`.
+
+`ThreadType` (`{ thread: LocalMessage; threadInstance: Thread }`) is removed, and `` no
+longer accepts a parent message. Where you only have the message, for example from a push
+notification, a deep link or a draft, build the thread with `client.threads.ensure`. It returns
+the instance the client already holds for that parent, or registers a new one.
+
+```diff
+- navigation.navigate('ThreadScreen', { thread: parentMessage });
++ navigation.navigate('ThreadScreen', {
++ thread: client.threads.ensure({ channel, parentMessage }),
++ });
+```
+
+```diff
+- open(threadInstance, channel)} />
++ open(thread, thread.channel)} />
+```
-> **`onThreadSelect` has different signatures per surface** — wire the right one:
-> - `MessageList` / `MessageFlashList`: `(message: LocalMessage | null) => void`
-> - message-level (`MessageProps`): `(message: LocalMessage) => void`
-> - `ThreadList` (`ThreadsContext`): `(thread: ThreadType, channel: Channel) => void`
+`onAlsoSentToChannelHeaderPress` receives `{ targetedMessageId, thread? }`. `thread` replaces
+`parentMessage` and is set when the header is pressed in a channel list.
### 10.2 Removed thread-related props / context fields
-- **`ThreadProps`**: removed `thread`, `closeThread`, `loadMoreThread`,
- `reloadThread`. Supply `threadInstance` instead.
+- **`ThreadProps`**: removed `closeThread`, `loadMoreThread`, `reloadThread`.
+ `thread` is now the `Thread` instance (it was the parent message) and defaults
+ to the one in `ThreadContext`.
- ⚠️ `closeThreadOnDismount?: boolean` **still exists on the type but is
inert** (never read — the thread no longer auto-closes on unmount, it only
`deactivate()`s). No TS error; silent behavior change.
-- **`MessageComposerContextValue`**: `thread` removed (only `threadInstance`
- remains).
+- **`MessageComposerContextValue`**: `thread` is now the `Thread` instance (it
+ was the parent message).
- **`ThreadFooterComponentProps`**: `thread` removed (type is now
`Partial>`). Read
- `threadInstance` via `useThreadContext()` inside a custom footer and get the
- parent/`replyCount` off `threadInstance.state`.
+ `thread` via `useThreadContext()` inside a custom footer and get the
+ parent/`replyCount` off `thread.state`.
- **`MessageProps`**: removed `openThread` (§9).
## 11. `InputMessageInputContextValue.editMessage` removed
@@ -695,7 +725,7 @@ in. Submitting goes through the composer; see P.17.
Removed props (from both list components): `channelUnreadStateStore`,
`setChannelUnreadState`, `setTargetedMessage`, `targetedMessage`, `thread`
(old `ThreadType` prop), `threadHasMore`, `loadMoreThread`,
-`loadMoreRecentThread`. The list now reads thread state from `threadInstance`,
+`loadMoreRecentThread`. The list now reads thread state from the `Thread` in `ThreadContext`,
unread from the paginator snapshot (§7), and targeting from the focus signal
(§6) — all internally.
@@ -974,9 +1004,8 @@ parameter shapes dropped the removed fields:
ops (§9) or `openThread`.
- `useCreateThreadContext` — accepts only `Pick` (§10).
-- `useCreateMessageInputContext` — its thread param changed
- `Pick` → `'threadInstance'` (derives `threadId` internally).
+ 'thread'>` (§10).
+- `useCreateMessageInputContext` — no longer takes a thread.
- `useCreateTypingContext` and `useCreatePaginatedMessageListContext` are
**removed entirely** (§2/§3).
@@ -1653,7 +1682,7 @@ useEffect(() => { channel?.activate?.(); return () => channel?.deactivate?.(); }
```
The open thread is declared the same way, but by **``**, which already did this before v10 —
-`threadInstance.activate()` on mount, `deactivate()` on unmount. `` does not need to (and
+`thread.activate()` on mount, `deactivate()` on unmount. `` does not need to (and
does not) activate it as well.
Neither reconnect handler was ever exported, so there is no symbol to migrate — this is here because
@@ -1707,7 +1736,7 @@ const lastQueryErrorSelector = (state: { lastQueryError?: Error }) => ({
});
const { lastQueryError } = useStateStore(
- (threadInstance ?? channel).messagePaginator.state,
+ (thread ?? channel).messagePaginator.state,
lastQueryErrorSelector,
);
```
@@ -1744,9 +1773,9 @@ const lastQueryErrorSelector = (state: { lastQueryError?: Error }) => ({
// After (v10) — in the component
const { channel } = useChannelContext();
-const { threadInstance } = useThreadContext();
+const { thread } = useThreadContext();
const { lastQueryError } = useStateStore(
- (threadInstance ?? channel).messagePaginator.state,
+ (thread ?? channel).messagePaginator.state,
lastQueryErrorSelector,
);
```
diff --git a/examples/ExpoMessaging/app/channel/[cid]/index.tsx b/examples/ExpoMessaging/app/channel/[cid]/index.tsx
index d2835e3694..94d185862a 100644
--- a/examples/ExpoMessaging/app/channel/[cid]/index.tsx
+++ b/examples/ExpoMessaging/app/channel/[cid]/index.tsx
@@ -128,7 +128,7 @@ export default function ChannelScreen() {
{
setThread(thread);
- router.push(`/channel/${channel.cid}/thread/${thread?.cid ?? ''}`);
+ router.push(`/channel/${channel.cid}/thread/${thread.id}`);
}}
/>
diff --git a/examples/ExpoMessaging/context/AppContext.tsx b/examples/ExpoMessaging/context/AppContext.tsx
index 26f1d851ad..076931dfac 100644
--- a/examples/ExpoMessaging/context/AppContext.tsx
+++ b/examples/ExpoMessaging/context/AppContext.tsx
@@ -1,12 +1,12 @@
import React, { PropsWithChildren, createContext, useState } from 'react';
-import { Channel as ChannelType, LocalMessage } from 'stream-chat';
+import { Channel as ChannelType, Thread } from 'stream-chat';
export type AppContextType = {
channel: ChannelType | undefined;
setChannel: React.Dispatch>;
- setThread: React.Dispatch>;
- thread: LocalMessage | null | undefined;
+ setThread: React.Dispatch>;
+ thread: Thread | null | undefined;
};
export const AppContext = createContext({
@@ -18,7 +18,7 @@ export const AppContext = createContext({
export const AppProvider = ({ children }: PropsWithChildren) => {
const [channel, setChannel] = useState(undefined);
- const [thread, setThread] = useState(undefined);
+ const [thread, setThread] = useState(undefined);
return (
diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json
index b5c27b33cf..99c18103c9 100644
--- a/examples/ExpoMessaging/package.json
+++ b/examples/ExpoMessaging/package.json
@@ -51,7 +51,7 @@
"react-native-teleport": "^1.1.12",
"react-native-web": "^0.21.2",
"react-native-worklets": "0.11.1",
- "stream-chat": "^10.0.0-rc.20",
+ "stream-chat": "^10.0.0-rc.22",
"stream-chat-expo": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock
index 4de2187ad1..141c630586 100644
--- a/examples/SampleApp/ios/Podfile.lock
+++ b/examples/SampleApp/ios/Podfile.lock
@@ -242,7 +242,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- - op-sqlite (18.1.1):
+ - op-sqlite (18.1.4):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -3110,14 +3110,14 @@ SPEC CHECKSUMS:
GoogleAppMeasurement: 57270ccc2b77472d7e85c4cbe45972564eff78bb
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850
- hermes-engine: 6a26e94b4a974cbadc751eed9caa12624c38f226
+ hermes-engine: 38b6a452e40fa51e26047d362f132f386320507b
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
NitroModules: 8cad24cfdb4fb7c561bc68d76b8e86c096a4e598
NitroSound: bc4220718f728f1f83093166dc1b5487cfba973f
- op-sqlite: 6c653c1e122b7b777bc0892839fd09a4bb0e264f
+ op-sqlite: e09741fc098106a52e33724080d3d3356fd7011e
PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273
PromisesSwift: 217dea0fd5d2ad65222a109c48698add13cc1c5b
RCT-Folly: 82921e585c0400eda46fac1068297aa9dfb035e4
diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json
index 0398f2b762..c6fb63a55b 100644
--- a/examples/SampleApp/package.json
+++ b/examples/SampleApp/package.json
@@ -65,7 +65,7 @@
"react-native-teleport": "^1.1.12",
"react-native-video": "^6.19.2",
"react-native-worklets": "^0.12.1",
- "stream-chat": "^10.0.0-rc.20",
+ "stream-chat": "^10.0.0-rc.22",
"stream-chat-react-native": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
diff --git a/examples/SampleApp/src/components/DraftsList.tsx b/examples/SampleApp/src/components/DraftsList.tsx
index 9091ace467..e13b1b20dd 100644
--- a/examples/SampleApp/src/components/DraftsList.tsx
+++ b/examples/SampleApp/src/components/DraftsList.tsx
@@ -11,7 +11,6 @@ import {
convertTimestampToDate,
DraftMessage,
DraftResponse,
- LocalMessage,
MessageResponse,
TimestampNS,
} from 'stream-chat';
@@ -55,12 +54,7 @@ export const DraftItem = ({ type, channel, date, message, thread }: DraftItemPro
if (type === 'thread' && thread?.id) {
navigation.navigate('ThreadScreen', {
- // `thread` is a draft's `parent_message` (`MessageResponseBase`, with
- // string timestamps). ThreadScreen consumes it as a thread root typed
- // as `LocalMessage | ThreadType`; the shapes only differ in date field
- // types, so a targeted cast preserves the existing behavior. See the
- // `DraftItemProps.thread` TODO about tightening this type upstream.
- thread: thread as unknown as LocalMessage,
+ thread: client.threads.ensure({ channel: resultChannel, parentMessage: thread }),
channel: resultChannel,
});
} else if (type === 'channel') {
diff --git a/examples/SampleApp/src/context/StreamChatContext.tsx b/examples/SampleApp/src/context/StreamChatContext.tsx
index 9b968a732a..574545106c 100644
--- a/examples/SampleApp/src/context/StreamChatContext.tsx
+++ b/examples/SampleApp/src/context/StreamChatContext.tsx
@@ -1,13 +1,13 @@
import React from 'react';
import { PropsWithChildren, createContext, useState } from 'react';
-import { Channel as ChannelType, LocalMessage } from 'stream-chat';
+import { Channel as ChannelType, Thread } from 'stream-chat';
export type StreamChatContextType = {
channel: ChannelType | undefined;
setChannel: React.Dispatch>;
- setThread: React.Dispatch>;
- thread: LocalMessage | null | undefined;
+ setThread: React.Dispatch>;
+ thread: Thread | null | undefined;
};
export const StreamChatContext = createContext({
@@ -19,7 +19,7 @@ export const StreamChatContext = createContext({
export const StreamChatProvider = ({ children }: PropsWithChildren) => {
const [channel, setChannel] = useState(undefined);
- const [thread, setThread] = useState(undefined);
+ const [thread, setThread] = useState(undefined);
return (
diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx
index acf1fb93ba..1592fe3e7d 100644
--- a/examples/SampleApp/src/screens/ChannelScreen.tsx
+++ b/examples/SampleApp/src/screens/ChannelScreen.tsx
@@ -6,7 +6,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { RouteProp, useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
-import type { LocalMessage, Channel as StreamChatChannel, StreamChat } from 'stream-chat';
+import type { LocalMessage, Channel as StreamChatChannel, StreamChat, Thread } from 'stream-chat';
import {
AITypingIndicatorView,
AlsoSentToChannelHeaderPressPayload,
@@ -25,8 +25,6 @@ import {
useSettledWSConnectionHealth,
} from 'stream-chat-react-native';
-import { ThreadType } from 'stream-chat-react-native-core';
-
import { MessageInfoBottomSheet } from '../components/MessageInfoBottomSheet.tsx';
import { NetworkDownIndicator } from '../components/NetworkDownIndicator';
import { ScreenHeader } from '../components/ScreenHeader';
@@ -207,7 +205,7 @@ export const ChannelScreen: React.FC = ({ navigation, route
.catch((error: unknown) => console.warn('Jump to message failed:', error));
}, [channel, messageId]);
- const [selectedThread, setSelectedThread] = useState();
+ const [selectedThread, setSelectedThread] = useState();
/**
* Message-list pruning is state-layer configuration now, not a `` prop: the paginator
@@ -256,8 +254,8 @@ export const ChannelScreen: React.FC = ({ navigation, route
};
const onThreadSelect = useCallback(
- (thread: LocalMessage | null) => {
- if (!thread || !channel) {
+ (thread: Thread) => {
+ if (!channel) {
return;
}
@@ -277,8 +275,8 @@ export const ChannelScreen: React.FC = ({ navigation, route
);
const onAlsoSentToChannelHeaderPress = useCallback(
- ({ parentMessage, targetedMessageId }: AlsoSentToChannelHeaderPressPayload) => {
- if (!channel || !parentMessage) {
+ ({ targetedMessageId, thread }: AlsoSentToChannelHeaderPressPayload) => {
+ if (!channel || !thread) {
return;
}
@@ -286,12 +284,12 @@ export const ChannelScreen: React.FC = ({ navigation, route
navigation.setParams({ messageId: undefined });
}
- setSelectedThread(parentMessage);
- setThread(parentMessage);
+ setSelectedThread(thread);
+ setThread(thread);
const params: StackNavigatorParamList['ThreadScreen'] = {
channel,
targetedMessageId,
- thread: parentMessage,
+ thread,
};
const hasThreadInStack = navigation.getState().routes.some((stackRoute) => {
if (stackRoute.name !== 'ThreadScreen') {
@@ -300,11 +298,8 @@ export const ChannelScreen: React.FC = ({ navigation, route
const routeParams = stackRoute.params as
| StackNavigatorParamList['ThreadScreen']
| undefined;
- const routeThreadId =
- (routeParams?.thread as LocalMessage)?.id ??
- (routeParams?.thread as ThreadType)?.thread?.id;
const routeChannelId = routeParams?.channel?.id;
- return routeThreadId === parentMessage.id && routeChannelId === channel.id;
+ return routeParams?.thread.id === thread.id && routeChannelId === channel.id;
});
if (hasThreadInStack) {
diff --git a/examples/SampleApp/src/screens/ThreadListScreen.tsx b/examples/SampleApp/src/screens/ThreadListScreen.tsx
index 969d66d1a9..b58eef7636 100644
--- a/examples/SampleApp/src/screens/ThreadListScreen.tsx
+++ b/examples/SampleApp/src/screens/ThreadListScreen.tsx
@@ -50,10 +50,10 @@ export const ThreadListScreen: React.FC = () => {
{
+ onThreadSelect={(thread) => {
navigation.navigate('ThreadScreen', {
+ channel: thread.channel,
thread,
- channel,
});
}}
/>
diff --git a/examples/SampleApp/src/screens/ThreadScreen.tsx b/examples/SampleApp/src/screens/ThreadScreen.tsx
index e5e829ca34..bea0a0581e 100644
--- a/examples/SampleApp/src/screens/ThreadScreen.tsx
+++ b/examples/SampleApp/src/screens/ThreadScreen.tsx
@@ -5,14 +5,13 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { type RouteProp } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
-import { LocalMessage, ThreadState, UserResponse } from 'stream-chat';
+import { Thread as ThreadInstance, ThreadState } from 'stream-chat';
import {
DEFAULT_HIGHLIGHT_DURATION,
AlsoSentToChannelHeaderPressPayload,
Channel,
MessageActionsParams,
Thread,
- ThreadType,
useChatContext,
useTheme,
useTranslationContext,
@@ -54,18 +53,13 @@ type ThreadScreenProps = {
};
export type ThreadHeaderProps = {
- thread: LocalMessage | ThreadType;
+ thread: ThreadInstance;
};
const ThreadHeader: React.FC = ({ thread }) => {
const typing = useTypingString();
- let subtitleText = ((thread as LocalMessage)?.user as UserResponse)?.name;
- const { parentMessage } =
- useStateStore((thread as ThreadType)?.threadInstance?.state, selector) || {};
-
- if (subtitleText == null) {
- subtitleText = (parentMessage?.user as UserResponse)?.name;
- }
+ const { parentMessage } = useStateStore(thread.state, selector);
+ const subtitleText = parentMessage?.user?.name;
useCreateDraftFocusEffect();
@@ -89,18 +83,17 @@ export const ThreadScreen: React.FC = ({ navigation, route })
* message list picks its initial anchor in a passive effect — so the jump lands first and there
* is no scroll-then-jump.
*/
- const threadInstanceForJump = (thread as ThreadType)?.threadInstance;
useLayoutEffect(() => {
- if (!threadInstanceForJump || !targetedMessageIdFromParams) {
+ if (!targetedMessageIdFromParams) {
return;
}
- threadInstanceForJump.messagePaginator
+ thread.messagePaginator
.jumpToMessage(targetedMessageIdFromParams, {
focusReason: 'jump-to-message',
focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
})
.catch((error: unknown) => console.warn('Jump to reply failed:', error));
- }, [threadInstanceForJump, targetedMessageIdFromParams]);
+ }, [thread, targetedMessageIdFromParams]);
const {
theme: { semantics },
} = useTheme();
diff --git a/examples/SampleApp/src/types.ts b/examples/SampleApp/src/types.ts
index aa300c0796..3114756bf8 100644
--- a/examples/SampleApp/src/types.ts
+++ b/examples/SampleApp/src/types.ts
@@ -1,6 +1,5 @@
import type { Theme } from '@react-navigation/native';
-import type { Channel, LocalMessage, SharedLocationResponseData, UserResponse } from 'stream-chat';
-import type { ThreadType } from 'stream-chat-react-native';
+import type { Channel, SharedLocationResponseData, Thread, UserResponse } from 'stream-chat';
export type DrawerNavigatorParamList = {
HomeScreen: undefined;
@@ -33,7 +32,7 @@ export type StackNavigatorParamList = {
NewGroupChannelAssignNameScreen: undefined;
ThreadScreen: {
channel: Channel;
- thread: LocalMessage | ThreadType;
+ thread: Thread;
targetedMessageId?: string;
};
};
diff --git a/package/package.json b/package/package.json
index 3cbb28275a..09484eacd1 100644
--- a/package/package.json
+++ b/package/package.json
@@ -80,7 +80,7 @@
"path": "0.12.7",
"react-native-markdown-package": "1.8.2",
"react-native-url-polyfill": "^2.0.0",
- "stream-chat": "^10.0.0-rc.20",
+ "stream-chat": "^10.0.0-rc.22",
"use-sync-external-store": "^1.7.0"
},
"peerDependencies": {
diff --git a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/__tests__/AttachmentPickerItem.test.tsx b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/__tests__/AttachmentPickerItem.test.tsx
index 8bab3e73c3..7fe34edbc3 100644
--- a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/__tests__/AttachmentPickerItem.test.tsx
+++ b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/__tests__/AttachmentPickerItem.test.tsx
@@ -39,7 +39,7 @@ describe('AttachmentPickerItem', () => {
render(
-
+
(renders += 1)}>
{renderAttachmentPickerItem({
item: { name: 'photo.jpg', size: 1024, type: 'image/jpeg', uri: 'file://photo.jpg' },
diff --git a/package/src/components/AttachmentPicker/components/__tests__/AttachmentCommandPicker.test.tsx b/package/src/components/AttachmentPicker/components/__tests__/AttachmentCommandPicker.test.tsx
index 9f6218766a..da7154734a 100644
--- a/package/src/components/AttachmentPicker/components/__tests__/AttachmentCommandPicker.test.tsx
+++ b/package/src/components/AttachmentPicker/components/__tests__/AttachmentCommandPicker.test.tsx
@@ -29,7 +29,7 @@ describe('AttachmentCommandPicker', () => {
render(
-
+
,
diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx
index 4ea010bb14..b2f7a13e86 100644
--- a/package/src/components/Channel/Channel.tsx
+++ b/package/src/components/Channel/Channel.tsx
@@ -5,7 +5,6 @@ import {
ChannelConfig,
ChannelDataState,
ChannelLifecycleState,
- LocalMessage,
MessageComposerConfig,
Event as StreamEvent,
Thread,
@@ -49,11 +48,7 @@ import {
OwnCapabilitiesProvider,
} from '../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext';
import { useTheme } from '../../contexts/themeContext/ThemeContext';
-import {
- ThreadContextValue,
- ThreadProvider,
- ThreadType,
-} from '../../contexts/threadContext/ThreadContext';
+import { ThreadContextValue, ThreadProvider } from '../../contexts/threadContext/ThreadContext';
import {
TranslationContextValue,
useTranslationContext,
@@ -234,7 +229,11 @@ export type ChannelPropsWithContext = Pick &
Pick
> & {
shouldSyncChannel: boolean;
- thread: ThreadType;
+ /**
+ * The thread to render. Pass the instance a thread was opened with (`onThreadSelect` on the
+ * message and thread lists), or one from `client.threads.ensure({ channel, parentMessage })`.
+ */
+ thread?: Thread | null;
/**
* Additional props passed to keyboard avoiding view
*/
@@ -408,7 +407,7 @@ const ChannelWithContext = (props: PropsWithChildren) =
shouldSyncChannel,
supportedReactions = reactionData,
t,
- thread: threadFromProps,
+ thread,
threadList,
topInset = 0,
initializeOnMount = true,
@@ -418,23 +417,7 @@ const ChannelWithContext = (props: PropsWithChildren) =
const components = useComponentsContext();
const { KeyboardCompatibleView, LoadingErrorIndicator } = components;
- const { thread: threadProps, threadInstance: threadInstanceFromProps } = threadFromProps;
-
const styles = useStyles();
- // The active thread is fully prop-driven: derive it synchronously during render so the reply
- // data is present on the first frame (no setState round-trip / one-frame gap). Opening a thread
- // is the integrator's job via `onThreadSelect` (they render a Channel with the `thread` prop).
- const thread = threadProps ?? null;
- const threadInstance = useMemo(() => {
- if (threadInstanceFromProps) {
- return threadInstanceFromProps;
- }
- if (!threadProps?.id || !channel) {
- return null;
- }
- return client.threads.ensure({ channel, parentMessage: threadProps });
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [threadInstanceFromProps, threadProps?.id, channel, client]);
const [messageInputHeightStore] = useState(() => new MessageInputHeightStore());
const { bottomSheetRef, closePicker, openPicker } = useAttachmentPickerBottomSheet();
@@ -485,7 +468,7 @@ const ChannelWithContext = (props: PropsWithChildren) =
* decision, and subscribing would re-render `Channel` on every jump for no benefit.
*/
const hasFocusTarget = useStableCallback(
- () => !!(threadInstance ?? channel).messagePaginator.messageFocusSignal.getLatestValue().signal,
+ () => !!(thread ?? channel).messagePaginator.messageFocusSignal.getLatestValue().signal,
);
const shouldLoadInitialChannelAtFirstUnreadMessage = useStableCallback((unreadCount?: number) => {
@@ -760,7 +743,7 @@ const ChannelWithContext = (props: PropsWithChildren) =
const threadContext = useCreateThreadContext({
allowThreadMessagesInChannel,
onAlsoSentToChannelHeaderPress,
- threadInstance,
+ thread,
});
const audioPlayerContext = useMemo(
@@ -768,10 +751,7 @@ const ChannelWithContext = (props: PropsWithChildren) =
[allowConcurrentAudioPlayback],
);
- const messageComposerContext = useMemo(
- () => ({ channel, threadInstance }),
- [channel, threadInstance],
- );
+ const messageComposerContext = useMemo(() => ({ channel, thread }), [channel, thread]);
if (!channel || blockingError) {
// Retry re-runs the query that failed. A new failure lands in the paginator's `lastQueryError`,
@@ -824,10 +804,8 @@ const ChannelWithContext = (props: PropsWithChildren) =
);
};
-export type ChannelProps = Partial> &
- Pick & {
- thread?: LocalMessage | ThreadType | null;
- };
+export type ChannelProps = Partial> &
+ Pick;
/**
*
@@ -845,18 +823,7 @@ export const Channel = (props: PropsWithChildren) => {
props.notificationHostId ??
(channel?.cid ? getChannelNotificationHostId(channel.cid) : undefined);
- const threadFromProps = props?.thread;
- const threadInstance = (threadFromProps as ThreadType)?.threadInstance as Thread;
- const threadMessage = (
- threadInstance ? (threadFromProps as ThreadType).thread : threadFromProps
- ) as LocalMessage;
-
- const thread: ThreadType = {
- thread: threadMessage,
- threadInstance,
- };
-
- const shouldSyncChannel = threadMessage?.id ? !!props.threadList : true;
+ const shouldSyncChannel = props.thread ? !!props.threadList : true;
const channelWithContext = (
) => {
{...props}
channel={channel}
shouldSyncChannel={shouldSyncChannel}
- thread={thread}
/>
);
diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx
index 45700f8cb7..74cf6eac59 100644
--- a/package/src/components/Channel/__tests__/Channel.test.tsx
+++ b/package/src/components/Channel/__tests__/Channel.test.tsx
@@ -226,16 +226,14 @@ describe('Channel', () => {
it('exposes a thread provided via props through the thread context', async () => {
const threadMessage = messages[0];
+ const thread = chatClient.threads.ensure({ channel, parentMessage: threadMessage });
const hasThread = jest.fn();
- // Threads are now fully prop-driven: passing `thread` to Channel exposes it through
- // ThreadContext synchronously as a `threadInstance` (whose `id` is the parent message id) —
- // the context no longer carries a raw `thread` message.
renderComponent(
- { channel, thread: threadMessage, threadList: true },
+ { channel, thread, threadList: true },
(ctx) => {
- const { threadInstance } = ctx as { threadInstance: { id: string } | null };
- if (threadInstance) {
- hasThread(threadInstance.id);
+ const { thread: contextThread } = ctx as ThreadContextValue;
+ if (contextThread) {
+ hasThread(contextThread.id);
}
},
ThreadContext as React.Context,
@@ -994,30 +992,11 @@ describe('Channel initial load useEffect', () => {
});
});
- it('registers the thread it builds for a thread prop before anything activates it', async () => {
- const mockedChannel = generateChannelResponse({ messages: [generateMessage({})] });
- useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
- const testChannel = chatClient.channel('messaging', mockedChannel.channel.id);
- await testChannel.watch();
- const parentMessage = testChannel.state.formatMessage(generateMessage({ user }));
-
- // No , so nothing calls activate(): a list query landing now must find this instance.
- render(
-
-
- ,
- );
-
- await waitFor(() => expect(chatClient.threads.get(parentMessage.id)).toBeDefined());
- expect(chatClient.threads.get(parentMessage.id)?.state.getLatestValue().active).toBe(false);
- });
-
// Regression guard for the reconnect refresh of an OPEN THREAD's replies, which now runs entirely in
// `client.connectionRecovery` — this component's only part is marking the thread active.
//
- // Asserted end to end on purpose: a `threadInstance` passed in as a prop (not built by the
- // `threadInstance` memo's `client.threads.ensure`) reaches the LLC's thread store only through
- // `threadInstance.activate()`. Drop that effect and recovery silently skips the thread with nothing
+ // Asserted end to end on purpose: a thread constructed directly (not through
+ // `client.threads.ensure`) reaches the LLC's thread store only through `threadInstance.activate()`. Drop that effect and recovery silently skips the thread with nothing
// else failing, so it is pinned here.
it('reloads an open thread on reconnect', async () => {
const mockedChannel = generateChannelResponse({ messages: [generateMessage({})] });
@@ -1043,7 +1022,7 @@ describe('Channel initial load useEffect', () => {
// `threadList` is what makes this the one that owns the thread view
// (`shouldSyncChannel`); without it the channel view would claim it instead.
threadList
- thread={{ thread: testChannel.state.formatMessage(parentMessage), threadInstance }}
+ thread={threadInstance}
>
{/* The real is what calls `threadInstance.activate()`, which is the ONLY thing
that registers the instance with `client.threads` for recovery to find. Rendering it
@@ -1074,11 +1053,7 @@ describe('Channel initial load useEffect', () => {
const openThread = (threadInstance: Thread, testChannel: ChannelType) =>
render(
-
+
,
diff --git a/package/src/components/Channel/hooks/useCreateThreadContext.ts b/package/src/components/Channel/hooks/useCreateThreadContext.ts
index 1b00cfc390..f86e61a9d2 100644
--- a/package/src/components/Channel/hooks/useCreateThreadContext.ts
+++ b/package/src/components/Channel/hooks/useCreateThreadContext.ts
@@ -4,20 +4,20 @@ import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadC
// The ThreadContext now carries only the Thread instance (+ a few UI-config props). Reply data,
// pagination and loading state — and the parent message — are read by consumers straight off
-// `threadInstance.messagePaginator` / `threadInstance.state` via useStateStore.
+// `thread.messagePaginator` / `thread.state` via useStateStore.
export const useCreateThreadContext = ({
allowThreadMessagesInChannel,
onAlsoSentToChannelHeaderPress,
- threadInstance,
+ thread,
}: Pick<
ThreadContextValue,
- 'allowThreadMessagesInChannel' | 'onAlsoSentToChannelHeaderPress' | 'threadInstance'
+ 'allowThreadMessagesInChannel' | 'onAlsoSentToChannelHeaderPress' | 'thread'
>): ThreadContextValue =>
useMemo(
() => ({
allowThreadMessagesInChannel,
onAlsoSentToChannelHeaderPress,
- threadInstance,
+ thread,
}),
- [allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, threadInstance],
+ [allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, thread],
);
diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx
index 90cf83d8d4..cf7e50cedf 100644
--- a/package/src/components/Message/Message.tsx
+++ b/package/src/components/Message/Message.tsx
@@ -16,6 +16,7 @@ import {
getAttachmentPreviewUrl,
type LocalMessage,
type MentionEntity,
+ type Thread,
type UserResponse,
} from 'stream-chat';
@@ -261,9 +262,9 @@ export type MessagePropsWithContext = Pick<
/**
* Handler to open the thread on message. This is callback for touch event for replies button.
*
- * @param message A message object to open the thread upon.
+ * @param thread The thread of the message, to render in a ``.
*/
- onThreadSelect?: (message: LocalMessage) => void;
+ onThreadSelect?: (thread: Thread) => void;
showUnreadUnderlay?: boolean;
style?: StyleProp;
} & Pick;
@@ -556,7 +557,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
const onOpenThread = () => {
if (onThreadSelect) {
- onThreadSelect(message);
+ onThreadSelect(client.threads.ensure({ channel, parentMessage: message }));
}
};
diff --git a/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx b/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx
index d72daa7a9e..7aae572ce1 100644
--- a/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx
+++ b/package/src/components/Message/MessageItemView/Headers/SentToChannelHeader.tsx
@@ -2,12 +2,11 @@ import React, { useMemo } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
-import { formatMessage } from 'stream-chat';
-
import {
ChannelContextValue,
useChannelContext,
} from '../../../../contexts/channelContext/ChannelContext';
+import { useChatContext } from '../../../../contexts/chatContext/ChatContext';
import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext';
import { useMessageContext } from '../../../../contexts/messageContext/MessageContext';
import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
@@ -70,32 +69,41 @@ export type SentToChannelHeaderProps = Partial {
const { onAlsoSentToChannelHeaderPress } = useThreadContext();
+ const { client } = useChatContext();
const { channel, threadList } = useChannelContext();
const { message } = useMessageContext();
- // TODO: V9: This should be handled by the channel/thread entirely. We should only fetch
- // if we don't have the message in state already.
const handleOnPress = useStableCallback(async () => {
if (threadList) {
onAlsoSentToChannelHeaderPress?.({ targetedMessageId: message.id });
return;
}
- if (!message.parent_id) {
+ const parentId = message.parent_id;
+ if (!parentId) {
+ return;
+ }
+
+ // The parent is only fetched when neither its thread nor the message itself is loaded. Opening
+ // the thread loads whatever else it is missing.
+ const registeredThread = client.threads.get(parentId);
+ if (registeredThread) {
+ onAlsoSentToChannelHeaderPress?.({ targetedMessageId: message.id, thread: registeredThread });
return;
}
try {
- const { messages } = await channel.getMessagesById([message.parent_id]);
- const parentThreadMessage = formatMessage(messages?.[0]);
+ const parentMessage =
+ client.messageStore.get(parentId) ??
+ (await channel.getMessagesById([parentId])).messages?.[0];
- if (!parentThreadMessage) {
+ if (!parentMessage) {
return;
}
onAlsoSentToChannelHeaderPress?.({
- parentMessage: formatMessage(parentThreadMessage),
targetedMessageId: message.id,
+ thread: client.threads.ensure({ channel, parentMessage }),
});
} catch (error) {
console.error('Error querying parent message:', error);
diff --git a/package/src/components/Message/MessageItemView/__tests__/Message.test.tsx b/package/src/components/Message/MessageItemView/__tests__/Message.test.tsx
index 93c23ae962..c42e262266 100644
--- a/package/src/components/Message/MessageItemView/__tests__/Message.test.tsx
+++ b/package/src/components/Message/MessageItemView/__tests__/Message.test.tsx
@@ -151,6 +151,114 @@ describe('Message', () => {
});
});
+ it('opens the thread with the registered thread instance of the parent message', async () => {
+ const message = channel.state.formatMessage(generateMessage({ reply_count: 2, user }));
+ const onThreadSelect = jest.fn();
+
+ const { getByTestId } = renderMessage({ message, onThreadSelect });
+
+ await waitFor(() => expect(getByTestId('message-replies')).toBeTruthy());
+ fireEvent.press(getByTestId('message-replies'));
+
+ expect(onThreadSelect).toHaveBeenCalledTimes(1);
+ const thread = onThreadSelect.mock.calls[0][0];
+ expect(thread.id).toBe(message.id);
+ expect(thread.channel).toBe(channel);
+ expect(chatClient.threads.get(message.id)).toBe(thread);
+ });
+
+ it('passes the thread of the parent message when "View" is pressed on a reply in the channel', async () => {
+ const parentMessage = generateMessage({ reply_count: 1, user });
+ const reply = channel.state.formatMessage(
+ generateMessage({ parent_id: parentMessage.id, show_in_channel: true, user }),
+ );
+ const getMessagesById = jest
+ .spyOn(channel, 'getMessagesById')
+ .mockResolvedValue({ messages: [parentMessage] } as unknown as Awaited<
+ ReturnType
+ >);
+ const onAlsoSentToChannelHeaderPress = jest.fn();
+
+ const { getByText } = renderMessage({ message: reply }, { onAlsoSentToChannelHeaderPress });
+
+ await waitFor(() => expect(getByText('Replied to a thread')).toBeTruthy());
+ fireEvent.press(getByText('View'));
+
+ await waitFor(() => expect(onAlsoSentToChannelHeaderPress).toHaveBeenCalledTimes(1));
+ expect(getMessagesById).toHaveBeenCalledWith([parentMessage.id]);
+ const { targetedMessageId, thread } = onAlsoSentToChannelHeaderPress.mock.calls[0][0];
+ expect(targetedMessageId).toBe(reply.id);
+ expect(thread.id).toBe(parentMessage.id);
+ expect(thread.channel).toBe(channel);
+ expect(chatClient.threads.get(parentMessage.id)).toBe(thread);
+ });
+
+ it('reuses the registered thread of the parent without fetching it', async () => {
+ const parentMessage = channel.state.formatMessage(generateMessage({ reply_count: 1, user }));
+ const registeredThread = chatClient.threads.ensure({ channel, parentMessage });
+ const reply = channel.state.formatMessage(
+ generateMessage({ parent_id: parentMessage.id, show_in_channel: true, user }),
+ );
+ const getMessagesById = jest.spyOn(channel, 'getMessagesById');
+ const onAlsoSentToChannelHeaderPress = jest.fn();
+
+ const { getByText } = renderMessage({ message: reply }, { onAlsoSentToChannelHeaderPress });
+
+ await waitFor(() => expect(getByText('Replied to a thread')).toBeTruthy());
+ fireEvent.press(getByText('View'));
+
+ await waitFor(() =>
+ expect(onAlsoSentToChannelHeaderPress).toHaveBeenCalledWith({
+ targetedMessageId: reply.id,
+ thread: registeredThread,
+ }),
+ );
+ expect(getMessagesById).not.toHaveBeenCalled();
+ });
+
+ it('builds the thread from a loaded parent without fetching it', async () => {
+ const parentMessage = channel.state.formatMessage(generateMessage({ reply_count: 1, user }));
+ chatClient.messageStore.upsert(parentMessage);
+ const reply = channel.state.formatMessage(
+ generateMessage({ parent_id: parentMessage.id, show_in_channel: true, user }),
+ );
+ const getMessagesById = jest.spyOn(channel, 'getMessagesById');
+ const onAlsoSentToChannelHeaderPress = jest.fn();
+
+ const { getByText } = renderMessage({ message: reply }, { onAlsoSentToChannelHeaderPress });
+
+ await waitFor(() => expect(getByText('Replied to a thread')).toBeTruthy());
+ expect(chatClient.threads.get(parentMessage.id)).toBeUndefined();
+ fireEvent.press(getByText('View'));
+
+ await waitFor(() => expect(onAlsoSentToChannelHeaderPress).toHaveBeenCalledTimes(1));
+ expect(getMessagesById).not.toHaveBeenCalled();
+ const { thread } = onAlsoSentToChannelHeaderPress.mock.calls[0][0];
+ expect(thread.id).toBe(parentMessage.id);
+ expect(chatClient.threads.get(parentMessage.id)).toBe(thread);
+ });
+
+ it('passes only the targeted message when "View" is pressed on a reply in the thread', async () => {
+ const parentMessage = channel.state.formatMessage(generateMessage({ reply_count: 1, user }));
+ const thread = chatClient.threads.ensure({ channel, parentMessage });
+ const reply = channel.state.formatMessage(
+ generateMessage({ parent_id: parentMessage.id, show_in_channel: true, user }),
+ );
+ const getMessagesById = jest.spyOn(channel, 'getMessagesById');
+ const onAlsoSentToChannelHeaderPress = jest.fn();
+
+ const { getByText } = renderMessage(
+ { message: reply },
+ { onAlsoSentToChannelHeaderPress, thread, threadList: true },
+ );
+
+ await waitFor(() => expect(getByText('Also sent in channel')).toBeTruthy());
+ fireEvent.press(getByText('View'));
+
+ expect(onAlsoSentToChannelHeaderPress).toHaveBeenCalledWith({ targetedMessageId: reply.id });
+ expect(getMessagesById).not.toHaveBeenCalled();
+ });
+
it('teleports a custom overlay target without applying overlay styles to siblings', async () => {
const message = generateMessage({ user });
const { getByTestId, getByText } = renderMessage(
diff --git a/package/src/components/Message/hooks/useMessageActions.tsx b/package/src/components/Message/hooks/useMessageActions.tsx
index 4a144d48d4..721fb14ee7 100644
--- a/package/src/components/Message/hooks/useMessageActions.tsx
+++ b/package/src/components/Message/hooks/useMessageActions.tsx
@@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import { Alert } from 'react-native';
-import { LocalMessage } from 'stream-chat';
+import { Thread } from 'stream-chat';
import { useMessageActionHandlers } from './useMessageActionHandlers';
import { MessageOperations } from './useMessageOperations';
@@ -55,7 +55,7 @@ export type MessageActionsHookProps = Pick<
Pick &
Pick &
Pick & {
- onThreadSelect?: (message: LocalMessage) => void;
+ onThreadSelect?: (thread: Thread) => void;
} & Pick;
export const useMessageActions = ({
@@ -124,7 +124,7 @@ export const useMessageActions = ({
const onOpenThread = useStableCallback(() => {
if (onThreadSelect) {
- onThreadSelect(message);
+ onThreadSelect(client.threads.ensure({ channel, parentMessage: message }));
}
});
diff --git a/package/src/components/Message/hooks/useMessageOperations.ts b/package/src/components/Message/hooks/useMessageOperations.ts
index ac4ca966c6..447a49a0a9 100644
--- a/package/src/components/Message/hooks/useMessageOperations.ts
+++ b/package/src/components/Message/hooks/useMessageOperations.ts
@@ -30,7 +30,7 @@ export type MessageOperations = {
export const useMessageOperations = (): MessageOperations => {
const { client } = useChatContext();
const { channel, enforceUniqueReaction } = useChannelContext();
- const { threadInstance } = useThreadContext();
+ const { thread } = useThreadContext();
const updateMessage: MessageOperations['updateMessage'] = useStableCallback((updatedMessage) => {
if (!channel) {
@@ -42,7 +42,7 @@ export const useMessageOperations = (): MessageOperations => {
channel.messagePaginator.ingestItem(formatted);
}
if (updatedMessage.parent_id) {
- threadInstance?.messagePaginator?.ingestItem(formatted);
+ thread?.messagePaginator?.ingestItem(formatted);
}
});
@@ -57,7 +57,7 @@ export const useMessageOperations = (): MessageOperations => {
// write (that store is being removed).
channel.messagePaginator.removeItem({ id: message.id });
if (message.parent_id) {
- threadInstance?.messagePaginator?.removeItem({ id: message.id });
+ thread?.messagePaginator?.removeItem({ id: message.id });
}
}
@@ -68,7 +68,7 @@ export const useMessageOperations = (): MessageOperations => {
const retrySendMessage: MessageOperations['retrySendMessage'] = useStableCallback(
async (localMessage) => {
- await (threadInstance ?? channel).messageOperations.retry({ localMessage });
+ await (thread ?? channel).messageOperations.retry({ localMessage });
},
);
@@ -83,7 +83,7 @@ export const useMessageOperations = (): MessageOperations => {
// paginator can't), otherwise the channel. Both instances forward the request through
// `channel.sendReaction`, so this only decides the optimistic ingest target — never sending a
// channel message's reaction into the thread paginator while a (channel-wide) thread is open.
- const target = threadInstance?.messagePaginator.getItem(messageId) ? threadInstance : channel;
+ const target = thread?.messagePaginator.getItem(messageId) ? thread : channel;
await target.messageOperations.addReaction({
messageId,
@@ -99,7 +99,7 @@ export const useMessageOperations = (): MessageOperations => {
throw new Error('Channel has not been initialized');
}
- const target = threadInstance?.messagePaginator.getItem(messageId) ? threadInstance : channel;
+ const target = thread?.messagePaginator.getItem(messageId) ? thread : channel;
await target.messageOperations.deleteReaction({ messageId, type });
},
@@ -134,9 +134,7 @@ export const useMessageOperations = (): MessageOperations => {
// Routed by MEMBERSHIP, like `sendReaction` above: a reply loaded in the open thread goes through
// the thread instance, everything else — including the thread's own parent message, which the
// reply paginator cannot hold — through the channel.
- const target = threadInstance?.messagePaginator.getItem(message.id)
- ? threadInstance
- : channel;
+ const target = thread?.messagePaginator.getItem(message.id) ? thread : channel;
await target.messageOperations.delete({
localMessage: message,
options,
diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx
index 6f17796212..324dca2998 100644
--- a/package/src/components/MessageList/MessageFlashList.tsx
+++ b/package/src/components/MessageList/MessageFlashList.tsx
@@ -4,7 +4,7 @@ import { LayoutChangeEvent, ScrollViewProps, StyleSheet, View, useColorScheme }
import Animated from 'react-native-reanimated';
import type { FlashListProps, FlashListRef } from '@shopify/flash-list';
-import type { LocalMessage } from 'stream-chat';
+import type { LocalMessage, Thread } from 'stream-chat';
import { getAttachmentPreviewUrl } from 'stream-chat';
@@ -108,7 +108,7 @@ type MessageFlashListPropsWithContext = Pick<
pendingUploadsEnabled: boolean;
markRead: (options?: MarkReadFunctionOptions) => void;
} & Pick &
- Pick & {
+ Pick & {
/**
* Besides existing (default) UX behavior of underlying FlatList of MessageList component, if you want
* to attach some additional props to underlying FlatList, you can add it to following prop.
@@ -150,9 +150,9 @@ type MessageFlashListPropsWithContext = Pick<
/**
* Handler to open the thread on message. This is callback for touch event for replies button.
*
- * @param message A message object to open the thread upon.
+ * @param thread The thread of the message, to render in a ``.
*/
- onThreadSelect?: (message: LocalMessage | null) => void;
+ onThreadSelect?: (thread: Thread) => void;
/**
* Use `setFlatListRef` to get access to ref to inner FlatList.
*
@@ -289,7 +289,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
onThreadSelect,
setFlatListRef,
hasPendingInitialTargetLoad,
- threadInstance,
+ thread,
threadList = false,
} = props;
const {
@@ -310,7 +310,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
messageInputHeightStore.store,
messageInputHeightStoreSelector,
);
- const paginator = threadList ? threadInstance?.messagePaginator : channel.messagePaginator;
+ const paginator = threadList ? thread?.messagePaginator : channel.messagePaginator;
const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false);
const [scrollEnabled, setScrollEnabled] = useState(true);
@@ -922,7 +922,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => {
const markRead = useMarkRead(channel);
const { client } = useChatContext();
const { disableTypingIndicator, myMessageTheme } = useMessagesContext();
- const { threadInstance } = useThreadContext();
+ const { thread } = useThreadContext();
const { readEvents } = useOwnCapabilitiesContext();
const pendingUploadsEnabled = usePendingUploadsEnabled();
const { messageInputFloating, messageInputHeightStore } = useMessageInputContext();
@@ -944,7 +944,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => {
pendingUploadsEnabled,
readEvents,
hasPendingInitialTargetLoad,
- threadInstance,
+ thread,
threadList,
}}
{...props}
diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx
index de44bd744b..ff261bb754 100644
--- a/package/src/components/MessageList/MessageList.tsx
+++ b/package/src/components/MessageList/MessageList.tsx
@@ -14,7 +14,7 @@ import Animated from 'react-native-reanimated';
import debounce from 'lodash/debounce';
-import type { LocalMessage } from 'stream-chat';
+import type { LocalMessage, Thread } from 'stream-chat';
import { useMarkRead } from './hooks/useMarkRead';
import { useMessageList } from './hooks/useMessageList';
@@ -189,7 +189,7 @@ type MessageListPropsWithContext = Pick<
markRead: (options?: MarkReadFunctionOptions) => void;
} & Pick &
Pick &
- Pick & {
+ Pick & {
/**
* Whether the composer lets a message be sent while its attachments are still uploading
* (`messageComposer.attachments.pendingUploadsEnabled`). Read from the composer by default.
@@ -236,9 +236,9 @@ type MessageListPropsWithContext = Pick<
/**
* Handler to open the thread on message. This is callback for touch event for replies button.
*
- * @param message A message object to open the thread upon.
+ * @param thread The thread of the message, to render in a ``.
*/
- onThreadSelect?: (message: LocalMessage | null) => void;
+ onThreadSelect?: (thread: Thread) => void;
/**
* Use `setFlatListRef` to get access to ref to inner FlatList.
*
@@ -302,7 +302,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
onThreadSelect,
readEvents,
setFlatListRef,
- threadInstance,
+ thread,
threadList = false,
} = props;
const {
@@ -323,10 +323,10 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
messageInputHeightStore.store,
messageInputHeightStoreSelector,
);
- const paginator = threadList ? threadInstance?.messagePaginator : channel.messagePaginator;
+ const paginator = threadList ? thread?.messagePaginator : channel.messagePaginator;
useIncomingMessageAnnouncements({
- activeThreadId: threadInstance?.id,
+ activeThreadId: thread?.id,
channel,
ownUserId: client.user?.id,
threadList,
@@ -975,7 +975,7 @@ export const MessageList = (props: MessageListProps) => {
const { disableTypingIndicator, FlatList, myMessageTheme } = useMessagesContext();
const pendingUploadsEnabled = usePendingUploadsEnabled();
const { messageInputFloating, messageInputHeightStore } = useMessageInputContext();
- const { threadInstance } = useThreadContext();
+ const { thread } = useThreadContext();
return (
{
myMessageTheme,
pendingUploadsEnabled,
readEvents,
- threadInstance,
+ thread,
threadList,
}}
{...props}
diff --git a/package/src/components/MessageList/TypingIndicatorContainer.tsx b/package/src/components/MessageList/TypingIndicatorContainer.tsx
index 32923923af..680562d9ba 100644
--- a/package/src/components/MessageList/TypingIndicatorContainer.tsx
+++ b/package/src/components/MessageList/TypingIndicatorContainer.tsx
@@ -57,14 +57,11 @@ export type TypingIndicatorContainerProps = PropsWithChildren<
export const TypingIndicatorContainer = (props: TypingIndicatorContainerProps) => {
const { channel } = useChannelContext();
const { client } = useChatContext();
- const { threadInstance } = useThreadContext();
+ const { thread } = useThreadContext();
const { typing } = useStateStore(channel.state, typingSelector) ?? { typing: {} };
return (
-
+
);
};
diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx
index e4febdd8ff..a0fd4dd37e 100644
--- a/package/src/components/MessageList/__tests__/MessageList.test.tsx
+++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx
@@ -1250,7 +1250,7 @@ describe('MessageList in a thread', () => {
const utils = render(
-
+
diff --git a/package/src/components/MessageList/hooks/useTypingUsers.ts b/package/src/components/MessageList/hooks/useTypingUsers.ts
index 3f99d10160..6a74d547c4 100644
--- a/package/src/components/MessageList/hooks/useTypingUsers.ts
+++ b/package/src/components/MessageList/hooks/useTypingUsers.ts
@@ -28,7 +28,7 @@ export const useChannelTypingUsers = (channel: Channel, threadId?: string) => {
*/
export const useTypingUsers = () => {
const { channel } = useChannelContext();
- const { threadInstance } = useThreadContext();
+ const { thread } = useThreadContext();
- return useChannelTypingUsers(channel, threadInstance?.id);
+ return useChannelTypingUsers(channel, thread?.id);
};
diff --git a/package/src/components/Notifications/hooks/__tests__/useNotificationTarget.test.tsx b/package/src/components/Notifications/hooks/__tests__/useNotificationTarget.test.tsx
index 54edb0f3d3..749a04f18d 100644
--- a/package/src/components/Notifications/hooks/__tests__/useNotificationTarget.test.tsx
+++ b/package/src/components/Notifications/hooks/__tests__/useNotificationTarget.test.tsx
@@ -58,7 +58,7 @@ describe('useNotificationTarget', () => {
it('prioritizes thread over channel', () => {
const wrapper = ({ children }: PropsWithChildren) => (
- {children}
+ {children}
);
diff --git a/package/src/components/Notifications/hooks/useNotificationTarget.ts b/package/src/components/Notifications/hooks/useNotificationTarget.ts
index 0e87d193d7..4e4240d4d4 100644
--- a/package/src/components/Notifications/hooks/useNotificationTarget.ts
+++ b/package/src/components/Notifications/hooks/useNotificationTarget.ts
@@ -28,7 +28,7 @@ export const useNotificationTarget = (): NotificationTargetPanel | undefined =>
const threadsContext = useContext(ThreadsContext) as ThreadsContextValue;
if (isProvided(channelContext) && channelContext.threadList) return 'thread';
- if (isProvided(threadContext) && threadContext.threadInstance) return 'thread';
+ if (isProvided(threadContext) && threadContext.thread) return 'thread';
if (isProvided(channelContext) && channelContext.channel) return 'channel';
if (isProvided(threadsContext)) return 'thread-list';
if (isProvided(channelsContext)) return 'channel-list';
diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx
index 2c77ae5b90..8ee6aa916f 100644
--- a/package/src/components/Thread/Thread.tsx
+++ b/package/src/components/Thread/Thread.tsx
@@ -29,7 +29,7 @@ try {
}
type ThreadPropsWithContext = Pick &
- Pick &
+ Pick &
Pick & {
/**
* Additional props for underlying MessageComposer component.
@@ -91,7 +91,7 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => {
onThreadDismount,
notificationHostId: notificationHostIdProp,
parentMessagePreventPress = true,
- threadInstance,
+ thread,
shouldUseFlashList = false,
} = props;
const {
@@ -101,42 +101,42 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => {
} = useComponentsContext();
const { hasItems, isLoading, lastQueryError } =
- useStateStore(threadInstance?.messagePaginator?.state, paginatorSelector) ?? {};
+ useStateStore(thread?.messagePaginator?.state, paginatorSelector) ?? {};
const { isStateStale, isThreadReloading } =
- useStateStore(threadInstance?.state, threadStaleSelector) ?? {};
- const threadId = threadInstance?.id;
+ useStateStore(thread?.state, threadStaleSelector) ?? {};
+ const threadId = thread?.id;
useEffect(() => {
- if (threadInstance && isStateStale) {
- void threadInstance.reload().catch((err) => console.warn('Thread reload failed', err));
+ if (thread && isStateStale) {
+ void thread.reload().catch((err) => console.warn('Thread reload failed', err));
}
- }, [isStateStale, threadInstance]);
+ }, [isStateStale, thread]);
// Activating registers the thread with `client.threads` for the session, which keeps it subscribed
// (incoming replies, read state, thread.updated) whether or not the thread list holds it. Keyed on
// the instance, which can arrive after mount; `activate()` returns the release of this activation.
- useEffect(() => threadInstance?.activate(), [threadInstance]);
+ useEffect(() => thread?.activate(), [thread]);
// Load the first reply page, but only when the paginator hasn't already been seeded from the
// thread's `latest_replies` (managed/queried threads seed on construction) or loaded/loading. A
// seeded paginator already holds its first page, so we skip the fetch and let scroll-up load older
// replies — mirroring stream-chat-react, whose thread list has no mount-time fetch. Reactive on
- // `threadInstance`/`hasItems` because the instance can arrive after mount; the `hasItems`
+ // `thread`/`hasItems` because the instance can arrive after mount; the `hasItems`
// guard makes this fire at most once (an unseeded thread fetches, and the fetch defines `items`).
useEffect(() => {
- if (!threadInstance || isLoading || hasItems || lastQueryError) {
+ if (!thread || isLoading || hasItems || lastQueryError) {
return;
}
// A reload in flight (a stale thread's, started by the effect above in this same commit) seeds the
// replies itself; read live, as this commit's render predates it. If it fails, this runs again.
- if (isThreadReloading || threadInstance.state.getLatestValue().isLoading) {
+ if (isThreadReloading || thread.state.getLatestValue().isLoading) {
return;
}
- void threadInstance.messagePaginator.toTail();
+ void thread.messagePaginator.toTail();
// `lastQueryError` is load-bearing here, not decorative: a failed query flips `isLoading` back to
// false with `hasItems` still false, which would re-run this effect and refetch forever. The
// retry is the user's to make, through the error indicator below.
- }, [threadInstance, hasItems, isLoading, lastQueryError, isThreadReloading]);
+ }, [thread, hasItems, isLoading, lastQueryError, isThreadReloading]);
// Deactivation is the activation effect's cleanup; this only notifies the integrator.
useEffect(
@@ -172,7 +172,7 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => {
// the reply paginator, and only this component knows how to retry it (a first-page `toTail`).
if (lastQueryError && !hasItems) {
const retry = () =>
- threadInstance.messagePaginator
+ thread.messagePaginator
.toTail()
.catch((err: unknown) => console.warn('Reloading the thread replies failed:', err));
@@ -219,9 +219,9 @@ export type ThreadProps = Partial;
export const Thread = (props: ThreadProps) => {
const { client } = useChatContext();
const { threadList } = useChannelContext();
- const { threadInstance } = useThreadContext();
+ const { thread } = useThreadContext();
- if (threadInstance?.id && !threadList) {
+ if (thread?.id && !threadList) {
throw new Error(
'Please add a threadList prop to your Channel component when rendering a thread list. Check our Channel documentation for more info: https://getstream.io/chat/docs/sdk/reactnative/core-components/channel/#threadlist',
);
@@ -231,7 +231,7 @@ export const Thread = (props: ThreadProps) => {
diff --git a/package/src/components/Thread/__tests__/Thread.test.tsx b/package/src/components/Thread/__tests__/Thread.test.tsx
index 8aef0c197c..e06055e8f6 100644
--- a/package/src/components/Thread/__tests__/Thread.test.tsx
+++ b/package/src/components/Thread/__tests__/Thread.test.tsx
@@ -3,7 +3,6 @@ import React from 'react';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
import type {
Channel as ChannelType,
- LocalMessage,
MessageResponse,
StreamChat,
UserResponse,
@@ -38,7 +37,7 @@ const renderComponent = ({
channel: ChannelType;
chatClient: StreamChat;
props?: Partial>;
- thread: LocalMessage | { thread: LocalMessage; threadInstance: ThreadClass };
+ thread: ThreadClass;
}) => {
return render(
@@ -89,7 +88,7 @@ describe('Thread', () => {
isLastPage: true,
});
- renderComponent({ channel, chatClient, thread: { thread, threadInstance } });
+ renderComponent({ channel, chatClient, thread: threadInstance });
const { getAllByText, getByText, queryByText } = screen;
@@ -170,7 +169,7 @@ describe('Thread', () => {
['value']}
>
-
+
@@ -218,7 +217,7 @@ describe('Thread', () => {
const { unmount } = renderComponent({
channel,
chatClient,
- thread: { thread: parentMessage, threadInstance },
+ thread: threadInstance,
});
await waitFor(() => expect(threadInstance.state.getLatestValue().active).toBe(true));
@@ -227,15 +226,12 @@ describe('Thread', () => {
});
describe('metadata reload on open', () => {
- const openThread = (
- threadInstance: ThreadClass,
- parentMessage: ReturnType,
- ) => {
+ const openThread = (threadInstance: ThreadClass) => {
const reload = jest.spyOn(threadInstance, 'reload').mockResolvedValue(undefined);
const toTail = jest
.spyOn(threadInstance.messagePaginator, 'toTail')
.mockResolvedValue(undefined);
- renderComponent({ channel, chatClient, thread: { thread: parentMessage, threadInstance } });
+ renderComponent({ channel, chatClient, thread: threadInstance });
return { reload, toTail };
};
@@ -246,7 +242,7 @@ describe('Thread', () => {
text: 'Parent',
});
const threadInstance = chatClient.threads.ensure({ channel, parentMessage });
- const { reload } = openThread(threadInstance, parentMessage);
+ const { reload } = openThread(threadInstance);
await waitFor(() => expect(reload).toHaveBeenCalled());
});
@@ -267,7 +263,7 @@ describe('Thread', () => {
const toTail = jest
.spyOn(threadInstance.messagePaginator, 'toTail')
.mockResolvedValue(undefined);
- renderComponent({ channel, chatClient, thread: { thread: parentMessage, threadInstance } });
+ renderComponent({ channel, chatClient, thread: threadInstance });
await waitFor(() => expect(threadInstance.reload).toHaveBeenCalled());
expect(toTail).not.toHaveBeenCalled();
@@ -296,7 +292,7 @@ describe('Thread', () => {
valueOrFactory: [threadInstance],
});
});
- const { reload, toTail } = openThread(threadInstance, parentMessage);
+ const { reload, toTail } = openThread(threadInstance);
// The first-page fetch runs from the same mount, so its call marks the effects as settled.
await waitFor(() => expect(toTail).toHaveBeenCalled());
@@ -313,11 +309,11 @@ describe('Thread', () => {
client: chatClient,
parentMessage,
});
- return { parentMessage, threadInstance };
+ return threadInstance;
};
it('renders the error indicator when the reply query failed and nothing loaded', async () => {
- const { parentMessage, threadInstance } = makeThread();
+ const threadInstance = makeThread();
// A failed first page leaves `items` undefined — the error belongs to the THREAD's paginator,
// so must not be the one showing it.
jest
@@ -329,7 +325,7 @@ describe('Thread', () => {
});
});
- renderComponent({ channel, chatClient, thread: { thread: parentMessage, threadInstance } });
+ renderComponent({ channel, chatClient, thread: threadInstance });
await waitFor(() => {
expect(screen.getByTestId('loading-error')).toBeTruthy();
@@ -337,7 +333,7 @@ describe('Thread', () => {
});
it('retries the reply query — and only the reply query — from the indicator', async () => {
- const { parentMessage, threadInstance } = makeThread();
+ const threadInstance = makeThread();
const toTail = jest
.spyOn(threadInstance.messagePaginator, 'toTail')
.mockRejectedValue(new Error('replies boom'));
@@ -348,7 +344,7 @@ describe('Thread', () => {
});
});
- renderComponent({ channel, chatClient, thread: { thread: parentMessage, threadInstance } });
+ renderComponent({ channel, chatClient, thread: threadInstance });
const indicator = await waitFor(() => screen.getByTestId('loading-error'));
// Clear AFTER mount: seeds its own list on open, which is not what this asserts.
@@ -365,7 +361,7 @@ describe('Thread', () => {
});
it('does not refetch in a loop while the query is failing', async () => {
- const { parentMessage, threadInstance } = makeThread();
+ const threadInstance = makeThread();
// The first-page effect reruns whenever `isLoading` settles back to false. Without the
// `lastQueryError` guard it would refetch forever and the indicator could never stay up.
const toTail = jest
@@ -378,7 +374,7 @@ describe('Thread', () => {
return Promise.resolve();
});
- renderComponent({ channel, chatClient, thread: { thread: parentMessage, threadInstance } });
+ renderComponent({ channel, chatClient, thread: threadInstance });
await waitFor(() => {
expect(screen.getByTestId('loading-error')).toBeTruthy();
@@ -387,14 +383,14 @@ describe('Thread', () => {
});
it('keeps the reply list when a later query fails but replies are already loaded', async () => {
- const { parentMessage, threadInstance } = makeThread();
+ const threadInstance = makeThread();
act(() => {
threadInstance.messagePaginator.setItems({
valueOrFactory: [
channel.state.formatMessage(
generateMessage({
cid: 'messaging:test-channel',
- parent_id: parentMessage.id,
+ parent_id: threadInstance.id,
text: 'Loaded reply',
}) as unknown as MessageResponse,
),
@@ -407,7 +403,7 @@ describe('Thread', () => {
});
});
- renderComponent({ channel, chatClient, thread: { thread: parentMessage, threadInstance } });
+ renderComponent({ channel, chatClient, thread: threadInstance });
await waitFor(() => {
expect(screen.getByText('Loaded reply')).toBeTruthy();
diff --git a/package/src/components/Thread/components/ThreadFooterComponent.tsx b/package/src/components/Thread/components/ThreadFooterComponent.tsx
index f7500d32e7..87dfbf7fb8 100644
--- a/package/src/components/Thread/components/ThreadFooterComponent.tsx
+++ b/package/src/components/Thread/components/ThreadFooterComponent.tsx
@@ -13,7 +13,7 @@ import { useTranslationContext } from '../../../contexts/translationContext/Tran
import { useStateStore } from '../../../hooks';
import { primitives } from '../../../theme';
-type ThreadFooterComponentPropsWithContext = Pick & {
+type ThreadFooterComponentPropsWithContext = Pick & {
/**
* Boolean to enable/disable parent message press
*/
@@ -23,13 +23,12 @@ type ThreadFooterComponentPropsWithContext = Pick ({ isLoading: state.isLoading });
export const InlineLoadingMoreThreadIndicator = () => {
- const { threadInstance } = useThreadContext();
+ const { thread } = useThreadContext();
const {
theme: { semantics },
} = useTheme();
const styles = useStyles();
- const { isLoading } =
- useStateStore(threadInstance?.messagePaginator?.state, loadingSelector) ?? {};
+ const { isLoading } = useStateStore(thread?.messagePaginator?.state, loadingSelector) ?? {};
if (!isLoading) {
return null;
@@ -49,7 +48,7 @@ const selector = (nextValue: ThreadState) =>
}) as const;
const ThreadFooterComponentWithContext = (props: ThreadFooterComponentPropsWithContext) => {
- const { parentMessagePreventPress, threadInstance } = props;
+ const { parentMessagePreventPress, thread } = props;
const { Message } = useComponentsContext();
const { t } = useTranslationContext();
@@ -58,7 +57,7 @@ const ThreadFooterComponentWithContext = (props: ThreadFooterComponentPropsWithC
// The parent message is read reactively from the thread instance so the header updates live when
// it is edited / reacted to / its reply_count changes.
const { parentMessage, replyCount = parentMessage?.reply_count } =
- useStateStore(threadInstance?.state, selector) ?? {};
+ useStateStore(thread?.state, selector) ?? {};
if (!parentMessage) {
return null;
@@ -91,21 +90,16 @@ const areEqual = (
prevProps: ThreadFooterComponentPropsWithContext,
nextProps: ThreadFooterComponentPropsWithContext,
) => {
- // The parent message + reply count are read reactively from `threadInstance.state` inside the
+ // The parent message + reply count are read reactively from `thread.state` inside the
// component (via useStateStore), so this memo only needs to gate parent-driven re-renders on the
// remaining props: the prevent-press flag and the thread instance identity.
- const {
- parentMessagePreventPress: prevParentMessagePreventPress,
- threadInstance: prevThreadInstance,
- } = prevProps;
- const {
- parentMessagePreventPress: nextParentMessagePreventPress,
- threadInstance: nextThreadInstance,
- } = nextProps;
+ const { parentMessagePreventPress: prevParentMessagePreventPress, thread: prevThread } =
+ prevProps;
+ const { parentMessagePreventPress: nextParentMessagePreventPress, thread: nextThread } =
+ nextProps;
return (
- prevParentMessagePreventPress === nextParentMessagePreventPress &&
- prevThreadInstance === nextThreadInstance
+ prevParentMessagePreventPress === nextParentMessagePreventPress && prevThread === nextThread
);
};
@@ -119,9 +113,9 @@ export type ThreadFooterComponentProps = Partial<
>;
export const ThreadFooterComponent = (props: ThreadFooterComponentProps) => {
- const { threadInstance } = useThreadContext();
+ const { thread } = useThreadContext();
- return ;
+ return ;
};
const useStyles = () => {
diff --git a/package/src/components/ThreadList/ThreadListItem.tsx b/package/src/components/ThreadList/ThreadListItem.tsx
index 179db9395d..5df81a7273 100644
--- a/package/src/components/ThreadList/ThreadListItem.tsx
+++ b/package/src/components/ThreadList/ThreadListItem.tsx
@@ -82,10 +82,7 @@ export const ThreadListItemComponent = () => {
{
if (onThreadSelect) {
- onThreadSelect(
- { thread: parentMessage as LocalMessage, threadInstance: thread },
- channel,
- );
+ onThreadSelect(thread);
}
}}
style={({ pressed }) => [
diff --git a/package/src/contexts/messageComposerContext/MessageComposerContext.tsx b/package/src/contexts/messageComposerContext/MessageComposerContext.tsx
index 380b9901b9..fe1fe0d624 100644
--- a/package/src/contexts/messageComposerContext/MessageComposerContext.tsx
+++ b/package/src/contexts/messageComposerContext/MessageComposerContext.tsx
@@ -17,7 +17,7 @@ import { isTestEnvironment } from '../utils/isTestEnvironment';
export type MessageComposerContextValue = {
channel: ChannelProps['channel'];
- threadInstance: ThreadContextValue['threadInstance'];
+ thread: ThreadContextValue['thread'];
/**
* The composer in use: the edit composer while a message is being edited, otherwise the thread's
* or the channel's own.
@@ -34,7 +34,7 @@ export const MessageComposerContext = React.createContext(
);
type Props = React.PropsWithChildren<{
- value: Pick;
+ value: Pick;
}>;
const editedMessageSelector = (state: MessageComposerState) => ({
@@ -43,11 +43,10 @@ const editedMessageSelector = (state: MessageComposerState) => ({
export const MessageComposerProvider = ({ children, value }: Props) => {
const { client } = useChatContext();
- const { channel, threadInstance } = value;
+ const { channel, thread } = value;
const [editComposer, setEditComposer] = useState(undefined);
- const messageComposer =
- editComposer ?? threadInstance?.messageComposer ?? channel.messageComposer;
+ const messageComposer = editComposer ?? thread?.messageComposer ?? channel.messageComposer;
// Submitting or clearing the edit composer clears its edited message, which ends the edit.
const { editedMessage } = useStateStore(editComposer?.state, editedMessageSelector) ?? {};
@@ -93,8 +92,8 @@ export const MessageComposerProvider = ({ children, value }: Props) => {
);
const messageComposerContextValue = useMemo(
- () => ({ channel, editing: editedMessage ?? undefined, messageComposer, threadInstance }),
- [channel, editedMessage, messageComposer, threadInstance],
+ () => ({ channel, editing: editedMessage ?? undefined, messageComposer, thread }),
+ [channel, editedMessage, messageComposer, thread],
);
const messageComposerAPIContextValue = useMemo(
diff --git a/package/src/contexts/messageComposerContext/__tests__/MessageComposerContext.test.tsx b/package/src/contexts/messageComposerContext/__tests__/MessageComposerContext.test.tsx
index f1fa553241..a86c6f58ea 100644
--- a/package/src/contexts/messageComposerContext/__tests__/MessageComposerContext.test.tsx
+++ b/package/src/contexts/messageComposerContext/__tests__/MessageComposerContext.test.tsx
@@ -27,7 +27,7 @@ describe('MessageComposerProvider', () => {
const wrapper = ({ children }: PropsWithChildren) => (
-
+
{children}
@@ -64,7 +64,7 @@ describe('MessageComposerProvider', () => {
render(
-
+
{Array.from({ length: 5 }, (_, i) => (
))}
diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx
index 21135c6e1a..7a5194837d 100644
--- a/package/src/contexts/messageContext/MessageContext.tsx
+++ b/package/src/contexts/messageContext/MessageContext.tsx
@@ -1,6 +1,6 @@
import React, { PropsWithChildren, useContext } from 'react';
-import type { Attachment, LocalMessage } from 'stream-chat';
+import type { Attachment, LocalMessage, Thread } from 'stream-chat';
import type { ActionHandler } from '../../components/Attachment/Attachment';
import type { ReactionSummary } from '../../components/Message/hooks/useProcessReactions';
@@ -142,13 +142,10 @@ export type MessageContextValue = {
showAvatar?: boolean;
/**
* Function to handle thread select
- * @param message - The message to select
- * @param targetedMessageId - The id of the targeted message
+ * @param thread - The thread of the message
* @returns void
- *
- * TODO: V9: Change function params to an object
*/
- onThreadSelect?: (message: LocalMessage, targetedMessageId?: string) => void;
+ onThreadSelect?: (thread: Thread) => void;
} & Pick &
Pick;
diff --git a/package/src/contexts/messageInputContext/__tests__/sendMessage.test.tsx b/package/src/contexts/messageInputContext/__tests__/sendMessage.test.tsx
index bdc6f957b1..d1b418224f 100644
--- a/package/src/contexts/messageInputContext/__tests__/sendMessage.test.tsx
+++ b/package/src/contexts/messageInputContext/__tests__/sendMessage.test.tsx
@@ -55,7 +55,7 @@ describe("MessageInputContext's sendMessage", () => {
{ sendLinks: true, sendMessage: true, ...capabilities } as OwnCapabilitiesContextValue
}
>
-
+
{children}
diff --git a/package/src/contexts/threadContext/ThreadContext.tsx b/package/src/contexts/threadContext/ThreadContext.tsx
index 1fd4dfbf12..9e18f2b616 100644
--- a/package/src/contexts/threadContext/ThreadContext.tsx
+++ b/package/src/contexts/threadContext/ThreadContext.tsx
@@ -1,23 +1,22 @@
import React, { PropsWithChildren, useContext } from 'react';
-import { LocalMessage, Thread } from 'stream-chat';
+import { Thread } from 'stream-chat';
import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue';
import { isTestEnvironment } from '../utils/isTestEnvironment';
-export type ThreadType = { thread: LocalMessage; threadInstance: Thread };
export type AlsoSentToChannelHeaderPressPayload = {
- parentMessage?: LocalMessage;
targetedMessageId: string;
+ thread?: Thread;
};
export type ThreadContextValue = {
allowThreadMessagesInChannel: boolean;
- threadInstance?: Thread | null;
+ thread?: Thread | null;
/**
* Function to handle press on the "Also sent to channel" header action.
- * @param payload - Navigation payload with optional parent thread message and targeted message id
+ * @param payload - Navigation payload with the targeted message id and, from a channel list, its thread
* @returns void
*/
onAlsoSentToChannelHeaderPress?: (payload: AlsoSentToChannelHeaderPressPayload) => void;
diff --git a/package/src/contexts/threadsContext/ThreadsContext.tsx b/package/src/contexts/threadsContext/ThreadsContext.tsx
index d23aeeb1f9..80cffa5a67 100644
--- a/package/src/contexts/threadsContext/ThreadsContext.tsx
+++ b/package/src/contexts/threadsContext/ThreadsContext.tsx
@@ -2,9 +2,8 @@ import React, { PropsWithChildren, useContext } from 'react';
import { FlatListProps } from 'react-native';
-import { Channel, Thread } from 'stream-chat';
+import { Thread } from 'stream-chat';
-import { ThreadType } from '../threadContext/ThreadContext';
import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue';
import { isTestEnvironment } from '../utils/isTestEnvironment';
@@ -16,7 +15,7 @@ export type ThreadsContextValue = {
threads: Thread[];
additionalFlatListProps?: Partial>;
loadMore?: () => Promise;
- onThreadSelect?: (thread: ThreadType, channel: Channel) => void;
+ onThreadSelect?: (thread: Thread) => void;
};
export const ThreadsContext = React.createContext(
diff --git a/package/src/hooks/useChannel.ts b/package/src/hooks/useChannel.ts
index 7e11d3f720..29261950d7 100644
--- a/package/src/hooks/useChannel.ts
+++ b/package/src/hooks/useChannel.ts
@@ -13,7 +13,7 @@ import { useThreadContext } from '../contexts/threadContext/ThreadContext';
*/
export const useChannel = (): Channel => {
const { channel } = useChannelContext();
- const { threadInstance } = useThreadContext();
+ const { thread } = useThreadContext();
- return threadInstance?.channel ?? channel;
+ return thread?.channel ?? channel;
};
diff --git a/package/src/hooks/useMessagePaginator.ts b/package/src/hooks/useMessagePaginator.ts
index 6db8fbd990..5e19fcb854 100644
--- a/package/src/hooks/useMessagePaginator.ts
+++ b/package/src/hooks/useMessagePaginator.ts
@@ -12,7 +12,7 @@ import { useThreadContext } from '../contexts/threadContext/ThreadContext';
*/
export const useMessagePaginator = () => {
const { channel, threadList } = useChannelContext();
- const { threadInstance } = useThreadContext();
+ const { thread } = useThreadContext();
- return threadList ? threadInstance?.messagePaginator : channel.messagePaginator;
+ return threadList ? thread?.messagePaginator : channel.messagePaginator;
};
diff --git a/yarn.lock b/yarn.lock
index 8b785e0e56..5252183379 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6610,7 +6610,7 @@ __metadata:
react-native-teleport: "npm:^1.1.12"
react-native-web: "npm:^0.21.2"
react-native-worklets: "npm:0.11.1"
- stream-chat: "npm:^10.0.0-rc.20"
+ stream-chat: "npm:^10.0.0-rc.22"
stream-chat-expo: "workspace:^"
stream-chat-react-native-core: "workspace:^"
typescript: "npm:6.0.3"
@@ -17608,7 +17608,7 @@ __metadata:
react-native-teleport: "npm:^1.1.12"
react-native-video: "npm:^6.19.2"
react-native-worklets: "npm:^0.12.1"
- stream-chat: "npm:^10.0.0-rc.20"
+ stream-chat: "npm:^10.0.0-rc.22"
stream-chat-react-native: "workspace:^"
stream-chat-react-native-core: "workspace:^"
typescript: "npm:6.0.3"
@@ -18352,7 +18352,7 @@ __metadata:
react-native-worklets: "npm:^0.12.1"
react-test-renderer: "npm:19.2.3"
rimraf: "npm:^6.0.1"
- stream-chat: "npm:^10.0.0-rc.20"
+ stream-chat: "npm:^10.0.0-rc.22"
typescript: "npm:6.0.3"
use-sync-external-store: "npm:^1.7.0"
uuid: "npm:^11.1.0"
@@ -18426,9 +18426,9 @@ __metadata:
languageName: unknown
linkType: soft
-"stream-chat@npm:^10.0.0-rc.20":
- version: 10.0.0-rc.20
- resolution: "stream-chat@npm:10.0.0-rc.20"
+"stream-chat@npm:^10.0.0-rc.22":
+ version: 10.0.0-rc.22
+ resolution: "stream-chat@npm:10.0.0-rc.22"
dependencies:
"@stream-io/logger": "npm:^2.0.0"
"@stream-io/state-store": "npm:^1.1.6"
@@ -18439,7 +18439,7 @@ __metadata:
built: true
husky:
built: true
- checksum: 10c0/6fa312d0a2fdb62c5493e5f97d188e15a142d351e5fcae77d9363fde5444d68a47f6206ec05fc4518aceb44a701a6b285b257cd1659e73391418f24d693188f9
+ checksum: 10c0/8c1e1006c022d3433c7df5806c84d5f7dd4b487d3c973ab90d017cdaae7f57d7b58d45c0e6ce884861c0bfe631fe6a5efa2bdb28e727b0dbc272d80e3bea0ff2
languageName: node
linkType: hard