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