diff --git a/ai-docs/ai-migration-v9-to-v10.md b/ai-docs/ai-migration-v9-to-v10.md new file mode 100644 index 0000000000..7cf15005ee --- /dev/null +++ b/ai-docs/ai-migration-v9-to-v10.md @@ -0,0 +1,193 @@ +# stream-chat-react-native v9 → v10 — Agent Migration Guide + +> Machine-oriented migration reference for AI coding agents, mirroring the style +> of `ai-migration.md` (v8 → v9). **WORK IN PROGRESS.** v10 adopts the +> `stream-chat` v10 reactive state layer (instance-owned `StateStore`s + +> `messagePaginator`). This file currently documents the **context-teardown +> breaking changes** that have shipped; more v10 changes (optimistic-ops +> migration, targeting, unread-state) are not yet landed and will be appended. + +## 0. For the agent (read first) + +1. **Your training data predates v10.** Do not rely on memory for v10 symbols or + export paths. Verify against the installed SDK source under + `node_modules/stream-chat-react-native-core/src/`. +2. **The unifying idea:** state that used to be copied into React context is now + read reactively from `stream-chat` instance stores via the SDK hook + `useStateStore(store, selector)`. The channel exposes per-concern stores on + `channel.state.*` and a message paginator on `channel.messagePaginator`. +3. **Resolution hooks** (already present since early v10): + - `useChannelContext().channel` — the active `Channel` instance. + - `useMessagePaginator()` — `threadInstance?.messagePaginator ?? channel.messagePaginator`. + - `useStateStore(store, selector)` — subscribe to a `StateStore` with a + memo-stable selector (return a stable object; do not allocate fresh arrays + inside the selector). +4. **Detect before editing.** Run §1; skip any section with zero hits. + +## 1. Detection (run first) + +Run each ripgrep against the customer's app source root. Zero hits = skip. + +```bash +# §2 — TypingContext removed +rg '\b(useTypingContext|TypingProvider|TypingContext|TypingContextValue|useCreateTypingContext)\b' src/ + +# §3 — ChannelContext scalar fields removed +rg 'useChannelContext\(\)' -A6 src/ | rg '\b(members|read|watchers|watcherCount)\b' + +# §4 — PaginatedMessageListContext removed +rg '\b(usePaginatedMessageListContext|PaginatedMessageListProvider|PaginatedMessageListContextValue|useCreatePaginatedMessageListContext)\b' src/ + +# §5 — custom FooterComponent / HeaderComponent reading loadingMore from context +rg '\b(FooterComponent|HeaderComponent)\b' src/ +``` + +## 2. `TypingContext` removed → `channel.state.typingStore` + +Removed symbols: `TypingContext`, `TypingProvider`, `useTypingContext`, +`TypingContextValue`, `useCreateTypingContext`. + +**Before (v9):** + +```tsx +import { useTypingContext } from 'stream-chat-react-native'; + +const { typing } = useTypingContext(); +``` + +**After (v10):** + +```tsx +import { useChannelContext, useStateStore } from 'stream-chat-react-native'; +import type { TypingUsersState } from 'stream-chat'; + +const typingSelector = (state: TypingUsersState) => ({ typing: state.typing }); + +const { channel } = useChannelContext(); +const { typing } = useStateStore(channel.state.typingStore, typingSelector) ?? { typing: {} }; +``` + +`typing` has the same shape as before (`Record`). + +## 3. `ChannelContext` scalar state fields removed → `channel.state.*Store` + +`useChannelContext()` no longer returns `members`, `read`, `watchers`, or +`watcherCount`. Read them from the per-channel stores. + +| Removed context field | Replacement store | Store state key | +|---|---|---| +| `members` | `channel.state.membersStore` | `members` | +| `read` | `channel.state.readStore` | `read` | +| `watchers` | `channel.state.watcherStore` | `watchers` | +| `watcherCount` | `channel.state.watcherStore` | `watcherCount` | + +**Before (v9):** + +```tsx +const { members, watcherCount } = useChannelContext(); +``` + +**After (v10):** + +```tsx +import { useChannelContext, useStateStore } from 'stream-chat-react-native'; +import type { MembersState, WatcherState } from 'stream-chat'; + +const membersSelector = (s: MembersState) => ({ members: s.members }); +const watcherSelector = (s: WatcherState) => ({ + watcherCount: s.watcherCount, + watchers: s.watchers, +}); + +const { channel } = useChannelContext(); +const { members } = useStateStore(channel.state.membersStore, membersSelector) ?? { members: {} }; +const { watcherCount } = useStateStore(channel.state.watcherStore, watcherSelector) ?? {}; +``` + +Note: `members` remains available on the per-message `MessageContext` +(`useMessageContext().members`) for message-level consumers such as a custom +message footer — only the **channel-level** `ChannelContext.members` was removed. + +## 4. `PaginatedMessageListContext` removed → `useMessagePaginator()` / `channel.messagePaginator` + +Removed symbols: `PaginatedMessageListContext`, `PaginatedMessageListProvider`, +`usePaginatedMessageListContext`, `PaginatedMessageListContextValue`, +`useCreatePaginatedMessageListContext`. + +The message-list state (messages / hasMore / loading) now lives on the paginator. +**Direction mapping** (non-obvious): the paginator is bi-directional — `tail` = +older messages, `head` = newer. + +| Removed context field | Replacement | +|---|---| +| `messages` | `channel.messagePaginator.state.items` | +| `hasMore` (older) | `channel.messagePaginator.state.hasMoreTail` | +| `hasMoreNewer` | `channel.messagePaginator.state.hasMoreHead` | +| `loading` | `channel.messagePaginator.state.isLoading` | +| `loadMore()` (older) | `channel.messagePaginator.toTail()` | +| `loadMoreRecent()` (newer) | `channel.messagePaginator.toHead()` | +| `loadLatestMessages()` | `channel.messagePaginator.jumpToTheLatestMessage()` | + +**Before (v9):** + +```tsx +const { messages, hasMore, loadMore } = usePaginatedMessageListContext(); +``` + +**After (v10):** + +```tsx +import { useChannelContext, useStateStore } from 'stream-chat-react-native'; +import type { LocalMessage } from 'stream-chat'; + +const selector = (s: { items?: LocalMessage[]; hasMoreTail: boolean }) => ({ + messages: s.items, + hasMore: s.hasMoreTail, +}); + +const { channel } = useChannelContext(); +const { messages, hasMore } = useStateStore(channel.messagePaginator.state, selector) ?? {}; +const loadMore = () => channel.messagePaginator.toTail(); +``` + +Use `channel.messagePaginator` for the channel message list. For thread replies, +use `useMessagePaginator()` (thread-aware) — but note the **main** channel list +must read `channel.messagePaginator` directly so it keeps showing channel +messages while a thread is open. + +## 5. Custom `FooterComponent` / `HeaderComponent` receive `loadingMore` as a prop + +The inline loading indicators no longer read `loadingMore` / +`loadingMoreRecent` from `PaginatedMessageListContext`; the list owns those flags +and passes them as props. If you override the message list's loading indicator: + +- **`MessageList`** (inverted): custom `FooterComponent` now receives + `{ loadingMore?: boolean }` as a prop. +- **`MessageFlashList`**: custom `HeaderComponent` now receives + `{ loadingMore?: boolean }` as a prop. + +**Before (v9):** + +```tsx +const MyFooter = () => { + const { loadingMore } = usePaginatedMessageListContext(); + return loadingMore ? : null; +}; +``` + +**After (v10):** + +```tsx +const MyFooter = ({ loadingMore }: { loadingMore?: boolean }) => + loadingMore ? : null; +``` + +No-prop override components remain assignable (the prop is optional), so +overrides that don't read the flag need no change. + +## 6. Verify + +- Typecheck the customer app; the removed symbols surface as + "Property does not exist" / "Cannot find name" errors — fix each per §2–§5. +- Run the app: typing indicator, member/watcher counts, message-list scroll + pagination (both directions), and any custom loading-indicator override. diff --git a/examples/ExpoMessaging/app/channel/[cid]/index.tsx b/examples/ExpoMessaging/app/channel/[cid]/index.tsx index 7db3afd0c3..8d78a92c80 100644 --- a/examples/ExpoMessaging/app/channel/[cid]/index.tsx +++ b/examples/ExpoMessaging/app/channel/[cid]/index.tsx @@ -10,7 +10,6 @@ import { ChannelAvatar, MessageComposer, MessageList, - ThreadContextValue, useChannelPreviewDisplayName, useChatContext, } from 'stream-chat-expo'; @@ -110,7 +109,7 @@ export default function ChannelScreen() { messageId={messageId} > { + onThreadSelect={(thread) => { setThread(thread); router.push(`/channel/${channel.cid}/thread/${thread?.cid ?? ''}`); }} diff --git a/examples/ExpoMessaging/context/AppContext.tsx b/examples/ExpoMessaging/context/AppContext.tsx index f69d4a2171..aeba3e7cf3 100644 --- a/examples/ExpoMessaging/context/AppContext.tsx +++ b/examples/ExpoMessaging/context/AppContext.tsx @@ -1,13 +1,12 @@ import React, { PropsWithChildren, createContext, useState } from 'react'; -import { Channel as ChannelType } from 'stream-chat'; -import { ThreadContextValue } from 'stream-chat-expo'; +import { Channel as ChannelType, LocalMessage } from 'stream-chat'; export type AppContextType = { channel: ChannelType | undefined; setChannel: React.Dispatch>; - setThread: React.Dispatch>; - thread: ThreadContextValue['thread'] | undefined; + setThread: React.Dispatch>; + thread: LocalMessage | null | undefined; }; export const AppContext = createContext({ @@ -19,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/SampleApp/metro.config.js b/examples/SampleApp/metro.config.js index c96816d4be..c3d30e5ce4 100644 --- a/examples/SampleApp/metro.config.js +++ b/examples/SampleApp/metro.config.js @@ -19,6 +19,9 @@ const metroExclusionList = require( const exclusionList = metroExclusionList.default || metroExclusionList; const packageDirPath = PATH.resolve(__dirname, '../../package'); const nativePackageDirPath = PATH.resolve(__dirname, '../../package/native-package'); +// Local checkout of stream-chat-js (portaled via root package.json `resolutions`). +// Metro needs the path explicitly — it does not honor Yarn's portal symlink for native bundling. +const streamChatLocalPath = '/Users/isekovanic/Projects/stream-chat-js'; const symlinked = { 'stream-chat-react-native': nativePackageDirPath, @@ -65,10 +68,13 @@ const uniqueModules = dependencyPackageNames.map((packageName) => { const blockList = uniqueModules.map(({ blockPattern }) => blockPattern); // provide the path for the unique modules -const extraNodeModules = uniqueModules.reduce((acc, item) => { - acc[item.packageName] = item.modulePath; - return acc; -}, {}); +const extraNodeModules = uniqueModules.reduce( + (acc, item) => { + acc[item.packageName] = item.modulePath; + return acc; + }, + { 'stream-chat': streamChatLocalPath }, +); config.resolver.blockList = exclusionList(blockList); config.resolver.extraNodeModules = extraNodeModules; @@ -76,6 +82,6 @@ config.resolver.extraNodeModules = extraNodeModules; config.resolver.nodeModulesPaths = [PATH.resolve(__dirname, 'node_modules')]; // add the package dir for metro to access the package folder -config.watchFolders = [packageDirPath]; +config.watchFolders = [packageDirPath, streamChatLocalPath]; module.exports = config; diff --git a/examples/SampleApp/src/components/UnreadCountBadge.tsx b/examples/SampleApp/src/components/UnreadCountBadge.tsx index 6092ae3b55..38cdab4ae4 100644 --- a/examples/SampleApp/src/components/UnreadCountBadge.tsx +++ b/examples/SampleApp/src/components/UnreadCountBadge.tsx @@ -23,28 +23,31 @@ export const ChannelsUnreadCountBadge: React.FC = () => { const { chatClient } = useAppContext(); const [unreadCount, setUnreadCount] = useState(0); /** - * Listen to changes in unread counts and update the badge count + * Listen to changes in unread counts and update the badge count. + * + * We deliberately derive the total from each channel's local `countUnread()` (a sum over the + * loaded/active channels) rather than the server-sent `event.total_unread_count`. The local count + * respects the "viewing live" gate — the channel the user is currently reading at the bottom stays + * at 0 — so the badge does NOT flicker when a message arrives in the active channel. The server + * total, by contrast, briefly counts that message until the mark-read round-trips. */ useEffect(() => { - const listener = chatClient?.on((e) => { - const event = e.me ?? e; - if (event.total_unread_count !== undefined) { - setUnreadCount(event.total_unread_count); - } else { - if (Object.keys(chatClient?.activeChannels).length > 0) { - const countUnread = Object.values(chatClient.activeChannels).reduce( - (count, channel) => count + channel.countUnread(), - 0, - ); - setUnreadCount(countUnread); - } - } + if (!chatClient) { + return; + } + const computeUnreadCount = () => + Object.values(chatClient.activeChannels).reduce( + (count, channel) => count + (channel?.countUnread() ?? 0), + 0, + ); + + setUnreadCount(computeUnreadCount()); + const listener = chatClient.on(() => { + setUnreadCount(computeUnreadCount()); }); return () => { - if (listener) { - listener.unsubscribe(); - } + listener.unsubscribe(); }; }, [chatClient]); diff --git a/examples/SampleApp/src/context/StreamChatContext.tsx b/examples/SampleApp/src/context/StreamChatContext.tsx index 58fd0627c5..9b968a732a 100644 --- a/examples/SampleApp/src/context/StreamChatContext.tsx +++ b/examples/SampleApp/src/context/StreamChatContext.tsx @@ -1,14 +1,13 @@ import React from 'react'; import { PropsWithChildren, createContext, useState } from 'react'; -import { Channel as ChannelType } from 'stream-chat'; -import { ThreadContextValue } from 'stream-chat-react-native'; +import { Channel as ChannelType, LocalMessage } from 'stream-chat'; export type StreamChatContextType = { channel: ChannelType | undefined; setChannel: React.Dispatch>; - setThread: React.Dispatch>; - thread: ThreadContextValue['thread'] | undefined; + setThread: React.Dispatch>; + thread: LocalMessage | null | undefined; }; export const StreamChatContext = createContext({ @@ -20,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 7b47278b1c..ea938da0ad 100644 --- a/examples/SampleApp/src/screens/ChannelScreen.tsx +++ b/examples/SampleApp/src/screens/ChannelScreen.tsx @@ -11,7 +11,6 @@ import { MessageComposer, MessageList, MessageFlashList, - ThreadContextValue, useAttachmentPickerContext, useChannelPreviewDisplayName, useChatContext, @@ -130,7 +129,7 @@ export const ChannelScreen: React.FC = ({ navigation, route const [channel, setChannel] = useState(channelFromProp); - const [selectedThread, setSelectedThread] = useState(); + const [selectedThread, setSelectedThread] = useState(); useEffect(() => { const initChannel = async () => { diff --git a/examples/TypeScriptMessaging/App.tsx b/examples/TypeScriptMessaging/App.tsx index 99714a0e3c..4f12001690 100644 --- a/examples/TypeScriptMessaging/App.tsx +++ b/examples/TypeScriptMessaging/App.tsx @@ -8,7 +8,7 @@ import { useHeaderHeight } from '@react-navigation/elements'; import { DarkTheme, DefaultTheme, NavigationContainer, RouteProp } from '@react-navigation/native'; import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack'; -import { Channel as ChannelType, ChannelSort } from 'stream-chat'; +import { Channel as ChannelType, ChannelSort, LocalMessage } from 'stream-chat'; import { Channel, ChannelList, @@ -19,7 +19,6 @@ import { SqliteClient, Streami18n, Thread, - ThreadContextValue, useCreateChatClient, useOverlayContext, } from 'stream-chat-react-native'; @@ -187,8 +186,8 @@ const Stack = createStackNavigator(); type AppContextType = { channel: ChannelType | undefined; setChannel: React.Dispatch>; - setThread: React.Dispatch>; - thread: ThreadContextValue['thread'] | undefined; + setThread: React.Dispatch>; + thread: LocalMessage | null | undefined; }; const AppContext = React.createContext({} as AppContextType); @@ -268,7 +267,7 @@ const App = () => { export default () => { const [channel, setChannel] = useState(); - const [thread, setThread] = useState(); + const [thread, setThread] = useState(); const theme = useStreamChatTheme(); const colorScheme = useColorScheme(); diff --git a/package.json b/package.json index a3e8cef113..a0b48341e0 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "examples/TypeScriptMessaging" ], "resolutions": { + "stream-chat": "portal:/Users/isekovanic/Projects/stream-chat-js", "@types/react": "^19.2.0", "@shopify/flash-list": "patch:@shopify/flash-list@npm%3A2.3.1#~/.yarn/patches/@shopify-flash-list-npm-2.3.1-8b5fd40241.patch" }, diff --git a/package/src/__tests__/offline-support/optimistic-update.tsx b/package/src/__tests__/offline-support/optimistic-update.tsx index aa12e875be..a64e5357c5 100644 --- a/package/src/__tests__/offline-support/optimistic-update.tsx +++ b/package/src/__tests__/offline-support/optimistic-update.tsx @@ -16,7 +16,11 @@ import { v4 as uuidv4 } from 'uuid'; import { Channel as ChannelRaw } from '../../components/Channel/Channel'; import { Chat } from '../../components/Chat/Chat'; -import { MessageInputContext, MessagesContext } from '../../contexts'; +import { + MessageOperations, + useMessageOperations, +} from '../../components/Message/hooks/useMessageOperations'; +import { MessageInputContext } from '../../contexts'; import { deleteMessageApi } from '../../mock-builders/api/deleteMessage'; import { deleteReactionApi } from '../../mock-builders/api/deleteReaction'; import { erroredDeleteApi, erroredPostApi } from '../../mock-builders/api/error'; @@ -210,6 +214,33 @@ export const OptimisticUpdates = () => { return <>{children}; }; + // Same as CallbackEffectWithContext, but sources the message operations (delete/react/etc.) + // from the useMessageOperations hook instead of a context — they no longer live on MessagesContext. + const CallbackEffectWithMessageOperations = ({ + callback, + children, + }: { + callback: (ops: MessageOperations) => Promise | void; + children: React.ReactNode; + }) => { + const ops = useMessageOperations(); + const [ready, setReady] = useState(false); + useEffect(() => { + const call = async () => { + await callback(ops); + setReady(true); + }; + + call(); + }, [callback, ops]); + + if (!ready) { + return null; + } + + return <>{children}; + }; + describe('delete message', () => { it('pending task should exist if deleteMessage request fails', async () => { const message = generateMessage(); @@ -217,7 +248,7 @@ export const OptimisticUpdates = () => { render( - { useMockedApis(chatClient, [erroredPostApi()]); try { @@ -226,10 +257,9 @@ export const OptimisticUpdates = () => { // do nothing } }} - context={MessagesContext} > - + , ); @@ -248,15 +278,14 @@ export const OptimisticUpdates = () => { render( - { useMockedApis(chatClient, [deleteMessageApi(message)]); await deleteMessage(message); }} - context={MessagesContext} > - + , ); @@ -277,7 +306,7 @@ export const OptimisticUpdates = () => { render( - { useMockedApis(chatClient, [erroredPostApi()]); try { @@ -286,10 +315,9 @@ export const OptimisticUpdates = () => { // do nothing } }} - context={MessagesContext} > - + , ); @@ -310,15 +338,14 @@ export const OptimisticUpdates = () => { render( - { useMockedApis(chatClient, [sendReactionApi(targetMessage, reaction)]); await sendReaction(reaction.type, targetMessage.id); }} - context={MessagesContext} > - + , ); @@ -407,7 +434,7 @@ export const OptimisticUpdates = () => { render( - { useMockedApis(chatClient, [erroredPostApi()]); try { @@ -416,10 +443,9 @@ export const OptimisticUpdates = () => { // do nothing } }} - context={MessagesContext} > - + , ); @@ -440,15 +466,14 @@ export const OptimisticUpdates = () => { render( - { useMockedApis(chatClient, [deleteReactionApi(targetMessage, reaction)]); await deleteReaction(reaction.type, targetMessage.id); }} - context={MessagesContext} > - + , ); @@ -696,7 +721,7 @@ export const OptimisticUpdates = () => { render( - { useMockedApis(chatClient, [erroredDeleteApi()]); try { @@ -712,10 +737,9 @@ export const OptimisticUpdates = () => { // do nothing } }} - context={MessagesContext} > - + , ); diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 1c7bfe054e..7def775dd5 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -1,20 +1,11 @@ import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; -import debounce from 'lodash/debounce'; -import throttle from 'lodash/throttle'; - import { - Channel as ChannelClass, - ChannelState, Channel as ChannelType, - DeleteMessageOptions, EventHandler, LocalMessage, - localMessageToNewMessagePayload, - MessageLabel, MessageResponse, - Reaction, SendMessageAPIResponse, SendMessageOptions, StreamChat, @@ -24,7 +15,7 @@ import { UpdateMessageOptions, } from 'stream-chat'; -import { useChannelDataState } from './hooks/useChannelDataState'; +import { useChannelRequestHandlers } from './hooks/useChannelRequestHandlers'; import { useCreateChannelContext } from './hooks/useCreateChannelContext'; import { useCreateInputMessageInputContext } from './hooks/useCreateInputMessageInputContext'; @@ -32,14 +23,13 @@ import { useCreateInputMessageInputContext } from './hooks/useCreateInputMessage import { useCreateMessagesContext } from './hooks/useCreateMessagesContext'; import { useCreateOwnCapabilitiesContext } from './hooks/useCreateOwnCapabilitiesContext'; -import { useCreatePaginatedMessageListContext } from './hooks/useCreatePaginatedMessageListContext'; import { useCreateThreadContext } from './hooks/useCreateThreadContext'; -import { useCreateTypingContext } from './hooks/useCreateTypingContext'; - -import { useMessageListPagination } from './hooks/useMessageListPagination'; -import { useTargetedMessage } from './hooks/useTargetedMessage'; +import { + DEFAULT_HIGHLIGHT_DURATION, + useMessageListPagination, +} from './hooks/useMessageListPagination'; import { AttachmentPickerContextValue, @@ -51,7 +41,6 @@ import { } from '../../contexts/audioPlayerContext/AudioPlayerContext'; import { ChannelContextValue, ChannelProvider } from '../../contexts/channelContext/ChannelContext'; -import type { UseChannelStateValue } from '../../contexts/channelsStateContext/useChannelState'; import { useChannelState } from '../../contexts/channelsStateContext/useChannelState'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; @@ -69,10 +58,6 @@ import { OwnCapabilitiesContextValue, OwnCapabilitiesProvider, } from '../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; -import { - PaginatedMessageListContextValue, - PaginatedMessageListProvider, -} from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, @@ -83,38 +68,26 @@ import { TranslationContextValue, useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; -import { TypingProvider } from '../../contexts/typingContext/TypingContext'; import { useStableCallback } from '../../hooks'; import { useAppStateListener } from '../../hooks/useAppStateListener'; import { useAttachmentPickerBottomSheet } from '../../hooks/useAttachmentPickerBottomSheet'; -import { usePrunableMessageList } from '../../hooks/usePrunableMessageList'; +import { useStateStore } from '../../hooks/useStateStore'; import { isDocumentPickerAvailable, isImageMediaLibraryAvailable, isImagePickerAvailable, NativeHandlers, } from '../../native'; -import { - ChannelUnreadStateStore, - ChannelUnreadStateStoreType, -} from '../../state-store/channel-unread-state'; import { MessageInputHeightStore } from '../../state-store/message-input-height-store'; import { primitives } from '../../theme'; -import { DefaultAttachmentData, FileTypes } from '../../types/types'; -import { addReactionToLocalState } from '../../utils/addReactionToLocalState'; -import { compressedImageURI } from '../../utils/compressImage'; +import type { ChannelUnreadState } from '../../types/types'; import { patchMessageTextCommand } from '../../utils/patchMessageTextCommand'; -import { - getFileNameFromPath, - isBouncedMessage, - isLocalUrl, - MessageStatusTypes, - ReactionData, -} from '../../utils/utils'; +import { MessageStatusTypes, ReactionData } from '../../utils/utils'; import { NotificationAnnouncer } from '../Accessibility/NotificationAnnouncer'; import { AttachmentPicker } from '../AttachmentPicker/AttachmentPicker'; import type { KeyboardCompatibleViewProps } from '../KeyboardCompatibleView/KeyboardCompatibleView'; +import { useMarkRead } from '../MessageList/hooks/useMarkRead'; import { Emoji } from '../MessageMenu/EmojiPickerList'; import { emojis } from '../MessageMenu/emojis'; import { toUnicodeScalarString } from '../MessageMenu/utils/toUnicodeScalarString'; @@ -168,17 +141,14 @@ export const reactionData: ReactionData[] = [ */ const scrollToFirstUnreadThreshold = 0; -const defaultThrottleInterval = 500; -const defaultDebounceInterval = 500; -const throttleOptions = { - leading: true, - trailing: true, -}; - -const debounceOptions = { - leading: true, - trailing: true, -}; +/** + * Initial message-list page size. stream-chat's `MessagePaginator` defaults to 100 + * (`DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE`). On native that makes the initial load — and therefore + * every subsequent message-list commit, whose cost scales with the number of loaded messages — several + * times heavier than necessary. We keep it to a screenful-plus-buffer; older messages load on demand + * via pagination. Mirrors the SDK's historical initial-load size. + */ +const DEFAULT_MESSAGE_LIST_PAGE_SIZE = 25; export type ChannelPropsWithContext = Pick & Partial< @@ -230,10 +200,6 @@ export type ChannelPropsWithContext = Pick & > > & Pick & - Partial< - Pick - > & - Pick & Partial< Pick< MessagesContextValue, @@ -305,7 +271,7 @@ export type ChannelPropsWithContext = Pick & */ doMarkReadRequest?: ( channel: ChannelType, - setChannelUnreadUiState?: (data: ChannelUnreadStateStoreType['channelUnreadState']) => void, + setChannelUnreadUiState?: (data: ChannelUnreadState | undefined) => void, ) => void; /** * Overrides the Stream default send message request (Advanced usage only) @@ -385,6 +351,12 @@ export type ChannelPropsWithContext = Pick & initializeOnMount?: boolean; }; +// The highlighted message id is derived from the paginator's messageFocusSignal (LLC), which is +// emitted by the jump fns and auto-cleared after its TTL — no separate targeted-message React state. +const messageFocusSignalSelector = (state: { signal: { messageId?: string } | null }) => ({ + highlightedMessageId: state.signal?.messageId, +}); + const ChannelWithContext = (props: PropsWithChildren) => { const { disableAttachmentPicker = !isImageMediaLibraryAvailable(), @@ -455,8 +427,6 @@ const ChannelWithContext = (props: PropsWithChildren) = isMessageAIGenerated = () => false, keyboardBehavior, keyboardVerticalOffset, - loadingMore: loadingMoreProp, - loadingMoreRecent: loadingMoreRecentProp, markdownRules, markReadOnMount = true, maxTimeBetweenGroupedMessages, @@ -477,8 +447,6 @@ const ChannelWithContext = (props: PropsWithChildren) = messageSwipeToReplyHitSlop, messageTextNumberOfLines, myMessageTheme, - // TODO: Think about this one - newMessageStateUpdateThrottleInterval = defaultThrottleInterval, onLongPressMessage, onPressInMessage, onPressMessage, @@ -489,17 +457,13 @@ const ChannelWithContext = (props: PropsWithChildren) = reactionListType = 'clustered', selectReaction, setInputRef, - setThreadMessages, shouldShowUnreadUnderlay = true, shouldSyncChannel, - stateUpdateThrottleInterval = defaultThrottleInterval, supportedReactions = reactionData, t, thread: threadFromProps, threadList, - threadMessages, topInset = 0, - isOnline, maximumMessageLimit, initializeOnMount = true, urlPreviewType = 'full', @@ -508,29 +472,40 @@ const ChannelWithContext = (props: PropsWithChildren) = const components = useComponentsContext(); const { KeyboardCompatibleView, LoadingErrorIndicator } = components; - const { thread: threadProps, threadInstance } = threadFromProps; + const { thread: threadProps, threadInstance: threadInstanceFromProps } = threadFromProps; const styles = useStyles(); const [deleted, setDeleted] = useState(false); const [error, setError] = useState(false); const lastReadRef = useRef(undefined); - const [thread, setThread] = useState(threadProps || null); - const [threadHasMore, setThreadHasMore] = useState(true); - const [threadLoadingMore, setThreadLoadingMore] = useState(false); - const [channelUnreadStateStore] = useState(() => new ChannelUnreadStateStore()); + // 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.threadsById[threadProps.id] ?? + new Thread({ channel, client, parentMessage: threadProps }) + ); + // Keyed on threadProps.id (stable) rather than the threadProps object so an unmanaged thread's + // constructed instance isn't recreated (losing paginator state) on unrelated re-renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [threadInstanceFromProps, threadProps?.id, channel, client]); const [messageInputHeightStore] = useState(() => new MessageInputHeightStore()); - // TODO: Think if we can remove this and just rely on the channelUnreadStateStore everywhere. - const setChannelUnreadState = useCallback( - (data: ChannelUnreadStateStoreType['channelUnreadState']) => { - channelUnreadStateStore.channelUnreadState = data; - }, - [channelUnreadStateStore], - ); const { bottomSheetRef, closePicker, openPicker } = useAttachmentPickerBottomSheet(); const syncingChannelRef = useRef(false); - const { highlightedMessageId, setTargetedMessage, targetedMessage } = useTargetedMessage(); + const { highlightedMessageId } = useStateStore( + (threadInstance ?? channel).messagePaginator.messageFocusSignal, + messageFocusSignalSelector, + ); /** * This ref will hold the abort controllers for @@ -548,22 +523,19 @@ const ChannelWithContext = (props: PropsWithChildren) = const channelId = channel?.id || ''; const pollCreationEnabled = !channel.disconnected && !!channel?.id && channel?.getConfig()?.polls; - const { - copyStateFromChannel, - initStateFromChannel, - setRead, - setTyping, - state: channelState, - } = useChannelDataState(channel); + // Register the integrator's custom message-request overrides into channel.configState so the + // stream-chat message-operations engine (send/retry/update via *WithLocalUpdate) honors them. + useChannelRequestHandlers({ + channel, + doMarkReadRequest, + doSendMessageRequest, + doUpdateMessageRequest, + }); const { - copyMessagesStateFromChannel: rawCopyMessagesStateFromChannel, loadChannelAroundMessage: loadChannelAroundMessageFn, loadChannelAtFirstUnreadMessage, - loadInitialMessagesStateFromChannel, loadLatestMessages, - loadMore, - loadMoreRecent, state: channelMessagesState, } = useMessageListPagination({ channel, @@ -581,52 +553,6 @@ const ChannelWithContext = (props: PropsWithChildren) = return !!messageId || shouldLoadInitialChannelAtFirstUnreadMessage(); }); - const { setMessages: copyMessagesStateFromChannel, viewabilityChangedCallback } = - usePrunableMessageList({ maximumMessageLimit, setMessages: rawCopyMessagesStateFromChannel }); - - const setReadThrottled = useMemo( - () => - throttle( - () => { - if (channel) { - setRead(channel); - } - }, - stateUpdateThrottleInterval, - throttleOptions, - ), - [channel, stateUpdateThrottleInterval, setRead], - ); - - const copyMessagesStateFromChannelThrottled = useMemo( - () => - throttle( - () => { - if (channel) { - copyMessagesStateFromChannel(channel); - } - }, - newMessageStateUpdateThrottleInterval, - throttleOptions, - ), - [channel, newMessageStateUpdateThrottleInterval, copyMessagesStateFromChannel], - ); - - const copyChannelState = useMemo( - () => - throttle( - () => { - if (channel) { - copyStateFromChannel(channel); - copyMessagesStateFromChannel(channel); - } - }, - stateUpdateThrottleInterval, - throttleOptions, - ), - [stateUpdateThrottleInterval, channel, copyStateFromChannel, copyMessagesStateFromChannel], - ); - const handleEvent: EventHandler = useStableCallback((event) => { if (shouldSyncChannel) { /** @@ -642,69 +568,19 @@ const ChannelWithContext = (props: PropsWithChildren) = return; } - // If the event is typing.start or typing.stop, set the typing state + // Typing state is sourced reactively from channel.state.typingStore; nothing to copy here. if (event.type === 'typing.start' || event.type === 'typing.stop') { - if (event.user?.id !== client.userID) { - setTyping(channel); - } return; - } else { - if (thread?.id) { - const updatedThreadMessages = - (thread.id && channel && channel.state.threads[thread.id]) || threadMessages; - setThreadMessages(updatedThreadMessages); - - if (channel && event.message?.id === thread.id && !threadInstance) { - const updatedThread = channel.state.formatMessage(event.message); - setThread(updatedThread); - } - } } - if (event.type === 'notification.mark_unread') { - if (!(event.last_read_at && event.user)) { - return; - } - setChannelUnreadState({ - first_unread_message_id: event.first_unread_message_id, - last_read: new Date(event.last_read_at), - last_read_message_id: event.last_read_message_id, - unread_messages: event.unread_messages ?? 0, - }); - } + // notification.mark_unread + channel.truncated update channel.messagePaginator.unreadStateSnapshot + // in the LLC (the single source of truth for unread state), so no manual handling here. - if (event.type === 'channel.truncated' && event.cid === channel.cid) { - setChannelUnreadState(undefined); - } - - // only update channel state if the events are not the previously subscribed useEffect's subscription events - if (channel) { - // we skip the new message events if we've already done an optimistic update for the new message - if (event.type === 'message.new' || event.type === 'notification.message_new') { - const messageId = event.message?.id ?? ''; - if ( - event.user?.id !== client.userID || - !optimisticallyUpdatedNewMessages.has(messageId) - ) { - copyMessagesStateFromChannelThrottled(); - } - optimisticallyUpdatedNewMessages.delete(messageId); - return; - } - - if (event.type === 'message.read_locally') { - // When local unread reset happens, the count is already updated in the client state, - // and the preview badge / unread divider are handled elsewhere, so there is nothing - // to copy into channel state here. Thus, we skip it. - return; - } - - if (event.type === 'message.read' || event.type === 'notification.mark_read') { - setReadThrottled(); - return; - } - - copyChannelState(); + // The message list is backed reactively by channel.messagePaginator (channel._handleChannelEvent + // ingests message.new/updated/deleted + reaction events), and read/typing/members come from + // their reactive stores — so the WS handler no longer copies channel.state into React state. + if (event.type === 'message.new' || event.type === 'notification.message_new') { + optimisticallyUpdatedNewMessages.delete(event.message?.id ?? ''); } } }); @@ -720,6 +596,10 @@ const ChannelWithContext = (props: PropsWithChildren) = } let errored = false; + // Keep the message-list page light: the list's per-update commit cost scales with the number + // of loaded messages, and the paginator otherwise defaults to a 100-message page. + channel.messagePaginator.pageSize = DEFAULT_MESSAGE_LIST_PAGE_SIZE; + if ((!channel.initialized || !channel.state.isUpToDate) && initializeOnMount) { try { await channel?.watch(); @@ -732,35 +612,32 @@ const ChannelWithContext = (props: PropsWithChildren) = } if (!errored) { - initStateFromChannel(channel); - loadInitialMessagesStateFromChannel(channel, channel.state.messagePagination.hasPrev); + // Seed the paginator for a cold open (deep link / push). Channels reached via the channel + // list are already seeded by client.hydrateActiveChannels, so guard on an empty paginator + // to avoid a redundant fetch. + if (!channel.messagePaginator.state.getLatestValue().items?.length) { + await channel.messagePaginator.reload(); + } } - if (client.user?.id && channel.state.read[client.user.id]) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { user, ...ownReadState } = channel.state.read[client.user.id]; - setChannelUnreadState(ownReadState); - } + // Re-seed the unread snapshot from the CURRENT read state on every open. The paginator is + // usually reused from cache (the reload above is skipped), and hydrateActiveChannels merges + // rather than re-seeds, so without this the snapshot's boundary/count/first-unread stay frozen + // at the very first open — making the separator, the "N new" banner and the jump-to-first-unread + // target all go stale on reopen. Mirrors stream-chat-react, which re-seeds by re-querying on open. + channel.messagePaginator.seedUnreadSnapshot(); if (messageId) { - await loadChannelAroundMessage({ messageId, setTargetedMessage }); + await loadChannelAroundMessage({ messageId }); } else if (shouldLoadAtFirstUnread) { - const clientUserId = client.user?.id; - if (!clientUserId) { - return; - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { user, ...ownReadState } = channel.state.read[clientUserId]; - - await loadChannelAtFirstUnreadMessage({ - channelUnreadState: ownReadState, - setChannelUnreadState, - setTargetedMessage, - }); + // jumpToTheFirstUnreadMessage resolves the first-unread id from the paginator's snapshot. + await loadChannelAtFirstUnreadMessage(); } if (unreadCount > 0 && markReadOnMount) { + // Keep the original unread UI (separator frozen at the boundary, "N new" banner) when + // opening a channel with unreads — don't reset the snapshot here. It clears once the user + // catches up (a subsequent markRead with the default updateChannelUnreadState: true). await markRead({ updateChannelUnreadState: false }); } @@ -770,8 +647,6 @@ const ChannelWithContext = (props: PropsWithChildren) = initChannel(); return () => { - copyChannelState.cancel(); - loadMoreThreadFinished.cancel(); listener?.unsubscribe(); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -788,20 +663,6 @@ const ChannelWithContext = (props: PropsWithChildren) = return unsubscribe; }, [channel?.cid, client]); - const threadPropsExists = !!threadProps; - - useEffect(() => { - if (threadProps && shouldSyncChannel) { - setThread(threadProps); - if (channel && threadProps?.id) { - setThreadMessages(channel.state.threads?.[threadProps.id] || []); - } - } else { - setThread(null); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [threadPropsExists, shouldSyncChannel]); - const handleAppBackground = useCallback(() => { const channelData = channel.data; if (channelData?.own_capabilities?.includes('send-typing-events')) { @@ -818,89 +679,9 @@ const ChannelWithContext = (props: PropsWithChildren) = /** * CHANNEL METHODS */ - const markReadInternal: ChannelContextValue['markRead'] = throttle( - async (options?: MarkReadFunctionOptions) => { - const { updateChannelUnreadState = true } = options ?? {}; - if (!channel || channel?.disconnected) { - return; - } - - // When read events are disabled (e.g. livestreams) we cannot mark read on the backend. If the - // client opted into a local unread count, reset it locally instead so the user's "caught up" - // state is reflected without a server round trip. - if (!clientChannelConfig?.read_events) { - if (client.options.isLocalUnreadCountEnabled) { - const event = channel.markReadLocally(); - if (updateChannelUnreadState && event && lastReadRef.current) { - setChannelUnreadState({ - last_read: lastReadRef.current, - last_read_message_id: event.last_read_message_id, - unread_messages: 0, - }); - lastReadRef.current = new Date(); - } - } - return; - } - - if (doMarkReadRequest) { - doMarkReadRequest(channel, updateChannelUnreadState ? setChannelUnreadState : undefined); - } else { - try { - const response = await channel.markRead(); - if (updateChannelUnreadState && response && lastReadRef.current) { - setChannelUnreadState({ - last_read: lastReadRef.current, - last_read_message_id: response?.event.last_read_message_id, - unread_messages: 0, - }); - lastReadRef.current = new Date(); - } - } catch (err) { - console.log('Error marking channel as read:', err); - } - } - }, - defaultThrottleInterval, - throttleOptions, - ); - - const markRead = useStableCallback(markReadInternal); - - const reloadThread = useStableCallback(async () => { - if (!channel || !thread?.id) { - return; - } - setThreadLoadingMore(true); - try { - const parentID = thread.id; - - const limit = 50; - // channel.state.threads[parentID] = []; - const queryResponse = await channel.getReplies(parentID, { - limit, - }); - - const updatedHasMore = queryResponse.messages.length === limit; - const updatedThreadMessages = channel.state.threads[parentID] || []; - loadMoreThreadFinished(updatedHasMore, updatedThreadMessages); - const { messages } = await channel.getMessagesById([parentID]); - const [threadMessage] = messages; - if (threadMessage && !threadInstance) { - const formattedMessage = channel.state.formatMessage(threadMessage); - setThread(formattedMessage); - } - } catch (err) { - console.warn('Thread loading request failed with error', err); - if (err instanceof Error) { - setError(err); - } else { - setError(true); - } - setThreadLoadingMore(false); - throw err; - } - }); + // markRead is no longer placed on the ChannelContext; the message lists own their own throttled + // instance via useMarkRead(channel). Channel still needs it internally (mark-read-on-mount + resync). + const markRead = useMarkRead(channel); const resyncChannel = useStableCallback(async () => { if (!channel || syncingChannelRef.current || (!channel.initialized && !channel.offlineMode)) { @@ -927,10 +708,10 @@ const ChannelWithContext = (props: PropsWithChildren) = .map(parseMessage); try { + let watchResponse; if (channelMessagesState?.messages) { - await channel?.watch({ + watchResponse = await channel?.watch({ messages: { - // Do we want to reduce this to the default as well ? limit: channelMessagesState.messages.length, }, }); @@ -938,21 +719,33 @@ const ChannelWithContext = (props: PropsWithChildren) = } if (!thread) { - copyChannelState(); - const failedMessages = getRecoverableFailedMessages(channelMessagesState.messages); + const newestWindow = (watchResponse?.messages ?? []).map((message) => + channel.state.formatMessage(message), + ); + channel.messagePaginator.mergeNewestPage(newestWindow); if (failedMessages?.length) { - channel.state.addMessagesSorted(failedMessages); + failedMessages.forEach((m) => + channel.messagePaginator.ingestItem(channel.state.formatMessage(m)), + ); } - await markRead(); - channel.state.setIsUpToDate(true); - } else { - await reloadThread(); + // The merge no-ops if the user had jumped away from the head; only claim up-to-date / mark + // read when it actually left us at the newest, otherwise keep their position untouched. + const atLatest = !channel.messagePaginator.hasMoreHead; + if (atLatest) { + await markRead(); + } + channel.state.setIsUpToDate(atLatest); + } else if (threadInstance) { + await threadInstance.reload(); - const failedThreadMessages = thread ? getRecoverableFailedMessages(threadMessages) : []; + const currentThreadMessages = + threadInstance.messagePaginator.state.getLatestValue().items ?? []; + const failedThreadMessages = getRecoverableFailedMessages(currentThreadMessages); if (failedThreadMessages.length) { - channel.state.addMessagesSorted(failedThreadMessages); - setThreadMessages([...channel.state.threads[thread.id]]); + failedThreadMessages.forEach((m) => + threadInstance.messagePaginator.ingestItem(channel.state.formatMessage(m)), + ); } } } catch (err) { @@ -1031,26 +824,24 @@ const ChannelWithContext = (props: PropsWithChildren) = } try { if (thread) { - setThreadLoadingMore(true); try { - await channel.state.loadMessageIntoState(messageIdToLoadAround, thread.id); - setThreadLoadingMore(false); - setThreadMessages(channel.state.threads[thread.id]); - if (setTargetedMessage) { - setTargetedMessage(messageIdToLoadAround); - } + // jumpToMessage loads the message range into thread.messagePaginator (which backs the + // reply list) and emits the focus signal driving the thread-aware highlight + scroll. + // The reply-list loading spinner is driven off the paginator's own isLoading flag. + await threadInstance?.messagePaginator?.jumpToMessage(messageIdToLoadAround, { + focusReason: 'jump-to-message', + focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION, + }); } catch (err) { if (err instanceof Error) { setError(err); } else { setError(true); } - setThreadLoadingMore(false); } } else { await loadChannelAroundMessageFn({ messageId: messageIdToLoadAround, - setTargetedMessage, }); } } catch (err) { @@ -1061,258 +852,29 @@ const ChannelWithContext = (props: PropsWithChildren) = /** * MESSAGE METHODS */ - const updateMessage: MessagesContextValue['updateMessage'] = useStableCallback( - (updatedMessage, extraState = {}, throttled = false) => { - if (!channel) { - return; - } - - channel.state.addMessageSorted(updatedMessage, true); - if (throttled) { - copyMessagesStateFromChannelThrottled(); - } else { - copyMessagesStateFromChannel(channel); - } - - if (thread && updatedMessage.parent_id) { - extraState.threadMessages = channel.state.threads[updatedMessage.parent_id] || []; - setThreadMessages(extraState.threadMessages); - } - }, - ); - - const replaceMessage = useStableCallback( - (oldMessage: LocalMessage, newMessage: MessageResponse) => { - if (channel) { - channel.state.removeMessage(oldMessage); - channel.state.addMessageSorted(newMessage, true); - copyMessagesStateFromChannel(channel); - - if (thread && newMessage.parent_id) { - const threadMessages = channel.state.threads[newMessage.parent_id] || []; - setThreadMessages(threadMessages); - } - } - }, - ); - - const uploadPendingAttachments = useStableCallback(async (message: LocalMessage) => { - const updatedMessage = { ...message }; - if (!updatedMessage.attachments?.length || !channel?.cid) { - return updatedMessage; - } - - const uploadOne = async (attachment: NonNullable[number]) => { - if ( - (attachment.image_url && !isLocalUrl(attachment.image_url)) || - (attachment.asset_url && !isLocalUrl(attachment.asset_url)) - ) { - return; - } - - const originalFile = attachment.originalFile; - if (!originalFile?.uri) { - return; - } - - const localId = (attachment as DefaultAttachmentData).localId; - if (!localId) { - console.warn('uploadPendingAttachments: local attachment missing localId, skipping upload'); - return; - } - - let fileForUpload = originalFile; - if (attachment.type === FileTypes.Image && !doFileUploadRequest) { - const filename = originalFile.name ?? getFileNameFromPath(originalFile.uri); - const compressedUri = await compressedImageURI(originalFile, compressImageQuality); - fileForUpload = { ...originalFile, name: filename, uri: compressedUri }; - } - - const response = await ( - client as typeof client & { - uploadManager: { - upload(args: { - channelCid: string; - file: { - name?: string; - type?: string; - uri: string; - }; - id: string; - }): Promise<{ file: string; thumb_url?: string }>; - }; - } - ).uploadManager.upload({ - channelCid: channel.cid, - file: fileForUpload, - id: localId, - }); - - if (attachment.type === FileTypes.Image) { - attachment.image_url = response.file; - } else { - attachment.asset_url = response.file; - if (response.thumb_url) { - attachment.thumb_url = response.thumb_url; - } - } - - delete attachment.originalFile; - delete (attachment as DefaultAttachmentData).localId; - - client.offlineDb?.executeQuerySafely( - (db) => - db.updateMessage({ - message: { ...updatedMessage, cid: channel.cid }, - }), - { method: 'updateMessage' }, - ); - }; - - await Promise.all(updatedMessage.attachments.map((att) => uploadOne(att))); - - return updatedMessage; - }); - - const sendMessageRequest = useStableCallback( - async ({ - localMessage, - message, - options, - retrying, - }: { - localMessage: LocalMessage; - message: StreamMessage; - options?: SendMessageOptions; - retrying?: boolean; - }) => { - let failedMessageUpdated = false; - const handleFailedMessage = () => { - if (!failedMessageUpdated) { - const updatedMessage = { - ...localMessage, - cid: channel.cid, - status: MessageStatusTypes.FAILED, - }; - updateMessage(updatedMessage); - threadInstance?.upsertReplyLocally?.({ message: updatedMessage }); - optimisticallyUpdatedNewMessages.delete(localMessage.id); - - client.offlineDb?.executeQuerySafely( - (db) => - db.updateMessage({ - message: updatedMessage, - }), - { method: 'updateMessage' }, - ); - - failedMessageUpdated = true; - } - }; - - try { - if (!isOnline) { - await handleFailedMessage(); - } - - const updatedLocalMessage = await uploadPendingAttachments(localMessage); - const { attachments } = updatedLocalMessage; - const { text, mentioned_users } = message; - if (!channel.id) { - return; - } - - const messageData = { - ...message, - attachments, - text: patchMessageTextCommand(text ?? '', mentioned_users ?? []), - // We cannot send an error message, so we convert it to a regular message. - type: message.type === 'error' ? 'regular' : message.type, - } as StreamMessage; - - let messageResponse = {} as SendMessageAPIResponse; - - if (doSendMessageRequest) { - messageResponse = await doSendMessageRequest(channel?.cid || '', messageData, options); - } else if (channel) { - messageResponse = await channel.sendMessage(messageData, options); - } - - if (messageResponse?.message) { - const newMessageResponse = { - ...messageResponse.message, - status: MessageStatusTypes.RECEIVED, - }; - - client.offlineDb?.executeQuerySafely( - (db) => - db.updateMessage({ - message: { ...newMessageResponse, cid: channel.cid }, - }), - { method: 'updateMessage' }, - ); - - if (retrying) { - replaceMessage(localMessage, newMessageResponse); - } else { - updateMessage(newMessageResponse, {}, true); - } - } - } catch (err) { - console.log('Error sending message:', err); - await handleFailedMessage(); - } - }, - ); - const sendMessage: InputMessageInputContextValue['sendMessage'] = useStableCallback( async ({ localMessage, message, options }) => { - if (channel?.state?.filterErrorMessages) { - channel.state.filterErrorMessages(); - } - - updateMessage(localMessage); - threadInstance?.upsertReplyLocally?.({ message: localMessage }); - optimisticallyUpdatedNewMessages.add(localMessage.id); - - // While sending a message, we add the message to local db with failed status, so that - // if app gets closed before message gets sent and next time user opens the app - // then user can see that message in failed state and can retry. - // If succesfull, it will be updated with received status. - client.offlineDb?.executeQuerySafely( - (db) => - db.upsertMessages({ - // @ts-ignore - messages: [{ ...localMessage, cid: channel.cid, status: MessageStatusTypes.FAILED }], - }), - { method: 'upsertMessages' }, - ); - if (preSendMessageRequest) { await preSendMessageRequest({ localMessage, message, options }); } - await sendMessageRequest({ localMessage, message, options }); - }, - ); - - const retrySendMessage: MessagesContextValue['retrySendMessage'] = useStableCallback( - async (localMessage) => { - const statusPendingMessage = { - ...localMessage, - status: MessageStatusTypes.SENDING, - }; - - const messageWithoutReservedFields = localMessageToNewMessagePayload(statusPendingMessage); - // For bounced messages, we don't need to update the message, instead always send a new message. - if (!isBouncedMessage(localMessage)) { - updateMessage(messageWithoutReservedFields as MessageResponse); - } - - await sendMessageRequest({ + // Preserve RN's moderation slash-command patching ("/mute @user" -> "/mute @userId"). + const messageToSend = message + ? { + ...message, + text: patchMessageTextCommand(message.text ?? '', message.mentioned_users ?? []), + } + : message; + + // The stream-chat message-operations engine owns the full optimistic lifecycle (pending -> + // received/failed), offline-DB persistence and paginator ingest — for both channel messages + // (channel.messagePaginator) and thread replies (thread.messagePaginator, which the thread + // instance ingests into directly). It throws on failure, which the MessageInput send flow + // catches to surface a notification. + await (threadInstance ?? channel).sendMessageWithLocalUpdate({ localMessage, - message: messageWithoutReservedFields, - retrying: true, + message: messageToSend, + options, }); }, ); @@ -1322,243 +884,14 @@ const ChannelWithContext = (props: PropsWithChildren) = if (!channel) { throw new Error('Channel has not been initialized'); } - - const cid = channel.cid; - const currentMessage = channel.state.findMessage(localMessage.id, localMessage.parent_id); - const isFailedMessage = - currentMessage?.status === MessageStatusTypes.FAILED || - localMessage.status === MessageStatusTypes.FAILED; - const optimisticEditedAt = new Date(); - const optimisticEditedAtString = optimisticEditedAt.toISOString(); - const optimisticMessage = { - ...currentMessage, - ...localMessage, - cid, - message_text_updated_at: isFailedMessage ? undefined : optimisticEditedAtString, - updated_at: optimisticEditedAt, - } as unknown as LocalMessage; - - updateMessage(optimisticMessage); - threadInstance?.updateParentMessageOrReplyLocally( - optimisticMessage as unknown as MessageResponse, - ); - client.offlineDb?.executeQuerySafely( - (db) => - db.updateMessage({ - message: { ...optimisticMessage, cid }, - }), - { method: 'updateMessage' }, - ); - - const response = doUpdateMessageRequest - ? await doUpdateMessageRequest(cid, localMessage, options) - : await client.updateMessage(localMessage, undefined, options); - - if (response?.message) { - updateMessage(response.message); - threadInstance?.updateParentMessageOrReplyLocally(response.message); - client.offlineDb?.executeQuerySafely( - (db) => - db.updateMessage({ - message: { ...response.message, cid }, - }), - { method: 'updateMessage' }, - ); - } - - return response; - }, - ); - - /** - * Removes the message from local state - */ - const removeMessage: MessagesContextValue['removeMessage'] = useStableCallback( - async (message) => { - if (channel) { - channel.state.removeMessage(message); - copyMessagesStateFromChannel(channel); - - if (thread) { - setThreadMessages(channel.state.threads[thread.id] || []); - } - } - - if (client.offlineDb) { - await client.offlineDb.handleRemoveMessage({ messageId: message.id }); - } - }, - ); - - const sendReaction = useStableCallback(async (type: string, messageId: string) => { - if (!channel?.id || !client.user) { - throw new Error('Channel has not been initialized'); - } - - const payload: Parameters = [ - messageId, - { - type, - } as Reaction, - { enforce_unique: enforceUniqueReaction }, - ]; - - if (enableOfflineSupport) { - await addReactionToLocalState({ - channel, - enforceUniqueReaction, - messageId, - reactionType: type, - user: client.user, - }); - - copyMessagesStateFromChannel(channel); - } - - const sendReactionResponse = await channel.sendReaction(...payload); - - if (sendReactionResponse?.message) { - threadInstance?.upsertReplyLocally?.({ message: sendReactionResponse.message }); - } - }); - - const deleteMessage: MessagesContextValue['deleteMessage'] = useStableCallback( - async (message, optionsOrHardDelete = false) => { - let options: DeleteMessageOptions = {}; - if (typeof optionsOrHardDelete === 'boolean') { - options = optionsOrHardDelete ? { hardDelete: true } : {}; - } else if (optionsOrHardDelete?.deleteForMe) { - options = { deleteForMe: true }; - } else if (optionsOrHardDelete?.hardDelete) { - options = { hardDelete: true }; - } - if (!channel.id) { - throw new Error('Channel has not been initialized yet'); - } - - if (message.status === MessageStatusTypes.FAILED) { - await removeMessage(message); - return; - } - const updatedMessage = { - ...message, - cid: channel.cid, - deleted_at: new Date(), - type: 'deleted' as MessageLabel, - }; - updateMessage(updatedMessage); - - threadInstance?.upsertReplyLocally({ message: updatedMessage }); - - const data = await client.deleteMessage(message.id, options); - - if (data?.message) { - updateMessage({ ...data.message }); - } - }, - ); - - const deleteReaction: MessagesContextValue['deleteReaction'] = useStableCallback( - async (type: string, messageId: string) => { - if (!channel?.id || !client.user) { - throw new Error('Channel has not been initialized'); - } - - const payload: Parameters = [messageId, type]; - - if (enableOfflineSupport) { - channel.state.removeReaction({ - created_at: '', - message_id: messageId, - type, - updated_at: '', - }); - - copyMessagesStateFromChannel(channel); - } - - await channel.deleteReaction(...payload); - }, - ); - - /** - * THREAD METHODS - */ - const openThread: ThreadContextValue['openThread'] = useCallback( - (message) => { - setThread(message); - - if (channel.initialized) { - channel.markRead({ thread_id: message.id }); - } - // This was causing inconsistencies within the thread state as well as being responsible - // of threads essentially never unloading (due to all of the previous threads + 50 loading - // every time we'd run this). It seemingly has no impact (other than a performance boost) - // and having it was causing issues with the Threads V2 architecture. - // setThreadMessages(newThreadMessages); + // The LLC handles the optimistic local update (ingest into the paginator), the network + // request (honoring any doUpdateMessageRequest registered into channel.configState in + // useChannelRequestHandlers), the received/failed state transitions, and offline queueing. + // Thread edits route through the thread instance's own message operations. + await (threadInstance ?? channel).updateMessageWithLocalUpdate({ localMessage, options }); }, - [channel, setThread], ); - const closeThread: ThreadContextValue['closeThread'] = useCallback(() => { - setThread(null); - setThreadMessages([]); - }, [setThread, setThreadMessages]); - - // hard limit to prevent you from scrolling faster than 1 page per 2 seconds - const loadMoreThreadFinished = useRef( - debounce( - (newThreadHasMore: boolean, updatedThreadMessages: ChannelState['threads'][string]) => { - setThreadHasMore(newThreadHasMore); - setThreadLoadingMore(false); - setThreadMessages(updatedThreadMessages); - }, - defaultDebounceInterval, - debounceOptions, - ), - ).current; - - const loadMoreThread: ThreadContextValue['loadMoreThread'] = useStableCallback(async () => { - if (threadLoadingMore || !thread?.id) { - return; - } - setThreadLoadingMore(true); - - try { - if (channel) { - const parentID = thread.id; - - /** - * In the channel is re-initializing, then threads may get wiped out during the process - * (check `addMessagesSorted` method on channel.state). In those cases, we still want to - * preserve the messages on active thread, so lets simply copy messages from UI state to - * `channel.state`. - */ - channel.state.threads[parentID] = threadMessages; - const oldestMessageID = threadMessages?.[0]?.id; - - const limit = 50; - const queryResponse = await channel.getReplies(parentID, { - id_lt: oldestMessageID, - limit, - }); - - const updatedHasMore = queryResponse.messages.length === limit; - const updatedThreadMessages = channel.state.threads[parentID] || []; - loadMoreThreadFinished(updatedHasMore, updatedThreadMessages); - } - } catch (err) { - console.warn('Message pagination request failed with error', err); - if (err instanceof Error) { - setError(err); - } else { - setError(true); - } - setThreadLoadingMore(false); - throw err; - } - }); - const handleClosePicker = useStableCallback(() => closePicker(bottomSheetRef)); const handleOpenPicker = useStableCallback(() => openPicker(bottomSheetRef)); @@ -1596,7 +929,6 @@ const ChannelWithContext = (props: PropsWithChildren) = const channelContext = useCreateChannelContext({ channel, - channelUnreadStateStore, disabled: !!channel?.data?.frozen, enableMessageGroupingByUser, enforceUniqueReaction, @@ -1608,21 +940,13 @@ const ChannelWithContext = (props: PropsWithChildren) = loadChannelAroundMessage, loadChannelAtFirstUnreadMessage, loading: channelMessagesState.loading, - markRead, maximumMessageLimit, maxTimeBetweenGroupedMessages, - members: channelState.members ?? {}, - read: channelState.read ?? {}, reloadChannel, scrollToFirstUnreadThreshold, - setChannelUnreadState, - setTargetedMessage, hasPendingInitialTargetLoad, - targetedMessage, threadList, uploadAbortControllerRef, - watcherCount: channelState.watcherCount, - watchers: channelState.watchers, }); // This is mainly a hack to get around an issue with sendMessage not being passed correctly as a @@ -1666,27 +990,10 @@ const ChannelWithContext = (props: PropsWithChildren) = setInputRef, }); - const messageListContext = useCreatePaginatedMessageListContext({ - channelId, - hasMore: channelMessagesState.hasMore, - loadingMore: loadingMoreProp !== undefined ? loadingMoreProp : channelMessagesState.loadingMore, - loadingMoreRecent: - loadingMoreRecentProp !== undefined - ? loadingMoreRecentProp - : channelMessagesState.loadingMoreRecent, - loadLatestMessages, - loadMore, - loadMoreRecent, - messages: channelMessagesState.messages ?? [], - viewabilityChangedCallback, - }); - const messagesContext = useCreateMessagesContext({ additionalPressableProps, channelId, customMessageSwipeAction, - deleteMessage, - deleteReaction, disableTypingIndicator, dismissKeyboardOnMessageTouch, enableMessageGroupingByUser, @@ -1726,34 +1033,16 @@ const ChannelWithContext = (props: PropsWithChildren) = onPressMessage, reactionListPosition, reactionListType, - removeMessage, - retrySendMessage, selectReaction, - sendReaction, shouldShowUnreadUnderlay, supportedReactions, - targetedMessage, - updateMessage, urlPreviewType, }); const threadContext = useCreateThreadContext({ allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, - closeThread, - loadMoreThread, - openThread, - reloadThread, - setThreadLoadingMore, - thread, - threadHasMore, threadInstance, - threadLoadingMore, - threadMessages, - }); - - const typingContext = useCreateTypingContext({ - typing: channelState.typing ?? {}, }); const audioPlayerContext = useMemo( @@ -1762,8 +1051,8 @@ const ChannelWithContext = (props: PropsWithChildren) = ); const messageComposerContext = useMemo( - () => ({ channel, thread, threadInstance }), - [channel, thread, threadInstance], + () => ({ channel, threadInstance }), + [channel, threadInstance], ); // TODO: replace the null view with appropriate message. Currently this is waiting a design decision. @@ -1792,25 +1081,21 @@ const ChannelWithContext = (props: PropsWithChildren) = > - - - - - - - - - - {children} - - - - - - - - - + + + + + + + + {children} + + + + + + + @@ -1850,10 +1135,7 @@ export const Channel = (props: PropsWithChildren) => { const shouldSyncChannel = threadMessage?.id ? !!props.threadList : true; - const { setThreadMessages, threadMessages } = useChannelState( - props.channel, - props.threadList ? threadMessage?.id : undefined, - ); + useChannelState(props.channel); const channelWithContext = ( ) => { {...{ isMessageAIGenerated, isOnline, - setThreadMessages, thread, - threadMessages, }} /> ); diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index dedad14568..c9f791e1d3 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -1,7 +1,7 @@ import React, { type ComponentProps, useContext, useEffect } from 'react'; import { View } from 'react-native'; -import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react-native'; +import { act, cleanup, render, waitFor } from '@testing-library/react-native'; import type { Channel as ChannelType, StreamChat as StreamChatType } from 'stream-chat'; import { StreamChat } from 'stream-chat'; @@ -28,13 +28,25 @@ import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Attachment } from '../../Attachment/Attachment'; import { Chat } from '../../Chat/Chat'; import { Channel } from '../Channel'; -import { - channelInitialState, - useChannelDataState, - useChannelMessageDataState, -} from '../hooks/useChannelDataState'; import * as MessageListPaginationHooks from '../hooks/useMessageListPagination'; +// Local test fixture (was previously imported from the now-removed useChannelDataState hook). +const channelInitialState = { + hasMore: true, + hasMoreNewer: false, + loading: false, + loadingMore: false, + loadingMoreRecent: false, + members: {}, + messages: [], + pinnedMessages: [], + read: {}, + targetedMessageId: undefined, + typing: {}, + watcherCount: 0, + watchers: {}, +}; + // This component is used for performing effects in a component that consumes ChannelContext, // i.e. making use of the callbacks & values provided by the Channel component. // the effect is called every time channelContext changes @@ -176,49 +188,22 @@ describe('Channel', () => { await waitFor(() => expect(channelOnSpy).toHaveBeenCalledWith(expect.any(Function))); }); - it('should be able to open threads', async () => { + it('exposes a thread provided via props through the thread context', async () => { const threadMessage = messages[0]; const hasThread = jest.fn(); - // this renders Channel, calls openThread from a child context consumer with a message, - // and then calls hasThread with the thread id if it was set. - const { rerender } = renderComponent( - { channel }, + // Threads are now fully prop-driven: passing `thread` to Channel exposes it through + // ThreadContext synchronously (no openThread state setter). + renderComponent( + { channel, thread: threadMessage, threadList: true }, (ctx) => { - const { openThread, thread } = ctx as { - openThread: (m: unknown) => void; - thread: { id: string } | null; - }; - if (!thread) { - openThread(threadMessage); - } else { + const { thread } = ctx as { thread: { id: string } | null }; + if (thread) { hasThread(thread.id); } }, ThreadContext as React.Context, ); - rerender( - - - - { - const { openThread, thread } = ctx as { - openThread: (m: unknown) => void; - thread: { id: string } | null; - }; - if (!thread) { - openThread(threadMessage); - } else { - hasThread(thread.id); - } - }} - context={ThreadContext as React.Context} - /> - - - , - ); await waitFor(() => expect(hasThread).toHaveBeenCalledWith(threadMessage.id)); }); @@ -268,8 +253,6 @@ describe('Channel', () => { const mockContext = { channel, client: chatClient, - markRead: () => {}, - watcherCount: 5, }; render( @@ -288,8 +271,6 @@ describe('Channel', () => { const ctx = context as unknown as typeof mockContext; expect(ctx.channel).toBeInstanceOf(Object); expect(ctx.client).toBeInstanceOf(StreamChat); - expect(ctx.markRead).toBeInstanceOf(Function); - expect(ctx.watcherCount).toBe(5); }); }); }); @@ -352,10 +333,7 @@ describe('Channel', () => { let context: ThreadContextValue | undefined; const mockContext = { - openThread: () => {}, - thread: {}, - threadHasMore: true, - threadLoadingMore: false, + allowThreadMessagesInChannel: true, }; render( @@ -371,10 +349,7 @@ describe('Channel', () => { await waitFor(() => { expect(context).toBeInstanceOf(Object); - expect(context!.openThread).toBeInstanceOf(Function); - expect(context!.thread).toBeInstanceOf(Object); - expect(context!.threadHasMore).toBe(true); - expect(context!.threadLoadingMore).toBe(false); + expect(context!.allowThreadMessagesInChannel).toBe(true); }); }); }); @@ -451,12 +426,9 @@ describe('Channel initial load useEffect', () => { renderComponent({ channel }); - const { result: channelMessageState } = renderHook(() => useChannelMessageDataState(channel)); - const { result: channelState } = renderHook(() => useChannelDataState(channel)); - await waitFor(() => expect(watchSpy).toHaveBeenCalled()); - await waitFor(() => expect(channelMessageState.current.state.messages!).toHaveLength(10)); - await waitFor(() => expect(Object.keys(channelState.current.state.members!)).toHaveLength(10)); + // members now come reactively from channel.state.membersStore (via the shim getter). + await waitFor(() => expect(Object.keys(channel.state.members)).toHaveLength(10)); }); function getElementsAround(array: T[], key: keyof T, id: unknown) { @@ -502,16 +474,6 @@ describe('Channel initial load useEffect', () => { await waitFor(() => { expect(loadMessageIntoState).toHaveBeenCalledWith(messageToSearch.id, undefined, 25); }); - - const { result: channelMessageState } = renderHook(() => useChannelMessageDataState(channel)); - await waitFor(() => expect(channelMessageState.current.state.messages!).toHaveLength(25)); - await waitFor(() => - expect( - channelMessageState.current.state.messages!.find( - (message) => message.id === messageToSearch.id, - ), - ).toBeTruthy(), - ); }); describe('initialScrollToFirstUnreadMessage', () => { @@ -528,10 +490,8 @@ describe('Channel initial load useEffect', () => { jest.spyOn(MessageListPaginationHooks, 'useMessageListPagination').mockImplementation( () => ({ - copyMessagesStateFromChannel: jest.fn(), loadChannelAroundMessage: jest.fn(), loadChannelAtFirstUnreadMessage: jest.fn(), - loadInitialMessagesStateFromChannel: jest.fn(), loadLatestMessages: jest.fn(), loadMore: jest.fn(), loadMoreRecent: jest.fn(), diff --git a/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx b/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx index c88a9a07b0..a33c6173c4 100644 --- a/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx +++ b/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx @@ -1,692 +1,165 @@ -import React, { PropsWithChildren } from 'react'; +import { act, cleanup, renderHook } from '@testing-library/react-native'; +import type { Channel, LocalMessage } from 'stream-chat'; -import { act, cleanup, renderHook, waitFor } from '@testing-library/react-native'; -import type { Channel as ChannelType, LocalMessage, StreamChat } from 'stream-chat'; - -import { ChatProvider } from '../../../contexts/chatContext/ChatContext'; -import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; -import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; -import { generateChannelResponse } from '../../../mock-builders/generator/channel'; -import { generateMessage } from '../../../mock-builders/generator/message'; -import { generateUser } from '../../../mock-builders/generator/user'; -import { getTestClientWithUser } from '../../../mock-builders/mock'; -import { NotificationTargetProvider } from '../../Notifications/NotificationTargetContext'; -import { channelInitialState } from '../hooks/useChannelDataState'; -import * as ChannelStateHooks from '../hooks/useChannelDataState'; import { useMessageListPagination } from '../hooks/useMessageListPagination'; -const createChatWrapper = - (client: StreamChat) => - ({ children }: PropsWithChildren) => ( - - - {children} - - - ); - -describe('useMessageListPagination', () => { - let chatClient: StreamChat; - let channel: ChannelType; - - const mockedHook = ( - state: Partial, - values?: Partial>, - ) => - jest.spyOn(ChannelStateHooks, 'useChannelMessageDataState').mockImplementation( - () => - ({ - copyMessagesStateFromChannel: jest.fn(), - jumpToLatestMessage: jest.fn(), - jumpToMessageFinished: jest.fn(), - loadInitialMessagesStateFromChannel: jest.fn(), - loadMoreFinished: jest.fn(), - loadMoreRecentFinished: jest.fn(), - setLoading: jest.fn(), - setLoadingMore: jest.fn(), - setLoadingMoreRecent: jest.fn(), - state: { ...channelInitialState, ...state }, - ...values, - }) as unknown as ReturnType, - ); - - beforeEach(async () => { - // Reset all modules before each test - jest.resetModules(); - const user = generateUser({ id: 'id', name: 'name' }); - chatClient = await getTestClientWithUser(user); +// NOTE: `stream-chat` is portaled during this migration; a runtime (value) import of it breaks +// jest resolution. Everything from `stream-chat` here is a type-only import, and the paginator is +// faked, so no runtime `require('stream-chat')` happens. + +jest.mock('../../Notifications', () => ({ + useNotificationApi: () => ({ addNotification: jest.fn() }), +})); +jest.mock('../../../contexts/translationContext/TranslationContext', () => ({ + useTranslationContext: () => ({ t: (key: string) => key }), +})); + +type PaginatorStateValue = { + hasMoreHead: boolean; + hasMoreTail: boolean; + isLoading: boolean; + items?: LocalMessage[]; +}; + +const makeStore = (value: T) => ({ + getLatestValue: () => value, + subscribeWithSelector: () => () => {}, +}); - const mockedChannel = generateChannelResponse({ - messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })), - }); +const makePaginator = (state: PaginatorStateValue, focusedMessageId?: string) => ({ + hasMoreHead: state.hasMoreHead, + hasMoreTail: state.hasMoreTail, + jumpToMessage: jest.fn().mockResolvedValue(true), + jumpToTheFirstUnreadMessage: jest.fn().mockResolvedValue(true), + jumpToTheLatestMessage: jest.fn().mockResolvedValue(true), + messageFocusSignal: makeStore({ + signal: focusedMessageId ? { messageId: focusedMessageId } : null, + }), + state: makeStore(state), + toHead: jest.fn().mockResolvedValue(undefined), + toTail: jest.fn().mockResolvedValue(undefined), +}); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - channel = chatClient.channel('messaging', mockedChannel.channel.id); - await channel.watch(); - }); +const makeChannel = (paginator: ReturnType) => + ({ messagePaginator: paginator }) as unknown as Channel; - afterEach(() => { - // Clear all mocks after each test - jest.clearAllMocks(); - // Restore all mocks to their original implementation - jest.restoreAllMocks(); - cleanup(); +describe('useMessageListPagination', () => { + afterEach(cleanup); + + it('maps paginator state (tailward/older→hasMore, headward/newer→hasMoreNewer, items→messages)', () => { + const items = [{ id: 'a' }, { id: 'b' }] as unknown as LocalMessage[]; + const paginator = makePaginator({ + hasMoreHead: false, + hasMoreTail: true, + isLoading: false, + items, + }); + const { result } = renderHook(() => + useMessageListPagination({ channel: makeChannel(paginator) }), + ); + expect(result.current.state.messages).toBe(items); + expect(result.current.state.hasMore).toBe(true); + expect(result.current.state.hasMoreNewer).toBe(false); }); - it('should set the state when the loadLatestMessages is called', async () => { - const loadMessageIntoState = jest.fn(() => { - channel.state.messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - (channel.state.messagePagination as { hasPrev: boolean }).hasPrev = true; + it('loadMore (older) delegates to paginator.toTail()', async () => { + const paginator = makePaginator({ + hasMoreHead: true, + hasMoreTail: true, + isLoading: false, + items: [], }); - channel.state = { - ...channelInitialState, - loadMessageIntoState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - } as unknown as typeof channel.state; - const { result } = renderHook(() => useMessageListPagination({ channel })); - + const { result } = renderHook(() => + useMessageListPagination({ channel: makeChannel(paginator) }), + ); await act(async () => { - await result.current.loadLatestMessages(); - }); - - await waitFor(() => { - expect(loadMessageIntoState).toHaveBeenCalledTimes(1); - expect(result.current.state.hasMore).toBe(true); - expect(result.current.state.messages!.length).toBe(20); + await result.current.loadMore(); }); + expect(paginator.toTail).toHaveBeenCalledTimes(1); + expect(paginator.toHead).not.toHaveBeenCalled(); }); - describe('loadMore', () => { - afterEach(() => { - // Clear all mocks after each test - jest.clearAllMocks(); - // Restore all mocks to their original implementation - jest.restoreAllMocks(); - cleanup(); - }); - it('should not set the state when the loadMore function is called and hasPrev is false', async () => { - const queryFn = jest.fn(); - channel.state = { - ...channelInitialState, - messagePagination: { - hasNext: true, - hasPrev: false, - }, - } as unknown as typeof channel.state; - channel.query = queryFn as typeof channel.query; - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadMore(); - }); - - await waitFor(() => { - expect(queryFn).toHaveBeenCalledTimes(0); - }); - }); - - it('should not set the state when the loading more and loading more recent boolean are true', async () => { - const queryFn = jest.fn(); - channel.state = { - ...channelInitialState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - } as unknown as typeof channel.state; - channel.query = queryFn as typeof channel.query; - - mockedHook({ loadingMore: true, loadingMoreRecent: true }); - - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadMore(); - }); - - await waitFor(() => { - expect(queryFn).toHaveBeenCalledTimes(0); - }); + it('loadMoreRecent (newer) delegates to paginator.toHead()', async () => { + const paginator = makePaginator({ + hasMoreHead: true, + hasMoreTail: true, + isLoading: false, + items: [], }); - - it('should set the state when the loadMore function is called and hasPrev is true and loadingMore is false and loadingMoreRecent is false', async () => { - const messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - const queryFn = jest.fn(() => { - channel.state.messages = Array.from({ length: 40 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - (channel.state.messagePagination as { hasPrev: boolean }).hasPrev = true; - }); - channel.state = { - ...channelInitialState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - messages, - } as unknown as typeof channel.state; - channel.query = queryFn as unknown as typeof channel.query; - - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadMore(); - }); - - await waitFor(() => { - expect(queryFn).toHaveBeenCalledWith({ - messages: { id_lt: messages[0].id, limit: 25 }, - watchers: { - limit: 25, - }, - }); - expect(result.current.state.hasMore).toBe(true); - expect(result.current.state.messages!.length).toBe(40); - }); + const { result } = renderHook(() => + useMessageListPagination({ channel: makeChannel(paginator) }), + ); + await act(async () => { + await result.current.loadMoreRecent(); }); + expect(paginator.toHead).toHaveBeenCalledTimes(1); + expect(paginator.toTail).not.toHaveBeenCalled(); }); - describe('loadMoreRecent', () => { - afterEach(() => { - // Clear all mocks after each test - jest.clearAllMocks(); - // Restore all mocks to their original implementation - jest.restoreAllMocks(); - cleanup(); - }); - - it('should not set the state when the loadMoreRecent function is called and hasNext is false', async () => { - const queryFn = jest.fn(); - channel.state = { - ...channelInitialState, - messagePagination: { - hasNext: false, - hasPrev: true, - }, - } as unknown as typeof channel.state; - channel.query = queryFn as typeof channel.query; - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadMoreRecent(); - }); - - await waitFor(() => { - expect(queryFn).toHaveBeenCalledTimes(0); - }); + it('does not paginate when the paginator has no more in the requested direction', async () => { + const paginator = makePaginator({ + hasMoreHead: false, + hasMoreTail: false, + isLoading: false, + items: [], }); - - it('should not set the state when the loading more and loading more recent boolean are true', async () => { - const queryFn = jest.fn(); - channel.state = { - ...channelInitialState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - } as unknown as typeof channel.state; - channel.query = queryFn as typeof channel.query; - - mockedHook({ loadingMore: true, loadingMoreRecent: true }); - - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadMoreRecent(); - }); - - await waitFor(() => { - expect(queryFn).toHaveBeenCalledTimes(0); - }); - }); - - it('should set the state when the loadMoreRecent function is called and hasNext is true and loadingMore is false and loadingMoreRecent is false', async () => { - const messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - const queryFn = jest.fn(() => { - channel.state.messages = Array.from({ length: 40 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - (channel.state.messagePagination as { hasPrev: boolean }).hasPrev = true; - }); - channel.state = { - ...channelInitialState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - messages, - } as unknown as typeof channel.state; - channel.query = queryFn as unknown as typeof channel.query; - - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadMoreRecent(); - }); - - await waitFor(() => { - expect(queryFn).toHaveBeenCalledWith({ - messages: { id_gt: messages[messages.length - 1].id, limit: 10 }, - watchers: { limit: 10 }, - }); - expect(result.current.state.hasMore).toBe(true); - expect(result.current.state.messages!.length).toBe(40); - }); + const { result } = renderHook(() => + useMessageListPagination({ channel: makeChannel(paginator) }), + ); + await act(async () => { + await result.current.loadMore(); + await result.current.loadMoreRecent(); }); + expect(paginator.toTail).not.toHaveBeenCalled(); + expect(paginator.toHead).not.toHaveBeenCalled(); }); - describe('loadChannelAroundMessage', () => { - afterEach(() => { - // Clear all mocks after each test - jest.clearAllMocks(); - // Restore all mocks to their original implementation - jest.restoreAllMocks(); - cleanup(); + it('loadLatestMessages delegates to paginator.jumpToTheLatestMessage()', async () => { + const paginator = makePaginator({ + hasMoreHead: true, + hasMoreTail: true, + isLoading: false, + items: [], }); - - it('should not do anything when the messageId to search for is not passed', async () => { - const loadMessageIntoState = jest.fn(() => { - channel.state.messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - (channel.state.messagePagination as { hasPrev: boolean }).hasPrev = true; - }); - channel.state = { - ...channelInitialState, - loadMessageIntoState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - } as unknown as typeof channel.state; - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadChannelAroundMessage({ messageId: undefined }); - }); - - await waitFor(() => { - expect(loadMessageIntoState).toHaveBeenCalledTimes(0); - }); - }); - - it('should call the loadMessageIntoState function when the messageId to search for is passed and set the state', async () => { - const loadMessageIntoState = jest.fn(() => { - channel.state.messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - (channel.state.messagePagination as { hasPrev: boolean }).hasPrev = true; - }); - channel.state = { - ...channelInitialState, - loadMessageIntoState, - messagePagination: { - hasNext: false, - hasPrev: true, - }, - } as unknown as typeof channel.state; - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadChannelAroundMessage({ messageId: 'message-5' }); - }); - - await waitFor(() => { - expect(loadMessageIntoState).toHaveBeenCalledTimes(1); - expect(result.current.state.hasMore).toBe(true); - expect(result.current.state.hasMoreNewer).toBe(false); - expect(result.current.state.messages!.length).toBe(20); - expect(result.current.state.targetedMessageId).toBe('message-5'); - }); + const { result } = renderHook(() => + useMessageListPagination({ channel: makeChannel(paginator) }), + ); + await act(async () => { + await result.current.loadLatestMessages(); }); + expect(paginator.jumpToTheLatestMessage).toHaveBeenCalledTimes(1); }); - describe('loadChannelAtFirstUnreadMessage', () => { - afterEach(() => { - // Clear all mocks after each test - jest.clearAllMocks(); - // Restore all mocks to their original implementation - jest.restoreAllMocks(); - cleanup(); - }); - - it('should not do anything when the unread count is 0', async () => { - const messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - const loadMessageIntoState = jest.fn(() => { - channel.state.messages = messages; - (channel.state.messagePagination as { hasPrev: boolean }).hasPrev = true; - }); - channel.state = { - ...channelInitialState, - loadMessageIntoState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - } as unknown as typeof channel.state; - - const user = generateUser(); - const channelUnreadState = { - unread_messages: 0, - user, - }; - - const jumpToMessageFinishedMock = jest.fn(); - mockedHook(channelInitialState, { jumpToMessageFinished: jumpToMessageFinishedMock }); - - const { result } = renderHook(() => useMessageListPagination({ channel })); - - await act(async () => { - await result.current.loadChannelAtFirstUnreadMessage({ - channelUnreadState: channelUnreadState as unknown as Parameters< - typeof result.current.loadChannelAtFirstUnreadMessage - >[0]['channelUnreadState'], - }); - }); - - await waitFor(() => { - expect(jumpToMessageFinishedMock).toHaveBeenCalledTimes(0); - }); + it('loadChannelAroundMessage jumps to the message (emitting the focus signal)', async () => { + const paginator = makePaginator({ + hasMoreHead: true, + hasMoreTail: true, + isLoading: false, + items: [], }); - - const generateMessageArray = (length = 20) => - Array.from({ length }, (_, i) => generateMessage({ id: String(i), text: `message-${i}` })); - - type TestCaseUnreadState = { - first_unread_message_id?: string; - last_read_message_id?: string; - unread_messages: number; - }; - - type TestCase = { - channelUnreadState: (messages: LocalMessage[]) => TestCaseUnreadState; - expectedCalls: { - jumpToMessageFinishedCalls: number; - loadMessageIntoStateCalls: number; - setChannelUnreadStateCalls: number; - setTargetedMessageIdCalls: number; - targetedMessageId: (messages: LocalMessage[]) => string; - }; - initialMessages: LocalMessage[]; - name: string; - setupLoadMessageIntoState: ((channel: ChannelType) => jest.Mock) | null; - }; - - // Test cases with different scenarios - const testCases: TestCase[] = [ - { - channelUnreadState: (messages) => ({ - first_unread_message_id: messages[2].id, - unread_messages: 2, - }), - expectedCalls: { - jumpToMessageFinishedCalls: 1, - loadMessageIntoStateCalls: 0, - setChannelUnreadStateCalls: 0, - setTargetedMessageIdCalls: 1, - targetedMessageId: (messages) => messages[2].id, - }, - initialMessages: generateMessageArray(), - name: 'first_unread_message_id present in current message set', - setupLoadMessageIntoState: null, - }, - { - channelUnreadState: () => ({ - first_unread_message_id: '21', - unread_messages: 2, - }), - expectedCalls: { - jumpToMessageFinishedCalls: 1, - loadMessageIntoStateCalls: 1, - setChannelUnreadStateCalls: 0, - setTargetedMessageIdCalls: 1, - targetedMessageId: () => '21', - }, - initialMessages: generateMessageArray(), - name: 'first_unread_message_id not present in current message set', - setupLoadMessageIntoState: (channel) => { - const loadMessageIntoState = jest.fn(() => { - const newMessages = Array.from({ length: 20 }, (_, i) => - generateMessage({ id: String(i + 21), text: `message-${i + 21}` }), - ); - channel.state.messages = newMessages; - (channel.state.messagePagination as { hasPrev: boolean }).hasPrev = true; - }); - (channel.state as unknown as { loadMessageIntoState: jest.Mock }).loadMessageIntoState = - loadMessageIntoState; - return loadMessageIntoState; - }, - }, - { - channelUnreadState: (messages) => ({ - last_read_message_id: messages[2].id, - unread_messages: 2, - }), - expectedCalls: { - jumpToMessageFinishedCalls: 1, - loadMessageIntoStateCalls: 0, - setChannelUnreadStateCalls: 1, - setTargetedMessageIdCalls: 1, - targetedMessageId: (messages) => messages[3].id, - }, - initialMessages: generateMessageArray(), - name: 'last_read_message_id present in current message set', - setupLoadMessageIntoState: null, - }, - { - channelUnreadState: () => ({ - last_read_message_id: '21', - unread_messages: 2, - }), - expectedCalls: { - jumpToMessageFinishedCalls: 1, - loadMessageIntoStateCalls: 1, - setChannelUnreadStateCalls: 1, - setTargetedMessageIdCalls: 1, - targetedMessageId: () => '22', - }, - initialMessages: generateMessageArray(), - name: 'last_read_message_id not present in current message set', - setupLoadMessageIntoState: (channel) => { - const loadMessageIntoState = jest.fn(() => { - const newMessages = Array.from({ length: 20 }, (_, i) => - generateMessage({ id: String(i + 21), text: `message-${i + 21}` }), - ); - channel.state.messages = newMessages; - (channel.state.messagePagination as { hasPrev: boolean }).hasPrev = true; - }); - (channel.state as unknown as { loadMessageIntoState: jest.Mock }).loadMessageIntoState = - loadMessageIntoState; - return loadMessageIntoState; - }, - }, - ]; - - it.each(testCases)('$name', async (testCase) => { - // Setup channel state - const messages = testCase.initialMessages; - channel.state = { - ...channelInitialState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - messages, - } as unknown as typeof channel.state; - - // Setup additional mocks if needed - const loadMessageIntoStateMock = testCase.setupLoadMessageIntoState - ? testCase.setupLoadMessageIntoState(channel) - : null; - - // Generate user and channel unread state - const user = generateUser(); - const channelUnreadState = { - user, - ...testCase.channelUnreadState(messages), - }; - - // Setup mocks - const jumpToMessageFinishedMock = jest.fn(); - mockedHook(channelInitialState, { jumpToMessageFinished: jumpToMessageFinishedMock }); - - const { result } = renderHook(() => useMessageListPagination({ channel })); - - const setChannelUnreadStateMock = jest.fn(); - const setTargetedMessageIdMock = jest.fn((message) => message); - - // Execute the method - await act(async () => { - await result.current.loadChannelAtFirstUnreadMessage({ - channelUnreadState: channelUnreadState as unknown as Parameters< - typeof result.current.loadChannelAtFirstUnreadMessage - >[0]['channelUnreadState'], - setChannelUnreadState: setChannelUnreadStateMock, - setTargetedMessage: setTargetedMessageIdMock, - }); - }); - - // Verify expectations - await waitFor(() => { - if (loadMessageIntoStateMock) { - expect(loadMessageIntoStateMock).toHaveBeenCalledTimes( - testCase.expectedCalls.loadMessageIntoStateCalls, - ); - } - - expect(jumpToMessageFinishedMock).toHaveBeenCalledTimes( - testCase.expectedCalls.jumpToMessageFinishedCalls, - ); - - expect(setChannelUnreadStateMock).toHaveBeenCalledTimes( - testCase.expectedCalls.setChannelUnreadStateCalls, - ); - - expect(setTargetedMessageIdMock).toHaveBeenCalledTimes( - testCase.expectedCalls.setTargetedMessageIdCalls, - ); - - if (testCase.expectedCalls.targetedMessageId) { - const expectedMessageId = testCase.expectedCalls.targetedMessageId(messages); - expect(setTargetedMessageIdMock).toHaveBeenCalledWith(expectedMessageId); - } - }); + const { result } = renderHook(() => + useMessageListPagination({ channel: makeChannel(paginator) }), + ); + await act(async () => { + await result.current.loadChannelAroundMessage({ messageId: 'm7' }); }); - - const messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ - created_at: new Date('2021-09-01T00:00:00.000Z'), - id: String(i), - text: `message-${i}`, - }), + expect(paginator.jumpToMessage).toHaveBeenCalledWith( + 'm7', + expect.objectContaining({ focusReason: 'jump-to-message' }), ); + }); - const user = generateUser(); - - it.each` - scenario | last_read | expectedQueryCalls | expectedJumpToMessageFinishedCalls | expectedSetChannelUnreadStateCalls | expectedSetTargetedMessageCalls | expectedTargetedMessageId - ${'when last_read matches a message'} | ${new Date(messages[10].created_at)} | ${0} | ${1} | ${1} | ${1} | ${'10'} - ${'when last_read does not match any message'} | ${new Date('2021-09-02T00:00:00.000Z')} | ${1} | ${0} | ${0} | ${0} | ${undefined} - `( - '$scenario', - async ({ - expectedJumpToMessageFinishedCalls, - expectedQueryCalls, - expectedSetChannelUnreadStateCalls, - expectedSetTargetedMessageCalls, - expectedTargetedMessageId, - last_read, - }: { - expectedJumpToMessageFinishedCalls: number; - expectedQueryCalls: number; - expectedSetChannelUnreadStateCalls: number; - expectedSetTargetedMessageCalls: number; - expectedTargetedMessageId: string | undefined; - last_read: Date; - }) => { - // Set up channel state - channel.state = { - ...channelInitialState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - messages, - } as unknown as typeof channel.state; - - const channelUnreadState = { - last_read, - unread_messages: 2, - user, - }; - - // Mock query if needed - const queryMock = jest.fn().mockResolvedValue({ messages: [] }); - channel.query = queryMock as unknown as typeof channel.query; - - // Set up mocks - const jumpToMessageFinishedMock = jest.fn(); - mockedHook(channelInitialState, { jumpToMessageFinished: jumpToMessageFinishedMock }); - const setChannelUnreadStateMock = jest.fn(); - const setTargetedMessageIdMock = jest.fn((message) => message); - const addNotificationSpy = jest.spyOn(chatClient.notifications, 'add'); - - // Render hook - const { result } = renderHook(() => useMessageListPagination({ channel }), { - wrapper: createChatWrapper(chatClient), - }); - - // Act - await act(async () => { - await result.current.loadChannelAtFirstUnreadMessage({ - channelUnreadState, - setChannelUnreadState: setChannelUnreadStateMock, - setTargetedMessage: setTargetedMessageIdMock, - }); - }); - - // Assert - await waitFor(() => { - expect(queryMock).toHaveBeenCalledTimes(expectedQueryCalls); - if (expectedQueryCalls) { - expect(addNotificationSpy).toHaveBeenCalledWith({ - message: 'Failed to jump to the first unread message', - options: { - originalError: expect.any(Error), - severity: 'error', - tags: ['target:channel:channel:messaging:general'], - type: 'channel:jumpToFirstUnread:failed', - }, - origin: { - context: { feature: 'jumpToFirstUnread' }, - emitter: 'Channel', - }, - }); - } - expect(jumpToMessageFinishedMock).toHaveBeenCalledTimes( - expectedJumpToMessageFinishedCalls, - ); - expect(setChannelUnreadStateMock).toHaveBeenCalledTimes( - expectedSetChannelUnreadStateCalls, - ); - expect(setTargetedMessageIdMock).toHaveBeenCalledTimes(expectedSetTargetedMessageCalls); - - if (expectedTargetedMessageId !== undefined) { - expect(setTargetedMessageIdMock).toHaveBeenCalledWith(expectedTargetedMessageId); - } - }); - }, + it('loadChannelAtFirstUnreadMessage jumps to first unread (emitting the focus signal)', async () => { + const paginator = makePaginator( + { hasMoreHead: true, hasMoreTail: true, isLoading: false, items: [] }, + 'm5', ); + const { result } = renderHook(() => + useMessageListPagination({ channel: makeChannel(paginator) }), + ); + await act(async () => { + await result.current.loadChannelAtFirstUnreadMessage(); + }); + expect(paginator.jumpToTheFirstUnreadMessage).toHaveBeenCalledTimes(1); }); }); diff --git a/package/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts b/package/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts new file mode 100644 index 0000000000..bdb66f52cc --- /dev/null +++ b/package/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts @@ -0,0 +1,97 @@ +import { renderHook } from '@testing-library/react-native'; +import type { Channel, LocalMessage, Message } from 'stream-chat'; + +import { useChannelRequestHandlers } from '../useChannelRequestHandlers'; + +// NOTE: `stream-chat` is portaled to a local checkout during this migration; a runtime +// (value) import of it breaks jest resolution. Everything from `stream-chat` here is a +// type-only import (erased at compile time), and `configState` is faked, so no runtime +// `require('stream-chat')` happens. + +type FakeRequestHandlers = Record unknown>; +type FakeConfig = { requestHandlers?: FakeRequestHandlers }; + +const createChannel = ( + sendMessage: jest.Mock = jest.fn().mockResolvedValue({ message: { id: 'fallback' } }), +) => { + let config: FakeConfig = {}; + const configState = { + getLatestValue: (): FakeConfig => config, + partialNext: (patch: FakeConfig) => { + config = { ...config, ...patch }; + }, + }; + const channel = { cid: 'messaging:test', configState, sendMessage } as unknown as Channel; + return { channel, configState, getHandlers: () => config.requestHandlers, sendMessage }; +}; + +const localMessage = { id: 'm1', text: 'hi' } as unknown as LocalMessage; +const message = { text: 'hi' } as unknown as Message; + +describe('useChannelRequestHandlers', () => { + it('registers no managed handlers when no overrides are provided', () => { + const { channel, getHandlers } = createChannel(); + + renderHook(() => useChannelRequestHandlers({ channel })); + + expect(getHandlers()).toBeUndefined(); + }); + + it('registers send + retry from doSendMessageRequest and returns the override response', async () => { + const { channel, getHandlers } = createChannel(); + const doSendMessageRequest = jest.fn().mockResolvedValue({ message: { id: 'override' } }); + + renderHook(() => useChannelRequestHandlers({ channel, doSendMessageRequest })); + + expect(getHandlers()?.sendMessageRequest).toBeDefined(); + // send and retry share the same wrapper. + expect(getHandlers()?.retrySendMessageRequest).toBe(getHandlers()?.sendMessageRequest); + + const result = await getHandlers()?.sendMessageRequest?.({ localMessage, message }); + expect(doSendMessageRequest).toHaveBeenCalledWith('messaging:test', message, undefined); + expect(result).toEqual({ message: { id: 'override' } }); + }); + + it('falls back to channel.sendMessage when the override resolves without a message', async () => { + const { channel, getHandlers, sendMessage } = createChannel(); + const doSendMessageRequest = jest.fn().mockResolvedValue(undefined); + + renderHook(() => useChannelRequestHandlers({ channel, doSendMessageRequest })); + + const result = await getHandlers()?.sendMessageRequest?.({ localMessage, message }); + expect(sendMessage).toHaveBeenCalledWith(message, undefined); + expect(result).toEqual({ message: { id: 'fallback' } }); + }); + + it('registers updateMessageRequest from doUpdateMessageRequest', async () => { + const { channel, getHandlers } = createChannel(); + const doUpdateMessageRequest = jest.fn().mockResolvedValue({ message: { id: 'updated' } }); + + renderHook(() => useChannelRequestHandlers({ channel, doUpdateMessageRequest })); + + const result = await getHandlers()?.updateMessageRequest?.({ localMessage }); + expect(doUpdateMessageRequest).toHaveBeenCalledWith('messaging:test', localMessage, undefined); + expect(result).toEqual({ message: { id: 'updated' } }); + }); + + it('clears managed handlers when overrides are removed, preserving unrelated handlers', () => { + const { channel, configState, getHandlers } = createChannel(); + const markReadRequest = jest.fn(); + configState.partialNext({ requestHandlers: { markReadRequest } }); + + const doSendMessageRequest = jest.fn(); + const { rerender } = renderHook( + ({ send }: { send?: typeof doSendMessageRequest }) => + useChannelRequestHandlers({ channel, doSendMessageRequest: send }), + { initialProps: { send: doSendMessageRequest as typeof doSendMessageRequest | undefined } }, + ); + expect(getHandlers()?.sendMessageRequest).toBeDefined(); + + rerender({ send: undefined }); + + expect(getHandlers()?.sendMessageRequest).toBeUndefined(); + expect(getHandlers()?.retrySendMessageRequest).toBeUndefined(); + // an unrelated handler registered elsewhere must be preserved. + expect(getHandlers()?.markReadRequest).toBe(markReadRequest); + }); +}); diff --git a/package/src/components/Channel/hooks/useChannelDataState.ts b/package/src/components/Channel/hooks/useChannelDataState.ts deleted file mode 100644 index 1a7990c27d..0000000000 --- a/package/src/components/Channel/hooks/useChannelDataState.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { useCallback, useState } from 'react'; - -import { Channel, LocalMessage, ChannelState as StreamChannelState } from 'stream-chat'; - -export const channelInitialState = { - hasMore: true, - hasMoreNewer: false, - loading: false, - loadingMore: false, - loadingMoreRecent: false, - members: {}, - messages: [], - pinnedMessages: [], - read: {}, - targetedMessageId: undefined, - typing: {}, - watcherCount: 0, - watchers: {}, -}; - -/** - * The ChannelMessagesState object - */ -export type ChannelMessagesState = { - hasMore?: boolean; - hasMoreNewer?: boolean; - loading?: boolean; - loadingMore?: boolean; - loadingMoreRecent?: boolean; - messages?: StreamChannelState['messages']; - pinnedMessages?: StreamChannelState['pinnedMessages']; - targetedMessageId?: string; -}; - -/** - * The ChannelThreadState object - */ -export type ChannelThreadState = { - thread: LocalMessage | null; - threadHasMore?: boolean; - threadLoadingMore?: boolean; - threadMessages?: StreamChannelState['messages']; -}; - -/** - * The ChannelState object - */ -export type ChannelState = ChannelMessagesState & { - members?: StreamChannelState['members']; - read?: StreamChannelState['read']; - typing?: StreamChannelState['typing']; - watcherCount?: number; - watchers?: StreamChannelState['watchers']; -}; - -/** - * The useChannelMessageDataState hook that handles the state for the channel messages. - */ -export const useChannelMessageDataState = (channel: Channel) => { - const [state, setState] = useState({ - hasMore: true, - hasMoreNewer: false, - loading: false, - loadingMore: false, - loadingMoreRecent: false, - messages: channel?.state?.messages || [], - pinnedMessages: channel?.state?.pinnedMessages || [], - targetedMessageId: undefined, - }); - - const copyMessagesStateFromChannel = useCallback((channel: Channel) => { - setState((prev) => ({ - ...prev, - messages: [...channel.state.messages], - pinnedMessages: [...channel.state.pinnedMessages], - })); - }, []); - - const loadInitialMessagesStateFromChannel = useCallback((channel: Channel, hasMore: boolean) => { - setState((prev) => ({ - ...prev, - hasMore, - loading: false, - messages: [...channel.state.messages], - pinnedMessages: [...channel.state.pinnedMessages], - })); - }, []); - - const jumpToLatestMessage = useCallback(() => { - setState((prev) => ({ - ...prev, - hasMoreNewer: false, - loading: false, - targetedMessageId: undefined, - })); - }, []); - - const jumpToMessageFinished = useCallback((hasMoreNewer: boolean, targetedMessageId: string) => { - setState((prev) => ({ - ...prev, - hasMoreNewer, - loading: false, - targetedMessageId, - })); - }, []); - - const loadMoreFinished = useCallback((hasMore: boolean, messages: ChannelState['messages']) => { - setState((prev) => ({ - ...prev, - hasMore, - loadingMore: false, - messages, - })); - }, []); - - const setLoadingMore = useCallback((loadingMore: boolean) => { - setState((prev) => ({ - ...prev, - loadingMore, - })); - }, []); - - const setLoadingMoreRecent = useCallback((loadingMoreRecent: boolean) => { - setState((prev) => ({ - ...prev, - loadingMoreRecent, - })); - }, []); - - const setLoading = useCallback((loading: boolean) => { - setState((prev) => ({ - ...prev, - loading, - })); - }, []); - - const loadMoreRecentFinished = useCallback( - (hasMoreNewer: boolean, messages: ChannelState['messages']) => { - setState((prev) => ({ - ...prev, - hasMoreNewer, - loadingMoreRecent: false, - messages, - })); - }, - [], - ); - - return { - copyMessagesStateFromChannel, - jumpToLatestMessage, - jumpToMessageFinished, - loadInitialMessagesStateFromChannel, - loadMoreFinished, - loadMoreRecentFinished, - setLoading, - setLoadingMore, - setLoadingMoreRecent, - state, - }; -}; - -/** - * The useChannelThreadState hook that handles the state for the channel member, read, typing, watchers, etc. - */ -export const useChannelDataState = (channel: Channel) => { - const [state, setState] = useState({ - members: channel.state.members, - read: channel.state.read, - typing: {}, - watcherCount: 0, - watchers: {}, - }); - - const initStateFromChannel = useCallback( - (channel: Channel) => { - setState({ - ...state, - members: { ...channel.state.members }, - read: { ...channel.state.read }, - typing: { ...channel.state.typing }, - watcherCount: channel.state.watcher_count, - watchers: { ...channel.state.watchers }, - }); - }, - [state], - ); - - const copyStateFromChannel = useCallback((channel: Channel) => { - setState((prev) => ({ - ...prev, - members: { ...channel.state.members }, - read: { ...channel.state.read }, - watcherCount: channel.state.watcher_count, - watchers: { ...channel.state.watchers }, - })); - }, []); - - const setRead = useCallback((channel: Channel) => { - setState((prev) => ({ - ...prev, - read: { ...channel.state.read }, // Synchronize the read state from the channel - })); - }, []); - - const setTyping = useCallback((channel: Channel) => { - setState((prev) => ({ - ...prev, - typing: { ...channel.state.typing }, // Synchronize the typing state from the channel - })); - }, []); - - return { - copyStateFromChannel, - initStateFromChannel, - setRead, - setTyping, - state, - }; -}; diff --git a/package/src/components/Channel/hooks/useChannelRequestHandlers.ts b/package/src/components/Channel/hooks/useChannelRequestHandlers.ts new file mode 100644 index 0000000000..2c0b65244b --- /dev/null +++ b/package/src/components/Channel/hooks/useChannelRequestHandlers.ts @@ -0,0 +1,96 @@ +import { useEffect } from 'react'; + +import type { + Channel, + ChannelInstanceConfig, + Message, + SendMessageAPIResponse, + SendMessageOptions, + StreamChat, + UpdateMessageOptions, +} from 'stream-chat'; + +type RequestHandlers = NonNullable; + +export type ChannelRequestHandlersParams = { + channel: Channel; + /** Overrides the default mark-read request. Mirrors the `` prop. */ + doMarkReadRequest?: (channel: Channel) => void; + /** Overrides the default send/retry request. Mirrors the `` prop. */ + doSendMessageRequest?: ( + channelId: string, + message: Message, + options?: SendMessageOptions, + ) => Promise; + /** Overrides the default update request. Mirrors the `` prop. */ + doUpdateMessageRequest?: ( + channelId: string, + updatedMessage: Parameters[0], + options?: UpdateMessageOptions, + ) => ReturnType; +}; + +/** + * Registers the integrator's custom message-request overrides into + * `channel.configState.requestHandlers` so the `stream-chat` message-operations engine + * (`channel.sendMessageWithLocalUpdate` / `retrySendMessageWithLocalUpdate` / + * `updateMessageWithLocalUpdate`) honors them. + * + * The handlers this hook manages are (re)written whenever the channel or an override + * changes; overrides that are not provided are cleared so the client defaults apply. + * Delete and mark-read are intentionally left to the client default / the mark-read flow. + */ +export const useChannelRequestHandlers = ({ + channel, + doMarkReadRequest, + doSendMessageRequest, + doUpdateMessageRequest, +}: ChannelRequestHandlersParams) => { + useEffect(() => { + const currentRequestHandlers = channel.configState.getLatestValue().requestHandlers; + const nextRequestHandlers: RequestHandlers = { ...(currentRequestHandlers ?? {}) }; + + // Reset the handlers this hook manages, then register only the provided overrides. + delete nextRequestHandlers.markReadRequest; + delete nextRequestHandlers.retrySendMessageRequest; + delete nextRequestHandlers.sendMessageRequest; + delete nextRequestHandlers.updateMessageRequest; + + if (doMarkReadRequest) { + // RN's doMarkReadRequest performs the custom mark-read itself (returns void); its obsolete 2nd + // (setChannelUnreadUiState) arg is dropped now that unread state is the paginator snapshot. + nextRequestHandlers.markReadRequest = ({ channel: markReadChannel }) => { + doMarkReadRequest(markReadChannel); + return Promise.resolve(null); + }; + } + + if (doSendMessageRequest) { + const sendMessageRequest: RequestHandlers['sendMessageRequest'] = async ({ + message, + options, + }) => { + const response = await doSendMessageRequest(channel.cid, message as Message, options); + if (response?.message) { + return { message: response.message }; + } + const fallback = await channel.sendMessage(message as Message, options); + return { message: fallback.message }; + }; + + nextRequestHandlers.sendMessageRequest = sendMessageRequest; + nextRequestHandlers.retrySendMessageRequest = sendMessageRequest; + } + + if (doUpdateMessageRequest) { + nextRequestHandlers.updateMessageRequest = async ({ localMessage, options }) => ({ + message: (await doUpdateMessageRequest(channel.cid, localMessage, options)).message, + }); + } + + channel.configState.partialNext({ + requestHandlers: + Object.keys(nextRequestHandlers).length > 0 ? nextRequestHandlers : undefined, + }); + }, [channel, doMarkReadRequest, doSendMessageRequest, doUpdateMessageRequest]); +}; diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index 67a495449b..5a05e81755 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -4,7 +4,6 @@ import type { ChannelContextValue } from '../../../contexts/channelContext/Chann export const useCreateChannelContext = ({ channel, - channelUnreadStateStore, disabled, enableMessageGroupingByUser, enforceUniqueReaction, @@ -16,35 +15,19 @@ export const useCreateChannelContext = ({ loadChannelAroundMessage, loadChannelAtFirstUnreadMessage, loading, - markRead, maxTimeBetweenGroupedMessages, maximumMessageLimit, - members, - read, reloadChannel, scrollToFirstUnreadThreshold, - setChannelUnreadState, - setTargetedMessage, hasPendingInitialTargetLoad, - targetedMessage, threadList, uploadAbortControllerRef, - watcherCount, - watchers, }: ChannelContextValue) => { const channelId = channel?.id; - const membersLength = Object.keys(members).length; - - const readUsers = Object.values(read); - const readUsersLength = readUsers.length; - const readUsersLastReads = readUsers - .map(({ last_read }) => last_read?.toISOString() ?? '') - .join(); const channelContext: ChannelContextValue = useMemo( () => ({ channel, - channelUnreadStateStore, disabled, enableMessageGroupingByUser, enforceUniqueReaction, @@ -56,21 +39,13 @@ export const useCreateChannelContext = ({ loadChannelAroundMessage, loadChannelAtFirstUnreadMessage, loading, - markRead, maximumMessageLimit, maxTimeBetweenGroupedMessages, - members, - read, reloadChannel, scrollToFirstUnreadThreshold, - setChannelUnreadState, - setTargetedMessage, hasPendingInitialTargetLoad, - targetedMessage, threadList, uploadAbortControllerRef, - watcherCount, - watchers, }), // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -80,12 +55,7 @@ export const useCreateChannelContext = ({ isChannelActive, highlightedMessageId, loading, - membersLength, - readUsersLength, - readUsersLastReads, - targetedMessage, threadList, - watcherCount, maximumMessageLimit, ], ); diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index a237709a95..aed765164a 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -6,8 +6,6 @@ export const useCreateMessagesContext = ({ additionalPressableProps, channelId, customMessageSwipeAction, - deleteMessage, - deleteReaction, disableTypingIndicator, dismissKeyboardOnMessageTouch, enableMessageGroupingByUser, @@ -46,14 +44,9 @@ export const useCreateMessagesContext = ({ onPressMessage, reactionListPosition, reactionListType, - removeMessage, - retrySendMessage, selectReaction, - sendReaction, shouldShowUnreadUnderlay, supportedReactions, - targetedMessage, - updateMessage, urlPreviewType, }: MessagesContextValue & { /** @@ -70,8 +63,6 @@ export const useCreateMessagesContext = ({ () => ({ additionalPressableProps, customMessageSwipeAction, - deleteMessage, - deleteReaction, disableTypingIndicator, dismissKeyboardOnMessageTouch, enableMessageGroupingByUser, @@ -110,14 +101,9 @@ export const useCreateMessagesContext = ({ onPressMessage, reactionListPosition, reactionListType, - removeMessage, - retrySendMessage, selectReaction, - sendReaction, shouldShowUnreadUnderlay, supportedReactions, - targetedMessage, - updateMessage, urlPreviewType, }), // eslint-disable-next-line react-hooks/exhaustive-deps @@ -132,7 +118,6 @@ export const useCreateMessagesContext = ({ messageOverlayTargetId, supportedReactionsLength, myMessageTheme, - targetedMessage, hasCreatePoll, ], ); diff --git a/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts b/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts index 053c477db7..ecb885698d 100644 --- a/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts +++ b/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import type { Channel } from 'stream-chat'; @@ -7,6 +7,11 @@ import { OwnCapabilitiesContextValue, OwnCapability, } from '../../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; +import { useStateStore } from '../../../hooks/useStateStore'; + +const selector = (state: { ownCapabilities: string[] }) => ({ + ownCapabilities: state.ownCapabilities, +}); export const useCreateOwnCapabilitiesContext = ({ channel, @@ -15,35 +20,19 @@ export const useCreateOwnCapabilitiesContext = ({ channel: Channel; overrideCapabilities?: Partial; }) => { - const [own_capabilities, setOwnCapabilites] = useState( - JSON.stringify(channel.data?.own_capabilities as Array), - ); + // Sourced reactively from channel.state.ownCapabilitiesStore (kept up to date by the client + // on watch/query and `capabilities.changed`). + const { ownCapabilities = [] } = + useStateStore(channel.state.ownCapabilitiesStore, selector) ?? {}; + const overrideCapabilitiesStr = overrideCapabilities ? JSON.stringify(Object.values(overrideCapabilities)) : null; - - // Effect to watch for changes in channel.data?.own_capabilities and update the own_capabilities state accordingly. - useEffect(() => { - setOwnCapabilites(JSON.stringify(channel.data?.own_capabilities as Array)); - }, [channel.data?.own_capabilities]); - - // Effect to listen to the `capabilities.changed` event. - useEffect(() => { - const listener = channel.on('capabilities.changed', (event) => { - if (event.own_capabilities) { - setOwnCapabilites(JSON.stringify(event.own_capabilities as Array)); - } - }); - - return () => { - listener.unsubscribe(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const ownCapabilitiesStr = JSON.stringify(ownCapabilities); const ownCapabilitiesContext: OwnCapabilitiesContextValue = useMemo(() => { - const capabilities = (own_capabilities || []) as Array; - const ownCapabilitiesContext = Object.keys(allOwnCapabilities).reduce( + const capabilities = ownCapabilities as Array; + return Object.keys(allOwnCapabilities).reduce( (result, capability) => ({ ...result, [capability]: @@ -52,10 +41,8 @@ export const useCreateOwnCapabilitiesContext = ({ }), {} as OwnCapabilitiesContextValue, ); - - return ownCapabilitiesContext; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [channel.id, overrideCapabilitiesStr, own_capabilities]); + }, [channel.id, overrideCapabilitiesStr, ownCapabilitiesStr]); return ownCapabilitiesContext; }; diff --git a/package/src/components/Channel/hooks/useCreatePaginatedMessageListContext.ts b/package/src/components/Channel/hooks/useCreatePaginatedMessageListContext.ts deleted file mode 100644 index a06bafa032..0000000000 --- a/package/src/components/Channel/hooks/useCreatePaginatedMessageListContext.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useMemo } from 'react'; - -import type { PaginatedMessageListContextValue } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; - -import { reduceMessagesToString } from '../../../utils/utils'; - -export const useCreatePaginatedMessageListContext = ({ - channelId, - hasMore, - loadingMore, - loadingMoreRecent, - loadLatestMessages, - loadMore, - loadMoreRecent, - messages, - setLoadingMore, - setLoadingMoreRecent, - viewabilityChangedCallback, -}: PaginatedMessageListContextValue & { - channelId?: string; -}) => { - const messagesStr = reduceMessagesToString(messages); - - const paginatedMessagesContext: PaginatedMessageListContextValue = useMemo( - () => ({ - hasMore, - loadingMore, - loadingMoreRecent, - loadLatestMessages, - loadMore, - loadMoreRecent, - messages, - setLoadingMore, - setLoadingMoreRecent, - viewabilityChangedCallback, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [channelId, hasMore, loadingMore, loadingMoreRecent, messagesStr, viewabilityChangedCallback], - ); - - return paginatedMessagesContext; -}; diff --git a/package/src/components/Channel/hooks/useCreateThreadContext.ts b/package/src/components/Channel/hooks/useCreateThreadContext.ts index 6af2aa5a79..1b00cfc390 100644 --- a/package/src/components/Channel/hooks/useCreateThreadContext.ts +++ b/package/src/components/Channel/hooks/useCreateThreadContext.ts @@ -1,55 +1,23 @@ -import { ThreadState } from 'stream-chat'; +import { useMemo } from 'react'; import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext'; -import { useStateStore } from '../../../hooks'; - -const selector = (nextValue: ThreadState) => - ({ - isLoadingNext: nextValue.pagination.isLoadingNext, - isLoadingPrev: nextValue.pagination.isLoadingPrev, - latestReplies: nextValue.replies, - }) as const; +// 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. export const useCreateThreadContext = ({ allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, - closeThread, - loadMoreThread, - openThread, - reloadThread, - setThreadLoadingMore, - thread, - threadHasMore, threadInstance, - threadLoadingMore, - threadMessages, -}: ThreadContextValue) => { - const { isLoadingNext, isLoadingPrev, latestReplies } = - useStateStore(threadInstance?.state, selector) ?? {}; - - const contextAdapter = threadInstance - ? { - loadMoreRecentThread: threadInstance.loadNextPage, - loadMoreThread: threadInstance.loadPrevPage, - threadInstance, - threadLoadingMore: isLoadingPrev, - threadLoadingMoreRecent: isLoadingNext, - threadMessages: latestReplies ?? [], - } - : {}; - - return { - allowThreadMessagesInChannel, - onAlsoSentToChannelHeaderPress, - closeThread, - loadMoreThread, - openThread, - reloadThread, - setThreadLoadingMore, - thread, - threadHasMore, - threadLoadingMore, - threadMessages, - ...contextAdapter, - }; -}; +}: Pick< + ThreadContextValue, + 'allowThreadMessagesInChannel' | 'onAlsoSentToChannelHeaderPress' | 'threadInstance' +>): ThreadContextValue => + useMemo( + () => ({ + allowThreadMessagesInChannel, + onAlsoSentToChannelHeaderPress, + threadInstance, + }), + [allowThreadMessagesInChannel, onAlsoSentToChannelHeaderPress, threadInstance], + ); diff --git a/package/src/components/Channel/hooks/useCreateTypingContext.ts b/package/src/components/Channel/hooks/useCreateTypingContext.ts deleted file mode 100644 index 960177b8de..0000000000 --- a/package/src/components/Channel/hooks/useCreateTypingContext.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useMemo } from 'react'; - -import type { TypingContextValue } from '../../../contexts/typingContext/TypingContext'; - -export const useCreateTypingContext = ({ typing }: TypingContextValue) => { - const typingValue = Object.keys(typing).join(); - - const typingContext: TypingContextValue = useMemo( - () => ({ - typing, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [typingValue], - ); - - return typingContext; -}; diff --git a/package/src/components/Channel/hooks/useMessageListPagination.tsx b/package/src/components/Channel/hooks/useMessageListPagination.tsx index 44e74e41e5..77c1ca0626 100644 --- a/package/src/components/Channel/hooks/useMessageListPagination.tsx +++ b/package/src/components/Channel/hooks/useMessageListPagination.tsx @@ -1,189 +1,95 @@ -import { useRef } from 'react'; +import { useState } from 'react'; -import debounce from 'lodash/debounce'; -import { Channel, ChannelState, MessageResponse } from 'stream-chat'; - -import { useChannelMessageDataState } from './useChannelDataState'; +import { Channel, LocalMessage } from 'stream-chat'; import { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; -import { useStableCallback } from '../../../hooks'; -import { findInMessagesByDate, findInMessagesById } from '../../../utils/utils'; +import { useStableCallback, useStateStore } from '../../../hooks'; import { useNotificationApi } from '../../Notifications'; -const defaultDebounceInterval = 500; -const debounceOptions = { - leading: true, - trailing: true, +export const DEFAULT_HIGHLIGHT_DURATION = 3000; + +type MessagePaginatorState = { + hasMoreHead: boolean; + hasMoreTail: boolean; + isLoading: boolean; + items?: LocalMessage[]; }; +// Direction mapping (stream-chat MessagePaginator): +// tailward === id_lt === OLDER messages -> loadMore / hasMore +// headward === id_gt === NEWER messages -> loadMoreRecent / hasMoreNewer +const selector = (state: MessagePaginatorState) => ({ + hasMore: state.hasMoreTail, + hasMoreNewer: state.hasMoreHead, + isLoading: state.isLoading, + messages: state.items, +}); + /** - * The useMessageListPagination hook handles pagination for the message list. - * It provides functionality to load more messages, load more recent messages, load latest messages, and load channel around a specific message. + * The useMessageListPagination hook exposes the channel's message list + pagination, sourced + * from `channel.messagePaginator` (stream-chat). Pagination, jump-to-message, jump-to-latest and + * jump-to-first-unread are delegated to the paginator; the directional loading flags are kept as + * local UI state around the paginator calls. * - * @param channel The channel object for which the message list pagination is being handled. + * @param channel The channel whose message list is paginated. */ export const useMessageListPagination = ({ channel }: { channel: Channel }) => { const { addNotification } = useNotificationApi(); const { t } = useTranslationContext(); - const { - copyMessagesStateFromChannel, - jumpToLatestMessage, - jumpToMessageFinished, - loadInitialMessagesStateFromChannel, - loadMoreFinished: loadMoreFinishedFn, - loadMoreRecentFinished: loadMoreRecentFinishedFn, - setLoading, - setLoadingMore, - setLoadingMoreRecent, - state, - } = useChannelMessageDataState(channel); + const paginator = channel.messagePaginator; - // hard limit to prevent you from scrolling faster than 1 page per 2 seconds - const loadMoreFinished = useRef( - debounce( - (hasMore: boolean, messages: ChannelState['messages']) => { - loadMoreFinishedFn(hasMore, messages); - }, - defaultDebounceInterval, - debounceOptions, - ), - ).current; + const { hasMore, hasMoreNewer, isLoading, messages } = + useStateStore(paginator.state, selector) ?? {}; - // hard limit to prevent you from scrolling faster than 1 page per 2 seconds - const loadMoreRecentFinished = useRef( - debounce( - (hasMore: boolean, newMessages: ChannelState['messages']) => { - loadMoreRecentFinishedFn(hasMore, newMessages); - }, - defaultDebounceInterval, - debounceOptions, - ), - ).current; + const [loadingMore, setLoadingMore] = useState(false); + const [loadingMoreRecent, setLoadingMoreRecent] = useState(false); /** - * This function loads the latest messages in the channel. + * Loads the latest (newest) messages in the channel. */ const loadLatestMessages = useStableCallback(async () => { try { - setLoading(true); - await channel.state.loadMessageIntoState('latest'); - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - jumpToLatestMessage(); + await paginator.jumpToTheLatestMessage(); } catch (err) { console.warn('Loading latest messages failed with error:', err); } }); /** - * This function loads more messages before the first message in current channel state. + * Loads older messages (before the oldest loaded message). */ - const loadMore = useStableCallback(async (limit: number = 25) => { - if (!channel.state.messagePagination.hasPrev) { - return; - } - - if (state.loadingMore || state.loadingMoreRecent) { + const loadMore = useStableCallback(async () => { + if (!paginator.hasMoreTail || paginator.isLoading) { return; } - setLoadingMore(true); - const oldestMessage = state.messages?.[0]; - const oldestID = oldestMessage?.id; - try { - await channel.query({ - messages: { id_lt: oldestID, limit }, - watchers: { limit }, - }); - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - setLoadingMore(false); + await paginator.toTail(); } catch (e) { - setLoadingMore(false); console.warn('Message pagination(fetching old messages) request failed with error:', e); + } finally { + setLoadingMore(false); } }); /** - * This function loads more messages after the most recent message in current channel state. + * Loads newer messages (after the most recent loaded message). */ - const loadMoreRecent = useStableCallback(async (limit: number = 10) => { - if (!channel.state.messagePagination.hasNext) { - return; - } - - if (state.loadingMore || state.loadingMoreRecent) { + const loadMoreRecent = useStableCallback(async () => { + if (!paginator.hasMoreHead || paginator.isLoading) { return; } - setLoadingMoreRecent(true); - const newestMessage = state.messages?.[state?.messages.length - 1]; - const newestID = newestMessage?.id; - try { - await channel.query({ - messages: { id_gt: newestID, limit }, - watchers: { limit }, - }); - loadMoreRecentFinished(channel.state.messagePagination.hasNext, channel.state.messages); + await paginator.toHead(); } catch (e) { - setLoadingMoreRecent(false); console.warn('Message pagination(fetching new messages) request failed with error:', e); - return; + } finally { + setLoadingMoreRecent(false); } }); - /** - * Loads channel around a specific message - * - * @param messageId If undefined, channel will be loaded at most recent message. - */ - const loadChannelAroundMessage: ChannelContextValue['loadChannelAroundMessage'] = - useStableCallback( - async ({ limit = 25, messageId: messageIdToLoadAround, setTargetedMessage }) => { - if (!messageIdToLoadAround) { - return; - } - setLoadingMore(true); - setLoading(true); - try { - await channel.state.loadMessageIntoState(messageIdToLoadAround, undefined, limit); - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - jumpToMessageFinished(channel.state.messagePagination.hasNext, messageIdToLoadAround); - - if (setTargetedMessage) { - setTargetedMessage(messageIdToLoadAround); - } - } catch (error) { - setLoadingMore(false); - setLoading(false); - console.warn( - 'Message pagination(fetching messages in the channel around a message id) request failed with error:', - error, - ); - return; - } - }, - ); - - /** - * Fetch messages around a specific timestamp. - */ - const fetchMessagesAround = useStableCallback( - async (channel: Channel, timestamp: string, limit: number): Promise => { - try { - const { messages } = await channel.query( - { messages: { created_at_around: timestamp, limit } }, - 'new', - ); - return messages; - } catch (error) { - console.error('Error fetching messages around timestamp:', error); - throw error; - } - }, - ); - const notifyJumpToFirstUnreadError = useStableCallback((error: unknown) => { addNotification({ message: t('Failed to jump to the first unread message'), @@ -197,134 +103,59 @@ export const useMessageListPagination = ({ channel }: { channel: Channel }) => { }); /** - * Loads channel at first unread message. + * Loads the channel around a specific message and targets it for highlight. + * + * @param messageId If undefined, no-op. */ - const loadChannelAtFirstUnreadMessage: ChannelContextValue['loadChannelAtFirstUnreadMessage'] = - useStableCallback( - async ({ channelUnreadState, limit = 25, setChannelUnreadState, setTargetedMessage }) => { - try { - if (!channelUnreadState?.unread_messages) { - return; - } - const { first_unread_message_id, last_read, last_read_message_id } = channelUnreadState; - let firstUnreadMessageId = first_unread_message_id; - let lastReadMessageId = last_read_message_id; - let isInCurrentMessageSet = false; - const messagesState = channel.state.messages; - - // If the first unread message is already in the current message set, we don't need to load more messages. - if (firstUnreadMessageId) { - const messageIdx = findInMessagesById(messagesState, firstUnreadMessageId); - isInCurrentMessageSet = messageIdx !== -1; - } - // If the last read message is already in the current message set, we don't need to load more messages, and we set the first unread message id as that is what we want to operate on. - else if (lastReadMessageId) { - const messageIdx = findInMessagesById(messagesState, lastReadMessageId); - isInCurrentMessageSet = messageIdx !== -1; - firstUnreadMessageId = messageIdx > -1 ? messagesState[messageIdx + 1]?.id : undefined; - } else { - const lastReadTimestamp = last_read.getTime(); - const { index: lastReadIdx, message: lastReadMessage } = findInMessagesByDate( - messagesState, - last_read, - ); - if (lastReadMessage) { - lastReadMessageId = lastReadMessage.id; - firstUnreadMessageId = messagesState[lastReadIdx + 1].id; - isInCurrentMessageSet = !!firstUnreadMessageId; - } else { - setLoadingMore(true); - setLoading(true); - let messages; - try { - messages = await fetchMessagesAround(channel, last_read.toISOString(), limit); - } catch (error) { - setLoading(false); - loadMoreFinished(channel.state.messagePagination.hasPrev, messagesState); - notifyJumpToFirstUnreadError(error); - return; - } - - const firstMessageWithCreationDate = messages.find((msg) => msg.created_at); - if (!firstMessageWithCreationDate) { - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - throw new Error('Failed to jump to first unread message id.'); - } - const firstMessageTimestamp = new Date( - firstMessageWithCreationDate.created_at as string, - ).getTime(); - - if (lastReadTimestamp < firstMessageTimestamp) { - // whole channel is unread - firstUnreadMessageId = firstMessageWithCreationDate.id; - } else { - const result = findInMessagesByDate(messages, last_read); - lastReadMessageId = result.message?.id; - } - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - } - } - - // If we still don't have the first and last read message id, we can't proceed. - if (!firstUnreadMessageId && !lastReadMessageId) { - throw new Error('Failed to jump to first unread message id.'); - } - - // If the first unread message is not in the current message set, we need to load message around the id. - if (!isInCurrentMessageSet) { - try { - setLoadingMore(true); - setLoading(true); - const targetedMessage = (firstUnreadMessageId || lastReadMessageId) as string; - await channel.state.loadMessageIntoState(targetedMessage, undefined, limit); - /** - * if the index of the last read message on the page is beyond the half of the page, - * we have arrived to the oldest page of the channel - */ - const indexOfTarget = channel.state.messages.findIndex( - (message) => message.id === targetedMessage, - ); - - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - firstUnreadMessageId = - firstUnreadMessageId ?? channel.state.messages[indexOfTarget + 1].id; - } catch (error) { - setLoading(false); - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - notifyJumpToFirstUnreadError(error); - return; - } - } - - if (!firstUnreadMessageId) { - throw new Error('Failed to jump to first unread message id.'); - } - if (!first_unread_message_id && setChannelUnreadState) { - setChannelUnreadState({ - ...channelUnreadState, - first_unread_message_id: firstUnreadMessageId, - last_read_message_id: lastReadMessageId, - }); - } + const loadChannelAroundMessage: ChannelContextValue['loadChannelAroundMessage'] = + useStableCallback(async ({ messageId: messageIdToLoadAround }) => { + if (!messageIdToLoadAround) { + return; + } + try { + // jumpToMessage loads-around the target AND emits messageFocusSignal, which drives both the + // highlight and the scroll-to-target — no separate targeted-message React state needed. + await paginator.jumpToMessage(messageIdToLoadAround, { + focusReason: 'jump-to-message', + focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION, + }); + } catch (error) { + console.warn( + 'Message pagination(fetching messages around a message id) request failed with error:', + error, + ); + } + }); - jumpToMessageFinished(channel.state.messagePagination.hasNext, firstUnreadMessageId); - if (setTargetedMessage) { - setTargetedMessage(firstUnreadMessageId); - } - } catch (error) { - notifyJumpToFirstUnreadError(error); - } - }, - ); + /** + * Loads the channel at the first unread message. The paginator resolves the first-unread id + * from its unread snapshot / channel read state. + */ + const loadChannelAtFirstUnreadMessage: ChannelContextValue['loadChannelAtFirstUnreadMessage'] = + useStableCallback(async () => { + try { + // jumpToTheFirstUnreadMessage emits messageFocusSignal, which drives the highlight + scroll. + await paginator.jumpToTheFirstUnreadMessage({ + focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION, + }); + } catch (error) { + notifyJumpToFirstUnreadError(error); + } + }); return { - copyMessagesStateFromChannel, loadChannelAroundMessage, loadChannelAtFirstUnreadMessage, - loadInitialMessagesStateFromChannel, loadLatestMessages, loadMore, loadMoreRecent, - state, + state: { + hasMore, + hasMoreNewer, + loading: !!isLoading && !messages?.length, + loadingMore, + loadingMoreRecent, + messages, + }, }; }; diff --git a/package/src/components/Channel/hooks/useTargetedMessage.ts b/package/src/components/Channel/hooks/useTargetedMessage.ts deleted file mode 100644 index a158d93776..0000000000 --- a/package/src/components/Channel/hooks/useTargetedMessage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -export const useTargetedMessage = (messageId?: string) => { - const clearTargetedMessageCall = useRef>(undefined); - const [targetedMessage, setTargetedMessage] = useState(messageId); - const [highlightedMessageId, setHighlightedMessageId] = useState(); - const prevTargetedMessageRef = useRef(undefined); - - useEffect(() => { - prevTargetedMessageRef.current = targetedMessage; - if (targetedMessage) { - setHighlightedMessageId(targetedMessage); - } - }, [targetedMessage]); - - useEffect(() => { - clearTargetedMessageCall.current = setTimeout(() => { - setTargetedMessage(undefined); - setHighlightedMessageId(undefined); - }, 3000); - - return () => { - clearTargetedMessageCall.current && clearTimeout(clearTargetedMessageCall.current); - }; - }, []); - - const setTargetedMessageTimeoutRef = useRef((messageId: string | undefined) => { - clearTargetedMessageCall.current && clearTimeout(clearTargetedMessageCall.current); - - clearTargetedMessageCall.current = setTimeout(() => { - setTargetedMessage(undefined); - setHighlightedMessageId(undefined); - }, 3000); - - setTargetedMessage(messageId); - }); - - return { - highlightedMessageId, - prevTargetedMessage: prevTargetedMessageRef.current, - setTargetedMessage: setTargetedMessageTimeoutRef.current, - targetedMessage, - }; -}; diff --git a/package/src/components/ChannelList/hooks/useChannelMembersState.ts b/package/src/components/ChannelList/hooks/useChannelMembersState.ts index 0d150e62fa..fd06d966c4 100644 --- a/package/src/components/ChannelList/hooks/useChannelMembersState.ts +++ b/package/src/components/ChannelList/hooks/useChannelMembersState.ts @@ -1,21 +1,18 @@ -import { Channel, ChannelMemberResponse, EventTypes } from 'stream-chat'; +import type { Channel, ChannelMemberResponse } from 'stream-chat'; -import { useSelectedChannelState } from '../../../hooks/useSelectedChannelState'; +import { useStateStore } from '../../../hooks/useStateStore'; -const selector = (channel: Channel) => channel.state.members; -const keys: EventTypes[] = [ - 'member.added', - 'member.updated', - 'member.removed', - 'user.banned', - 'user.unbanned', - 'user.presence.changed', - 'user.updated', -]; +const selector = (state: { members: Record }) => ({ + members: state.members, +}); + +/** + * Returns the channel's members, sourced reactively from `channel.state.membersStore`. + */ export function useChannelMembersState(channel: Channel): Record; export function useChannelMembersState( channel?: Channel, ): Record | undefined; export function useChannelMembersState(channel?: Channel) { - return useSelectedChannelState({ channel, selector, stateChangeEventKeys: keys }); + return useStateStore(channel?.state?.membersStore, selector)?.members; } diff --git a/package/src/components/ChannelList/hooks/useChannelOnlineMemberCount.ts b/package/src/components/ChannelList/hooks/useChannelOnlineMemberCount.ts index 2fc758f85c..8df2764ef1 100644 --- a/package/src/components/ChannelList/hooks/useChannelOnlineMemberCount.ts +++ b/package/src/components/ChannelList/hooks/useChannelOnlineMemberCount.ts @@ -1,17 +1,15 @@ -import { Channel, EventTypes } from 'stream-chat'; +import type { Channel } from 'stream-chat'; -import { useChatContext } from '../../../contexts'; -import { useSyncClientEventsToChannel } from '../../../hooks/useSyncClientEvents'; +import { useStateStore } from '../../../hooks/useStateStore'; -const selector = (channel: Channel) => { - return channel.state.watcher_count ?? 0; -}; - -const keys: EventTypes[] = ['user.watching.start', 'user.watching.stop']; +const selector = (state: { watcherCount: number }) => ({ watcherCount: state.watcherCount }); +/** + * Returns the channel's online (watcher) count, sourced reactively from + * `channel.state.watcherStore`. + */ export function useChannelOnlineMemberCount(channel: Channel): number; export function useChannelOnlineMemberCount(channel?: Channel): number | undefined; export function useChannelOnlineMemberCount(channel?: Channel) { - const { client } = useChatContext(); - return useSyncClientEventsToChannel({ channel, client, selector, stateChangeEventKeys: keys }); + return useStateStore(channel?.state?.watcherStore, selector)?.watcherCount; } diff --git a/package/src/components/ChannelList/hooks/useMutedUsers.ts b/package/src/components/ChannelList/hooks/useMutedUsers.ts index fdab2ff06b..b49bfee0c4 100644 --- a/package/src/components/ChannelList/hooks/useMutedUsers.ts +++ b/package/src/components/ChannelList/hooks/useMutedUsers.ts @@ -1,19 +1,14 @@ -import { Channel, EventTypes, Mute, StreamChat } from 'stream-chat'; +import type { Mute } from 'stream-chat'; import { useChatContext } from '../../../contexts'; -import { useSyncClientEvents } from '../../../hooks/useSyncClientEvents'; +import { useStateStore } from '../../../hooks/useStateStore'; -const selector = (client: StreamChat) => client.mutedUsers; -const keys: EventTypes[] = ['health.check', 'notification.mutes_updated']; +const selector = (state: { mutedUsers: Mute[] }) => ({ mutedUsers: state.mutedUsers }); -export function useMutedUsers(): Array; /** - * - * @param @deprecated _channel - This parameter is deprecated because it is no longer necessary. It is kept for backwards compatibility only. - * @returns + * Returns the current user's muted users, sourced reactively from `client.mutedUsersStore`. */ -export function useMutedUsers(_channel: Channel): Array | undefined; -export function useMutedUsers(_channel?: Channel): Array { +export function useMutedUsers(): Mute[] { const { client } = useChatContext(); - return useSyncClientEvents({ client, selector, stateChangeEventKeys: keys }); + return useStateStore(client?.mutedUsersStore, selector)?.mutedUsers ?? []; } diff --git a/package/src/components/Chat/hooks/useClientMutedUsers.ts b/package/src/components/Chat/hooks/useClientMutedUsers.ts index 46f84de9b5..c42807ab25 100644 --- a/package/src/components/Chat/hooks/useClientMutedUsers.ts +++ b/package/src/components/Chat/hooks/useClientMutedUsers.ts @@ -1,19 +1,11 @@ -import { useEffect, useState } from 'react'; +import type { Mute, StreamChat } from 'stream-chat'; -import type { Event, Mute, StreamChat } from 'stream-chat'; +import { useStateStore } from '../../../hooks/useStateStore'; -export const useClientMutedUsers = (client: StreamChat) => { - const [mutedUsers, setMutedUsers] = useState(client?.mutedUsers || []); +const selector = (state: { mutedUsers: Mute[] }) => ({ mutedUsers: state.mutedUsers }); - useEffect(() => { - const handleEvent = (event: Event) => { - setMutedUsers((mutes) => event.me?.mutes || mutes || []); - }; - - const listener = client?.on('notification.mutes_updated', handleEvent); - return () => listener?.unsubscribe(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [setMutedUsers]); - - return mutedUsers; -}; +/** + * Returns the client's muted users, sourced reactively from `client.mutedUsersStore`. + */ +export const useClientMutedUsers = (client: StreamChat): Mute[] => + useStateStore(client?.mutedUsersStore, selector)?.mutedUsers ?? []; diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 212386b269..ccb76b0da2 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -11,13 +11,20 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Portal } from 'react-native-teleport'; -import type { Attachment, LocalMessage, MentionEntity, UserResponse } from 'stream-chat'; +import type { + Attachment, + LocalMessage, + MembersState, + MentionEntity, + UserResponse, +} from 'stream-chat'; import { useCreateMessageContext } from './hooks/useCreateMessageContext'; import { useMessageActionHandlers } from './hooks/useMessageActionHandlers'; import { useMessageActions } from './hooks/useMessageActions'; -import { useMessageDeliveredData } from './hooks/useMessageDeliveryData'; -import { useMessageReadData } from './hooks/useMessageReadData'; +import { useMessageDeliveredToCount } from './hooks/useMessageDeliveredToCount'; +import { MessageOperations, useMessageOperations } from './hooks/useMessageOperations'; +import { useMessageReadCount } from './hooks/useMessageReadCount'; import { useProcessReactions } from './hooks/useProcessReactions'; import { DEFAULT_MESSAGE_OVERLAY_TARGET_ID } from './messageOverlayConstants'; import { MessageOverlayWrapper } from './MessageOverlayWrapper'; @@ -50,13 +57,12 @@ import { } from '../../contexts/messagesContext/MessagesContext'; import { useOwnCapabilitiesContext } from '../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { TranslationContextValue, useTranslationContext, } from '../../contexts/translationContext/TranslationContext'; -import { useStableCallback } from '../../hooks'; +import { useStableCallback, useStateStore } from '../../hooks'; import { isVideoPlayerAvailable, NativeHandlers } from '../../native'; import { closeOverlay, @@ -186,9 +192,8 @@ export type MessageActionHandlers = { export type MessagePropsWithContext = Pick< ChannelContextValue, - 'channel' | 'enforceUniqueReaction' | 'members' -> & - Pick & + 'channel' | 'enforceUniqueReaction' +> & { members: MembersState['members'] } & Pick & Partial< Omit< MessageContextValue, @@ -205,9 +210,16 @@ export type MessagePropsWithContext = Pick< 'groupStyles' | 'message' | 'isMessageAIGenerated' | 'readBy' | 'deliveredToCount' > & Pick< - MessagesContextValue, + MessageOperations, | 'sendReaction' | 'deleteMessage' + | 'removeMessage' + | 'deleteReaction' + | 'retrySendMessage' + | 'updateMessage' + > & + Pick< + MessagesContextValue, | 'dismissKeyboardOnMessageTouch' | 'forceAlignMessages' | 'handleBan' @@ -231,14 +243,9 @@ export type MessagePropsWithContext = Pick< | 'onLongPressMessage' | 'onPressInMessage' | 'onPressMessage' - | 'removeMessage' - | 'deleteReaction' - | 'retrySendMessage' | 'selectReaction' | 'supportedReactions' - | 'updateMessage' > & - Pick & Pick & { chatContext: ChatContextValue; messagesContext: MessagesContextValue; @@ -305,7 +312,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { onPressInMessage: onPressInMessageProp, onPressMessage: onPressMessageProp, onThreadSelect, - openThread, preventPress, removeMessage, retrySendMessage, @@ -554,9 +560,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { if (onThreadSelect) { onThreadSelect(message); } - if (openThread) { - openThread(message); - } }; const { existingReactions, hasReactions } = useProcessReactions({ @@ -635,7 +638,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { handleBlockUser, message, onThreadSelect, - openThread, removeMessage, retrySendMessage, selectReaction, @@ -850,9 +852,12 @@ const MessageWithContext = (props: MessagePropsWithContext) => { groupStyles?.[0] === 'bottom' ? groupStyles[0] : undefined; + + const lastPaginatorMessageId = channel?.messagePaginator?.state + .getLatestValue() + .items?.slice(-1)[0]?.id; const isVeryLastBubble = - messagesContext.enableMessageGroupingByUser && - channel?.state.messages[channel.state.messages.length - 1]?.id === message.id; + messagesContext.enableMessageGroupingByUser && lastPaginatorMessageId === message.id; const styles = useStyles({ groupKey, highlightedMessage: (isTargetedMessage || message.pinned) && !isMessageTypeDeleted, @@ -1127,6 +1132,8 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit const MemoizedMessage = React.memo(MessageWithContext, areEqual) as typeof MessageWithContext; +const messageMembersSelector = (state: MembersState) => ({ members: state.members }); + export type MessageProps = Partial< Omit > & @@ -1140,29 +1147,32 @@ export type MessageProps = Partial< */ export const Message = (props: MessageProps) => { const { message } = props; - const { channel, enforceUniqueReaction, members } = useChannelContext(); + const { channel, enforceUniqueReaction } = useChannelContext(); + const { members } = useStateStore(channel.state.membersStore, messageMembersSelector) ?? { + members: {}, + }; const chatContext = useChatContext(); const { dismissKeyboard } = useKeyboardContext(); const messagesContext = useMessagesContext(); - const { openThread } = useThreadContext(); + const messageOperations = useMessageOperations(); const { t } = useTranslationContext(); - const readBy = useMessageReadData({ message }); - const deliveredTo = useMessageDeliveredData({ message }); + const readByCount = useMessageReadCount({ message }); + const deliveredToCount = useMessageDeliveredToCount({ message }); const { setQuotedMessage, setEditingState } = useMessageComposerAPIContext(); return ( & Pick & { @@ -95,7 +92,7 @@ export type MessageBounceProps = Partial & { export const MessageBounce = (props: MessageBounceProps) => { const { message } = useMessageContext(); - const { removeMessage, retrySendMessage } = useMessagesContext(); + const { removeMessage, retrySendMessage } = useMessageOperations(); const { setEditingState } = useMessageComposerAPIContext(); return ( > & +export type MessageFooterProps = Partial> & MessageFooterComponentProps & { alignment?: Alignment; lastGroupMessage?: boolean; diff --git a/package/src/components/Message/MessageItemView/MessageItemView.tsx b/package/src/components/Message/MessageItemView/MessageItemView.tsx index 4d9a842873..f4d9975894 100644 --- a/package/src/components/Message/MessageItemView/MessageItemView.tsx +++ b/package/src/components/Message/MessageItemView/MessageItemView.tsx @@ -330,7 +330,9 @@ const areEqual = ( return false; } - const channelEqual = prevChannel?.state.messages.length === nextChannel?.state.messages.length; + const channelEqual = + prevChannel?.messagePaginator.state.getLatestValue().items?.length === + nextChannel?.messagePaginator.state.getLatestValue().items?.length; if (!channelEqual) { return false; } diff --git a/package/src/components/Message/MessageItemView/MessageWrapper.tsx b/package/src/components/Message/MessageItemView/MessageWrapper.tsx index 2a86922a19..282cc45446 100644 --- a/package/src/components/Message/MessageItemView/MessageWrapper.tsx +++ b/package/src/components/Message/MessageItemView/MessageWrapper.tsx @@ -1,8 +1,8 @@ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; -import { LocalMessage } from 'stream-chat'; +import { LocalMessage, UnreadSnapshotState } from 'stream-chat'; import { useMessageDateSeparator } from '../../../components/MessageList/hooks/useMessageDateSeparator'; import { useMessageGroupStyles } from '../../../components/MessageList/hooks/useMessageGroupStyles'; @@ -14,16 +14,8 @@ import { useMessagesContext } from '../../../contexts/messagesContext/MessagesCo import { ThemeProvider, useTheme } from '../../../contexts/themeContext/ThemeContext'; import { useStateStore } from '../../../hooks/useStateStore'; -import { ChannelUnreadStateStoreType } from '../../../state-store/channel-unread-state'; import { primitives } from '../../../theme'; -const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ - first_unread_message_id: state.channelUnreadState?.first_unread_message_id, - last_read_message_id: state.channelUnreadState?.last_read_message_id, - last_read_timestamp: state.channelUnreadState?.last_read?.getTime(), - unread_messages: state.channelUnreadState?.unread_messages, -}); - export type MessageWrapperProps = { message: LocalMessage; previousMessage?: LocalMessage; @@ -34,7 +26,6 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW const { message, previousMessage, nextMessage } = props; const { client } = useChatContext(); const { - channelUnreadStateStore, channel, hideDateSeparators, highlightedMessageId, @@ -52,7 +43,6 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW previousMessage, }); - const isNewestMessage = nextMessage === undefined; const groupStyles = useMessageGroupStyles({ dateSeparatorDate, getMessageGroupStyle, @@ -63,8 +53,66 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW noGroupByUser, }); - const { first_unread_message_id, last_read_timestamp, last_read_message_id, unread_messages } = - useStateStore(channelUnreadStateStore.state, channelUnreadStateSelector); + const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); + const nextMessageId = nextMessage?.id; + const nextMessageIsOwn = nextMessage?.user?.id === client.userID; + const nextMessageCreatedAt = nextMessage?.created_at + ? new Date(nextMessage.created_at).getTime() + : undefined; + + // The unread separator belongs above the first UNREAD message from another user — i.e. on the row + // whose newer neighbour is that first unread. We locate it per-message inside the selector so + // `useStateStore`'s per-key comparison keeps the flag referentially stable (`false === false`) for + // every non-boundary row: a mark-read changes the channel-wide unread fields but only re-renders + // the one or two boundary rows whose flag actually flips, not the whole list. + // + // We deliberately do NOT anchor on `lastReadMessageId`. It tracks the last read message from + // ANOTHER user and is not advanced by our own sends, so anchoring on it drops the separator in + // front of our own just-sent messages (read → us → new-unread would wrongly separate before "us"). + // Skipping our own messages (they are always read) places it correctly above the first incoming + // unread instead. + const showUnreadSeparatorSelector = useCallback( + (snapshot: UnreadSnapshotState) => { + let showUnreadSeparator: boolean; + if (snapshot.firstUnreadMessageId) { + // Frozen boundary (channel open / mark-unread): anchor directly to the known first-unread id. + showUnreadSeparator = nextMessageId === snapshot.firstUnreadMessageId; + } else if (snapshot.unreadCount) { + // Live boundary: the separator sits above the FIRST unread-from-another message — i.e. on + // the last genuinely-read row (created at/before the read boundary) whose newer neighbour is + // that first unread. Gating on "this row is read" (rather than "this row isn't itself an + // unread-from-another") keeps it to a SINGLE separator even when our own messages are + // interleaved among the unreads: an own message sent after the boundary is not "read" by + // this test, so `own → unread-from-another` transitions further down never start a second + // separator. Chronological ordering guarantees exactly one read→unread transition. + const lastReadAtMs = snapshot.lastReadAt?.getTime() ?? 0; + const nextIsUnreadFromOther = + !!nextMessageId && + !nextMessageIsOwn && + nextMessageCreatedAt !== undefined && + nextMessageCreatedAt > lastReadAtMs; + const thisIsRead = + typeof createdAtTimestamp === 'number' && createdAtTimestamp <= lastReadAtMs; + showUnreadSeparator = nextIsUnreadFromOther && thisIsRead; + } else { + showUnreadSeparator = false; + } + + return { + showUnreadSeparator, + // Only the boundary row needs the unread count (for its inline indicator). Gating it on + // `showUnreadSeparator` keeps this `undefined` for every other row, so the channel-wide + // count changing on each mark-read doesn't re-render the whole list. + unreadCount: showUnreadSeparator ? snapshot.unreadCount : undefined, + }; + }, + [createdAtTimestamp, nextMessageCreatedAt, nextMessageId, nextMessageIsOwn], + ); + const { showUnreadSeparator, unreadCount } = useStateStore( + channel.messagePaginator.unreadStateSnapshot, + showUnreadSeparatorSelector, + ); + const { theme: { messageList: { messageContainer }, @@ -75,17 +123,6 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW return null; } - const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); - const isLastReadMessage = - last_read_message_id === message.id || - (!unread_messages && createdAtTimestamp === last_read_timestamp); - - const showUnreadSeparator = - isLastReadMessage && - !isNewestMessage && - // The `channelUnreadState?.first_unread_message_id` is here for sent messages unread label - (!!first_unread_message_id || !!unread_messages); - const showUnreadUnderlay = !!shouldShowUnreadUnderlay && showUnreadSeparator; const wrapMessageInTheme = client.userID === message.user?.id && !!myMessageTheme; @@ -125,7 +162,7 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW )} {showUnreadUnderlay && ( - + )} diff --git a/package/src/components/Message/hooks/useMessageActionHandlers.ts b/package/src/components/Message/hooks/useMessageActionHandlers.ts index 8ed432d9c7..8b55ae29d1 100644 --- a/package/src/components/Message/hooks/useMessageActionHandlers.ts +++ b/package/src/components/Message/hooks/useMessageActionHandlers.ts @@ -3,6 +3,7 @@ import { Alert } from 'react-native'; import { UserResponse } from 'stream-chat'; +import { MessageOperations } from './useMessageOperations'; import { useUserMuteActive } from './useUserMuteActive'; import { useScreenReaderEnabled } from '../../../a11y/hooks/useScreenReaderEnabled'; @@ -60,9 +61,10 @@ export const useMessageActionHandlers = ({ setEditingState, setQuotedMessage, }: Pick< - MessagesContextValue, - 'sendReaction' | 'deleteMessage' | 'deleteReaction' | 'retrySendMessage' | 'supportedReactions' + MessageOperations, + 'sendReaction' | 'deleteMessage' | 'deleteReaction' | 'retrySendMessage' > & + Pick & Pick & Pick & Pick & diff --git a/package/src/components/Message/hooks/useMessageActions.tsx b/package/src/components/Message/hooks/useMessageActions.tsx index 3f6a2b62ed..d6be87b145 100644 --- a/package/src/components/Message/hooks/useMessageActions.tsx +++ b/package/src/components/Message/hooks/useMessageActions.tsx @@ -4,6 +4,7 @@ import { Alert } from 'react-native'; import { LocalMessage } from 'stream-chat'; import { useMessageActionHandlers } from './useMessageActionHandlers'; +import { MessageOperations } from './useMessageOperations'; import { useUserMuteActive } from './useUserMuteActive'; @@ -14,7 +15,6 @@ import { MessageComposerAPIContextValue } from '../../../contexts/messageCompose import type { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; import type { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; import { useTheme } from '../../../contexts/themeContext/ThemeContext'; -import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext'; import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext'; import { useStableCallback } from '../../../hooks'; @@ -24,33 +24,35 @@ import { MessageStatusTypes } from '../../../utils/utils'; import type { MessageActionType } from '../../MessageMenu/MessageActionListItem'; export type MessageActionsHookProps = Pick< - MessagesContextValue, + MessageOperations, | 'deleteMessage' | 'sendReaction' - | 'handleBan' - | 'handleCopy' - | 'handleDelete' - | 'handleDeleteForMe' - | 'handleEdit' - | 'handleFlag' - | 'handleQuotedReply' - | 'handleMarkUnread' - | 'handleMute' - | 'handlePinMessage' - | 'handleRetry' - | 'handleReaction' - | 'handleThreadReply' - | 'handleBlockUser' | 'removeMessage' | 'deleteReaction' | 'retrySendMessage' - | 'selectReaction' - | 'supportedReactions' | 'updateMessage' > & + Pick< + MessagesContextValue, + | 'handleBan' + | 'handleCopy' + | 'handleDelete' + | 'handleDeleteForMe' + | 'handleEdit' + | 'handleFlag' + | 'handleQuotedReply' + | 'handleMarkUnread' + | 'handleMute' + | 'handlePinMessage' + | 'handleRetry' + | 'handleReaction' + | 'handleThreadReply' + | 'handleBlockUser' + | 'selectReaction' + | 'supportedReactions' + > & Pick & Pick & - Pick & Pick & Pick & { onThreadSelect?: (message: LocalMessage) => void; @@ -78,7 +80,6 @@ export const useMessageActions = ({ handleBlockUser, message, onThreadSelect, - openThread, retrySendMessage, selectReaction, sendReaction, @@ -125,9 +126,6 @@ export const useMessageActions = ({ if (onThreadSelect) { onThreadSelect(message); } - if (openThread) { - openThread(message); - } }); const onBanUser = useStableCallback(async () => { diff --git a/package/src/components/Message/hooks/useMessageData.ts b/package/src/components/Message/hooks/useMessageData.ts index d4f501a15f..ef9be449b5 100644 --- a/package/src/components/Message/hooks/useMessageData.ts +++ b/package/src/components/Message/hooks/useMessageData.ts @@ -27,8 +27,9 @@ export const useMessageData = ({ const isMyMessage = propIsMyMessage || contextIsMyMessage; const message = propMessage || contextMessage; + const paginatorItems = channel?.messagePaginator?.state.getLatestValue().items; const isVeryLastMessage = - channel?.state.messages[channel?.state.messages.length - 1]?.id === message.id; + !!paginatorItems?.length && paginatorItems[paginatorItems.length - 1]?.id === message.id; const messageGroupedSingle = groupStyles.includes('single'); const messageGroupedBottom = groupStyles.includes('bottom'); diff --git a/package/src/components/Message/hooks/useMessageDeliveredToCount.ts b/package/src/components/Message/hooks/useMessageDeliveredToCount.ts new file mode 100644 index 0000000000..a5ec721b36 --- /dev/null +++ b/package/src/components/Message/hooks/useMessageDeliveredToCount.ts @@ -0,0 +1,34 @@ +import { useCallback } from 'react'; + +import type { LocalMessage, MessageReceiptsSnapshot } from 'stream-chat'; + +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; +import { useStateStore } from '../../../hooks/useStateStore'; + +/** + * Returns the number of users the given message has been delivered to, sourced reactively from + * the channel's `messageReceiptsTracker.snapshotStore`. + * + * Selecting the count (a number, compared by value) instead of the delivered-to array (compared + * by reference) means a row only re-renders when the count actually changes — not on every + * receipts emit that reallocates the array. Prefer this over `useMessageDeliveredData` whenever + * you only need the count; use `useMessageDeliveredData` when you need the actual users. + */ +export const useMessageDeliveredToCount = ({ message }: { message?: LocalMessage }) => { + const { channel } = useChannelContext(); + const messageId = message?.id ?? ''; + + const selector = useCallback( + (snapshot: MessageReceiptsSnapshot) => ({ + deliveredToCount: snapshot.deliveredByMessageId[messageId]?.length ?? 0, + }), + [messageId], + ); + + const { deliveredToCount } = useStateStore( + channel.messageReceiptsTracker.snapshotStore, + selector, + ); + + return deliveredToCount; +}; diff --git a/package/src/components/Message/hooks/useMessageDeliveryData.ts b/package/src/components/Message/hooks/useMessageDeliveryData.ts index 508ec4a202..75af50df8c 100644 --- a/package/src/components/Message/hooks/useMessageDeliveryData.ts +++ b/package/src/components/Message/hooks/useMessageDeliveryData.ts @@ -1,46 +1,29 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback } from 'react'; -import { Event, LocalMessage, UserResponse } from 'stream-chat'; +import type { LocalMessage, MessageReceiptsSnapshot, UserResponse } from 'stream-chat'; import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; -import { useChatContext } from '../../../contexts/chatContext/ChatContext'; -import { useStableCallback } from '../../../hooks'; +import { useStateStore } from '../../../hooks/useStateStore'; +const EMPTY_DELIVERED: UserResponse[] = []; + +/** + * Returns the users the given message has been delivered to, sourced reactively from the + * channel's `messageReceiptsTracker.snapshotStore` (precomputed `deliveredByMessageId`). + * Updates whenever the receipts snapshot changes — no manual event subscription required. + */ export const useMessageDeliveredData = ({ message }: { message?: LocalMessage }) => { const { channel } = useChannelContext(); - const { client } = useChatContext(); - - const messageIdRef = useRef(message?.id); - - const calculate = useStableCallback(() => { - if (!message?.created_at) { - return []; - } - const messageRef = { - msgId: message.id, - timestampMs: new Date(message.created_at).getTime(), - }; - return channel.messageReceiptsTracker.deliveredForMessage(messageRef); - }); - - const [deliveredTo, setDeliveredTo] = useState(() => calculate()); + const messageId = message?.id ?? ''; - if (!!messageIdRef.current && !!message?.id && messageIdRef.current !== message.id) { - setDeliveredTo(calculate()); - messageIdRef.current = message.id; - } + const selector = useCallback( + (snapshot: MessageReceiptsSnapshot) => ({ + deliveredTo: snapshot.deliveredByMessageId[messageId] ?? EMPTY_DELIVERED, + }), + [messageId], + ); - useEffect(() => { - const { unsubscribe } = channel.on('message.delivered', (event: Event) => { - /** - * An optimization to only re-calculate if the event is received by a different user. - */ - if (event.user?.id !== client.user?.id) { - setDeliveredTo(calculate()); - } - }); - return unsubscribe; - }, [channel, calculate, client.user?.id]); + const { deliveredTo } = useStateStore(channel.messageReceiptsTracker.snapshotStore, selector); return deliveredTo; }; diff --git a/package/src/components/Message/hooks/useMessageOperations.ts b/package/src/components/Message/hooks/useMessageOperations.ts new file mode 100644 index 0000000000..ff8bfd8b43 --- /dev/null +++ b/package/src/components/Message/hooks/useMessageOperations.ts @@ -0,0 +1,195 @@ +import { + Channel as ChannelClass, + DeleteMessageOptions, + LocalMessage, + MessageResponse, + Reaction, +} from 'stream-chat'; + +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useThreadContext } from '../../../contexts/threadContext/ThreadContext'; +import { useStableCallback } from '../../../hooks'; +import { addReactionToLocalState } from '../../../utils/addReactionToLocalState'; +import { MessageStatusTypes } from '../../../utils/utils'; + +export type MessageOperations = { + // FIXME: Remove the signature with optionsOrHardDelete boolean with the next major release + deleteMessage: ( + message: LocalMessage, + optionsOrHardDelete?: boolean | DeleteMessageOptions, + ) => Promise; + deleteReaction: (type: string, messageId: string) => Promise; + removeMessage: (message: { id: string; parent_id?: string }) => Promise; + retrySendMessage: (message: LocalMessage) => Promise; + sendReaction: (type: string, messageId: string) => Promise; + updateMessage: (updatedMessage: MessageResponse | LocalMessage) => void; +}; + +/** + * Message operations (delete/remove/retry/react/update) resolved from the channel and — when a + * thread is open — the thread instance. These are thin wrappers over the stream-chat message + * operations engine (`*WithLocalUpdate`, `messagePaginator.ingestItem/removeItem`), which owns the + * optimistic lifecycle, offline-DB persistence and paginator ingest. Previously these lived on the + * MessagesContext; they now resolve per-message (mirroring stream-chat-react, which has no shared + * action context) so the operations stay co-located with the LLC state they drive. + */ +export const useMessageOperations = (): MessageOperations => { + const { client, enableOfflineSupport } = useChatContext(); + const { channel, enforceUniqueReaction } = useChannelContext(); + const { threadInstance } = useThreadContext(); + + const updateMessage: MessageOperations['updateMessage'] = useStableCallback((updatedMessage) => { + if (!channel) { + return; + } + + const formatted = channel.state.formatMessage(updatedMessage); + if (!updatedMessage.parent_id || updatedMessage.show_in_channel) { + channel.messagePaginator.ingestItem(formatted); + } + if (updatedMessage.parent_id) { + threadInstance?.messagePaginator?.ingestItem(formatted); + } + }); + + /** + * Removes the message from local state + */ + const removeMessage: MessageOperations['removeMessage'] = useStableCallback(async (message) => { + if (channel) { + // removeItem is a no-op when the item isn't in that paginator, so removing from the channel + // unconditionally is safe (a hidden reply simply isn't there) and covers replies shown in + // the channel; replies additionally live in the open thread's paginator. No channel.state + // write (that store is being removed). + channel.messagePaginator.removeItem({ id: message.id }); + if (message.parent_id) { + threadInstance?.messagePaginator?.removeItem({ id: message.id }); + } + } + + if (client.offlineDb) { + await client.offlineDb.handleRemoveMessage({ messageId: message.id }); + } + }); + + const retrySendMessage: MessageOperations['retrySendMessage'] = useStableCallback( + async (localMessage) => { + await (threadInstance ?? channel).retrySendMessageWithLocalUpdate({ localMessage }); + }, + ); + + const sendReaction: MessageOperations['sendReaction'] = useStableCallback( + async (type, messageId) => { + if (!channel?.id || !client.user) { + throw new Error('Channel has not been initialized'); + } + + const payload: Parameters = [ + messageId, + { + type, + } as Reaction, + { enforce_unique: enforceUniqueReaction }, + ]; + + if (enableOfflineSupport) { + await addReactionToLocalState({ + channel, + enforceUniqueReaction, + messageId, + reactionType: type, + user: client.user, + }); + + const reactedMessage = channel.state.findMessage(messageId); + if (reactedMessage) { + const formatted = channel.state.formatMessage(reactedMessage); + if (!reactedMessage.parent_id || reactedMessage.show_in_channel) { + channel.messagePaginator.ingestItem(formatted); + } + if (reactedMessage.parent_id) { + threadInstance?.messagePaginator?.ingestItem(formatted); + } + } + } + + const sendReactionResponse = await channel.sendReaction(...payload); + + if (sendReactionResponse?.message) { + threadInstance?.upsertReplyLocally?.({ message: sendReactionResponse.message }); + } + }, + ); + + const deleteReaction: MessageOperations['deleteReaction'] = useStableCallback( + async (type, messageId) => { + if (!channel?.id || !client.user) { + throw new Error('Channel has not been initialized'); + } + + const payload: Parameters = [messageId, type]; + + if (enableOfflineSupport) { + channel.state.removeReaction({ + created_at: '', + message_id: messageId, + type, + updated_at: '', + }); + + const reactedMessage = channel.state.findMessage(messageId); + if (reactedMessage) { + const formatted = channel.state.formatMessage(reactedMessage); + if (!reactedMessage.parent_id || reactedMessage.show_in_channel) { + channel.messagePaginator.ingestItem(formatted); + } + if (reactedMessage.parent_id) { + threadInstance?.messagePaginator?.ingestItem(formatted); + } + } + } + + await channel.deleteReaction(...payload); + }, + ); + + const deleteMessage: MessageOperations['deleteMessage'] = useStableCallback( + async (message, optionsOrHardDelete = false) => { + if (!channel?.id) { + throw new Error('Channel has not been initialized yet'); + } + + // A failed (never-sent) message exists only locally — remove it without a server delete. + if (message.status === MessageStatusTypes.FAILED) { + await removeMessage(message); + return; + } + + let options: DeleteMessageOptions = {}; + if (typeof optionsOrHardDelete === 'boolean') { + options = optionsOrHardDelete ? { hardDelete: true } : {}; + } else if (optionsOrHardDelete?.deleteForMe) { + options = { deleteForMe: true }; + } else if (optionsOrHardDelete?.hardDelete) { + options = { hardDelete: true }; + } + + // The LLC performs the delete request (honoring any configState delete handler) and ingests + // the deleted message into the paginator. Thread deletes route through the thread instance. + await (threadInstance ?? channel).deleteMessageWithLocalUpdate({ + localMessage: message, + options, + }); + }, + ); + + return { + deleteMessage, + deleteReaction, + removeMessage, + retrySendMessage, + sendReaction, + updateMessage, + }; +}; diff --git a/package/src/components/Message/hooks/useMessageReadCount.ts b/package/src/components/Message/hooks/useMessageReadCount.ts new file mode 100644 index 0000000000..ec56016a01 --- /dev/null +++ b/package/src/components/Message/hooks/useMessageReadCount.ts @@ -0,0 +1,31 @@ +import { useCallback } from 'react'; + +import type { LocalMessage, MessageReceiptsSnapshot } from 'stream-chat'; + +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; +import { useStateStore } from '../../../hooks/useStateStore'; + +/** + * Returns the number of users who have read the given message, sourced reactively from the + * channel's `messageReceiptsTracker.snapshotStore`. + * + * Selecting the count (a number, compared by value) instead of the readers array (compared by + * reference) means a row only re-renders when the count actually changes — not on every receipts + * emit that reallocates the array. Prefer this over `useMessageReadData` whenever you only need + * the count (e.g. read indicators); use `useMessageReadData` when you need the actual users. + */ +export const useMessageReadCount = ({ message }: { message?: LocalMessage }) => { + const { channel } = useChannelContext(); + const messageId = message?.id ?? ''; + + const selector = useCallback( + (snapshot: MessageReceiptsSnapshot) => ({ + readCount: snapshot.readersByMessageId[messageId]?.length ?? 0, + }), + [messageId], + ); + + const { readCount } = useStateStore(channel.messageReceiptsTracker.snapshotStore, selector); + + return readCount; +}; diff --git a/package/src/components/Message/hooks/useMessageReadData.ts b/package/src/components/Message/hooks/useMessageReadData.ts index 1c72027c7d..9b619eca20 100644 --- a/package/src/components/Message/hooks/useMessageReadData.ts +++ b/package/src/components/Message/hooks/useMessageReadData.ts @@ -1,47 +1,29 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback } from 'react'; -import { Event, LocalMessage, UserResponse } from 'stream-chat'; +import type { LocalMessage, MessageReceiptsSnapshot, UserResponse } from 'stream-chat'; import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; -import { useChatContext } from '../../../contexts/chatContext/ChatContext'; -import { useStableCallback } from '../../../hooks'; +import { useStateStore } from '../../../hooks/useStateStore'; +const EMPTY_READERS: UserResponse[] = []; + +/** + * Returns the users who have read the given message, sourced reactively from the channel's + * `messageReceiptsTracker.snapshotStore` (precomputed `readersByMessageId`). Updates whenever + * the receipts snapshot changes — no manual event subscription required. + */ export const useMessageReadData = ({ message }: { message?: LocalMessage }) => { const { channel } = useChannelContext(); - const { client } = useChatContext(); - - const messageIdRef = useRef(message?.id); - - const calculate = useStableCallback(() => { - if (!message?.created_at) { - return []; - } - const messageRef = { - msgId: message.id, - timestampMs: new Date(message.created_at).getTime(), - }; - - return channel.messageReceiptsTracker.readersForMessage(messageRef); - }); - - const [readBy, setReadBy] = useState(() => calculate()); + const messageId = message?.id ?? ''; - if (!!messageIdRef.current && !!message?.id && messageIdRef.current !== message.id) { - setReadBy(calculate()); - messageIdRef.current = message.id; - } + const selector = useCallback( + (snapshot: MessageReceiptsSnapshot) => ({ + readBy: snapshot.readersByMessageId[messageId] ?? EMPTY_READERS, + }), + [messageId], + ); - useEffect(() => { - const { unsubscribe } = channel.on('message.read', (event: Event) => { - /** - * An optimization to only re-calculate if the event is received by a different user. - */ - if (event.user?.id !== client.user?.id) { - setReadBy(calculate()); - } - }); - return unsubscribe; - }, [channel, calculate, client.user?.id]); + const { readBy } = useStateStore(channel.messageReceiptsTracker.snapshotStore, selector); return readBy; }; diff --git a/package/src/components/Message/utils/messageActions.ts b/package/src/components/Message/utils/messageActions.ts index 7c98b2c155..6566a4914c 100644 --- a/package/src/components/Message/utils/messageActions.ts +++ b/package/src/components/Message/utils/messageActions.ts @@ -1,10 +1,10 @@ import type { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; -import type { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; import type { OwnCapabilitiesContextValue } from '../../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; import { isClipboardAvailable } from '../../../native'; import { FileTypes } from '../../../types/types'; import type { MessageActionType } from '../../MessageMenu/MessageActionListItem'; +import type { MessageOperations } from '../hooks/useMessageOperations'; export type MessageActionsParams = { banUser: MessageActionType; @@ -27,7 +27,7 @@ export type MessageActionsParams = { // Optional Actions deleteForMeMessage?: MessageActionType; } & Pick & - Pick; + Pick; export type MessageActionsProp = (param: MessageActionsParams) => MessageActionType[]; diff --git a/package/src/components/MessageInput/MessageComposer.tsx b/package/src/components/MessageInput/MessageComposer.tsx index d0c9bba4e8..3f1229a70b 100644 --- a/package/src/components/MessageInput/MessageComposer.tsx +++ b/package/src/components/MessageInput/MessageComposer.tsx @@ -10,7 +10,7 @@ import Animated, { import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { type UserResponse } from 'stream-chat'; +import { type MembersState, type UserResponse, type WatcherState } from 'stream-chat'; import { MicPositionProvider } from './contexts/MicPositionContext'; @@ -138,8 +138,10 @@ const useStyles = () => { }; type MessageComposerPropsWithContext = Pick & - Pick & - Pick< + Pick & { + members: MembersState['members']; + watchers: WatcherState['watchers']; + } & Pick< MessageInputContextValue, | 'audioRecorderManager' | 'additionalTextInputProps' @@ -175,6 +177,9 @@ const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ height: state.height, }); +const membersSelector = (state: MembersState) => ({ members: state.members }); +const watchersSelector = (state: WatcherState) => ({ watchers: state.watchers }); + const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { const { additionalTextInputProps, @@ -612,7 +617,11 @@ export const MessageComposer = (props: MessageComposerProps) => { const { isOnline } = useChatContext(); const ownCapabilities = useOwnCapabilitiesContext(); - const { channel, members, watchers } = useChannelContext(); + const { channel } = useChannelContext(); + const { members } = useStateStore(channel.state.membersStore, membersSelector) ?? { members: {} }; + const { watchers } = useStateStore(channel.state.watcherStore, watchersSelector) ?? { + watchers: {}, + }; const { audioRecorderManager, @@ -649,8 +658,14 @@ export const MessageComposer = (props: MessageComposerProps) => { /** * Disable the message input if the channel is frozen, or the user doesn't have the capability to send a message. * Enable it in frozen mode, if it the input has editing state. + * */ - if (!ownCapabilities.sendMessage && !editing && SendMessageDisallowedIndicator) { + if ( + channel.initialized && + !ownCapabilities.sendMessage && + !editing && + SendMessageDisallowedIndicator + ) { return ; } diff --git a/package/src/components/MessageList/InlineLoadingMoreIndicator.tsx b/package/src/components/MessageList/InlineLoadingMoreIndicator.tsx index dd7d5cefd4..89a618c469 100644 --- a/package/src/components/MessageList/InlineLoadingMoreIndicator.tsx +++ b/package/src/components/MessageList/InlineLoadingMoreIndicator.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; -import { usePaginatedMessageListContext } from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; const styles = StyleSheet.create({ @@ -53,8 +52,8 @@ const MemoizedInlineLoadingMoreIndicator = React.memo( areEqual, ) as typeof InlineLoadingMoreIndicatorWithContext; -export const InlineLoadingMoreIndicator = () => { - const { loadingMore } = usePaginatedMessageListContext(); - - return ; -}; +export const InlineLoadingMoreIndicator = ({ + loadingMore, +}: InlineLoadingMoreIndicatorPropsWithContext) => ( + +); diff --git a/package/src/components/MessageList/InlineLoadingMoreRecentIndicator.tsx b/package/src/components/MessageList/InlineLoadingMoreRecentIndicator.tsx index c531f15754..308dfb56f6 100644 --- a/package/src/components/MessageList/InlineLoadingMoreRecentIndicator.tsx +++ b/package/src/components/MessageList/InlineLoadingMoreRecentIndicator.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; -import { usePaginatedMessageListContext } from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { primitives } from '../../theme'; @@ -54,8 +53,8 @@ const MemoizedInlineLoadingMoreRecentIndicator = React.memo( areEqual, ) as typeof InlineLoadingMoreRecentIndicatorWithContext; -export const InlineLoadingMoreRecentIndicator = () => { - const { loadingMoreRecent } = usePaginatedMessageListContext(); - - return ; -}; +export const InlineLoadingMoreRecentIndicator = ({ + loadingMoreRecent, +}: InlineLoadingMoreRecentIndicatorPropsWithContext) => ( + +); diff --git a/package/src/components/MessageList/InlineLoadingMoreRecentThreadIndicator.tsx b/package/src/components/MessageList/InlineLoadingMoreRecentThreadIndicator.tsx index a752a49513..74408a5945 100644 --- a/package/src/components/MessageList/InlineLoadingMoreRecentThreadIndicator.tsx +++ b/package/src/components/MessageList/InlineLoadingMoreRecentThreadIndicator.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; -import { useThreadContext } from '../../contexts'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { primitives } from '../../theme'; @@ -54,8 +53,12 @@ const MemoizedInlineLoadingMoreRecentIndicator = React.memo( areEqual, ) as typeof InlineLoadingMoreRecentIndicatorWithContext; -export const InlineLoadingMoreRecentThreadIndicator = () => { - const { threadLoadingMoreRecent } = useThreadContext(); - - return ; +export const InlineLoadingMoreRecentThreadIndicator = ( + _props: InlineLoadingMoreRecentThreadIndicatorPropsWithContext, +) => { + // The reply paginator exposes a single `isLoading` flag with no head/tail direction, so a + // dedicated "loading newer replies" state isn't available yet; keep this indicator dormant (it + // was previously driven by `threadLoadingMoreRecent`, which was never actually produced). + // TODO: wire to a directional loading flag on MessagePaginator once one exists. + return ; }; diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 2fd8404012..51f746069d 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -1,5 +1,6 @@ import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + AppState, LayoutChangeEvent, ScrollViewProps, StyleSheet, @@ -11,9 +12,11 @@ import { import Animated from 'react-native-reanimated'; import type { FlashListProps, FlashListRef } from '@shopify/flash-list'; -import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; +import type { Channel, Event, LocalMessage } from 'stream-chat'; +import { useMarkRead } from './hooks/useMarkRead'; import { useMessageList } from './hooks/useMessageList'; + import { useScrollToBottomAccessibilityAction } from './hooks/useScrollToBottomAccessibilityAction'; import { useShouldScrollToRecentOnNewOwnMessage } from './hooks/useShouldScrollToRecentOnNewOwnMessage'; import { useTypingUsers } from './hooks/useTypingUsers'; @@ -47,10 +50,6 @@ import { OwnCapabilitiesContextValue, useOwnCapabilitiesContext, } from '../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; -import { - PaginatedMessageListContextValue, - usePaginatedMessageListContext, -} from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; @@ -61,6 +60,9 @@ import { MessageInputHeightState } from '../../state-store/message-input-height- import { primitives } from '../../theme'; import { FileTypes } from '../../types/types'; import { transitions } from '../../utils/animations/transitions'; +import { getChannelUnreadState } from '../../utils/getChannelUnreadState'; +import { MarkReadFunctionOptions } from '../Channel/Channel'; +import { useMessageListPagination } from '../Channel/hooks/useMessageListPagination'; import { MessageWrapper } from '../Message/MessageItemView/MessageWrapper'; import { excludeCanceledUploadNotifications } from '../Notifications/notificationFilters'; import { PortalWhileClosingView } from '../UIComponents/PortalWhileClosingView'; @@ -93,26 +95,13 @@ const flatListViewabilityConfig: ViewabilityConfig = { }; const hasReadLastMessage = (channel: Channel, userId: string) => { - const latestMessageIdInChannel = - channel.state.latestMessages[channel.state.latestMessages.length - 1]?.id; + const latestMessageIdInChannel = channel.messagePaginator.state + .getLatestValue() + .items?.at(-1)?.id; const lastReadMessageIdServer = channel.state.read[userId]?.last_read_message_id; return latestMessageIdInChannel === lastReadMessageIdServer; }; -const getPreviousLastMessage = (messages: LocalMessage[], newMessage?: MessageResponse) => { - if (!newMessage) return; - let previousLastMessage; - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (!msg?.id) break; - if (msg.id !== newMessage.id) { - previousLastMessage = msg; - break; - } - } - return previousLastMessage; -}; - const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ height: state.height, }); @@ -125,19 +114,14 @@ type MessageFlashListPropsWithContext = Pick< Pick< ChannelContextValue, | 'channel' - | 'channelUnreadStateStore' | 'disabled' | 'hideStickyDateHeader' | 'highlightedMessageId' | 'loadChannelAroundMessage' | 'loading' - | 'markRead' | 'reloadChannel' | 'scrollToFirstUnreadThreshold' - | 'setChannelUnreadState' - | 'setTargetedMessage' | 'hasPendingInitialTargetLoad' - | 'targetedMessage' | 'threadList' | 'maximumMessageLimit' > & @@ -145,16 +129,17 @@ type MessageFlashListPropsWithContext = Pick< Pick< MessageInputContextValue, 'allowSendBeforeAttachmentsUpload' | 'messageInputFloating' | 'messageInputHeightStore' - > & - Pick & - Pick< + > & { + loadMore: () => Promise; + loadMoreRecent: () => Promise; + markRead: (options?: MarkReadFunctionOptions) => void; + loadingMore?: boolean; + loadingMoreRecent?: boolean; + } & Pick< MessagesContextValue, 'disableTypingIndicator' | 'FlatList' | 'myMessageTheme' | 'shouldShowUnreadUnderlay' > & - Pick< - ThreadContextValue, - 'loadMoreRecentThread' | 'loadMoreThread' | 'thread' | 'threadInstance' - > & { + 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. @@ -187,7 +172,7 @@ type MessageFlashListPropsWithContext = Pick< * This is a [ListFooterComponent](https://facebook.github.io/react-native/docs/flatlist#listheadercomponent) of FlatList * used in MessageList. Should be used for header if inverted is false */ - HeaderComponent?: React.ComponentType; + HeaderComponent?: React.ComponentType<{ loadingMore?: boolean }>; /** Whether or not the FlatList is inverted. Defaults to true */ inverted?: boolean; /** Turn off grouping of messages by user */ @@ -198,7 +183,7 @@ type MessageFlashListPropsWithContext = Pick< * * @param message A message object to open the thread upon. */ - onThreadSelect?: (message: ThreadContextValue['thread']) => void; + onThreadSelect?: (message: LocalMessage | null) => void; /** * Use `setFlatListRef` to get access to ref to inner FlatList. * @@ -311,6 +296,13 @@ const getItemTypeInternal = (message: LocalMessage) => { return 'generic-message'; }; +const messageFocusSelector = (state: { + signal: { messageId?: string; token?: number } | null; +}) => ({ + focusedMessageId: state.signal?.messageId, + focusToken: state.signal?.token, +}); + const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => { const LoadingMoreRecentIndicator = props.threadList ? InlineLoadingMoreRecentThreadIndicator @@ -320,7 +312,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => attachmentPickerStore, additionalFlashListProps, channel, - channelUnreadStateStore, client, closePicker, disabled, @@ -332,10 +323,10 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => isLiveStreaming = false, loadChannelAroundMessage, loading, + loadingMore, + loadingMoreRecent, loadMore, loadMoreRecent, - loadMoreRecentThread, - loadMoreThread, markRead, maximumMessageLimit, messageInputFloating, @@ -346,12 +337,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => onListScroll, onThreadSelect, reloadChannel, - setChannelUnreadState, setFlatListRef, - setTargetedMessage, hasPendingInitialTargetLoad, - targetedMessage, - thread, threadInstance, threadList = false, } = props; @@ -376,6 +363,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const [hasMoved, setHasMoved] = useState(false); const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); + const [isAppActive, setIsAppActive] = useState(() => AppState.currentState === 'active'); + const isNewestMessageVisibleRef = useRef(false); const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); const [stickyHeaderDate, setStickyHeaderDate] = useState(); const [scrollEnabled, setScrollEnabled] = useState(true); @@ -411,6 +400,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const { processedMessageList, rawMessageList, viewabilityChangedCallback } = useMessageList({ isFlashList: true, isLiveStreaming, + maximumMessageLimit, threadList, }); @@ -479,66 +469,77 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => } }, [disabled]); + // Scroll-to-target is driven by the paginator's messageFocusSignal (thread-aware): a jump emits + // it, and the effect below scrolls to it. `token` re-fires the effect on every jump (even to the + // same id); see MessageList for the full rationale. + const focusPaginator = threadList ? threadInstance?.messagePaginator : channel.messagePaginator; + const { focusedMessageId, focusToken } = + useStateStore(focusPaginator?.messageFocusSignal, messageFocusSelector) ?? {}; + const lastFocusScrollTokenRef = useRef(undefined); + + // Clear the focus/highlight signal on unmount (or when switching channel/thread). The signal + // lives on the LLC paginator, which outlives this component — without this, a highlight still + // active when you navigate away re-fires the scroll+highlight on return (the scroll-token ref + // resets on remount). Mirrors the old React-state highlight that cleaned up on unmount. + useEffect(() => { + const paginator = focusPaginator; + return () => paginator?.clearMessageFocusSignal(); + }, [focusPaginator]); + /** - * Check if a messageId needs to be scrolled to after list loads, and scroll to it - * Note: This effect fires on every list change with a small debounce so that scrolling isnt abrupted by an immediate rerender + * Scrolls to the focused message (messageFocusSignal) once it's rendered. Re-attempts when the + * list updates while a focus is pending, and marks each focus token handled so unrelated list + * changes during the highlight window don't re-scroll. */ useEffect(() => { - if (!targetedMessage) { + if (!focusedMessageId || focusToken === lastFocusScrollTokenRef.current) { return; } const indexOfParentInMessageList = processedMessageList.findIndex( - (message) => message?.id === targetedMessage, + (message) => message?.id === focusedMessageId, ); - // the message we want to scroll to has not been loaded in the state yet + // Not in the rendered window yet (jumpToMessage already loaded-around it, so a later + // processedMessageList change re-runs this) — bail rather than re-jump (no jump↔effect loop). if (indexOfParentInMessageList === -1) { - loadChannelAroundMessage({ messageId: targetedMessage, setTargetedMessage }); - } else { - scrollToDebounceTimeoutRef.current = setTimeout(async () => { - clearTimeout(scrollToDebounceTimeoutRef.current); + return; + } - const scrollToIndex = async () => { - const list = flashListRef.current; + lastFocusScrollTokenRef.current = focusToken; + scrollToDebounceTimeoutRef.current = setTimeout(async () => { + clearTimeout(scrollToDebounceTimeoutRef.current); - if (!list) { - return false; - } + const scrollToIndex = async () => { + const list = flashListRef.current; + + if (!list) { + return false; + } - await list.scrollToIndex({ - animated: true, - index: indexOfParentInMessageList, - viewPosition: 0.5, - }); + await list.scrollToIndex({ + animated: true, + index: indexOfParentInMessageList, + viewPosition: 0.5, + }); - return true; - }; + return true; + }; + await scrollToIndex(); + requestAnimationFrame(async () => { await scrollToIndex(); - requestAnimationFrame(async () => { - await scrollToIndex(); - setTargetedMessage(undefined); - }); - }, WAIT_FOR_SCROLL_TIMEOUT); - } - }, [loadChannelAroundMessage, processedMessageList, setTargetedMessage, targetedMessage]); + }); + // Start the highlight's auto-dismiss countdown now that the message is scrolled into view. + // The LLC deliberately does NOT start it on emit (the message may not be visible yet), so + // without this the highlight would persist forever. + focusPaginator?.scheduleMessageFocusSignalClear({ token: focusToken }); + }, WAIT_FOR_SCROLL_TIMEOUT); + }, [focusPaginator, focusToken, focusedMessageId, processedMessageList]); const goToMessage = useStableCallback(async (messageId: string) => { - const indexOfParentInMessageList = processedMessageList.findIndex( - (message) => message?.id === messageId, - ); - - try { - if (indexOfParentInMessageList === -1) { - clearTimeout(scrollToDebounceTimeoutRef.current); - await loadChannelAroundMessage({ messageId, setTargetedMessage }); - } else { - setTargetedMessage(messageId); - } - } catch (e) { - console.warn('Error while scrolling to message', e); - } + // jumpToMessage loads-around + emits messageFocusSignal → the effect scrolls and highlights. + await loadChannelAroundMessage({ messageId }); }); useEffect(() => { @@ -592,7 +593,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => return; } - const notLatestSet = channel.state.messages !== channel.state.latestMessages; + const notLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead; if (notLatestSet) { latestNonCurrentMessageBeforeUpdateRef.current = channel.state.latestMessages[channel.state.latestMessages.length - 1]; @@ -625,41 +626,47 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => }, [channel, processedMessageList, shouldScrollToRecentOnNewOwnMessageRef, threadList]); /** - * Effect to mark the channel as read when the user scrolls to the bottom of the message list. + * Track app foreground/background. Combined with viewability (above) it decides whether we are + * "viewing live"; the LLC skips the unread bump while live (see `messagePaginator.isViewingLive`). + */ + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextAppState) => + setIsAppActive(nextAppState === 'active'), + ); + return () => subscription.remove(); + }, []); + + /** + * Push the "viewing live" signal when the app foreground state changes (viewability pushes it on + * scroll). Reset to false on unmount / channel switch so a backgrounded or torn-down list never + * suppresses unread counting. + */ + useEffect(() => { + const { messagePaginator } = channel; + messagePaginator.setViewingLive(isAppActive && isNewestMessageVisibleRef.current); + return () => messagePaginator.setViewingLive(false); + }, [channel, isAppActive]); + + /** + * Mark the channel read when a message arrives while the user is viewing the latest messages. + * The LLC skips the unread bump while live, so — unlike before — no synchronous snapshot reset is + * needed here (that was the fragile bump-then-undo); we just tell the server. */ useEffect(() => { const shouldMarkRead = () => { - const channelUnreadState = channelUnreadStateStore.channelUnreadState; + const channelUnreadState = getChannelUnreadState(channel); return ( + channel.messagePaginator.isViewingLive && !channelUnreadState?.first_unread_message_id && - !scrollToBottomButtonVisible && client.user?.id && !hasReadLastMessage(channel, client.user?.id) ); }; - const handleEvent = async (event: Event) => { + const handleEvent = (event: Event) => { const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; - const isMyOwnMessage = event.message?.user?.id === client.user?.id; - const channelUnreadState = channelUnreadStateStore.channelUnreadState; - // When the scrollToBottomButtonVisible is true, we need to manually update the channelUnreadState when its a received message. - if ( - (scrollToBottomButtonVisible || channelUnreadState?.first_unread_message_id) && - !isMyOwnMessage - ) { - const previousUnreadCount = channelUnreadState?.unread_messages ?? 0; - const previousLastMessage = getPreviousLastMessage(channel.state.messages, event.message); - setChannelUnreadState({ - ...channelUnreadState, - last_read: - channelUnreadState?.last_read ?? - (previousUnreadCount === 0 && previousLastMessage?.created_at - ? new Date(previousLastMessage.created_at) - : new Date(0)), // not having information about the last read message means the whole channel is unread, - unread_messages: previousUnreadCount + 1, - }); - } else if (mainChannelUpdated && shouldMarkRead()) { - await markRead(); + if (mainChannelUpdated && shouldMarkRead()) { + markRead(); } }; @@ -668,15 +675,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => return () => { listener?.unsubscribe(); }; - }, [ - channel, - channelUnreadStateStore, - client.user?.id, - markRead, - scrollToBottomButtonVisible, - setChannelUnreadState, - threadList, - ]); + }, [channel, client.user?.id, markRead]); const updateStickyHeaderDateIfNeeded = useStableCallback((viewableItems: ViewToken[]) => { if (!viewableItems.length) { @@ -711,7 +710,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => * This function should show or hide the unread indicator depending on the */ const updateStickyUnreadIndicator = useStableCallback((viewableItems: ViewToken[]) => { - const channelUnreadState = channelUnreadStateStore.channelUnreadState; + const channelUnreadState = getChannelUnreadState(channel); // we need this check to make sure that regular list change do not trigger // the unread notification to appear (for example if the old last read messages // go out of the viewport). @@ -787,6 +786,24 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => updateStickyHeaderDateIfNeeded(viewableItems); } updateStickyUnreadIndicator(viewableItems); + + // Report whether the user is viewing the latest messages (the newest channel message is on + // screen) so the LLC can skip the unread bump while live (see `messagePaginator.isViewingLive`). + // Viewability reflects the real layout, so this is correct even at mount — a channel opened at + // its first unread has the newest message off-screen and therefore reports `false`. + // The newest message comes from the paginator (what the list actually renders), not + // channel.state.latestMessages. The last loaded item is the true newest only when the head is + // loaded (`!hasMoreHead`) — if newer messages exist beyond the loaded window we're not live even + // when the last loaded item is visible. + const paginatorState = channel.messagePaginator.state.getLatestValue(); + const loadedItems = paginatorState.items ?? []; + const newestMessageId = paginatorState.hasMoreHead + ? undefined + : loadedItems[loadedItems.length - 1]?.id; + isNewestMessageVisibleRef.current = + !!newestMessageId && + viewableItems.some((viewable) => viewable.item.message?.id === newestMessageId); + channel.messagePaginator.setViewingLive(isAppActive && isNewestMessageVisibleRef.current); }; const onViewableItemsChanged = useRef(unstableOnViewableItemsChanged); @@ -869,9 +886,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => await onEndReachedInPromise.current; } onStartReachedInPromise.current = ( - threadList && !!threadInstance && loadMoreRecentThread - ? loadMoreRecentThread({}) - : loadMoreRecent() + threadList && threadInstance ? threadInstance.messagePaginator.toHead() : loadMoreRecent() ) .then(callback) .catch(onError); @@ -910,7 +925,9 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => await onStartReachedInPromise.current; } - onEndReachedInPromise.current = (threadList ? loadMoreThread() : loadMore()) + onEndReachedInPromise.current = ( + threadList ? (threadInstance?.messagePaginator.toTail() ?? Promise.resolve()) : loadMore() + ) .then(callback) .catch(onError); }); @@ -957,7 +974,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const isScrollAtStart = contentLength - visibleLength - offset < messageInputHeight; - const notLatestSet = channel.state.messages !== channel.state.latestMessages; + const notLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead; const showScrollToBottomButton = messageListHasMessages && ((!threadList && notLatestSet) || !isScrollAtStart); @@ -976,7 +993,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => }); const goToNewMessages = useStableCallback(async () => { - const isNotLatestSet = channel.state.messages !== channel.state.latestMessages; + const isNotLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead; if (isNotLatestSet) { resetPaginationTrackersRef.current(); @@ -997,6 +1014,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => }); }); + // Non-reactive read for the accessibility action label only (the button owns its own reactive + // count). Refreshes on the list's normal re-renders (e.g. scroll), which is sufficient for a11y. const scrollToBottomUnreadCount = scrollToBottomButtonVisible && !threadList ? channel?.countUnread() : undefined; const { @@ -1089,6 +1108,11 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => currentListHeightRef.current = height; }); + const ListHeaderComponent = useCallback( + () => , + [HeaderComponent, loadingMore], + ); + const ListFooterComponent = useCallback(() => { if (FooterComponent) { return ; @@ -1096,7 +1120,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => return ( - + {!disableTypingIndicator && TypingIndicator && ( @@ -1107,6 +1131,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => }, [ FooterComponent, LoadingMoreRecentIndicator, + loadingMoreRecent, TypingIndicator, TypingIndicatorContainer, disableTypingIndicator, @@ -1128,7 +1153,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => return ( - {processedMessageList.length === 0 && !thread ? ( + {processedMessageList.length === 0 && !threadInstance ? ( {EmptyStateIndicator ? : null} @@ -1142,7 +1167,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => keyboardShouldPersistTaps='handled' keyExtractor={keyExtractor} ListFooterComponent={ListFooterComponent} - ListHeaderComponent={HeaderComponent} + ListHeaderComponent={ListHeaderComponent} maintainVisibleContentPosition={maintainVisibleContentPosition} onMomentumScrollEnd={onUserScrollEvent} onScroll={handleScroll} @@ -1188,15 +1213,14 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => {isUnreadNotificationOpen && !threadList ? ( ) : null} @@ -1279,7 +1303,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { const { closePicker, attachmentPickerStore } = useAttachmentPickerContext(); const { channel, - channelUnreadStateStore, disabled, enableMessageGroupingByUser, error, @@ -1288,21 +1311,22 @@ export const MessageFlashList = (props: MessageFlashListProps) => { isChannelActive, loadChannelAroundMessage, loading, - markRead, maximumMessageLimit, reloadChannel, scrollToFirstUnreadThreshold, - setChannelUnreadState, - setTargetedMessage, hasPendingInitialTargetLoad, - targetedMessage, threadList, } = useChannelContext(); + const markRead = useMarkRead(channel); const { client } = useChatContext(); const { disableTypingIndicator, FlatList, myMessageTheme, shouldShowUnreadUnderlay } = useMessagesContext(); - const { loadMore, loadMoreRecent } = usePaginatedMessageListContext(); - const { loadMoreRecentThread, loadMoreThread, thread, threadInstance } = useThreadContext(); + const { + loadMore, + loadMoreRecent, + state: { loadingMore, loadingMoreRecent }, + } = useMessageListPagination({ channel }); + const { threadInstance } = useThreadContext(); const { readEvents } = useOwnCapabilitiesContext(); const { allowSendBeforeAttachmentsUpload, messageInputFloating, messageInputHeightStore } = useMessageInputContext(); @@ -1313,7 +1337,6 @@ export const MessageFlashList = (props: MessageFlashListProps) => { allowSendBeforeAttachmentsUpload, attachmentPickerStore, channel, - channelUnreadStateStore, client, closePicker, disabled, @@ -1328,8 +1351,8 @@ export const MessageFlashList = (props: MessageFlashListProps) => { loading, loadMore, loadMoreRecent, - loadMoreRecentThread, - loadMoreThread, + loadingMore, + loadingMoreRecent, markRead, maximumMessageLimit, messageInputFloating, @@ -1338,12 +1361,8 @@ export const MessageFlashList = (props: MessageFlashListProps) => { readEvents, reloadChannel, scrollToFirstUnreadThreshold, - setChannelUnreadState, - setTargetedMessage, hasPendingInitialTargetLoad, shouldShowUnreadUnderlay, - targetedMessage, - thread, threadInstance, threadList, }} diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 2ef08ac23b..ad6d93954c 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + AppState, FlatList, FlatListProps, FlatList as FlatListType, @@ -15,9 +16,11 @@ import Animated from 'react-native-reanimated'; import debounce from 'lodash/debounce'; -import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; +import type { Channel, Event, LocalMessage } from 'stream-chat'; +import { useMarkRead } from './hooks/useMarkRead'; import { useMessageList } from './hooks/useMessageList'; + import { useScrollToBottomAccessibilityAction } from './hooks/useScrollToBottomAccessibilityAction'; import { useShouldScrollToRecentOnNewOwnMessage } from './hooks/useShouldScrollToRecentOnNewOwnMessage'; @@ -59,10 +62,6 @@ import { OwnCapabilitiesContextValue, useOwnCapabilitiesContext, } from '../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext'; -import { - PaginatedMessageListContextValue, - usePaginatedMessageListContext, -} from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; @@ -72,7 +71,10 @@ import { bumpOverlayLayoutRevision, useHasActiveId } from '../../state-store'; import { MessageInputHeightState } from '../../state-store/message-input-height-store'; import { primitives } from '../../theme'; import { transitions } from '../../utils/animations/transitions'; +import { getChannelUnreadState } from '../../utils/getChannelUnreadState'; import { useIncomingMessageAnnouncements } from '../Accessibility/hooks/useIncomingMessageAnnouncements'; +import { MarkReadFunctionOptions } from '../Channel/Channel'; +import { useMessageListPagination } from '../Channel/hooks/useMessageListPagination'; import { MessageWrapper } from '../Message/MessageItemView/MessageWrapper'; import { excludeCanceledUploadNotifications } from '../Notifications/notificationFilters'; import { PortalWhileClosingView } from '../UIComponents'; @@ -183,25 +185,13 @@ const flatListViewabilityConfig: ViewabilityConfig = { }; const hasReadLastMessage = (channel: Channel, userId: string) => { - const latestMessageIdInChannel = channel.state.latestMessages.slice(-1)[0]?.id; + const latestMessageIdInChannel = channel.messagePaginator.state + .getLatestValue() + .items?.at(-1)?.id; const lastReadMessageIdServer = channel.state.read[userId]?.last_read_message_id; return latestMessageIdInChannel === lastReadMessageIdServer; }; -const getPreviousLastMessage = (messages: LocalMessage[], newMessage?: MessageResponse) => { - if (!newMessage) return; - let previousLastMessage; - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (!msg?.id) break; - if (msg.id !== newMessage.id) { - previousLastMessage = msg; - break; - } - } - return previousLastMessage; -}; - type MessageListPropsWithContext = Pick< AttachmentPickerContextValue, 'closePicker' | 'attachmentPickerStore' @@ -210,23 +200,23 @@ type MessageListPropsWithContext = Pick< Pick< ChannelContextValue, | 'channel' - | 'channelUnreadStateStore' | 'disabled' | 'hideStickyDateHeader' | 'loadChannelAroundMessage' | 'loading' - | 'markRead' | 'reloadChannel' | 'scrollToFirstUnreadThreshold' - | 'setChannelUnreadState' - | 'setTargetedMessage' - | 'targetedMessage' | 'threadList' | 'maximumMessageLimit' > & - Pick & - Pick & - Pick< + Pick & { + loadMore: () => Promise; + loadMoreRecent: () => Promise; + markRead: (options?: MarkReadFunctionOptions) => void; + hasMore?: boolean; + loadingMore?: boolean; + loadingMoreRecent?: boolean; + } & Pick< MessagesContextValue, 'disableTypingIndicator' | 'FlatList' | 'myMessageTheme' | 'shouldShowUnreadUnderlay' > & @@ -234,10 +224,7 @@ type MessageListPropsWithContext = Pick< MessageInputContextValue, 'allowSendBeforeAttachmentsUpload' | 'messageInputFloating' | 'messageInputHeightStore' > & - Pick< - ThreadContextValue, - 'loadMoreRecentThread' | 'loadMoreThread' | 'threadHasMore' | 'thread' | 'threadInstance' - > & { + 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. @@ -256,12 +243,12 @@ type MessageListPropsWithContext = Pick< /** * UI component for footer of message list. By default message list will use `InlineLoadingMoreIndicator` * as FooterComponent. If you want to implement your own inline loading indicator, you can access `loadingMore` - * from context. + * from props. * * This is a [ListHeaderComponent](https://facebook.github.io/react-native/docs/flatlist#listheadercomponent) of FlatList * used in MessageList. Should be used for header by default if inverted is true or defaulted */ - FooterComponent?: React.ComponentType; + FooterComponent?: React.ComponentType<{ loadingMore?: boolean }>; /** * UI component for header of message list. By default message list will use `InlineLoadingMoreRecentIndicator` * as HeaderComponent. If you want to implement your own inline loading indicator, you can access `loadingMoreRecent` @@ -281,7 +268,7 @@ type MessageListPropsWithContext = Pick< * * @param message A message object to open the thread upon. */ - onThreadSelect?: (message: ThreadContextValue['thread']) => void; + onThreadSelect?: (message: LocalMessage | null) => void; /** * Use `setFlatListRef` to get access to ref to inner FlatList. * @@ -319,6 +306,13 @@ const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({ * [ThreadContext](https://getstream.io/chat/docs/sdk/reactnative/contexts/thread-context/) * [TranslationContext](https://getstream.io/chat/docs/sdk/reactnative/contexts/translation-context/) */ +const messageFocusSelector = (state: { + signal: { messageId?: string; token?: number } | null; +}) => ({ + focusedMessageId: state.signal?.messageId, + focusToken: state.signal?.token, +}); + const MessageListWithContext = (props: MessageListPropsWithContext) => { const LoadingMoreRecentIndicator = props.threadList ? InlineLoadingMoreRecentThreadIndicator @@ -329,7 +323,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { attachmentPickerStore, additionalFlatListProps, channel, - channelUnreadStateStore, client, closePicker, disabled, @@ -342,10 +335,10 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { isLiveStreaming = false, loadChannelAroundMessage, loading, + loadingMore, + loadingMoreRecent, loadMore, loadMoreRecent, - loadMoreRecentThread, - loadMoreThread, markRead, maximumMessageLimit, messageInputFloating, @@ -356,15 +349,10 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { onThreadSelect, readEvents, reloadChannel, - setChannelUnreadState, setFlatListRef, - setTargetedMessage, - targetedMessage, - thread, threadInstance, threadList = false, hasMore, - threadHasMore, } = props; const { EmptyStateIndicator, @@ -387,7 +375,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { ); useIncomingMessageAnnouncements({ - activeThreadId: thread?.id, + activeThreadId: threadInstance?.id, channel, ownUserId: client.user?.id, threadList, @@ -407,6 +395,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { */ const { processedMessageList, rawMessageList, viewabilityChangedCallback } = useMessageList({ isLiveStreaming, + maximumMessageLimit, threadList, }); @@ -497,14 +486,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { */ const onScrollEventTimeoutRef = useRef>(undefined); - /** - * Last messageID that was scrolled to after loading a new message list, - * this flag keeps track of it so that we dont scroll to it again on target message set - */ - const messageIdLastScrolledToRef = useRef(undefined); const [hasMoved, setHasMoved] = useState(false); const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); + const [isAppActive, setIsAppActive] = useState(() => AppState.currentState === 'active'); + const isNewestMessageVisibleRef = useRef(false); const [stickyHeaderDate, setStickyHeaderDate] = useState(); const stickyHeaderDateRef = useRef(undefined); @@ -546,7 +532,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { * This function should show or hide the unread indicator depending on the */ const updateStickyUnreadIndicator = useStableCallback((viewableItems: ViewToken[]) => { - const channelUnreadState = channelUnreadStateStore.channelUnreadState; + const channelUnreadState = getChannelUnreadState(channel); // we need this check to make sure that regular list change do not trigger // the unread notification to appear (for example if the old last read messages // go out of the viewport). @@ -625,6 +611,16 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { updateStickyHeaderDateIfNeeded(viewableItems); } updateStickyUnreadIndicator(viewableItems); + + const paginatorState = channel.messagePaginator.state.getLatestValue(); + const loadedItems = paginatorState.items ?? []; + const newestMessageId = paginatorState.hasMoreHead + ? undefined + : loadedItems[loadedItems.length - 1]?.id; + isNewestMessageVisibleRef.current = + !!newestMessageId && + viewableItems.some((viewable) => viewable.item.message?.id === newestMessageId); + channel.messagePaginator.setViewingLive(isAppActive && isNewestMessageVisibleRef.current); }; const onViewableItemsChanged = useRef(unstableOnViewableItemsChanged); @@ -652,41 +648,47 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }, [disabled]); /** - * Effect to mark the channel as read when the user scrolls to the bottom of the message list. + * Track app foreground/background. Combined with viewability (above) it decides whether we are + * "viewing live"; the LLC skips the unread bump while live (see `messagePaginator.isViewingLive`). + */ + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextAppState) => + setIsAppActive(nextAppState === 'active'), + ); + return () => subscription.remove(); + }, []); + + /** + * Push the "viewing live" signal when the app foreground state changes (viewability pushes it on + * scroll). Reset to false on unmount / channel switch so a backgrounded or torn-down list never + * suppresses unread counting. + */ + useEffect(() => { + const { messagePaginator } = channel; + messagePaginator.setViewingLive(isAppActive && isNewestMessageVisibleRef.current); + return () => messagePaginator.setViewingLive(false); + }, [channel, isAppActive]); + + /** + * Mark the channel read when a message arrives while the user is viewing the latest messages. + * The LLC skips the unread bump while live, so — unlike before — no synchronous snapshot reset is + * needed here (that was the fragile bump-then-undo); we just tell the server. */ useEffect(() => { const shouldMarkRead = () => { - const channelUnreadState = channelUnreadStateStore.channelUnreadState; + const channelUnreadState = getChannelUnreadState(channel); return ( + channel.messagePaginator.isViewingLive && !channelUnreadState?.first_unread_message_id && - !scrollToBottomButtonVisible && client.user?.id && !hasReadLastMessage(channel, client.user?.id) ); }; - const handleEvent = async (event: Event) => { + const handleEvent = (event: Event) => { const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; - const isMyOwnMessage = event.message?.user?.id === client.user?.id; - const channelUnreadState = channelUnreadStateStore.channelUnreadState; - // When the scrollToBottomButtonVisible is true, we need to manually update the channelUnreadState when its a received message. - if ( - (scrollToBottomButtonVisible || channelUnreadState?.first_unread_message_id) && - !isMyOwnMessage - ) { - const previousUnreadCount = channelUnreadState?.unread_messages ?? 0; - const previousLastMessage = getPreviousLastMessage(channel.state.messages, event.message); - setChannelUnreadState({ - ...channelUnreadState, - last_read: - channelUnreadState?.last_read ?? - (previousUnreadCount === 0 && previousLastMessage?.created_at - ? new Date(previousLastMessage.created_at) - : new Date(0)), // not having information about the last read message means the whole channel is unread, - unread_messages: previousUnreadCount + 1, - }); - } else if (mainChannelUpdated && shouldMarkRead()) { - await markRead(); + if (mainChannelUpdated && shouldMarkRead()) { + markRead(); } }; @@ -695,15 +697,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { return () => { listener?.unsubscribe(); }; - }, [ - channel, - channelUnreadStateStore, - client.user?.id, - markRead, - scrollToBottomButtonVisible, - setChannelUnreadState, - threadList, - ]); + }, [channel, client.user?.id, markRead]); useEffect(() => { /** @@ -770,7 +764,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { return; } - const notLatestSet = channel.state.messages !== channel.state.latestMessages; + const notLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead; if (notLatestSet) { latestNonCurrentMessageBeforeUpdateRef.current = channel.state.latestMessages[channel.state.latestMessages.length - 1]; @@ -820,73 +814,63 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { maximumMessageLimit, ]); + // Scroll-to-target is driven by the paginator's messageFocusSignal (thread-aware): a jump + // (jumpToMessage / jumpToTheFirstUnreadMessage / emitMessageFocusSignal) emits it, and the effect + // below scrolls to it. `token` re-fires the effect on every jump, even to the same message id. + const focusPaginator = threadList ? threadInstance?.messagePaginator : channel.messagePaginator; + const { focusedMessageId, focusToken } = + useStateStore(focusPaginator?.messageFocusSignal, messageFocusSelector) ?? {}; + const lastFocusScrollTokenRef = useRef(undefined); + + // Clear the focus/highlight signal on unmount (or when switching channel/thread). The signal + // lives on the LLC paginator, which outlives this component — without this, a highlight still + // active when you navigate away re-fires the scroll+highlight on return (the scroll-token ref + // resets on remount). Mirrors the old React-state highlight that cleaned up on unmount. + useEffect(() => { + const paginator = focusPaginator; + return () => paginator?.clearMessageFocusSignal(); + }, [focusPaginator]); + const goToMessage = useStableCallback(async (messageId: string) => { - const indexOfParentInMessageList = processedMessageList.findIndex( - (message) => message?.id === messageId, - ); - try { - if (indexOfParentInMessageList === -1) { - await loadChannelAroundMessage({ messageId }); - return; - } else { - if (!flatListRef.current) { - return; - } - clearTimeout(failScrollTimeoutId.current); - scrollToIndexFailedRetryCountRef.current = 0; - // keep track of this messageId, so that we dont scroll to again in useEffect for targeted message change - messageIdLastScrolledToRef.current = messageId; - setTargetedMessage(messageId); - // now scroll to it with animated=true - flatListRef.current.scrollToIndex({ - animated: true, - index: indexOfParentInMessageList, - viewPosition: 0.5, // try to place message in the center of the screen - }); - return; - } - } catch (e) { - console.warn('Error while scrolling to message', e); - } + // jumpToMessage loads-around the target + emits messageFocusSignal → the effect scrolls and the + // message highlights (mirrors stream-chat-react — no bespoke scroll/highlight bookkeeping). + await loadChannelAroundMessage({ messageId }); }); /** - * Check if a messageId needs to be scrolled to after list loads, and scroll to it - * Note: This effect fires on every list change with a small debounce so that scrolling isnt abrupted by an immediate rerender + * Scrolls to the focused message (messageFocusSignal) once it's rendered. Keyed on the focus + * token so it re-fires on every jump (even to the same id); also re-attempts when the list + * updates while a focus is pending, and marks each token handled so unrelated list changes during + * the highlight window don't re-scroll. */ useEffect(() => { - if (!targetedMessage) { + if (!focusedMessageId || focusToken === lastFocusScrollTokenRef.current) { return; } - scrollToDebounceTimeoutRef.current = setTimeout(async () => { + scrollToDebounceTimeoutRef.current = setTimeout(() => { const indexOfParentInMessageList = processedMessageList.findIndex( - (message) => message?.id === targetedMessage, + (message) => message?.id === focusedMessageId, ); - - // the message we want to scroll to has not been loaded in the state yet - if (indexOfParentInMessageList === -1) { - await loadChannelAroundMessage({ messageId: targetedMessage, setTargetedMessage }); - } else { - if (!flatListRef.current) { - return; - } - // By a fresh scroll we should clear the retries for the previous failed scroll - clearTimeout(scrollToDebounceTimeoutRef.current); - clearTimeout(failScrollTimeoutId.current); - // reset the retry count - scrollToIndexFailedRetryCountRef.current = 0; - // now scroll to it - flatListRef.current.scrollToIndex({ - animated: true, - index: indexOfParentInMessageList, - viewPosition: 0.5, // try to place message in the center of the screen - }); - setTargetedMessage(undefined); + // Not in the rendered window yet (jumpToMessage already loaded-around it, so a later + // processedMessageList change re-runs this) — bail rather than re-jump (no jump↔effect loop). + if (indexOfParentInMessageList === -1 || !flatListRef.current) { + return; } + clearTimeout(scrollToDebounceTimeoutRef.current); + clearTimeout(failScrollTimeoutId.current); + scrollToIndexFailedRetryCountRef.current = 0; + lastFocusScrollTokenRef.current = focusToken; + flatListRef.current.scrollToIndex({ + animated: true, + index: indexOfParentInMessageList, + viewPosition: 0.5, // try to place message in the center of the screen + }); + // Start the highlight's auto-dismiss countdown now that the message is scrolled into view. + // The LLC deliberately does NOT start it on emit (the message may not be visible yet), so + // without this the highlight would persist forever. + focusPaginator?.scheduleMessageFocusSignalClear({ token: focusToken }); }, WAIT_FOR_SCROLL_TIMEOUT); - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [targetedMessage]); + }, [focusPaginator, focusToken, focusedMessageId, processedMessageList]); const setNativeScrollability = useStableCallback((value: boolean) => { if (flatListRef.current) { @@ -959,9 +943,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { await onEndReachedInPromise.current; } onStartReachedInPromise.current = ( - threadList && !!threadInstance && loadMoreRecentThread - ? loadMoreRecentThread({}) - : loadMoreRecent() + threadList && threadInstance ? threadInstance.messagePaginator.toHead() : loadMoreRecent() ) .then(callback) .catch(onError); @@ -973,7 +955,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { * 3. If the call to `loadMoreRecent` is in progress, we wait for it to finish to make sure scroll doesn't jump. */ const maybeCallOnEndReached = useStableCallback(async () => { - const shouldQuery = (threadList && threadHasMore) || (!threadList && hasMore); + const shouldQuery = + (threadList && !!threadInstance?.messagePaginator.hasMoreTail) || (!threadList && hasMore); // If onEndReached has already been called for given messageList length, then ignore. if ( (processedMessageList?.length && onEndReachedTracker.current[processedMessageList.length]) || @@ -1002,7 +985,9 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { if (onStartReachedInPromise.current) { await onStartReachedInPromise.current; } - onEndReachedInPromise.current = (threadList ? loadMoreThread() : loadMore()) + onEndReachedInPromise.current = ( + threadList ? (threadInstance?.messagePaginator.toTail() ?? Promise.resolve()) : loadMore() + ) .then(callback) .catch(onError); }); @@ -1035,7 +1020,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const offset = event.nativeEvent.contentOffset.y; const isScrollAtBottom = offset <= messageInputHeight; - const notLatestSet = channel.state.messages !== channel.state.latestMessages; + const notLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead; const showScrollToBottomButton = messageListHasMessages && ((!threadList && notLatestSet) || !isScrollAtBottom); @@ -1054,7 +1039,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }); const goToNewMessages = useStableCallback(async () => { - const isNotLatestSet = channel.state.messages !== channel.state.latestMessages; + const isNotLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead; if (isNotLatestSet) { resetPaginationTrackersRef.current(); @@ -1076,6 +1061,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }); }); + // Non-reactive read for the accessibility action label only (the button owns its own reactive + // count). Refreshes on the list's normal re-renders (e.g. scroll), which is sufficient for a11y. const scrollToBottomUnreadCount = scrollToBottomButtonVisible && !threadList ? channel?.countUnread() : undefined; const { @@ -1108,11 +1095,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { index: info.index, viewPosition: 0.5, // try to place message in the center of the screen }); - if (messageIdLastScrolledToRef.current) { - // in case the target message was cleared out - // the state being set again will trigger the highlight again - setTargetedMessage(messageIdLastScrolledToRef.current); - } + // The highlight is driven by the live messageFocusSignal (persists for its TTL), so there's + // no targeted-message state to re-set across scroll-fail retries. scrollToIndexFailedRetryCountRef.current = 0; } catch (e) { if ( @@ -1175,7 +1159,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { } if (debugRef.current.setSendEventParams) { debugRef.current.setSendEventParams({ - action: thread ? 'ThreadList' : 'Messages', + action: threadInstance ? 'ThreadList' : 'Messages', data: processedMessageList, }); } @@ -1257,6 +1241,11 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { viewportHeightRef.current = nextViewportHeight; }); + const ListFooterComponent = useCallback( + () => , + [FooterComponent, loadingMore], + ); + const ListHeaderComponent = useCallback(() => { if (HeaderComponent) { return ; @@ -1264,7 +1253,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { return ( <> - + {!disableTypingIndicator && TypingIndicator && ( @@ -1275,6 +1264,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }, [ HeaderComponent, LoadingMoreRecentIndicator, + loadingMoreRecent, TypingIndicator, TypingIndicatorContainer, disableTypingIndicator, @@ -1296,7 +1286,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { return ( {/* Don't show the empty list indicator for Thread messages */} - {processedMessageList.length === 0 && !thread ? ( + {processedMessageList.length === 0 && !threadInstance ? ( {EmptyStateIndicator ? : null} @@ -1312,7 +1302,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { inverted={inverted} keyboardShouldPersistTaps='handled' keyExtractor={keyExtractor} - ListFooterComponent={FooterComponent} + ListFooterComponent={ListFooterComponent} ListHeaderComponent={ListHeaderComponent} /** If autoscrollToTopThreshold is 10, we scroll to recent only if before the update, the list was already at the @@ -1371,7 +1361,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { ) : null} @@ -1380,8 +1369,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { {isUnreadNotificationOpen && !threadList ? ( ) : null} @@ -1415,7 +1404,6 @@ export const MessageList = (props: MessageListProps) => { const { closePicker, attachmentPickerStore } = useAttachmentPickerContext(); const { channel, - channelUnreadStateStore, disabled, enableMessageGroupingByUser, error, @@ -1424,23 +1412,23 @@ export const MessageList = (props: MessageListProps) => { loadChannelAroundMessage, loading, maximumMessageLimit, - markRead, reloadChannel, scrollToFirstUnreadThreshold, - setChannelUnreadState, - setTargetedMessage, - targetedMessage, threadList, } = useChannelContext(); + const markRead = useMarkRead(channel); const { client } = useChatContext(); const { readEvents } = useOwnCapabilitiesContext(); const { disableTypingIndicator, FlatList, myMessageTheme, shouldShowUnreadUnderlay } = useMessagesContext(); const { allowSendBeforeAttachmentsUpload, messageInputFloating, messageInputHeightStore } = useMessageInputContext(); - const { loadMore, loadMoreRecent, hasMore } = usePaginatedMessageListContext(); - const { loadMoreRecentThread, loadMoreThread, threadHasMore, thread, threadInstance } = - useThreadContext(); + const { + loadMore, + loadMoreRecent, + state: { hasMore, loadingMore, loadingMoreRecent }, + } = useMessageListPagination({ channel }); + const { threadInstance } = useThreadContext(); return ( { allowSendBeforeAttachmentsUpload, attachmentPickerStore, channel, - channelUnreadStateStore, client, closePicker, disabled, @@ -1462,8 +1449,6 @@ export const MessageList = (props: MessageListProps) => { loading, loadMore, loadMoreRecent, - loadMoreRecentThread, - loadMoreThread, markRead, maximumMessageLimit, messageInputFloating, @@ -1472,15 +1457,12 @@ export const MessageList = (props: MessageListProps) => { readEvents, reloadChannel, scrollToFirstUnreadThreshold, - setChannelUnreadState, - setTargetedMessage, shouldShowUnreadUnderlay, - targetedMessage, - thread, threadInstance, threadList, hasMore, - threadHasMore, + loadingMore, + loadingMoreRecent, }} {...props} noGroupByUser={!enableMessageGroupingByUser || props.noGroupByUser} diff --git a/package/src/components/MessageList/ScrollToBottomButton.tsx b/package/src/components/MessageList/ScrollToBottomButton.tsx index e31f92c34d..999619fb59 100644 --- a/package/src/components/MessageList/ScrollToBottomButton.tsx +++ b/package/src/components/MessageList/ScrollToBottomButton.tsx @@ -1,10 +1,15 @@ -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; import Animated, { ZoomIn, ZoomOut } from 'react-native-reanimated'; +import type { ReadState } from 'stream-chat'; + +import { useChannelContext } from '../../contexts/channelContext/ChannelContext'; +import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; +import { useStateStore } from '../../hooks/useStateStore'; import { primitives } from '../../theme'; import { BadgeNotification } from '../ui'; import { Button } from '../ui/Button'; @@ -18,16 +23,26 @@ export type ScrollToBottomButtonProps = { onPress: () => void; /** If we should show the notification or not */ showNotification?: boolean; - unreadCount?: number; }; export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => { - const { onPress, showNotification = true, unreadCount } = props; + const { onPress, showNotification = true } = props; + const { channel, threadList } = useChannelContext(); + const { client } = useChatContext(); const { theme: { semantics }, } = useTheme(); const { icons } = useComponentsContext(); - const accessibilityLabelParams = React.useMemo( + const userId = client?.userID; + const ownUnreadSelector = useCallback( + (state: ReadState) => ({ + unreadCount: userId ? (state.read[userId]?.unread_messages ?? 0) : 0, + }), + [userId], + ); + const ownRead = useStateStore(channel?.state?.readStore, ownUnreadSelector); + const unreadCount = threadList ? undefined : ownRead?.unreadCount; + const accessibilityLabelParams = useMemo( () => (unreadCount ? { count: unreadCount } : undefined), [unreadCount], ); diff --git a/package/src/components/MessageList/TypingIndicatorContainer.tsx b/package/src/components/MessageList/TypingIndicatorContainer.tsx index d410baf4c9..44e3f3cbcb 100644 --- a/package/src/components/MessageList/TypingIndicatorContainer.tsx +++ b/package/src/components/MessageList/TypingIndicatorContainer.tsx @@ -1,12 +1,15 @@ import React, { PropsWithChildren } from 'react'; import { StyleSheet, View } from 'react-native'; +import { TypingUsersState } from 'stream-chat'; + import { filterTypingUsers } from './utils/filterTypingUsers'; +import { useChannelContext } from '../../contexts/channelContext/ChannelContext'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; -import { TypingContextValue, useTypingContext } from '../../contexts/typingContext/TypingContext'; +import { useThreadContext } from '../../contexts/threadContext/ThreadContext'; +import { useStateStore } from '../../hooks/useStateStore'; import { primitives } from '../../theme'; const styles = StyleSheet.create({ @@ -17,21 +20,24 @@ const styles = StyleSheet.create({ }, }); -type TypingIndicatorContainerPropsWithContext = Pick & - Pick & - Pick; +const typingSelector = (state: TypingUsersState) => ({ typing: state.typing }); + +type TypingIndicatorContainerPropsWithContext = { + threadId?: string; + typing: TypingUsersState['typing']; +} & Pick; const TypingIndicatorContainerWithContext = ( props: PropsWithChildren, ) => { - const { children, client, thread, typing } = props; + const { children, client, threadId, typing } = props; const { theme: { messageList: { typingIndicatorContainer }, }, } = useTheme(); - const typingUsers = filterTypingUsers({ client, thread, typing }); + const typingUsers = filterTypingUsers({ client, threadId, typing }); if (!typingUsers.length) { return null; @@ -49,11 +55,17 @@ export type TypingIndicatorContainerProps = PropsWithChildren< >; export const TypingIndicatorContainer = (props: TypingIndicatorContainerProps) => { - const { typing } = useTypingContext(); + const { channel } = useChannelContext(); const { client } = useChatContext(); - const { thread } = useThreadContext(); + const { threadInstance } = useThreadContext(); + const { typing } = useStateStore(channel.state.typingStore, typingSelector) ?? { typing: {} }; - return ; + return ( + + ); }; TypingIndicatorContainer.displayName = diff --git a/package/src/components/MessageList/UnreadMessagesNotification.tsx b/package/src/components/MessageList/UnreadMessagesNotification.tsx index 73e6575d54..3f2de7748a 100644 --- a/package/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/package/src/components/MessageList/UnreadMessagesNotification.tsx @@ -1,22 +1,23 @@ import React, { useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; -import { - ChannelContextValue, - useChannelContext, -} from '../../contexts/channelContext/ChannelContext'; +import type { UnreadSnapshotState } from 'stream-chat'; + +import { useChannelContext } from '../../contexts/channelContext/ChannelContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; import { useStateStore } from '../../hooks/useStateStore'; -import { ChannelUnreadStateStoreType } from '../../state-store/channel-unread-state'; import { primitives } from '../../theme'; +import { MarkReadFunctionOptions } from '../Channel/Channel'; import { Button } from '../ui'; -export type UnreadMessagesNotificationProps = Pick< - ChannelContextValue, - 'channelUnreadStateStore' -> & { +export type UnreadMessagesNotificationProps = { + /** + * Marks the channel as read. Passed down from the message list so the notification shares the + * list's throttled `markRead` instance. + */ + markRead?: (options?: MarkReadFunctionOptions) => void; /** * Callback to handle the close event */ @@ -31,19 +32,18 @@ export type UnreadMessagesNotificationProps = Pick< unreadCount?: number; }; -const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ - unread_messages: state.channelUnreadState?.unread_messages, +const unreadCountSelector = (snapshot: UnreadSnapshotState) => ({ + unread_messages: snapshot.unreadCount, }); export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProps) => { - const { onCloseHandler, onPressHandler, unreadCount, channelUnreadStateStore } = props; + const { markRead, onCloseHandler, onPressHandler, unreadCount } = props; const { t } = useTranslationContext(); const { icons } = useComponentsContext(); - const { loadChannelAtFirstUnreadMessage, markRead, setChannelUnreadState, setTargetedMessage } = - useChannelContext(); + const { channel, loadChannelAtFirstUnreadMessage } = useChannelContext(); const { unread_messages } = useStateStore( - channelUnreadStateStore.state, - channelUnreadStateSelector, + channel.messagePaginator.unreadStateSnapshot, + unreadCountSelector, ); const count = unread_messages ?? unreadCount; @@ -52,11 +52,7 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp if (onPressHandler) { await onPressHandler(); } else { - await loadChannelAtFirstUnreadMessage({ - channelUnreadState: channelUnreadStateStore.channelUnreadState, - setChannelUnreadState, - setTargetedMessage, - }); + await loadChannelAtFirstUnreadMessage(); } }; @@ -64,7 +60,7 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp if (onCloseHandler) { await onCloseHandler(); } else { - await markRead(); + await markRead?.(); } }; diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index bfdee3cb8d..9c03aec4fe 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -16,13 +16,29 @@ import { generateMessage } from '../../../mock-builders/generator/message'; import { generateUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Channel } from '../../Channel/Channel'; -import { channelInitialState } from '../../Channel/hooks/useChannelDataState'; import * as MessageListPaginationHook from '../../Channel/hooks/useMessageListPagination'; import { Chat } from '../../Chat/Chat'; import { SCROLL_TO_BOTTOM_ACCESSIBILITY_ACTION_NAME } from '../hooks/useScrollToBottomAccessibilityAction'; import { MessageList } from '../MessageList'; +// Local test fixture (was previously imported from the now-removed useChannelDataState hook). +const channelInitialState = { + hasMore: true, + hasMoreNewer: false, + loading: false, + loadingMore: false, + loadingMoreRecent: false, + members: {}, + messages: [], + pinnedMessages: [], + read: {}, + targetedMessageId: undefined, + typing: {}, + watcherCount: 0, + watchers: {}, +}; + describe('MessageList', () => { afterEach(() => { cleanup(); @@ -272,11 +288,17 @@ describe('MessageList', () => { const channel = chatClient.channel('messaging', mockedChannel.channel.id); await channel.watch(); + channel.messagePaginator.emitMessageFocusSignal({ + messageId: targetedMessageId, + reason: 'jump-to-message', + ttlMs: 3000, + }); + render( - + , @@ -428,11 +450,21 @@ describe('MessageList', () => { .spyOn(FlatList.prototype, 'scrollToIndex') .mockImplementation(() => {}); + // Targeting is driven by the paginator's messageFocusSignal now (not a prop): emitting it makes + // the list scroll to the focused message. NOTE: like the other full-list-render tests here, this + // is runtime-stale under the portal (it seeds channel.state.messages, not + // channel.messagePaginator.state.items) — the seed needs updating when the portal is removed. + channel.messagePaginator.emitMessageFocusSignal({ + messageId: targetedMessage, + reason: 'jump-to-message', + ttlMs: 3000, + }); + render( - + , @@ -446,54 +478,6 @@ describe('MessageList', () => { }); }); }); - - it("should load more messages around the message id if the targeted message isn't present in the list", async () => { - const user = generateUser(); - const mockedChannel = generateChannelResponse({ - members: [generateMember({ user })], - }); - - const messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ id: `${i}`, text: `message-${i}` }), - ); - - const chatClient = await getTestClientWithUser({ id: user.id }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.channel.id); - await channel.watch(); - - const targetedMessage = '21'; - const setTargetedMessage = jest.fn(); - - channel.state = { - ...channelInitialState, - latestMessages: [], - messages, - } as unknown as typeof channel.state; - - const loadChannelAroundMessage = jest.fn(() => Promise.resolve()); - - render( - - - - - - - , - ); - - await waitFor(() => { - expect(loadChannelAroundMessage).toHaveBeenCalledWith({ - messageId: targetedMessage, - setTargetedMessage, - }); - }); - }); }); describe('MessageList pagination', () => { @@ -511,10 +495,8 @@ describe('MessageList pagination', () => { return jest .spyOn(MessageListPaginationHook, 'useMessageListPagination') .mockImplementation(() => ({ - copyMessagesStateFromChannel: jest.fn(), loadChannelAroundMessage: jest.fn(), loadChannelAtFirstUnreadMessage: jest.fn(), - loadInitialMessagesStateFromChannel: jest.fn(), loadLatestMessages: jest.fn(), loadMore: jest.fn(), loadMoreRecent: jest.fn(), diff --git a/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx b/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx index 1445742812..74b48ef37d 100644 --- a/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx +++ b/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx @@ -2,6 +2,10 @@ import React from 'react'; import { cleanup, fireEvent, render, waitFor } from '@testing-library/react-native'; +import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; +import { ChannelProvider } from '../../../contexts/channelContext/ChannelContext'; +import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext'; +import { ChatProvider } from '../../../contexts/chatContext/ChatContext'; import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext'; import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; @@ -67,10 +71,37 @@ describe('ScrollToBottomButton', () => { const t = jest.fn((key: string) => key); const i18nInstance = new Streami18n(); const translators = await i18nInstance.getTranslators(); + // The button reads OUR user's count from the channel read store (see ScrollToBottomButton), + // keyed by the chat client's userID. Provide a minimal fake client + channel read store + // reporting 3 unread for that user. Hand-rolled to avoid a runtime `stream-chat` import (which + // breaks jest under the local portal). + const readState = { + read: { + me: { + last_read: new Date(), + unread_messages: 3, + user: { id: 'me' }, + }, + }, + }; + const channel = { + state: { + readStore: { + getLatestValue: () => readState, + subscribeWithSelector: () => () => {}, + }, + }, + }; const { getByTestId, getByText } = render( - null} showNotification={true} unreadCount={3} /> + + + null} showNotification={true} /> + + , ); diff --git a/package/src/components/MessageList/__tests__/TypingIndicator.test.tsx b/package/src/components/MessageList/__tests__/TypingIndicator.test.tsx index 4d37e202de..eb2cb8b96c 100644 --- a/package/src/components/MessageList/__tests__/TypingIndicator.test.tsx +++ b/package/src/components/MessageList/__tests__/TypingIndicator.test.tsx @@ -2,34 +2,38 @@ import React from 'react'; import { cleanup, render, waitFor } from '@testing-library/react-native'; -import type { Event, StreamChat } from 'stream-chat'; - -import { TypingProvider } from '../../../contexts/typingContext/TypingContext'; +import type { TypingUsersState } from 'stream-chat'; +import { ChannelProvider } from '../../../contexts/channelContext/ChannelContext'; +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; import { generateStaticUser, generateUser } from '../../../mock-builders/generator/user'; -import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Chat } from '../../Chat/Chat'; import { TypingIndicator } from '../TypingIndicator'; afterEach(cleanup); describe('TypingIndicator', () => { - let chatClient: StreamChat; - it('should render typing indicator for two users', async () => { const user0 = generateUser(); const user1 = generateUser(); const user2 = generateUser(); - chatClient = await getTestClientWithUser({ id: user0.id }); - await chatClient.setUser(user0, 'testToken'); - const typing = { user1: { user: user1 }, user2: { user: user2 } }; + const { + channels: [channel], + client, + } = await initiateClientWithChannels({ customUser: user0 }); + channel.state.typingStore.partialNext({ + typing: { + user1: { user: user1 }, + user2: { user: user2 }, + } as unknown as TypingUsersState['typing'], + }); const { getAllByTestId, getByTestId } = render( - - }}> + + - + , ); await waitFor(() => { @@ -42,15 +46,19 @@ describe('TypingIndicator', () => { const user0 = generateUser(); const user1 = generateUser(); - chatClient = await getTestClientWithUser({ id: user0.id }); - await chatClient.setUser(user0, 'testToken'); - const typing = { user1: { user: user1 } }; + const { + channels: [channel], + client, + } = await initiateClientWithChannels({ customUser: user0 }); + channel.state.typingStore.partialNext({ + typing: { user1: { user: user1 } } as unknown as TypingUsersState['typing'], + }); const { getAllByTestId, getByTestId } = render( - - }}> + + - + , ); await waitFor(() => { @@ -64,15 +72,22 @@ describe('TypingIndicator', () => { const user1 = generateStaticUser(1); const user2 = generateStaticUser(3); - chatClient = await getTestClientWithUser({ id: user0.id }); - await chatClient.setUser(user0, 'testToken'); - const typing = { user1: { user: user1 }, user2: { user: user2 } }; + const { + channels: [channel], + client, + } = await initiateClientWithChannels({ customUser: user0 }); + channel.state.typingStore.partialNext({ + typing: { + user1: { user: user1 }, + user2: { user: user2 }, + } as unknown as TypingUsersState['typing'], + }); const { toJSON } = render( - - }}> + + - + , ); await waitFor(() => { diff --git a/package/src/components/MessageList/__tests__/useMessageList.test.tsx b/package/src/components/MessageList/__tests__/useMessageList.test.tsx index 2d0c0a3336..50882b8920 100644 --- a/package/src/components/MessageList/__tests__/useMessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/useMessageList.test.tsx @@ -1,66 +1,40 @@ -import React, { FC } from 'react'; +import React from 'react'; import { renderHook } from '@testing-library/react-native'; -import type { StreamChat } from 'stream-chat'; - -import { useCreatePaginatedMessageListContext } from '../../../components/Channel/hooks/useCreatePaginatedMessageListContext'; -import { - ChatContext, - ChatContextValue, - PaginatedMessageListContextValue, - PaginatedMessageListProvider, -} from '../../../contexts'; +import type { Channel, LocalMessage } from 'stream-chat'; +import { ChannelProvider } from '../../../contexts/channelContext/ChannelContext'; +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; import { generateMessage } from '../../../mock-builders/generator/message'; -import { generateUser } from '../../../mock-builders/generator/user'; -import { getTestClientWithUser } from '../../../mock-builders/mock'; - import { useMessageList } from '../hooks/useMessageList'; -const clientUser = generateUser(); -let chatClient: StreamChat; - -beforeEach(async () => { - chatClient = await getTestClientWithUser(clientUser); -}); - const messages = new Array(10) .fill(undefined) - .map((_: undefined, id: number) => generateMessage({ id: String(id) })); - -const Providers: FC<{ children: React.ReactNode }> = ({ children }) => { - const messageListContext = useCreatePaginatedMessageListContext({ - messages, - } as unknown as PaginatedMessageListContextValue); - - return ( - - - {children} - - - ); -}; + .map((_: undefined, id: number) => + generateMessage({ id: String(id) }), + ) as unknown as LocalMessage[]; describe('useMessageList', () => { + let channel: Channel; + + beforeEach(async () => { + const { + channels: [ch], + } = await initiateClientWithChannels(); + channel = ch; + // The message list is sourced reactively from channel.messagePaginator. + channel.messagePaginator.state.partialNext({ items: messages }); + }); + it('should always return a list of reversed messages', () => { - const { result } = renderHook( - () => - useMessageList({ - noGroupByUser: true, - threadList: false, - } as unknown as Parameters[0]), - { wrapper: Providers }, + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} ); - const reversedMessages = messages.reverse(); + + const { result } = renderHook(() => useMessageList({ threadList: false }), { wrapper }); + + const reversedMessages = [...messages].reverse(); expect(result.current.processedMessageList.map(({ id }) => id)).toEqual( reversedMessages.map(({ id }) => id), ); diff --git a/package/src/components/MessageList/hooks/useMarkRead.ts b/package/src/components/MessageList/hooks/useMarkRead.ts new file mode 100644 index 0000000000..8a29228c08 --- /dev/null +++ b/package/src/components/MessageList/hooks/useMarkRead.ts @@ -0,0 +1,76 @@ +import type { Channel } from 'stream-chat'; + +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useStableCallback } from '../../../hooks'; +import { MarkReadFunctionOptions } from '../../Channel/Channel'; + +/** + * Returns a `markRead` callback for the active channel. + * + * Marks the channel read through the client's `messageDeliveryReporter.throttledMarkRead` — the + * canonical v10 read-reporting path, which the reporter throttles + coordinates — rather than calling + * `channel.markRead()` behind our own throttle. It is a no-op when the channel is missing or + * disconnected; when read events are disabled it resets the local unread count via `markReadLocally()` + * (dispatches `message.read_locally`) if the client opted into a local count; otherwise it reports the + * read and resets the paginator's unread snapshot (unless `updateChannelUnreadState` is `false`) so the + * "N new messages" banner + unread separator clear once caught up. The returned function is stabilized + * so it can be used as a dependency without triggering rerenders. + */ +export const useMarkRead = (channel: Channel) => { + const { client } = useChatContext(); + + // In case the channel is disconnected which may happen when channel is deleted, + // underlying js client throws an error. Following function ensures that we don't + // result in an error in such a case. + const getChannelConfigSafely = () => { + try { + return channel?.getConfig(); + } catch (_) { + return null; + } + }; + + const markRead = useStableCallback((options?: MarkReadFunctionOptions) => { + if (!channel || channel?.disconnected) { + return; + } + + // Read events disabled (e.g. livestreams): if the client opted into a local unread count, reset + // it locally (dispatches message.read_locally) — no backend round trip. The paginator's unread + // snapshot updates from that. + if (!getChannelConfigSafely()?.read_events) { + if (client.options.isLocalUnreadCountEnabled) { + channel.markReadLocally(); + } + return; + } + + // Canonical v10 read reporting: the reporter throttles + coordinates the `/read` calls and honors + // any custom markReadRequest handler registered in channel.configState (see + // useChannelRequestHandlers). + client.messageDeliveryReporter.throttledMarkRead(channel); + + // Reset the paginator's unread snapshot so the "N new messages" banner and the unread separator + // clear once the channel is caught up. The LLC bumps `unreadCount` on every incoming + // `message.new` but never clears it on `message.read`, so without this the banner latches on and + // can't be dismissed. `throttledMarkRead` is fire-and-forget (no response to read), so advance + // the boundary to the latest loaded message. + // + // Gated on `updateChannelUnreadState` (default true): the mark-read-on-mount call passes `false` + // so opening a channel with unreads keeps its original unread UI (separator frozen at the + // boundary) until the user actually catches up. + const { updateChannelUnreadState = true } = options ?? {}; + if (updateChannelUnreadState) { + const loadedItems = channel.messagePaginator.state.getLatestValue().items ?? []; + const previous = channel.messagePaginator.unreadStateSnapshot.getLatestValue(); + channel.messagePaginator.unreadStateSnapshot.next({ + firstUnreadMessageId: null, + lastReadAt: new Date(), + lastReadMessageId: loadedItems[loadedItems.length - 1]?.id ?? previous.lastReadMessageId, + unreadCount: 0, + }); + } + }); + + return markRead; +}; diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index 3ff51c61a9..dd1713fb8e 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -2,15 +2,17 @@ import { useMemo } from 'react'; import type { LocalMessage } from 'stream-chat'; -import { usePaginatedMessageListContext } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; import { useThreadContext } from '../../../contexts/threadContext/ThreadContext'; -import { useRAFCoalescedValue } from '../../../hooks'; +import { useRAFCoalescedValue, useStateStore } from '../../../hooks'; +import { usePrunableMessageList } from '../../../hooks/usePrunableMessageList'; export type UseMessageListParams = { threadList?: boolean; isLiveStreaming?: boolean; isFlashList?: boolean; + maximumMessageLimit?: number; }; /** @@ -22,11 +24,23 @@ export type MessageGroupStyles = { [key: string]: string[]; }; +const EMPTY_MESSAGES: LocalMessage[] = []; + +const messageListSelector = (state: { items?: LocalMessage[] }) => ({ messages: state.items }); + export const useMessageList = (params: UseMessageListParams) => { - const { threadList, isLiveStreaming, isFlashList = false } = params; - const { messages, viewabilityChangedCallback } = usePaginatedMessageListContext(); - const { threadMessages } = useThreadContext(); - const messageList = threadList ? threadMessages : messages; + const { threadList, isLiveStreaming, isFlashList = false, maximumMessageLimit } = params; + const { channel } = useChannelContext(); + const { threadInstance } = useThreadContext(); + const messagePaginatorState = threadList + ? threadInstance?.messagePaginator?.state + : channel.messagePaginator.state; + const { messages } = useStateStore(messagePaginatorState, messageListSelector) ?? {}; + const { viewabilityChangedCallback } = usePrunableMessageList({ + maximumMessageLimit, + setMessages: () => {}, + }); + const messageList = messages ?? EMPTY_MESSAGES; const processedMessageList = useMemo(() => { const newMessageList: LocalMessage[] = []; diff --git a/package/src/components/MessageList/hooks/useTypingUsers.ts b/package/src/components/MessageList/hooks/useTypingUsers.ts index b4e3a52248..64d13286be 100644 --- a/package/src/components/MessageList/hooks/useTypingUsers.ts +++ b/package/src/components/MessageList/hooks/useTypingUsers.ts @@ -1,12 +1,21 @@ import { useMemo } from 'react'; -import { useChatContext, useThreadContext, useTypingContext } from '../../../contexts'; +import { TypingUsersState } from 'stream-chat'; + +import { useChannelContext, useChatContext, useThreadContext } from '../../../contexts'; +import { useStateStore } from '../../../hooks'; import { filterTypingUsers } from '../utils/filterTypingUsers'; +const selector = (state: TypingUsersState) => ({ typing: state.typing }); + export const useTypingUsers = () => { const { client } = useChatContext(); - const { thread } = useThreadContext(); - const { typing } = useTypingContext(); + const { channel } = useChannelContext(); + const { threadInstance } = useThreadContext(); + const { typing } = useStateStore(channel.state.typingStore, selector) ?? { typing: {} }; - return useMemo(() => filterTypingUsers({ client, thread, typing }), [client, thread, typing]); + return useMemo( + () => filterTypingUsers({ client, threadId: threadInstance?.id, typing }), + [client, threadInstance, typing], + ); }; diff --git a/package/src/components/MessageList/utils/filterTypingUsers.ts b/package/src/components/MessageList/utils/filterTypingUsers.ts index e84e9d5783..b889e4b084 100644 --- a/package/src/components/MessageList/utils/filterTypingUsers.ts +++ b/package/src/components/MessageList/utils/filterTypingUsers.ts @@ -1,14 +1,13 @@ -import { UserResponse } from 'stream-chat'; +import { TypingUsersState, UserResponse } from 'stream-chat'; import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext'; -import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext'; -import type { TypingContextValue } from '../../../contexts/typingContext/TypingContext'; -type FilterTypingUsersParams = Pick & - Pick & - Pick; +type FilterTypingUsersParams = { threadId?: string; typing: TypingUsersState['typing'] } & Pick< + ChatContextValue, + 'client' +>; -export const filterTypingUsers = ({ client, thread, typing }: FilterTypingUsersParams) => { +export const filterTypingUsers = ({ client, threadId, typing }: FilterTypingUsersParams) => { const nonSelfUsers: UserResponse[] = []; if (!client || !client.user || !typing) { @@ -27,8 +26,8 @@ export const filterTypingUsers = ({ client, thread, typing }: FilterTypingUsersP return; } - const isRegularEvent = !typing[typingKey].parent_id && !thread?.id; - const isCurrentThreadEvent = typing[typingKey].parent_id === thread?.id; + const isRegularEvent = !typing[typingKey].parent_id && !threadId; + const isCurrentThreadEvent = typing[typingKey].parent_id === threadId; /** filters different threads events */ if (!isRegularEvent && !isCurrentThreadEvent) { diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index fef622b043..16266f28e8 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -1,4 +1,6 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; + +import type { LocalMessage, Thread as StreamThread } from 'stream-chat'; import { ThreadFooterComponent } from './components/ThreadFooterComponent'; @@ -7,6 +9,8 @@ import { ChatContextValue, useChatContext } from '../../contexts/chatContext/Cha import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; +import { useStateStore } from '../../hooks/useStateStore'; + import type { MessageComposerProps } from '../MessageInput/MessageComposer'; import { MessageFlashList, MessageFlashListProps } from '../MessageList/MessageFlashList'; import { MessageListProps } from '../MessageList/MessageList'; @@ -22,15 +26,7 @@ try { } type ThreadPropsWithContext = Pick & - Pick< - ThreadContextValue, - | 'closeThread' - | 'loadMoreThread' - | 'parentMessagePreventPress' - | 'reloadThread' - | 'thread' - | 'threadInstance' - > & { + Pick & { /** * Additional props for underlying MessageComposer component. * Available props - https://getstream.io/chat/docs/sdk/reactnative/ui-components/message-input/#props @@ -62,50 +58,129 @@ type ThreadPropsWithContext = Pick & shouldUseFlashList?: boolean; }; +type ThreadReplyPaginatorState = { + isLoading: boolean; + items?: LocalMessage[]; + lastQueryError?: Error; +}; + +const paginatorSelector = (state: ThreadReplyPaginatorState) => ({ + isLoading: state.isLoading, + items: state.items, + lastQueryError: state.lastQueryError, +}); + +const threadStaleSelector = (state: { isStateStale: boolean }) => ({ + isStateStale: state.isStateStale, +}); + +const threadManagerSelector = (state: { threads: StreamThread[] }) => ({ + threads: state.threads, +}); + const ThreadWithContext = (props: ThreadPropsWithContext) => { const { + client, additionalMessageComposerProps, additionalMessageListProps, additionalMessageFlashListProps, autoFocus = false, - closeThread, - closeThreadOnDismount = true, disabled, - loadMoreThread, onThreadDismount, notificationHostId: notificationHostIdProp, parentMessagePreventPress = true, - thread, threadInstance, shouldUseFlashList = false, } = props; const { MessageList, ThreadMessageComposer: MessageComposer } = useComponentsContext(); + const { isLoading, items, lastQueryError } = + useStateStore(threadInstance?.messagePaginator?.state, paginatorSelector) ?? {}; + const { isStateStale } = useStateStore(threadInstance?.state, threadStaleSelector) ?? {}; + const { threads } = useStateStore(client.threads.state, threadManagerSelector) ?? { + threads: client.threads.state.getLatestValue().threads, + }; + const isThreadManaged = threadInstance?.id + ? threads.some((managedThread) => managedThread.id === threadInstance.id) + : false; + + // Mirror stream-chat-react: an unmanaged thread whose reply paginator hasn't loaded yet gets a + // metadata reload (parent message, read state, participants) — not a paginator reload. useEffect(() => { - if (threadInstance?.activate) { - threadInstance.activate(); + if (!threadInstance || isThreadManaged) return; + if (items !== undefined || isLoading) return; + void threadInstance.reload().catch((err) => console.warn('Thread reload failed', err)); + }, [isThreadManaged, threadInstance, isLoading, items]); + + // Reload when the thread's state goes stale (e.g. user stopped then resumed watching the channel). + useEffect(() => { + if (threadInstance && isStateStale) { + void threadInstance.reload().catch((err) => console.warn('Thread reload failed', err)); } - const loadMoreThreadAsync = async () => { - await loadMoreThread(); - }; + }, [isStateStale, threadInstance]); - if (thread?.id && thread.reply_count) { - loadMoreThreadAsync(); + // Once the reply paginator has loaded, adopt the instance into the ThreadManager. The manager + // registers the thread's subscriptions on adoption, which keeps the reply list live (incoming + // replies, read state, thread.updated) — mirrors stream-chat-react. + useEffect(() => { + if (!threadInstance || isThreadManaged) return; + if (isLoading || lastQueryError || items === undefined) return; + client.threads.state.next((current) => + current.threads.some((managedThread) => managedThread.id === threadInstance.id) + ? current + : { ...current, threads: [threadInstance, ...current.threads] }, + ); + }, [client.threads.state, isThreadManaged, threadInstance, isLoading, items, lastQueryError]); + + // Activate the thread instance once it is available. `threadInstance` can resolve asynchronously + // (Channel adopts it from the ThreadManager after this component mounts), so keying on it — rather + // than running on mount alone — ensures activation isn't missed when the instance arrives late. + useEffect(() => { + threadInstance?.activate?.(); + }, [threadInstance]); + + // Mark the thread read on open. Mirrors the pre-refactor openThread behavior: channel.markRead with + // a thread_id marks reliably even when the thread instance's own unread count is 0 (so it can't + // rely on the LLC's active-thread auto-read); a reply-less parent has no server-side thread yet and + // rejects with "thread not found", which we swallow. + useEffect(() => { + const channel = threadInstance?.channel; + if (!threadInstance?.id || !channel?.initialized) { + return; } + channel + .markRead({ thread_id: threadInstance.id }) + .catch((err) => console.warn('Marking thread as read on open failed with error:', err)); + }, [threadInstance]); - return () => { - if (threadInstance?.deactivate) { - threadInstance.deactivate(); - } - if (closeThreadOnDismount) { - closeThread(); - } + // 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`/`items` because the instance can arrive after mount; the `items === undefined` + // guard makes this fire at most once (an unseeded thread fetches; the fetch defines `items`, which + // also lets the adopt effect register it with the manager). + useEffect(() => { + if (!threadInstance || isLoading || items !== undefined) { + return; + } + void threadInstance.messagePaginator.toTail(); + }, [threadInstance, items, isLoading]); + + // Tear down on unmount. Use a ref so we deactivate whichever instance is current at unmount, not + // the (possibly null) one captured when this effect first ran. + const threadInstanceRef = useRef(threadInstance); + threadInstanceRef.current = threadInstance; + useEffect( + () => () => { + threadInstanceRef.current?.deactivate?.(); if (onThreadDismount) { onThreadDismount(); } - }; + }, // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + [], + ); const MemoizedThreadFooterComponent = useCallback( () => , @@ -120,14 +195,15 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { [disabled, autoFocus], ); - if (!thread?.id) { + const threadId = threadInstance?.id; + if (!threadId) { return null; } - const notificationHostId = notificationHostIdProp ?? getThreadNotificationHostId(thread.id); + const notificationHostId = notificationHostIdProp ?? getThreadNotificationHostId(threadId); return ( - + {FlashList && shouldUseFlashList ? ( ; export const Thread = (props: ThreadProps) => { const { client } = useChatContext(); const { threadList } = useChannelContext(); - const { closeThread, loadMoreThread, reloadThread, thread, threadInstance } = useThreadContext(); + const { threadInstance } = useThreadContext(); - if (thread?.id && !threadList) { + if (threadInstance?.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', ); @@ -178,10 +254,6 @@ export const Thread = (props: ThreadProps) => { { const cid = 'messaging:test-channel'; const thread = generateMessage({ cid, text: 'Thread Message Text' }); const parent_id = thread.id; - const props = { - thread, - }; const threadResponses = [ generateMessage({ cid, parent_id, text: 'Response Message Text' }), @@ -79,7 +75,7 @@ describe('Thread', () => { threadResponses as unknown as Parameters[0], ); - renderComponent({ channel, chatClient, props, thread }); + renderComponent({ channel, chatClient, thread }); const { getAllByText, getByText, queryByText } = screen; @@ -142,10 +138,6 @@ describe('Thread', () => { threadResponses as unknown as Parameters[0], ); - let setChannelUnreadState: - | React.ContextType['setChannelUnreadState'] - | undefined; - const { getByText, toJSON } = render( @@ -161,12 +153,7 @@ describe('Thread', () => { value={{} as React.ComponentProps['value']} > - - {(c) => { - setChannelUnreadState = c.setChannelUnreadState; - return ; - }} - + @@ -180,7 +167,14 @@ describe('Thread', () => { expect(getByText('Message6')).toBeTruthy(); }); - act(() => setChannelUnreadState!(undefined)); + act(() => { + channel.messagePaginator.unreadStateSnapshot.next({ + firstUnreadMessageId: null, + lastReadAt: null, + lastReadMessageId: null, + unreadCount: 0, + }); + }); const snapshot = toJSON() as unknown as { children: Array<{ diff --git a/package/src/components/Thread/components/ThreadFooterComponent.tsx b/package/src/components/Thread/components/ThreadFooterComponent.tsx index bc9ac420dd..7ebd2c3248 100644 --- a/package/src/components/Thread/components/ThreadFooterComponent.tsx +++ b/package/src/components/Thread/components/ThreadFooterComponent.tsx @@ -15,17 +15,21 @@ import { primitives } from '../../../theme'; type ThreadFooterComponentPropsWithContext = Pick< ThreadContextValue, - 'parentMessagePreventPress' | 'thread' | 'threadInstance' + 'parentMessagePreventPress' | 'threadInstance' >; +const loadingSelector = (state: { isLoading: boolean }) => ({ isLoading: state.isLoading }); + export const InlineLoadingMoreThreadIndicator = () => { - const { threadLoadingMore } = useThreadContext(); + const { threadInstance } = useThreadContext(); const { theme: { semantics }, } = useTheme(); const styles = useStyles(); + const { isLoading } = + useStateStore(threadInstance?.messagePaginator?.state, loadingSelector) ?? {}; - if (!threadLoadingMore) { + if (!isLoading) { return null; } @@ -38,19 +42,23 @@ export const InlineLoadingMoreThreadIndicator = () => { const selector = (nextValue: ThreadState) => ({ + parentMessage: nextValue.parentMessage, replyCount: nextValue.replyCount, }) as const; const ThreadFooterComponentWithContext = (props: ThreadFooterComponentPropsWithContext) => { - const { parentMessagePreventPress, thread, threadInstance } = props; + const { parentMessagePreventPress, threadInstance } = props; const { Message } = useComponentsContext(); const { t } = useTranslationContext(); const styles = useStyles(); - const { replyCount = thread?.reply_count } = useStateStore(threadInstance?.state, selector) ?? {}; + // 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) ?? {}; - if (!thread) { + if (!parentMessage) { return null; } @@ -58,7 +66,7 @@ const ThreadFooterComponentWithContext = (props: ThreadFooterComponentPropsWithC { + // The parent message + reply count are read reactively from `threadInstance.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, - thread: prevThread, threadInstance: prevThreadInstance, } = prevProps; const { parentMessagePreventPress: nextParentMessagePreventPress, - thread: nextThread, threadInstance: nextThreadInstance, } = nextProps; - if (prevParentMessagePreventPress !== nextParentMessagePreventPress) { - return false; - } - - const threadEqual = - prevThread?.id === nextThread?.id && - prevThread?.text === nextThread?.text && - prevThread?.reply_count === nextThread?.reply_count; - - if (!threadEqual) { - return false; - } - - if (prevThreadInstance !== nextThreadInstance) { - return false; - } - - const latestReactionsEqual = - prevThread && - nextThread && - Array.isArray(prevThread.latest_reactions) && - Array.isArray(nextThread.latest_reactions) - ? prevThread.latest_reactions.length === nextThread.latest_reactions.length && - prevThread.latest_reactions.every( - ({ type }, index) => type === nextThread.latest_reactions?.[index].type, - ) - : prevThread?.latest_reactions === nextThread?.latest_reactions; - if (!latestReactionsEqual) { - return false; - } - - return true; + return ( + prevParentMessagePreventPress === nextParentMessagePreventPress && + prevThreadInstance === nextThreadInstance + ); }; const MemoizedThreadFooter = React.memo( @@ -132,20 +113,17 @@ const MemoizedThreadFooter = React.memo( ) as typeof ThreadFooterComponentWithContext; export type ThreadFooterComponentProps = Partial< - Pick + Pick >; export const ThreadFooterComponent = (props: ThreadFooterComponentProps) => { - const { parentMessagePreventPress, thread, threadInstance, threadLoadingMore } = - useThreadContext(); + const { parentMessagePreventPress, threadInstance } = useThreadContext(); return ( diff --git a/package/src/components/ThreadList/ThreadListItem.tsx b/package/src/components/ThreadList/ThreadListItem.tsx index 20e6ff5e60..fad85c0039 100644 --- a/package/src/components/ThreadList/ThreadListItem.tsx +++ b/package/src/components/ThreadList/ThreadListItem.tsx @@ -46,6 +46,13 @@ const attachmentManagerStateSelector = (state: AttachmentManagerState) => ({ attachments: state.attachments, }); +type ThreadReplyPaginatorState = { items?: LocalMessage[] }; + +// The messagePaginator is the sole reply source (Thread.state.replies was removed). +const lastReplySelector = (state: ThreadReplyPaginatorState) => ({ + lastReply: state.items?.at(-1), +}); + export const ThreadListItemComponent = () => { const { channel, @@ -153,7 +160,6 @@ export const ThreadListItem = (props: ThreadListItemProps) => { ({ channel: nextValue.channel, deletedAt: nextValue.deletedAt, - lastReply: nextValue.replies.at(-1), ownUnreadMessageCount: (client.userID && nextValue.read[client.userID]?.unreadMessageCount) || 0, parentMessage: nextValue.parentMessage, @@ -161,11 +167,13 @@ export const ThreadListItem = (props: ThreadListItemProps) => { [client], ); - const { channel, deletedAt, lastReply, ownUnreadMessageCount, parentMessage } = useStateStore( + const { channel, deletedAt, ownUnreadMessageCount, parentMessage } = useStateStore( thread.state, selector, ); + const { lastReply } = useStateStore(thread.messagePaginator.state, lastReplySelector) ?? {}; + const timestamp = lastReply?.created_at; useEffect(() => { diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 7195904f8d..0f63e0939b 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -34,10 +34,7 @@ export * from './Channel/Channel'; export * from './Channel/hooks/useCreateChannelContext'; export * from './Channel/hooks/useCreateInputMessageInputContext'; export * from './Channel/hooks/useCreateMessagesContext'; -export * from './Channel/hooks/useCreatePaginatedMessageListContext'; export * from './Channel/hooks/useCreateThreadContext'; -export * from './Channel/hooks/useCreateTypingContext'; -export * from './Channel/hooks/useTargetedMessage'; /** Channel List exports*/ export * from './ChannelList/ChannelList'; @@ -91,7 +88,9 @@ export * from './Message/hooks/useCreateMessageContext'; export * from './Message/hooks/useMessageActions'; export * from './Message/hooks/useMessageActionHandlers'; export * from './Message/hooks/useStreamingMessage'; +export * from './Message/hooks/useMessageDeliveredToCount'; export * from './Message/hooks/useMessageDeliveryData'; +export * from './Message/hooks/useMessageReadCount'; export * from './Message/hooks/useMessageReadData'; export * from './Message/Message'; export * from './Message/MessageOverlayWrapper'; @@ -166,7 +165,9 @@ export * from './MessageList/TypingIndicator'; export * from './MessageList/TypingIndicatorContainer'; export * from './MessageList/utils/getGroupStyles'; export * from './MessageList/utils/getLastReceivedMessage'; +export * from './Message/hooks/useMessageDeliveredToCount'; export * from './Message/hooks/useMessageDeliveryData'; +export * from './Message/hooks/useMessageReadCount'; export * from './Message/hooks/useMessageReadData'; export * from './MessageList/hooks/useMessageDateSeparator'; export * from './MessageList/hooks/useMessageGroupStyles'; diff --git a/package/src/components/ui/Avatar/ChannelAvatar.tsx b/package/src/components/ui/Avatar/ChannelAvatar.tsx index 1048410650..7a361e445f 100644 --- a/package/src/components/ui/Avatar/ChannelAvatar.tsx +++ b/package/src/components/ui/Avatar/ChannelAvatar.tsx @@ -61,7 +61,7 @@ export const ChannelAvatar = (props: ChannelAvatarProps) => { const members = useChannelMembersState(channel); const usersForGroup = useMemo( - () => Object.values(members).map((member) => member.user as UserResponse), + () => Object.values(members ?? {}).map((member) => member.user as UserResponse), [members], ); const usersWithoutSelf = useMemo( diff --git a/package/src/contexts/__tests__/index.test.tsx b/package/src/contexts/__tests__/index.test.tsx index ec79514ab4..09d14f3b2c 100644 --- a/package/src/contexts/__tests__/index.test.tsx +++ b/package/src/contexts/__tests__/index.test.tsx @@ -13,10 +13,8 @@ import { useMessagesContext, useOverlayContext, useOwnCapabilitiesContext, - usePaginatedMessageListContext, useTheme, useThreadContext, - useTypingContext, } from '../'; import { useChannelsStateContext } from '../channelsStateContext/ChannelsStateContext'; @@ -33,10 +31,6 @@ describe('contexts hooks in a component throws an error with message when not wr useOverlayContext, 'The useOverlayContext hook was called outside the OverlayContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', ], - [ - usePaginatedMessageListContext, - 'The usePaginatedMessageListContext hook was called outside of the PaginatedMessageList provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', - ], [ useChannelsStateContext, 'The useChannelsStateContext hook was called outside the ChannelStateContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', @@ -45,10 +39,6 @@ describe('contexts hooks in a component throws an error with message when not wr useOwnCapabilitiesContext, 'The useOwnCapabilitiesContext hook was called outside the Channel Component. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', ], - [ - useTypingContext, - 'The useTypingContext hook was called outside of the TypingContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', - ], [ useTheme, 'The useThemeContext hook was called outside the ThemeContext Provider. Make sure you have configured OverlayProvider component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#overlay-provider', diff --git a/package/src/contexts/channelContext/ChannelContext.tsx b/package/src/contexts/channelContext/ChannelContext.tsx index e9de901882..f40c1f1961 100644 --- a/package/src/contexts/channelContext/ChannelContext.tsx +++ b/package/src/contexts/channelContext/ChannelContext.tsx @@ -1,12 +1,7 @@ import React, { PropsWithChildren, useContext } from 'react'; -import type { Channel, ChannelState } from 'stream-chat'; +import type { Channel } from 'stream-chat'; -import { MarkReadFunctionOptions } from '../../components/Channel/Channel'; -import { - ChannelUnreadStateStore, - ChannelUnreadStateStoreType, -} from '../../state-store/channel-unread-state'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; @@ -49,68 +44,27 @@ export type ChannelContextValue = { hideDateSeparators: boolean; hideStickyDateHeader: boolean; /** - * Loads channel around a specific message + * Loads channel around a specific message. Emits `messageFocusSignal` on the paginator, which + * drives the highlight + scroll-to-target. * @param limit - The number of messages to load around the message * @param messageId - The message around which to load messages - * @param setTargetedMessage - Callback to set the targeted message */ loadChannelAroundMessage: ({ limit, messageId, - setTargetedMessage, }: { limit?: number; messageId?: string; - setTargetedMessage?: (messageId: string) => void; }) => Promise; /** - * Loads channel at first unread message. - * @param channelUnreadState - The unread state of the channel + * Loads channel at first unread message. Emits `messageFocusSignal` on the paginator. * @param limit - The number of messages to load around the first unread message - * @param setChannelUnreadState - Callback to set the channel unread state */ - loadChannelAtFirstUnreadMessage: ({ - channelUnreadState, - limit, - setTargetedMessage, - }: { - channelUnreadState?: ChannelUnreadStateStoreType['channelUnreadState']; - limit?: number; - setChannelUnreadState?: (data: ChannelUnreadStateStoreType['channelUnreadState']) => void; - setTargetedMessage?: (messageId: string) => void; - }) => Promise; + loadChannelAtFirstUnreadMessage: (options?: { limit?: number }) => Promise; - markRead: (options?: MarkReadFunctionOptions) => void; - /** - * - * ```json - * { - * "thierry-123": { - * "id": "thierry-123", - * "role": "user", - * "created_at": "2019-04-03T14:42:47.087869Z", - * "updated_at": "2019-04-16T09:20:03.982283Z", - * "last_active": "2019-04-16T11:23:51.168113408+02:00", - * "online": true - * }, - * "vishal-123": { - * "id": "vishal-123", - * "role": "user", - * "created_at": "2019-05-03T14:42:47.087869Z", - * "updated_at": "2019-05-16T09:20:03.982283Z", - * "last_active": "2019-06-16T11:23:51.168113408+02:00", - * "online": false - * } - * } - * ``` - */ - members: ChannelState['members']; - read: ChannelState['read']; reloadChannel: () => Promise; scrollToFirstUnreadThreshold: number; - setChannelUnreadState: (data: ChannelUnreadStateStoreType['channelUnreadState']) => void; - setTargetedMessage: (messageId?: string) => void; /** * Returns true when Channel is about to load an initial targeted message. * @@ -122,7 +76,6 @@ export type ChannelContextValue = { * Its a map of filename and AbortController */ uploadAbortControllerRef: React.MutableRefObject>; - channelUnreadStateStore: ChannelUnreadStateStore; disabled?: boolean; enableMessageGroupingByUser?: boolean; /** @@ -142,37 +95,7 @@ export type ChannelContextValue = { * currently near them within the viewport. */ maximumMessageLimit?: number; - /** - * Id of message, around which Channel/MessageList gets loaded when opened. - * You will see a highlighted background for targetted message, when opened. - */ - targetedMessage?: string; threadList?: boolean; - watcherCount?: ChannelState['watcher_count']; - /** - * - * ```json - * { - * "thierry-123": { - * "id": "thierry-123", - * "role": "user", - * "created_at": "2019-04-03T14:42:47.087869Z", - * "updated_at": "2019-04-16T09:20:03.982283Z", - * "last_active": "2019-04-16T11:23:51.168113408+02:00", - * "online": true - * }, - * "vishal-123": { - * "id": "vishal-123", - * "role": "user", - * "created_at": "2019-05-03T14:42:47.087869Z", - * "updated_at": "2019-05-16T09:20:03.982283Z", - * "last_active": "2019-06-16T11:23:51.168113408+02:00", - * "online": false - * } - * } - * ``` - */ - watchers?: ChannelState['watchers']; }; export const ChannelContext = React.createContext( diff --git a/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx b/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx index ac9d493f10..772bb7549d 100644 --- a/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx +++ b/package/src/contexts/channelsStateContext/ChannelsStateContext.tsx @@ -10,13 +10,15 @@ import React, { import { ActiveChannelsProvider } from '../activeChannelsRefContext/ActiveChannelsRefContext'; -import type { ThreadContextValue } from '../threadContext/ThreadContext'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; +// Per-channel state is no longer stored here — message/thread state lives in the LLC paginators. +// This context now only tracks which channels are mounted ("active"), which the ChannelList uses +// to skip re-initializing their state on a reconnect query (see useChannelState / usePaginatedChannels). export type ChannelState = { - threadMessages: ThreadContextValue['threadMessages']; + active: boolean; }; type ChannelsState = { diff --git a/package/src/contexts/channelsStateContext/useChannelState.ts b/package/src/contexts/channelsStateContext/useChannelState.ts index 8500a90f18..21f8c953ca 100644 --- a/package/src/contexts/channelsStateContext/useChannelState.ts +++ b/package/src/contexts/channelsStateContext/useChannelState.ts @@ -1,66 +1,22 @@ -import { useCallback, useMemo } from 'react'; +import { useEffect } from 'react'; import type { Channel as ChannelType } from 'stream-chat'; import { useChannelsStateContext } from './ChannelsStateContext'; -import type { ChannelsStateContextValue, ChannelState, Keys } from './ChannelsStateContext'; - -type StateManagerParams = ChannelsStateContextValue & { - cid: string; - key: Key; -}; - -/* - This hook takes care of creating a useState-like interface which can be used later to call - updates to the ChannelsStateContext reducer. It receives the cid and key which it wants to update - and perform the state updates. Also supports a initialState. -*/ -export function useStateManager( - { cid, key, setState, state }: StateManagerParams, - initialValue?: ChannelState[Key], -) { - // eslint-disable-next-line react-hooks/exhaustive-deps - const memoizedInitialValue = useMemo(() => initialValue, []); - const value = state[cid]?.[key] || (memoizedInitialValue as ChannelState[Key]); - - const setValue = useCallback( - (value: ChannelState[Key]) => setState({ cid, key, value }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [cid, key], - ); - - return [value, setValue] as const; -} - -export type UseChannelStateValue = { - setThreadMessages: (value: ChannelState['threadMessages']) => void; - threadMessages: ChannelState['threadMessages']; -}; - -export function useChannelState( - channel: ChannelType | undefined, - threadId?: string, -): UseChannelStateValue { +/** + * Registers the channel as "active" in the ChannelsStateContext while it is mounted, so the + * ChannelList's `queryChannels` call can skip re-initializing (and thereby clearing) the state + * of channels that are currently open — preventing a reconnect race between ChannelList and + * Channel/Thread. Message and thread-reply state itself now lives in the LLC paginators + * (`channel.messagePaginator` / `thread.messagePaginator`), not in this context. + */ +export function useChannelState(channel: ChannelType | undefined): void { const cid = channel?.id || 'id'; // in case channel is not initialized, use generic id string for indexing - const { setState, state } = useChannelsStateContext(); + const { setState } = useChannelsStateContext(); - const [threadMessages, setThreadMessagesInternal] = useStateManager( - { - cid, - key: 'threadMessages', - setState, - state, - }, - (threadId && channel?.state?.threads?.[threadId]) || [], - ); - const setThreadMessages = useCallback( - (value: ChannelState['threadMessages']) => setThreadMessagesInternal([...value]), - [setThreadMessagesInternal], - ); - - return { - setThreadMessages, - threadMessages, - }; + useEffect(() => { + setState({ cid, key: 'active', value: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cid]); } diff --git a/package/src/contexts/index.ts b/package/src/contexts/index.ts index 6344249b01..c59653016b 100644 --- a/package/src/contexts/index.ts +++ b/package/src/contexts/index.ts @@ -17,7 +17,6 @@ export * from './messageInputContext/hooks/useAttachmentManagerState'; export * from './messageInputContext/hooks/useMessageComposerHasSendableData'; export * from './messageComposerContext/MessageComposerAPIContext'; export * from './messagesContext/MessagesContext'; -export * from './paginatedMessageListContext/PaginatedMessageListContext'; export * from './overlayContext/OverlayContext'; export * from './overlayContext/OverlayProvider'; export * from './ownCapabilitiesContext/OwnCapabilitiesContext'; @@ -27,7 +26,6 @@ export * from './threadContext/ThreadContext'; export * from './threadsContext/ThreadsContext'; export * from './threadsContext/ThreadListItemContext'; export * from './translationContext'; -export * from './typingContext/TypingContext'; export * from './utils/getDisplayName'; export * from './channelDetailsContext'; export * from './channelAddMembersContext/ChannelAddMembersContext'; diff --git a/package/src/contexts/messageComposerContext/MessageComposerContext.tsx b/package/src/contexts/messageComposerContext/MessageComposerContext.tsx index 3d79099319..f8d6adf7ff 100644 --- a/package/src/contexts/messageComposerContext/MessageComposerContext.tsx +++ b/package/src/contexts/messageComposerContext/MessageComposerContext.tsx @@ -16,7 +16,6 @@ import { isTestEnvironment } from '../utils/isTestEnvironment'; export type MessageComposerContextValue = { channel: ChannelProps['channel']; - thread: ThreadContextValue['thread']; threadInstance: ThreadContextValue['threadInstance']; /** * Variable that tracks the editing state. @@ -30,7 +29,7 @@ export const MessageComposerContext = React.createContext( ); type Props = React.PropsWithChildren<{ - value: Pick; + value: Pick; }>; export const MessageComposerProvider = ({ children, value }: Props) => { diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx index 6dcd1585e4..6bbf46ff96 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -1,7 +1,7 @@ import React, { PropsWithChildren, useContext } from 'react'; import type { View } from 'react-native'; -import type { Attachment, LocalMessage } from 'stream-chat'; +import type { Attachment, ChannelState, LocalMessage } from 'stream-chat'; import type { ActionHandler } from '../../components/Attachment/Attachment'; import type { ReactionSummary } from '../../components/Message/hooks/useProcessReactions'; @@ -157,8 +157,10 @@ export type MessageContextValue = { * TODO: V9: Change function params to an object */ onThreadSelect?: (message: LocalMessage, targetedMessageId?: string) => void; -} & Pick & - Pick; +} & Pick & { members: ChannelState['members'] } & Pick< + MessageComposerAPIContextValue, + 'setQuotedMessage' + >; export const MessageContext = React.createContext( DEFAULT_BASE_CONTEXT_VALUE as MessageContextValue, diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index bca66a675a..6402d6b796 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -14,7 +14,6 @@ import { LocalMessage, MessageComposer, SendMessageOptions, - StreamChat, Message as StreamMessage, UpdateMessageOptions, UploadRequestFn, @@ -126,7 +125,7 @@ export type InputMessageInputContextValue = { editMessage: (params: { localMessage: LocalMessage; options?: UpdateMessageOptions; - }) => ReturnType; + }) => Promise; /** * Controls what happens when the attach button is pressed while the attachment picker is open. @@ -228,7 +227,7 @@ export const MessageInputProvider = ({ const { uploadAbortControllerRef } = useChannelContext(); const { clearEditingState } = useMessageComposerAPIContext(); - const { thread } = useThreadContext(); + const { threadInstance } = useThreadContext(); const { t } = useTranslationContext(); const { addNotification } = useNotificationApi(); const inputBoxRef = useRef(null); @@ -560,7 +559,7 @@ export const MessageInputProvider = ({ pickFile, setInputBoxRef, takeAndUploadImage, - thread, + threadInstance, uploadNewFile, ...value, closePollCreationDialog, diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts index fd1a908d7f..e1e61cc625 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts @@ -7,10 +7,9 @@ import { MessageComposerContextValue } from '../../messageComposerContext/Messag export const useCreateMessageComposer = ({ editing: editedMessage, - thread: parentMessage, threadInstance, channel, -}: Pick) => { +}: Pick) => { const { client } = useChatContext(); const { messageComposerCache: queueCache } = client; @@ -21,17 +20,8 @@ export const useCreateMessageComposer = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [editedMessage?.id]); - const cachedParentMessage = useMemo(() => { - if (!parentMessage) return undefined; - - return parentMessage; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [parentMessage?.id]); - - // composer hierarchy - // edited message (always new) -> thread instance (own) -> thread message (always new) -> channel (own) - // editedMessage ?? thread ?? parentMessage ?? channel; - + // composer hierarchy: edited message (always new) -> thread instance (own) -> channel (own) + // editedMessage ?? threadInstance ?? channel const messageComposer = useMemo(() => { if (cachedEditedMessage) { const tag = MessageComposer.constructTag(cachedEditedMessage); @@ -49,38 +39,13 @@ export const useCreateMessageComposer = ({ }); } else if (threadInstance) { return threadInstance.messageComposer; - } else if (cachedParentMessage) { - const compositionContext = { - ...cachedParentMessage, - legacyThreadId: cachedParentMessage.id, - }; - - const tag = MessageComposer.constructTag(compositionContext); - - const cachedComposer = queueCache.get(tag); - if (cachedComposer) return cachedComposer; - - // legacy thread will receive new composer - return new MessageComposer({ - client, - compositionContext, - }); } else { return channel.messageComposer; } - }, [ - cachedEditedMessage, - cachedParentMessage, - channel.messageComposer, - client, - queueCache, - threadInstance, - ]); + }, [cachedEditedMessage, channel.messageComposer, client, queueCache, threadInstance]); if ( - (['legacy_thread', 'message'] as MessageComposer['contextType'][]).includes( - messageComposer.contextType, - ) && + (['message'] as MessageComposer['contextType'][]).includes(messageComposer.contextType) && !queueCache.peek(messageComposer.tag) ) { queueCache.add(messageComposer.tag, messageComposer); diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index 83cdd1bef4..7b1c34996d 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts @@ -42,9 +42,9 @@ export const useCreateMessageInputContext = ({ deleteVoiceRecording, uploadVoiceRecording, stopVoiceRecording, - thread, -}: MessageInputContextValue & Pick) => { - const threadId = thread?.id; + threadInstance, +}: MessageInputContextValue & Pick) => { + const threadId = threadInstance?.id; const messageInputContext: MessageInputContextValue = useMemo( () => ({ diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index b4846b074b..e92622fee8 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -2,15 +2,7 @@ import React, { PropsWithChildren, useContext } from 'react'; import { PressableProps, ViewProps } from 'react-native'; -import type { - Attachment, - Channel, - ChannelState, - CommandSuggestion, - DeleteMessageOptions, - LocalMessage, - MessageResponse, -} from 'stream-chat'; +import type { Attachment, Channel, LocalMessage } from 'stream-chat'; import type { MessagePressableHandlerPayload } from '../../components/Message/Message'; import type { MarkdownRules } from '../../components/Message/MessageItemView/utils/renderText'; @@ -45,13 +37,6 @@ export type MessageLocationProps = { }; export type MessagesContextValue = Pick & { - // FIXME: Remove the signature with optionsOrHardDelete boolean with the next major release - deleteMessage: ( - message: LocalMessage, - optionsOrHardDelete?: boolean | DeleteMessageOptions, - ) => Promise; - deleteReaction: (type: string, messageId: string) => Promise; - /** Should keyboard be dismissed when messaged is touched */ dismissKeyboardOnMessageTouch: boolean; @@ -80,21 +65,6 @@ export type MessagesContextValue = Pick Promise; - /** - * Override the api request for retry message functionality. - */ - retrySendMessage: (message: LocalMessage) => Promise; - sendReaction: (type: string, messageId: string) => Promise; - updateMessage: ( - updatedMessage: MessageResponse | LocalMessage, - extraState?: { - commands?: CommandSuggestion[]; - messageInput?: string; - threadMessages?: ChannelState['threads'][string]; - }, - throttled?: boolean, - ) => void; /** * Provide any additional props for `Pressable` which wraps inner MessageContent component here. * Please check docs for Pressable for supported props - https://reactnative.dev/docs/pressable#props @@ -339,8 +309,6 @@ export type MessagesContextValue = Pick - */ - loadLatestMessages: () => Promise; - /** - * Load more messages - */ - loadMore: (limit?: number) => Promise; - /** - * Load more recent messages - */ - loadMoreRecent: (limit?: number) => Promise; - /** - * Messages from client state - */ - messages: ChannelState['messages']; - /** - * A callback that is to be passed to onViewableItemsChanged in the underlying `MessageList` - */ - viewabilityChangedCallback: (config: ViewabilityChangedCallbackInput) => void; - /** - * Has more messages to load - */ - hasMore?: boolean; - /** - * Is loading more messages - */ - loadingMore?: boolean; - /** - * Is loading more recent messages - */ - loadingMoreRecent?: boolean; - /** - * Set loadingMore - */ - setLoadingMore?: (loadingMore: boolean) => void; - /** - * Set loadingMoreRecent - */ - setLoadingMoreRecent?: (loadingMoreRecent: boolean) => void; -}; - -export const PaginatedMessageListContext = React.createContext( - DEFAULT_BASE_CONTEXT_VALUE as PaginatedMessageListContextValue, -); - -export const PaginatedMessageListProvider = ({ - children, - value, -}: PropsWithChildren<{ - value?: PaginatedMessageListContextValue; -}>) => ( - - {children} - -); - -export const usePaginatedMessageListContext = () => { - const contextValue = useContext( - PaginatedMessageListContext, - ) as unknown as PaginatedMessageListContextValue; - - if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { - throw new Error( - 'The usePaginatedMessageListContext hook was called outside of the PaginatedMessageList provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', - ); - } - - return contextValue; -}; diff --git a/package/src/contexts/threadContext/ThreadContext.tsx b/package/src/contexts/threadContext/ThreadContext.tsx index bb182ea99d..2741b87032 100644 --- a/package/src/contexts/threadContext/ThreadContext.tsx +++ b/package/src/contexts/threadContext/ThreadContext.tsx @@ -1,6 +1,6 @@ import React, { PropsWithChildren, useContext } from 'react'; -import { ChannelState, LocalMessage, Thread } from 'stream-chat'; +import { LocalMessage, Thread } from 'stream-chat'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; @@ -14,22 +14,11 @@ export type AlsoSentToChannelHeaderPressPayload = { export type ThreadContextValue = { allowThreadMessagesInChannel: boolean; - closeThread: () => void; - loadMoreThread: () => Promise; - openThread: (message: LocalMessage) => void; - reloadThread: () => void; - setThreadLoadingMore: React.Dispatch>; - thread: LocalMessage | null; - threadHasMore: boolean; - threadMessages: ChannelState['threads'][string]; - loadMoreRecentThread?: (opts: { limit?: number }) => Promise; /** * Boolean to enable/disable parent message press */ parentMessagePreventPress?: boolean; threadInstance?: Thread | null; - threadLoadingMore?: boolean; - threadLoadingMoreRecent?: boolean; /** * 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 diff --git a/package/src/contexts/typingContext/TypingContext.tsx b/package/src/contexts/typingContext/TypingContext.tsx deleted file mode 100644 index 86f70c5560..0000000000 --- a/package/src/contexts/typingContext/TypingContext.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { PropsWithChildren, useContext } from 'react'; - -import type { ChannelState } from 'stream-chat'; - -import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; - -import { isTestEnvironment } from '../utils/isTestEnvironment'; - -export type TypingContextValue = { - typing: ChannelState['typing']; -}; - -export const TypingContext = React.createContext(DEFAULT_BASE_CONTEXT_VALUE as TypingContextValue); - -export const TypingProvider = ({ - children, - value, -}: PropsWithChildren<{ - value: TypingContextValue; -}>) => ( - - {children} - -); - -export const useTypingContext = () => { - const contextValue = useContext(TypingContext) as unknown as TypingContextValue; - - if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { - throw new Error( - 'The useTypingContext hook was called outside of the TypingContext provider. Make sure you have configured Channel component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#channel', - ); - } - - return contextValue; -}; diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts index ba43aa48f0..a31e202253 100644 --- a/package/src/hooks/index.ts +++ b/package/src/hooks/index.ts @@ -17,6 +17,8 @@ export * from './useViewport'; export * from './useScreenDimensions'; export * from './useScreenOrientation'; export * from './useStateStore'; +export * from './useChannel'; +export * from './useMessagePaginator'; export * from './usePendingAttachmentUpload'; export * from './useStableCallback'; export * from './useLoadingImage'; diff --git a/package/src/hooks/useChannel.ts b/package/src/hooks/useChannel.ts new file mode 100644 index 0000000000..7e11d3f720 --- /dev/null +++ b/package/src/hooks/useChannel.ts @@ -0,0 +1,19 @@ +import type { Channel } from 'stream-chat'; + +import { useChannelContext } from '../contexts/channelContext/ChannelContext'; +import { useThreadContext } from '../contexts/threadContext/ThreadContext'; + +/** + * Resolves the active `Channel` instance: the thread's channel when rendered inside a + * thread, otherwise the `ChannelContext` channel. + * + * Thread-aware analogue of stream-chat-react's `useChannel()`. Prefer this over reading + * `channel` from `useChannelContext()` directly in message/thread interaction code, so the + * same code path targets the correct channel in both the main list and a thread. + */ +export const useChannel = (): Channel => { + const { channel } = useChannelContext(); + const { threadInstance } = useThreadContext(); + + return threadInstance?.channel ?? channel; +}; diff --git a/package/src/hooks/useChannelOwnCapabilities.ts b/package/src/hooks/useChannelOwnCapabilities.ts index 94f52bdb86..a5377df5fd 100644 --- a/package/src/hooks/useChannelOwnCapabilities.ts +++ b/package/src/hooks/useChannelOwnCapabilities.ts @@ -1,12 +1,15 @@ -import { Channel, EventTypes } from 'stream-chat'; +import type { Channel } from 'stream-chat'; -import { useSelectedChannelState } from './useSelectedChannelState'; +import { useStateStore } from './useStateStore'; -const selector = (channel: Channel) => channel.data?.own_capabilities as string[] | undefined; -const keys: EventTypes[] = ['capabilities.changed']; +const selector = (state: { ownCapabilities: string[] }) => ({ + ownCapabilities: state.ownCapabilities, +}); -export function useChannelOwnCapabilities(channel: Channel): string[] | undefined; -export function useChannelOwnCapabilities(channel?: Channel): string[] | undefined; -export function useChannelOwnCapabilities(channel?: Channel) { - return useSelectedChannelState({ channel, selector, stateChangeEventKeys: keys }); +/** + * Returns the current user's capabilities for the channel, sourced reactively from + * `channel.state.ownCapabilitiesStore`. + */ +export function useChannelOwnCapabilities(channel?: Channel): string[] | undefined { + return useStateStore(channel?.state?.ownCapabilitiesStore, selector)?.ownCapabilities; } diff --git a/package/src/hooks/useMessagePaginator.ts b/package/src/hooks/useMessagePaginator.ts new file mode 100644 index 0000000000..8a751491ec --- /dev/null +++ b/package/src/hooks/useMessagePaginator.ts @@ -0,0 +1,17 @@ +import type { MessagePaginator } from 'stream-chat'; + +import { useChannelContext } from '../contexts/channelContext/ChannelContext'; +import { useThreadContext } from '../contexts/threadContext/ThreadContext'; + +/** + * Resolves the active `MessagePaginator` from `stream-chat`: the thread's paginator when + * rendered inside a thread, otherwise the channel's main-list paginator. + * + * Must be called within a `` subtree (and optionally a `` subtree). + */ +export const useMessagePaginator = (): MessagePaginator => { + const { channel } = useChannelContext(); + const { threadInstance } = useThreadContext(); + + return threadInstance?.messagePaginator ?? channel.messagePaginator; +}; diff --git a/package/src/hooks/usePrunableMessageList.ts b/package/src/hooks/usePrunableMessageList.ts index 4973ec74f1..e67b5b0d02 100644 --- a/package/src/hooks/usePrunableMessageList.ts +++ b/package/src/hooks/usePrunableMessageList.ts @@ -63,15 +63,16 @@ export function usePrunableMessageList({ if ( maximumMessageLimit == null || - channel.state.messages.length <= maximumMessageLimit || + (channel.messagePaginator.state.getLatestValue().items?.length ?? 0) <= maximumMessageLimit || isNearEnd({ maximumMessageLimit, rangeConfig }) ) { rawSetMessages(channel); return; } - channel.state.pruneOldest(maximumMessageLimit); - + // TODO(#6): reimplement pruning over channel.messagePaginator — it has no prune API yet, and + // channel.state.pruneOldest is being removed. This path is currently dead (setMessages is wired + // to a no-op in useMessageList), so no window-cap is enforced today. rawSetMessages(channel); }); diff --git a/package/src/state-store/channel-unread-state.ts b/package/src/state-store/channel-unread-state.ts deleted file mode 100644 index 2e938dec5b..0000000000 --- a/package/src/state-store/channel-unread-state.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { StateStore } from 'stream-chat'; - -import type { ChannelUnreadState as ChannelUnreadStateType } from '../types/types'; - -export type ChannelUnreadStateStoreType = { - channelUnreadState?: ChannelUnreadStateType; -}; - -const INITIAL_STATE: ChannelUnreadStateStoreType = { - channelUnreadState: undefined, -}; - -export class ChannelUnreadStateStore { - public state: StateStore; - - constructor() { - this.state = new StateStore(INITIAL_STATE); - } - - set channelUnreadState(data: ChannelUnreadStateStoreType['channelUnreadState']) { - this.state.next({ channelUnreadState: data }); - } - - get channelUnreadState() { - const { channelUnreadState } = this.state.getLatestValue(); - return channelUnreadState; - } -} diff --git a/package/src/utils/getChannelUnreadState.ts b/package/src/utils/getChannelUnreadState.ts new file mode 100644 index 0000000000..de98a96100 --- /dev/null +++ b/package/src/utils/getChannelUnreadState.ts @@ -0,0 +1,27 @@ +import type { Channel } from 'stream-chat'; + +import type { ChannelUnreadState } from '../types/types'; + +/** + * Maps the LLC's `channel.messagePaginator.unreadStateSnapshot` (the single source of truth for + * unread state) into the SDK's `ChannelUnreadState` shape used across the message list. + * + * Returns `undefined` when there is nothing unread to represent (no first-unread marker, no + * last-read message, no unread count) — mirroring the previous store's "empty means undefined" + * semantics so downstream `channelUnreadState?.x` checks behave identically. `last_read` falls back + * to the epoch (whole channel unread) exactly as before. + */ +export const getChannelUnreadState = (channel: Channel): ChannelUnreadState | undefined => { + const snapshot = channel.messagePaginator.unreadStateSnapshot.getLatestValue(); + + if (!snapshot.firstUnreadMessageId && !snapshot.lastReadMessageId && !snapshot.unreadCount) { + return undefined; + } + + return { + first_unread_message_id: snapshot.firstUnreadMessageId ?? undefined, + last_read: snapshot.lastReadAt ?? new Date(0), + last_read_message_id: snapshot.lastReadMessageId ?? undefined, + unread_messages: snapshot.unreadCount, + }; +}; diff --git a/package/src/utils/removeReactionFromLocalState.ts b/package/src/utils/removeReactionFromLocalState.ts deleted file mode 100644 index 2caf00be1a..0000000000 --- a/package/src/utils/removeReactionFromLocalState.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Channel, UserResponse } from 'stream-chat'; - -export const removeReactionFromLocalState = ({ - channel, - messageId, - reactionType, - user, -}: { - channel: Channel; - messageId: string; - reactionType: string; - user: UserResponse; -}) => { - const message = channel.state.messages.find(({ id }) => id === messageId); - if (!message || !channel?.id || !user?.id) { - return; - } - - message.own_reactions = message.own_reactions?.filter( - (reaction) => reaction.type !== reactionType, - ); - message.latest_reactions = message.latest_reactions?.filter( - (r) => !(r.user_id === user?.id && r.type === reactionType), - ); - - if (message.reaction_groups?.[reactionType]) { - if ( - message.reaction_groups[reactionType].count > 0 || - message.reaction_groups[reactionType].sum_scores > 0 - ) { - message.reaction_groups[reactionType].count = Math.max( - 0, - message.reaction_groups[reactionType].count - 1, - ); - message.reaction_groups[reactionType].sum_scores = Math.max( - 0, - message.reaction_groups[reactionType].sum_scores - 1, - ); - if ( - message.reaction_groups[reactionType].count === 0 || - message.reaction_groups[reactionType].sum_scores === 0 - ) { - delete message.reaction_groups[reactionType]; - } - } else { - delete message.reaction_groups[reactionType]; - } - } -}; diff --git a/yarn.lock b/yarn.lock index a31c62eb27..f0e7e9c1b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19735,9 +19735,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^9.50.1": - version: 9.50.1 - resolution: "stream-chat@npm:9.50.1" +"stream-chat@portal:/Users/isekovanic/Projects/stream-chat-js::locator=root%40workspace%3A.": + version: 0.0.0-use.local + resolution: "stream-chat@portal:/Users/isekovanic/Projects/stream-chat-js::locator=root%40workspace%3A." dependencies: "@types/jsonwebtoken": "npm:^9.0.8" "@types/ws": "npm:^8.18.1" @@ -19753,9 +19753,8 @@ __metadata: built: true husky: built: true - checksum: 10c0/a9ce574be496765934b2c46fe8b18a9ab1fe4a3127a3f66a6f7dfd3ea7cf597e2018e9ddf6851ab074119e27239d333d3779c70cc3cef685862d94387b83fbb0 languageName: node - linkType: hard + linkType: soft "stream-combiner2@npm:~1.1.1": version: 1.1.1