diff --git a/AGENTS.md b/AGENTS.md
index abf78f323d..a3ad572300 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -211,7 +211,7 @@ Native code shared by both wrappers lives in `package/shared-native/{ios,android
### DO NOT
1. **Edit generated or synced files** (see below) — regenerate them instead.
-2. **Add `channel` or `channel.state` to dependency arrays** — use `channel.cid`, which is stable.
+2. **Key on `channel.cid` where you mean the channel instance** — depend on `channel` itself. A disposed channel comes back as a new instance under the same cid, so anything bound to the instance (a context value holding it, a `useStateStore` subscription, a listener, `watch()`) must rebuild when `channel` changes. Use `cid` only when you mean the conversation.
3. **Mutate `channel.state.messages` directly** — go through the `stream-chat` client's state API.
4. **Inline a `useStateStore` selector** — define it at module scope.
5. **Use unguarded web-only APIs in shared code** — it runs on Hermes, not a browser.
diff --git a/ai-docs/ai-migration-v9-to-v10.md b/ai-docs/ai-migration-v9-to-v10.md
index 3336ec1909..1deee82024 100644
--- a/ai-docs/ai-migration-v9-to-v10.md
+++ b/ai-docs/ai-migration-v9-to-v10.md
@@ -24,7 +24,8 @@
3. **Resolution hooks** (new in v10, additive — use these as the entry points):
- `useChannelContext().channel` — the active `Channel` instance.
- `useChannel()` — `threadInstance?.channel ?? channel` (thread-aware).
- - `useMessagePaginator()` — `threadInstance?.messagePaginator ?? channel.messagePaginator`.
+ - `useMessagePaginator()` — the open thread's `messagePaginator` when the subtree renders a thread
+ list (`threadList`), `channel.messagePaginator` otherwise.
- `useStateStore(store, selector)` — subscribe to a `StateStore` with a
memo-stable selector (return a stable object; do not allocate fresh arrays
inside the selector).
@@ -154,7 +155,7 @@ means changed. Details in the linked section.
| `channel.serverConfig?.typing_events` (and the other gated flags) | `channel.config.typingEvents.enabled` — resolved, server ANDed with yours | §13.1 |
| `client.setMessageComposerSetupFunction(fn)` | `client.config.setSetupFunction('messageComposer', fn)` | §13.1 |
| re-setting `channel.messagePaginator.pageSize` after mount | `client.config.set({ channel: { messagePaginator: { pageSize } } })` | §13.1, §16.1 |
-| `useTargetedMessage()` / `setTargetedMessage(id)` | `useActiveMessagePaginator()?.jumpToMessage(id, { focusReason: 'jump-to-message', focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION })`; read with `useIsTargetedMessage(id)` | §6 |
+| `useTargetedMessage()` / `setTargetedMessage(id)` | `useMessagePaginator()?.jumpToMessage(id, { focusReason: 'jump-to-message', focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION })`; read with `useIsTargetedMessage(id)` | §6 |
| `useChannelContext().channelUnreadStateStore` / `setChannelUnreadState` | `channel.messagePaginator.unreadStateSnapshot` | §7 |
| `` | self-derived from `channel.state` `read` (override the component to control) | §7 |
| app-wide unread from `event.total_unread_count` | sum `channel.countUnread()` over `client.activeChannels` | §7.1 |
@@ -167,7 +168,7 @@ means changed. Details in the linked section.
| `useThreadContext().reloadThread()` | `threadInstance.reload()` | §10 |
| `openThread(msg)` / `closeThread()` | lift `onThreadSelect` → `` | §10.1 |
| `useMessageComposerContext().thread` | `threadInstance` | §10.2 |
-| `editMessage()` → `UpdateMessageAPIResponse` | `editMessage()` → `Promise`; for the response, `client.updateMessage()` | §11 |
+| `editMessage()` on the message input context | removed: `useMessageInputContext().sendMessage()` saves an edit; intercept with `updateMessageRequest` | P.17 |
| `` props | removed — list reads `threadInstance` + paginator internally | §12 |
| custom `FooterComponent` / `HeaderComponent` reading `loadingMore` from context | receive `{ loadingMore?: boolean }` as a prop | §12.1 |
| `` props | removed — state lives in the paginator | §13 |
@@ -303,9 +304,8 @@ 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.
+use `useMessagePaginator()`. It follows `threadList`, so the **main** channel
+list keeps reading `channel.messagePaginator` even while a thread is open.
---
@@ -411,12 +411,12 @@ setTargetedMessage(messageId);
**After (v10):**
```tsx
-import { useActiveMessagePaginator, useIsTargetedMessage, DEFAULT_HIGHLIGHT_DURATION } from 'stream-chat-react-native';
+import { useMessagePaginator, useIsTargetedMessage, DEFAULT_HIGHLIGHT_DURATION } from 'stream-chat-react-native';
// Jump to + highlight a message (loads it if not in the current window).
-// useActiveMessagePaginator resolves to the open thread's reply list when
+// useMessagePaginator resolves to the open thread's reply list when
// `threadList` is set, and the channel's main list otherwise.
-const paginator = useActiveMessagePaginator();
+const paginator = useMessagePaginator();
await paginator?.jumpToMessage(messageId, {
focusReason: 'jump-to-message',
focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
@@ -435,8 +435,8 @@ on `MessageListProps`; `channelUnreadStateStore` on
`UnreadMessagesNotificationProps`; `unreadCount` on `ScrollToBottomButtonProps`).
The unread snapshot now lives on `channel.messagePaginator.unreadStateSnapshot`
-(a `StateStore`). An internal `getChannelUnreadState(channel)` helper maps it to
-the public `ChannelUnreadState` shape for imperative readers.
+(a `StateStore` of `UnreadSnapshotState`). Read it with `useStateStore`, or
+`getLatestValue()` for a one-off read. The SDK's own `ChannelUnreadState` type is gone (P.9).
| Removed | Replacement |
|---|---|
@@ -519,7 +519,7 @@ only once the query lands, which for a message that is not loaded yet is a round
removed prop encoded this precedence internally (`!messageId && initialScrollToFirstUnreadMessage`);
the caller states it now. With both enabled at once the two jumps race and the last one wins.
-where `const paginator = useActiveMessagePaginator()` — thread-aware: the open
+where `const paginator = useMessagePaginator()` — thread-aware: the open
thread's reply paginator when `threadList` is set, the channel's otherwise. Use
`channel.messagePaginator` directly if you specifically want the channel list.
@@ -680,20 +680,10 @@ const [thread, setThread] = useState(null);
parent/`replyCount` off `threadInstance.state`.
- **`MessageProps`**: removed `openThread` (§9).
-## 11. `InputMessageInputContextValue.editMessage` return retyped
+## 11. `InputMessageInputContextValue.editMessage` removed
-`editMessage` now returns `Promise` (was
-`ReturnType`, i.e. a resolved
-`UpdateMessageAPIResponse`). Callers that read the resolved API response break —
-read the updated state from the paginator/composer after the promise resolves
-instead.
-
-```tsx
-// Before: const { message } = await editMessage(...);
-// After:
-await editMessage({ localMessage, options });
-// message state is already reflected in channel.messagePaginator
-```
+`editMessage` is gone from the message input context, along with `sendMessage` as a value you pass
+in. Submitting goes through the composer; see P.17.
---
@@ -708,8 +698,8 @@ Removed props (from both list components): `channelUnreadStateStore`,
unread from the paginator snapshot (§7), and targeting from the focus signal
(§6) — all internally.
-`loadMore` / `loadMoreRecent` / `markRead` are still passed but are now inline
-prop types; `loadingMore?` / `loadingMoreRecent?` are **added**.
+The paging props `loadMore`, `loadMoreRecent`, `hasMore`, `loadingMore` and `loadingMoreRecent` are
+removed too: the list pages its own paginator (P.7). `markRead` is still passed.
### 12.1 Custom `FooterComponent` / `HeaderComponent` receive `loadingMore` as a prop
@@ -1118,9 +1108,9 @@ Highlights that hit integrator code:
What changed on **this SDK's** own surface:
- **`findInMessagesByDate(messages, targetTimestamp)`** takes a `TimestampNS` (was a `Date`).
Exported from the package root. Pass a server timestamp, or `msToNs(ms)` / `dateToNs(date)`.
- - **`getChannelUnreadState`** returns `last_read` as a `TimestampNS`, and the epoch
- (`asTimestampNS(0)`) — not `new Date(0)` — is the "never read" sentinel. Guard it with `!= null`,
- never with truthiness.
+ - **`getChannelUnreadState`** (internal) and the `ChannelUnreadState` type are removed (P.9). The
+ snapshot's `lastReadAt` is a `TimestampNS | null`; the lists treat `null` as the epoch, meaning the
+ whole channel is unread. Guard it with `!= null`, never with truthiness.
- **`useIsChannelMuted`**'s `muteStatus` is core's `ChannelMuteStatus`: `{ createdAt: TimestampNS |
null; expiresAt: TimestampNS | null; muted: boolean }`.
- **Offline DB rows** hold plain integers. The SDK's read mappers brand them on the way out
@@ -1811,8 +1801,8 @@ whether the failure is shown on the message:
- **Offline support enabled and the request was queued for replay** — the message does **not** enter a
failed state. It is pending, not failed, and marking it failed lights up the retry affordance, which
- re-*sends* the message rather than re-editing it. The promise still rejects, so any notification you
- surface from a rejected `editMessage` is unaffected.
+ re-*sends* the message rather than re-editing it. The request still rejects, so the composer still
+ reports the failed edit.
- **No offline DB, or a definitive rejection** (a server error that is not retryable, or a cancelled
request) —
the message keeps the edit and gains `status: 'failed'` plus `error`, as before.
@@ -2203,6 +2193,332 @@ props are **removed, not deprecated**, so TypeScript flags them.
---
+# Part P — UI state cleanup over the v10 state layer
+
+The SDK no longer keeps its own copies of state that `stream-chat` v10 publishes reactively. This part
+lists what that cleanup removed or renamed. Every entry names its replacement.
+
+## P.1 Unused exports removed (breaking)
+
+These had no callers left in the SDK and only existed for the pre-v10 state model:
+
+| Removed | Use instead |
+| ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
+| `useSelectedChannelState({ channel, selector, stateChangeEventKeys })` | `useStateStore(channel.state, selector)`. The old hook only updated on the events you listed. |
+| `reduceMessagesToString`, `findInMessagesById`, `findInMessagesByDate` | `channel.messagePaginator.getItem(id)`, `jumpToMessage(id)`, `jumpToTheFirstUnreadMessage()` |
+| `getLastReceivedMessage(messages)` | `messages.find((m) => m.status !== 'failed')`, which is what it did |
+| `DebugContextProvider`, `useDebugContext`, `DebugContextValue`, `DebugDataType` | Nothing. It fed the Flipper plugin, which React Native dropped in 0.74. |
+
+## P.2 `useIsOnline` → `useConnectionLifecycle` (breaking)
+
+In v9 `useIsOnline(client)` returned `{ isOnline, connectionRecovering }`. In v10 it only installs the
+NetInfo status reporter and closes/reopens the socket on background/foreground, and it returns nothing.
+It is renamed to say so:
+
+```diff
+- useIsOnline(client, closeConnectionOnBackground);
++ useConnectionLifecycle(client, closeConnectionOnBackground);
+```
+
+`` already calls it, so most apps never did. To read the status, use `useNetworkConnectionState()`
+for the device's network or `useWSConnectionState()` for the socket.
+
+## P.3 `useMutedChannels()` takes no argument (breaking)
+
+It returns the current user's muted channels, a client-wide list. The `channel` argument was never used,
+but without it the hook returned `undefined`.
+
+```diff
+- const mutedChannels = useMutedChannels(channel);
++ const mutedChannels = useMutedChannels();
+```
+
+For one channel's mute state use `useIsChannelMuted(channel)`.
+
+## P.4 Context fields that nothing read (breaking, type-level)
+
+| Removed | Use instead |
+| -------------------------------------------------- | -------------------------------------------------------------------------------------------- |
+| `useChannelContext().isChannelActive` | `!thread \|\| threadList` for the old meaning; `useStateStore(channel.state, (s) => ({ active: s.active }))` for "is this channel being read" |
+| `useChannelContext().scrollToFirstUnreadThreshold` | Nothing. It was always `0`. |
+| `useMessagesContext().initialScrollToFirstUnreadMessage` | The value you pass to `` |
+| `useMessagesContext().quotedMessage` | It was never set. Read the composer: `useStateStore(messageComposer.state, (s) => ({ quotedMessage: s.quotedMessage }))` |
+| `useThreadContext().parentMessagePreventPress` | It was never set. Pass `parentMessagePreventPress` to `` or ``, as before. |
+
+## P.5 Props that did nothing (breaking, type-level)
+
+- `` / ``: `scrollToFirstUnreadThreshold` and `shouldShowUnreadUnderlay`.
+ Rows read `shouldShowUnreadUnderlay` from ``, so set it there.
+- ``: FlashList never used it.
+- ``: accepted by the props type and ignored.
+
+## P.6 `OfflineStoreApis`: `currentUserId` → `userId` (breaking)
+
+Every `OfflineStoreApis` function that took `currentUserId` now takes `userId`, matching
+`AbstractOfflineDB`:
+
+```diff
+- OfflineStoreApis.getChannels({ channelIds, currentUserId });
++ OfflineStoreApis.getChannels({ channelIds, userId });
+```
+
+`OfflineStoreApis.deleteReactionsForMessage` is removed. Nothing called it and `AbstractOfflineDB` has no
+slot for it; `deleteReaction` deletes one reaction.
+
+## P.7 Message lists page their own paginator (breaking)
+
+`` and `` no longer take `loadMore`, `loadMoreRecent`, `hasMore`,
+`loadingMore` or `loadingMoreRecent`. They page the paginator they render (the channel's, or the open
+thread's in a thread list) with `toTail()` (older) and `toHead()` (newer), and track the two loading
+flags themselves. The paginator already ignores a request while one is running or when that end has
+nothing more.
+
+```diff
+-
++
+```
+
+To change how a page is fetched, configure the paginator instead:
+`client.config.set({ channel: { messagePaginator: { … } } })`, or a request handler. A custom
+`FooterComponent` (`MessageList`) / `HeaderComponent` (`MessageFlashList`) still receives
+`{ loadingMore }` (§12.1).
+
+## P.8 `useMessageList` takes the paginator (breaking)
+
+```diff
+- const { processedMessageList } = useMessageList({ threadList });
++ const { processedMessageList } = useMessageList({ paginator: useMessagePaginator() });
+```
+
+## P.9 Unread state types (breaking, type-level)
+
+| Removed | Use instead |
+| ------------------------------------------ | --------------------------------------------------------------------------------- |
+| `ChannelUnreadState` type | `UnreadSnapshotState` from `stream-chat` (`channel.messagePaginator.unreadStateSnapshot`) |
+| `UnreadMessagesNotificationProps.unreadCount` | Nothing. It was never applied; the count comes from the unread snapshot. |
+
+## P.10 Message list behaviour fixes
+
+These change what the user sees:
+
+- **Thread lists use the thread's paginator for everything.** They used to drive the channel's. So:
+ - closing a thread no longer leaves the channel counting new messages as unread while the user is at
+ its bottom (the "1 new messages" separator that stuck after returning from a thread);
+ - the scroll-to-bottom button in a thread jumps the thread to its latest replies, where it used to
+ move the hidden channel list;
+ - in `MessageFlashList` threads, a channel that had jumped away no longer turns autoscroll off;
+ - the sticky date header hides at the top of the thread;
+ - a spinner shows while newer replies load, and the empty-state check follows `threadList`.
+- **A failed page no longer stops pagination.** The lists remembered every list length they had
+ requested at, so after a failed request that edge stayed dead until a new message arrived.
+- **Removing a message no longer scrolls `MessageList` to the bottom.** Cancelling a giphy or a hard
+ delete used to jump a user who had scrolled up back to the newest message. (`MessageFlashList` never
+ scrolled; it only blocked paging for a moment.)
+- The scroll-to-bottom accessibility action's unread count updates as reads arrive.
+
+
+## P.11 `ChatContext` holds only `client`, `getAppSettings` and `isMessageAIGenerated` (breaking)
+
+The other fields were either never read or duplicated state the client already publishes. Each one
+made `` rebuild the context, and every component reading `useChatContext()` re-rendered with it.
+The context value now only changes with the `client` or `isMessageAIGenerated` props.
+
+| Removed | Use instead |
+| --- | --- |
+| `useChatContext().appSettings` | `await useChatContext().getAppSettings()` |
+| `useChatContext().channel`, `.setActiveChannel` | Keep the open channel in your own navigation state and render ``. Inside a ``, `useChannelContext().channel`. |
+| `useChatContext().mutedUsers` | `useMutedUsers()` |
+| `useChatContext().enableOfflineSupport` | `client.offlineDb !== undefined` |
+
+`getAppSettings()` works like the one in `stream-chat-react`: the first call fetches the app settings
+and later calls reuse them. `` already calls it once the user is connected, so it usually
+resolves immediately. With offline support, a successful fetch is copied to the offline database and a
+failed one falls back to that copy. A failed fetch is not kept, so the next call tries again.
+
+```diff
+- const { appSettings, mutedUsers } = useChatContext();
++ const { getAppSettings } = useChatContext();
++ const mutedUsers = useMutedUsers();
++ const appSettings = await getAppSettings();
+```
+
+`useCreateChatContext` takes `{ client, getAppSettings, isMessageAIGenerated }` and memoizes on
+exactly those three.
+
+## P.12 Chat hooks removed (breaking)
+
+| Removed | Use instead |
+| --- | --- |
+| `useClientMutedUsers(client)` | `useMutedUsers()`. Same store, same result. |
+| `useAppSettings(client, isOnline, enableOfflineSupport, dbReady)` | `useChatContext().getAppSettings()` |
+
+## P.13 `MessagePropsWithContext.chatContext` removed (breaking, type-level)
+
+`Message` passed the whole chat context to its memoized inner component. The inner component now reads
+`useChatContext()` itself, which is safe because the context no longer changes. Only code typed against
+`MessagePropsWithContext` changes:
+
+```diff
+- const { chatContext } = props as MessagePropsWithContext;
+- const { client } = chatContext;
++ const { client } = useChatContext();
+```
+
+A message row still updates when its author is muted: the row reads the mutes itself.
+
+## P.14 `` follows the connected user
+
+`` used to read `client.userID` during render, so a `` mounted before `connectUser()`
+resolved only noticed the user, and only set up the offline database for them, if something
+unrelated re-rendered it. It now re-reads the user whenever the client's connection state changes,
+and the offline database's state from the instance it attached. No API change.
+
+## P.15 `` keeps rendering a deleted channel (breaking, behavioural)
+
+`` used to render nothing once its channel was deleted, through its own `channel.deleted`
+listener. It no longer handles a gone channel at all, as in `stream-chat-react`: what to show is up to the
+app. A channel instance is gone for good when the channel is deleted, when the user is removed from it,
+or when the client disconnects, and its `pendingDisposal` state says so. To keep the old behaviour, or show
+something else, read that state above ``:
+
+```tsx
+const selector = (state: ChannelLifecycleState) => ({ pendingDisposal: state.pendingDisposal });
+
+const { pendingDisposal } = useStateStore(channel.state, selector);
+if (pendingDisposal) return null; // or your own "this channel is gone" view
+return …;
+```
+
+The SampleApp's `useLeaveGoneChannel` (`examples/SampleApp/src/hooks/useLeaveGoneChannel.ts`) does this
+on the channel and thread screens, returning to the channel list.
+
+Message rows no longer check `channel.pendingDisposal` each either, so a gone channel's messages stay
+visible. A disposed instance is never revived. To open the same conversation again, get a new instance
+with `client.channel(type, id)`.
+
+## P.16 Channel behaviour fixes
+
+- **The contexts follow the channel instance, not its id.** A disposed channel comes back as a new
+ instance under the same cid. `ChannelContext` kept handing out the old one, whose paginators were
+ already disposed. `useCreateChannelContext` now rebuilds when `channel` changes.
+- **`useChannelContext().disabled` updates when the channel is frozen or unfrozen.** It used to update
+ only because an unrelated write happened to re-render ``.
+- **`` no longer re-renders on channel updates that leave its own capabilities unchanged.**
+- **`overrideOwnCapabilities` applies when a different capability is overridden.** `{ sendMessage: false }`
+ followed by `{ uploadFile: false }` used to keep the first override.
+- **Going to the background sends `typing.stop` only if the user was typing.** It used to send one for
+ every mounted `` each time.
+
+## P.17 The composer submits through `stream-chat` (breaking)
+
+`useMessageInputContext().sendMessage()` now submits the composer: `messageComposer.update()` while a
+message is being edited, `messageComposer.send()` otherwise. The SDK used to compose the message itself
+and hand it to ``'s own send and edit functions. What the user sees is unchanged: the input
+clears at once, a poll keeps the rest of the draft, links are refused where they are not allowed, and an
+edited bounced message is sent again as a new one.
+
+| Removed | Use instead |
+| --- | --- |
+| `` | `client.config.set({ channel: { requestHandlers: { sendMessageRequest } } })` |
+| `sendMessage` / `editMessage` in a custom `MessageInputProvider` `value` | the `sendMessageRequest` / `updateMessageRequest` handlers |
+| `useMessageInputContext().editMessage` | `useMessageInputContext().sendMessage()` while editing |
+| `patchMessageTextCommand` | nothing; the backend resolves the target from the mentioned users |
+
+```diff
+- prepare(localMessage)}>
++ client.config.set({
++ channel: {
++ requestHandlers: {
++ sendMessageRequest: async (params, defaultRequest) => {
++ await prepare(params.localMessage);
++ return defaultRequest(params);
++ },
++ },
++ },
++ });
+```
+
+The handler runs at a different point than `preSendMessageRequest` did. It runs after the message
+appears in the list and after its attachments finish uploading, not before. If it throws, the message
+is marked failed, where it used to stay pending. It runs for the first send only; retries go through
+`retrySendMessageRequest`.
+
+A failed send or edit is reported by the composer through `client.notifications`, with the type
+`CORE_NOTIFICATION_TYPE.messageSendFailed` or `messageUpdateFailed`. The toast copy is unchanged, and a
+failed request is no longer logged as `Error while sending message`.
+
+Moderation commands (`/mute`, `/unmute`, `/ban`, `/unban`) are sent as typed. The SDK used to rewrite
+the mentioned name into the user id (`/mute @Jane Doe` → `/mute @jane-id`) to work around a backend bug
+with names containing spaces. The backend now takes the target from the message's mentioned users, so the
+rewrite is gone, and so is the `patchMessageTextCommand` export (breaking). To keep rewriting command
+text, add a composition middleware through `client.setMessageComposerSetupFunction`.
+
+## P.18 Editing a message
+
+- Editing a message again after cancelling an edit starts from the message, not from the text the
+ cancelled edit left behind.
+- Saving or cancelling an edit leaves edit mode by itself; the composer clearing is the signal.
+- `useMessageComposer()` returns the same composer as before, read from context. Calling it no longer
+ subscribes the composer once per component.
+
+## P.19 Composer behaviour fixes
+
+- `SendMessageDisallowedIndicator` shows as soon as the channel's capabilities are known, including a
+ channel loaded from the offline database. It used to wait for the channel to be initialized, which
+ could arrive after the composer had already rendered.
+- The commands button, the attach button and the commands sheet follow capabilities and server config
+ that arrive after the composer mounted.
+- Attachment picker cells no longer re-render on every upload progress tick; a cell re-renders only
+ when its own selection changes.
+
+## P.20 `ChannelsContext` holds the list's configuration, not its state (breaking)
+
+`ChannelsContext` used to carry a copy of the list's query state next to its configuration, and every
+channel row read it. Any list update re-rendered every row: a reorder, a new message in any channel, an
+unread count, a presence change. The context now holds the configuration, the list's
+`ChannelPaginator` and the stable `loadNextPage` / `refreshList` / `reloadList` functions. `ChannelList`
+passes the query state to `ChannelListView` as props.
+
+| Removed from `ChannelsContextValue` | Use instead |
+| --- | --- |
+| `channels`, `hasNextPage`, `error` | `useStateStore(useChannelsContext().paginator.state, selector)`: `items`, `hasMoreTail`, `lastQueryError` |
+| `loadingChannels`, `channelListInitialized` | the same store: `items === undefined` |
+| `loadingNextPage`, `refreshing` | `ChannelListView` props |
+| `forceUpdate` | nothing; it was always `0` |
+
+```tsx
+const selector = (state: ChannelPaginatorState) => ({ channels: state.items });
+
+const { paginator } = useChannelsContext();
+const { channels } = useStateStore(paginator.state, selector);
+```
+
+`ChannelListViewProps` now requires the query state (`channels`, `channelListInitialized`, `error`,
+`hasNextPage`, `loadingChannels`, `loadingNextPage`, `refreshing`).
+
+A channel row now re-renders only when its own channel changes (the list's FlatList also sets
+`strictMode`). A custom `ChannelPreview` that reads `channel.data` or `channel.state` directly, without
+subscribing, no longer picks up changes through other rows' updates. Subscribe instead, for example with
+`useChannelName(channel)` or `useStateStore(channel.state, selector)`.
+
+## P.21 Channel list behaviour fixes
+
+- **Changing `onSelect`, `getChannelActionItems`, `maxUnreadCount`, `additionalFlatListProps`,
+ `loadMoreThreshold`, `numberOfSkeletons` or `setFlatListRef` on a mounted `ChannelList` takes effect.**
+ They used to be kept until the list happened to change. `setFlatListRef` is no longer called with
+ `null` and then the list again on every list update.
+- **A mounted `ChannelList` loads again after `client.disconnectUser()` and the next `connectUser()`.**
+ It used to stay on its loading skeleton.
+- **A channel row shows a draft that has only attachments**, as thread rows already did.
+
+## P.22 Thread list behaviour fix
+
+A thread list row takes its latest reply, which sets its avatar and time, from
+`thread.messagePaginator.aggregateState`. It used to take the last reply of the thread's loaded page,
+which is an older reply once the thread has been scrolled back.
+
+---
+
# Part I — i18n
## 19. English-only bundle, dotted keys, shared runtime
diff --git a/examples/SampleApp/src/hooks/useLeaveGoneChannel.ts b/examples/SampleApp/src/hooks/useLeaveGoneChannel.ts
new file mode 100644
index 0000000000..896e57c5c8
--- /dev/null
+++ b/examples/SampleApp/src/hooks/useLeaveGoneChannel.ts
@@ -0,0 +1,25 @@
+import { useEffect } from 'react';
+
+import { useNavigation } from '@react-navigation/native';
+import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
+import type { Channel, ChannelLifecycleState } from 'stream-chat';
+import { useStateStore } from 'stream-chat-react-native';
+
+import type { StackNavigatorParamList } from '../types';
+
+const selector = (state: ChannelLifecycleState) => ({ pendingDisposal: state.pendingDisposal });
+
+/**
+ * Returns to the channel list once the channel is gone: deleted, or the user removed from it.
+ * `` keeps rendering a gone channel, so what to show instead is up to the app.
+ */
+export const useLeaveGoneChannel = (channel: Channel | undefined) => {
+ const navigation = useNavigation>();
+ const { pendingDisposal } = useStateStore(channel?.state, selector) ?? {};
+
+ useEffect(() => {
+ if (pendingDisposal) {
+ navigation.popTo('MessagingScreen');
+ }
+ }, [navigation, pendingDisposal]);
+};
diff --git a/examples/SampleApp/src/screens/ChannelScreen.tsx b/examples/SampleApp/src/screens/ChannelScreen.tsx
index 2de44031ab..acf1fb93ba 100644
--- a/examples/SampleApp/src/screens/ChannelScreen.tsx
+++ b/examples/SampleApp/src/screens/ChannelScreen.tsx
@@ -37,6 +37,7 @@ import {
import { useAppContext } from '../context/AppContext';
import { useStreamChatContext } from '../context/StreamChatContext.tsx';
import { useChannelMembersStatus } from '../hooks/useChannelMembersStatus';
+import { useLeaveGoneChannel } from '../hooks/useLeaveGoneChannel';
import type { StreamThemeWithColors } from '../theme/AppTheme';
import type { StackNavigatorParamList } from '../types';
@@ -192,6 +193,7 @@ export const ChannelScreen: React.FC = ({ navigation, route
const [selectedMessage, setSelectedMessage] = useState(undefined);
const [channel, setChannel] = useState(channelFromProp);
+ useLeaveGoneChannel(channel);
useLayoutEffect(() => {
if (!channel || !messageId) {
diff --git a/examples/SampleApp/src/screens/ThreadScreen.tsx b/examples/SampleApp/src/screens/ThreadScreen.tsx
index 7f0773a42f..e5e829ca34 100644
--- a/examples/SampleApp/src/screens/ThreadScreen.tsx
+++ b/examples/SampleApp/src/screens/ThreadScreen.tsx
@@ -25,6 +25,7 @@ import { ScreenHeader } from '../components/ScreenHeader';
import { useAppContext } from '../context/AppContext.ts';
import { useStreamChatContext } from '../context/StreamChatContext.tsx';
+import { useLeaveGoneChannel } from '../hooks/useLeaveGoneChannel';
import { useLegacyColors } from '../theme/useLegacyColors';
import type { StackNavigatorParamList } from '../types';
import { channelMessageActions } from '../utils/messageActions.tsx';
@@ -110,6 +111,7 @@ export const ThreadScreen: React.FC = ({ navigation, route })
const { messageInputFloating, messageListImplementation } = useAppContext();
const { setInputRef } = useScreenReaderComposerFocusEffect();
+ useLeaveGoneChannel(channel);
const onPressMessage: NonNullable['onPressMessage']> = (
payload,
diff --git a/package/src/__tests__/offline-support/optimistic-update.tsx b/package/src/__tests__/offline-support/optimistic-update.tsx
index 9e0e2f83fe..067059267d 100644
--- a/package/src/__tests__/offline-support/optimistic-update.tsx
+++ b/package/src/__tests__/offline-support/optimistic-update.tsx
@@ -629,9 +629,9 @@ export const OptimisticUpdates = () => {
{
+ callback={async () => {
await flushMountEffects();
- await editMessage({
+ await channel.messageOperations.update({
localMessage: {
...message,
cid: channel.cid,
@@ -696,10 +696,10 @@ export const OptimisticUpdates = () => {
// one.
>
{
+ callback={async () => {
await flushMountEffects();
try {
- await editMessage({
+ await channel.messageOperations.update({
localMessage: {
...message,
cid: channel.cid,
@@ -766,9 +766,9 @@ export const OptimisticUpdates = () => {
{
+ callback={async () => {
await flushMountEffects();
- await editMessage({
+ await channel.messageOperations.update({
localMessage: {
...message,
cid: channel.cid,
@@ -845,10 +845,10 @@ export const OptimisticUpdates = () => {
// Persist the optimistic attachment edit locally, then reject the request (offline).
>
{
+ callback={async () => {
await flushMountEffects();
try {
- await editMessage({
+ await channel.messageOperations.update({
localMessage: {
...message,
attachments: editedAttachments,
@@ -906,18 +906,18 @@ export const OptimisticUpdates = () => {
{
+ callback={async () => {
// Same barrier every other "edit message" test uses. Without it the edit fires from
// this child mount effect BEFORE `Channel`'s own effect runs `channel.watch()`, whose
// seed then re-ingests the pre-edit copy from the mocked query response and overwrites
- // the optimistic edit. Measured: the optimistic copy is correct at `editMessage`
+ // the optimistic edit. Measured: the optimistic copy is correct at `messageOperations.update`
// resolution and still correct a macrotask later, then the in-flight watch lands on
// top of it. That window is unreachable in production (see flushMountEffects).
await flushMountEffects();
// Go offline BEFORE editing so the default (no-handler) offline path runs.
markConnectionUnhealthy(chatClient);
try {
- await editMessage({
+ await channel.messageOperations.update({
localMessage: { ...message, cid: channel.cid, text: editedText },
options: {},
});
@@ -1139,10 +1139,10 @@ export const OptimisticUpdates = () => {
{
+ callback={async () => {
await flushMountEffects();
try {
- await editMessage({
+ await channel.messageOperations.update({
localMessage: { ...message, cid: channel.cid, text: editedText },
options: {},
});
diff --git a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx
index e125133dd2..38bce115f4 100644
--- a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx
+++ b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/AttachmentPickerItem.tsx
@@ -1,19 +1,25 @@
-import React from 'react';
+import React, { useCallback } from 'react';
import { Alert, Image, StyleSheet, Text, View } from 'react-native';
-import { FileReference, isLocalImageAttachment, isLocalVideoAttachment } from 'stream-chat';
+import {
+ AttachmentManagerState,
+ FileReference,
+ isLocalImageAttachment,
+ isLocalVideoAttachment,
+ LocalAttachment,
+} from 'stream-chat';
import { isIosLimited, type PhotoContentItemType } from './shared';
import { useA11yLabel } from '../../../../a11y/hooks/useA11yLabel';
import { useAttachmentPickerContext } from '../../../../contexts';
import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext';
-import { useAttachmentManagerState } from '../../../../contexts/messageInputContext/hooks/useAttachmentManagerState';
import { useMessageComposer } from '../../../../contexts/messageInputContext/hooks/useMessageComposer';
import { useMessageInputContext } from '../../../../contexts/messageInputContext/MessageInputContext';
import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext';
+import { useStateStore } from '../../../../hooks/useStateStore';
import { useWindowContentWidth } from '../../../../hooks/useWindowContentWidth';
import { NativeHandlers } from '../../../../native';
import { primitives } from '../../../../theme';
@@ -25,22 +31,35 @@ type AttachmentPickerItemType = {
asset: File;
};
+/**
+ * Where this cell's asset sits among the composer's attachments, or -1. Selected per cell, so an
+ * upload progressing re-renders no cell and a selection change only the cells whose index moved.
+ */
+const useSelectedIndex = (isAsset: (attachment: LocalAttachment) => boolean) => {
+ const { attachmentManager } = useMessageComposer();
+ const selector = useCallback(
+ (state: AttachmentManagerState) => ({ selectedIndex: state.attachments.findIndex(isAsset) }),
+ [isAsset],
+ );
+ return useStateStore(attachmentManager.state, selector).selectedIndex;
+};
+
const AttachmentVideo = (props: AttachmentPickerItemType) => {
const { asset } = props;
const { numberOfAttachmentPickerImageColumns } = useAttachmentPickerContext();
const { ImageOverlaySelectedComponent } = useComponentsContext();
const contentWidth = useWindowContentWidth();
const { t } = useTranslationContext();
- const messageComposer = useMessageComposer();
+ const { attachmentManager } = useMessageComposer();
const { uploadNewFile } = useMessageInputContext();
- const { attachmentManager } = messageComposer;
- const { attachments, availableUploadSlots } = useAttachmentManagerState();
-
- const selectedIndex = attachments.findIndex((attachment) =>
- isLocalVideoAttachment(attachment)
- ? (attachment.localMetadata.file as FileReference).uri === asset.uri
- : false,
+ const isAsset = useCallback(
+ (attachment: LocalAttachment) =>
+ isLocalVideoAttachment(attachment)
+ ? (attachment.localMetadata.file as FileReference).uri === asset.uri
+ : false,
+ [asset.uri],
);
+ const selectedIndex = useSelectedIndex(isAsset);
const {
theme: {
@@ -61,12 +80,12 @@ const AttachmentVideo = (props: AttachmentPickerItemType) => {
const onPressVideo = async () => {
if (selected) {
- const attachment = attachments[selectedIndex];
+ const attachment = attachmentManager.attachments.find(isAsset);
if (attachment) {
attachmentManager.removeAttachments([attachment.localMetadata.id]);
}
} else {
- if (!availableUploadSlots) {
+ if (!attachmentManager.availableUploadSlots) {
Alert.alert(t('attachmentPicker.maxFiles.error', 'Maximum number of files reached'));
return;
}
@@ -112,12 +131,15 @@ const AttachmentImage = (props: AttachmentPickerItemType) => {
const contentWidth = useWindowContentWidth();
const { t } = useTranslationContext();
const { uploadNewFile } = useMessageInputContext();
- const messageComposer = useMessageComposer();
- const { attachmentManager } = messageComposer;
- const { attachments, availableUploadSlots } = useAttachmentManagerState();
- const selectedIndex = attachments.findIndex((attachment) =>
- isLocalImageAttachment(attachment) ? attachment.localMetadata.previewUri === asset.uri : false,
+ const { attachmentManager } = useMessageComposer();
+ const isAsset = useCallback(
+ (attachment: LocalAttachment) =>
+ isLocalImageAttachment(attachment)
+ ? attachment.localMetadata.previewUri === asset.uri
+ : false,
+ [asset.uri],
);
+ const selectedIndex = useSelectedIndex(isAsset);
const size = contentWidth / (numberOfAttachmentPickerImageColumns || 3) - 2;
const selected = selectedIndex !== -1;
@@ -131,12 +153,12 @@ const AttachmentImage = (props: AttachmentPickerItemType) => {
const onPressImage = async () => {
if (selected) {
- const attachment = attachments[selectedIndex];
+ const attachment = attachmentManager.attachments.find(isAsset);
if (attachment) {
await attachmentManager.removeAttachments([attachment.localMetadata.id]);
}
} else {
- if (!availableUploadSlots) {
+ if (!attachmentManager.availableUploadSlots) {
Alert.alert(t('attachmentPicker.maxFiles.error', 'Maximum number of files reached'));
return;
}
diff --git a/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/__tests__/AttachmentPickerItem.test.tsx b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/__tests__/AttachmentPickerItem.test.tsx
new file mode 100644
index 0000000000..8bab3e73c3
--- /dev/null
+++ b/package/src/components/AttachmentPicker/components/AttachmentMediaPicker/__tests__/AttachmentPickerItem.test.tsx
@@ -0,0 +1,63 @@
+import React, { Profiler } from 'react';
+
+import { act, cleanup, render } from '@testing-library/react-native';
+import type { Channel, LocalImageAttachment, StreamChat } from 'stream-chat';
+
+import { MessageComposerProvider } from '../../../../../contexts/messageComposerContext/MessageComposerContext';
+import { initiateClientWithChannels } from '../../../../../mock-builders/api/initiateClientWithChannels';
+import { Chat } from '../../../../Chat/Chat';
+import { renderAttachmentPickerItem } from '../AttachmentPickerItem';
+
+const uploadingImage = (uploadProgress: number) =>
+ ({
+ localMetadata: {
+ file: { name: 'photo.jpg', type: 'image/jpeg', uri: 'file://photo.jpg' },
+ id: 'photo',
+ previewUri: 'file://photo.jpg',
+ uploadProgress,
+ uploadState: 'uploading',
+ },
+ type: 'image',
+ }) as unknown as LocalImageAttachment;
+
+describe('AttachmentPickerItem', () => {
+ let channel: Channel;
+ let chatClient: StreamChat;
+
+ beforeEach(async () => {
+ const { client, channels } = await initiateClientWithChannels();
+ channel = channels[0];
+ chatClient = client;
+ });
+
+ afterEach(cleanup);
+
+ it('does not re-render while its selected image uploads', () => {
+ const { attachmentManager } = channel.messageComposer;
+ act(() => attachmentManager.upsertAttachments([uploadingImage(10)]));
+ let renders = 0;
+
+ render(
+
+
+ (renders += 1)}>
+ {renderAttachmentPickerItem({
+ item: { name: 'photo.jpg', size: 1024, type: 'image/jpeg', uri: 'file://photo.jpg' },
+ })}
+
+
+ ,
+ );
+ const rendersBefore = renders;
+
+ // What each upload progress tick publishes: a new attachments array, the cell's index unchanged.
+ const progress = (uploadProgress: number) =>
+ attachmentManager.state.partialNext({ attachments: [uploadingImage(uploadProgress)] });
+ act(() => progress(50));
+ act(() => progress(90));
+
+ // Guard against a vacuous pass: the progress really was written.
+ expect(attachmentManager.attachments[0].localMetadata.uploadProgress).toBe(90);
+ expect(renders).toBe(rendersBefore);
+ });
+});
diff --git a/package/src/components/AttachmentPicker/components/AttachmentPickerContent.tsx b/package/src/components/AttachmentPicker/components/AttachmentPickerContent.tsx
index 8f68fa7c7e..4af9095c37 100644
--- a/package/src/components/AttachmentPicker/components/AttachmentPickerContent.tsx
+++ b/package/src/components/AttachmentPicker/components/AttachmentPickerContent.tsx
@@ -22,6 +22,7 @@ import {
import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext';
import { useTheme } from '../../../contexts/themeContext/ThemeContext';
import { useAttachmentPickerState, useStableCallback } from '../../../hooks';
+import { useStateStore } from '../../../hooks/useStateStore';
import { primitives } from '../../../theme';
import { CommandSuggestionItem } from '../../AutoCompleteInput/AutoCompleteSuggestionItem';
@@ -124,13 +125,13 @@ const useCommandPickerStyle = () => {
export const AttachmentCommandPicker = () => {
const { t } = useTranslationContext();
- const messageComposer = useMessageComposer();
- const [commands] = useState(() => {
- const commandsSearchSource = new CommandSearchSource(messageComposer.channel);
- const result = commandsSearchSource.query('');
-
- return result.items;
- });
+ const { channel } = useMessageComposer();
+ const commandSearchSource = useMemo(() => new CommandSearchSource(channel), [channel]);
+ const commandsSelector = useCallback(
+ () => ({ commands: commandSearchSource.query('').items }),
+ [commandSearchSource],
+ );
+ const { commands } = useStateStore(channel.configState, commandsSelector);
const styles = useCommandPickerStyle();
return (
diff --git a/package/src/components/AttachmentPicker/components/__tests__/AttachmentCommandPicker.test.tsx b/package/src/components/AttachmentPicker/components/__tests__/AttachmentCommandPicker.test.tsx
new file mode 100644
index 0000000000..9f6218766a
--- /dev/null
+++ b/package/src/components/AttachmentPicker/components/__tests__/AttachmentCommandPicker.test.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+
+import { act, cleanup, render, screen } from '@testing-library/react-native';
+import type { Channel, Command, StreamChat } from 'stream-chat';
+
+import { MessageComposerProvider } from '../../../../contexts/messageComposerContext/MessageComposerContext';
+import { initiateClientWithChannels } from '../../../../mock-builders/api/initiateClientWithChannels';
+import { Chat } from '../../../Chat/Chat';
+import { AttachmentCommandPicker } from '../AttachmentPickerContent';
+
+describe('AttachmentCommandPicker', () => {
+ let channel: Channel;
+ let chatClient: StreamChat;
+
+ beforeEach(async () => {
+ const { client, channels } = await initiateClientWithChannels();
+ channel = channels[0];
+ chatClient = client;
+ });
+
+ afterEach(cleanup);
+
+ it('lists commands that arrive after it mounted', () => {
+ const setCommands = (commands: Command[]) =>
+ chatClient.channelServerConfigsStore.partialNext({
+ configs: { ...chatClient.channelServerConfigs, [channel.cid]: { commands } as never },
+ });
+ act(() => setCommands([]));
+
+ render(
+
+
+
+
+ ,
+ );
+ expect(screen.queryByText('Giphy')).toBeNull();
+
+ act(() =>
+ setCommands([
+ { args: '[text]', description: 'Post a random gif', name: 'giphy', set: 'fun_set' },
+ ]),
+ );
+
+ expect(screen.getByText('Giphy')).toBeTruthy();
+ });
+});
diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx
index 809f4e2f50..261ae9c42c 100644
--- a/package/src/components/Channel/Channel.tsx
+++ b/package/src/components/Channel/Channel.tsx
@@ -3,11 +3,11 @@ import { StyleSheet, Text, View } from 'react-native';
import {
ChannelConfig,
+ ChannelDataState,
+ ChannelLifecycleState,
LocalMessage,
MessageComposerConfig,
- SendMessageOptions,
Event as StreamEvent,
- MessageRequest as StreamMessage,
Thread,
} from 'stream-chat';
@@ -21,8 +21,6 @@ import { useCreateOwnCapabilitiesContext } from './hooks/useCreateOwnCapabilitie
import { useCreateThreadContext } from './hooks/useCreateThreadContext';
-import { DEFAULT_HIGHLIGHT_DURATION } from './hooks/useMessageListPagination';
-
import { useSupersededChannelSwap } from './hooks/useSupersededChannelSwap';
import {
@@ -75,13 +73,12 @@ import {
import { MessageInputHeightStore } from '../../state-store/message-input-height-store';
import { primitives } from '../../theme';
-import { patchMessageTextCommand } from '../../utils/patchMessageTextCommand';
import { ReactionData } from '../../utils/utils';
import { NotificationAnnouncer } from '../Accessibility/NotificationAnnouncer';
import { AttachmentPicker } from '../AttachmentPicker/AttachmentPicker';
-import { useSettledWSConnectionHealth } from '../Chat/hooks/useWSConnectionState';
import type { KeyboardCompatibleViewProps } from '../KeyboardCompatibleView/KeyboardCompatibleView';
import { useMarkRead } from '../MessageList/hooks/useMarkRead';
+import { DEFAULT_HIGHLIGHT_DURATION } from '../MessageList/hooks/useMessageListFocus';
import { Emoji } from '../MessageMenu/EmojiPickerList';
import { emojis } from '../MessageMenu/emojis';
import { toUnicodeScalarString } from '../MessageMenu/utils/toUnicodeScalarString';
@@ -91,7 +88,7 @@ import { NotificationTargetProvider } from '../Notifications/NotificationTargetC
export type MarkReadFunctionOptions = {
/**
- * Signal, whether the `channelUnreadUiState` should be updated.
+ * Signal, whether the message paginator's unread snapshot should be updated.
* By default, the local state update is prevented when the Channel component is mounted.
* This is in order to keep the UI indicating the original unread state, when the user opens a channel.
*/
@@ -130,12 +127,6 @@ export const reactionData: ReactionData[] = [
})),
];
-/**
- * If count of unread messages is less than 4, then no need to scroll to first unread message,
- * since first unread message will be in visible frame anyways.
- */
-const scrollToFirstUnreadThreshold = 0;
-
/**
* 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
@@ -168,7 +159,8 @@ export type ChannelPropsWithContext = Pick &
| 'maxTimeBetweenGroupedMessages'
>
> &
- Pick & { isOnline: boolean } & Partial<
+ Pick &
+ Partial<
Pick<
InputMessageInputContextValue,
| 'additionalTextInputProps'
@@ -258,19 +250,6 @@ export type ChannelPropsWithContext = Pick &
* KeyboardAvoidingView works well when your component occupies 100% of screen height, otherwise it may raise some issues.
*/
disableKeyboardCompatibleView?: boolean;
- /**
- * A method invoked just after the first optimistic update of a new message,
- * but before any other HTTP requests happen. Can be used to do extra work
- * (such as creating a channel, or editing a message) before the local message
- * is sent.
- * @param channelId
- * @param messageData Message object
- */
- preSendMessageRequest?: (options: {
- localMessage: LocalMessage;
- message: StreamMessage;
- options?: SendMessageOptions;
- }) => Promise;
/**
* When true, messageList will be scrolled at first unread message, when opened.
*/
@@ -282,7 +261,8 @@ export type ChannelPropsWithContext = Pick &
*/
markReadOnMount?: boolean;
/**
- * Load the channel at a specified message instead of the most recent message.
+ * The notification host this channel's notifications are routed to. Defaults to the channel's
+ * own host, derived from its cid.
*/
notificationHostId?: string;
overrideOwnCapabilities?: Partial;
@@ -328,6 +308,11 @@ const channelQuerySelector = (state: { items?: unknown[]; lastQueryError?: Error
blockingError: state.items?.length ? undefined : state.lastQueryError,
});
+const channelStatusSelector = (state: ChannelDataState & ChannelLifecycleState) => ({
+ frozen: state.data?.frozen ?? false,
+ pendingDisposal: state.pendingDisposal,
+});
+
const ChannelWithContext = (props: PropsWithChildren) => {
const {
disableAttachmentPicker = !isImageMediaLibraryAvailable(),
@@ -357,7 +342,6 @@ const ChannelWithContext = (props: PropsWithChildren) =
disableKeyboardCompatibleView = false,
disableTypingIndicator,
dismissKeyboardOnMessageTouch = true,
- preSendMessageRequest,
enableMessageGroupingByUser = true,
enableSwipeToReply = true,
enforceUniqueReaction = false,
@@ -440,7 +424,6 @@ const ChannelWithContext = (props: PropsWithChildren) =
const { thread: threadProps, threadInstance: threadInstanceFromProps } = threadFromProps;
const styles = useStyles();
- const [deleted, setDeleted] = useState(false);
// 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).
@@ -463,12 +446,14 @@ const ChannelWithContext = (props: PropsWithChildren) =
const { blockingError } =
useStateStore(channel.messagePaginator.state, channelQuerySelector) ?? {};
+ const { frozen, pendingDisposal } = useStateStore(channel.state, channelStatusSelector);
+
const channelId = channel?.id || '';
const { pollsEnabled } = useStateStore(
channel?.messageComposer?.configState,
composerPollsSelector,
) ?? { pollsEnabled: false };
- const pollCreationEnabled = !channel.pendingDisposal && !!channel?.id && pollsEnabled;
+ const pollCreationEnabled = !pendingDisposal && !!channel?.id && pollsEnabled;
const { addNotification } = useNotificationApi();
@@ -511,7 +496,7 @@ const ChannelWithContext = (props: PropsWithChildren) =
return false;
}
- return (unreadCount ?? channel.countUnread()) > scrollToFirstUnreadThreshold;
+ return (unreadCount ?? channel.countUnread()) > 0;
});
const hasPendingInitialTargetLoad = useStableCallback(() => {
@@ -578,7 +563,7 @@ const ChannelWithContext = (props: PropsWithChildren) =
initChannel();
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [channel.cid, shouldSyncChannel]);
+ }, [channel, shouldSyncChannel]);
// Mark the channel active while this is mounted. The LLC refcounts `active`, so a
// Channel instance shared with the channel-list preview or a thread stays active until the last
@@ -588,29 +573,19 @@ const ChannelWithContext = (props: PropsWithChildren) =
// instance before activating the new one.
useEffect(() => channel?.activate(), [channel]);
- // subscribe to channel.deleted event
- useEffect(() => {
- const { unsubscribe } = client.on('channel.deleted', (event) => {
- if (event.cid === channel?.cid) {
- setDeleted(true);
- }
- });
-
- return unsubscribe;
- }, [channel?.cid, client]);
-
+ // Sent directly rather than through `channel.stopTyping()`, which does nothing once the socket is
+ // closed, and `` usually closes it on background before this runs.
const handleAppBackground = useCallback(() => {
- const channelData = channel.data;
- if (channelData?.own_capabilities?.includes('send-typing-events')) {
- channel.sendEvent({
- event: {
- parent_id: thread?.id,
- type: 'typing.stop',
- },
- } as { event: StreamEvent });
+ if (!channel.isTyping || !channel.data?.own_capabilities?.includes('send-typing-events')) {
+ return;
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [thread?.id, channelId]);
+ channel.sendEvent({
+ event: {
+ parent_id: thread?.id,
+ type: 'typing.stop',
+ },
+ } as { event: StreamEvent });
+ }, [channel, thread?.id]);
useAppStateListener(undefined, handleAppBackground);
@@ -659,58 +634,6 @@ const ChannelWithContext = (props: PropsWithChildren) =
availableCommands: [],
};
- /**
- * MESSAGE METHODS
- */
- const sendMessage: InputMessageInputContextValue['sendMessage'] = useStableCallback(
- async ({ localMessage, message, options }) => {
- if (preSendMessageRequest) {
- await preSendMessageRequest({ localMessage, message, options });
- }
-
- // 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). Its single optimistic ingest shows the message (pending)
- // instantly, then it awaits any attachment uploads still in flight and POSTs — through a
- // `sendMessageRequest` registered via `client.config.set(...)`, if any. It throws on failure, which the MessageInput send flow
- // catches to surface a notification.
- await (threadInstance ?? channel).messageOperations.send({
- localMessage,
- message: messageToSend,
- options,
- });
- },
- );
-
- const editMessage: InputMessageInputContextValue['editMessage'] = useStableCallback(
- async ({ localMessage, options }) => {
- if (!channel) {
- throw new Error('Channel has not been initialized');
- }
- // The LLC handles the optimistic local update, the network request (honoring any
- // `updateMessageRequest` registered through `client.config.set({ channel: { requestHandlers } })`),
- // the received/failed state transitions, offline queueing and the offline-DB write.
- //
- // Routed by MEMBERSHIP rather than "a thread is open", mirroring `useMessageOperations`'
- // `sendReaction`: a reply loaded in the open thread is edited through the thread instance, and
- // anything else — including the thread's own PARENT message, which the reply paginator cannot
- // hold — through the channel.
- const target = threadInstance?.messagePaginator.getItem(localMessage.id)
- ? threadInstance
- : channel;
- await target.messageOperations.update({ localMessage, options });
- },
- );
-
const handleClosePicker = useStableCallback(() => closePicker(bottomSheetRef));
const handleOpenPicker = useStableCallback(() => openPicker(bottomSheetRef));
@@ -750,32 +673,17 @@ const ChannelWithContext = (props: PropsWithChildren) =
const channelContext = useCreateChannelContext({
channel,
- disabled: !!channel?.data?.frozen,
+ disabled: frozen,
enableMessageGroupingByUser,
enforceUniqueReaction,
allowDateSeparatorForSystemMessages,
hideDateSeparators,
hideStickyDateHeader,
- isChannelActive: shouldSyncChannel,
maxTimeBetweenGroupedMessages,
- scrollToFirstUnreadThreshold,
hasPendingInitialTargetLoad,
threadList,
});
- // This is mainly a hack to get around an issue with sendMessage not being passed correctly as a
- // useMemo() dependency. The easy fix is to add it to the dependency array, however that would mean
- // that this (very used) context is essentially going to cause rerenders on pretty much every Channel
- // render, since sendMessage is an inline function. Wrapping it in useCallback() is one way to fix it
- // but it is definitely not trivial, especially considering it depends on other inline functions that
- // are not wrapped in a useCallback() themselves hence creating a huge cascading change. Can be removed
- // once our memoization issues are fixed in most places in the app or we move to a reactive state store.
- // const sendMessageRef = useRef(sendMessage);
- // sendMessageRef.current = sendMessage;
- // const sendMessageStable = useCallback((...args) => {
- // return sendMessageRef.current(...args);
- // }, []);
-
const inputMessageInputContext = useCreateInputMessageInputContext({
additionalTextInputProps,
asyncMessagesLockDistance,
@@ -788,7 +696,6 @@ const ChannelWithContext = (props: PropsWithChildren) =
channelId,
compressImageQuality,
createPollOptionGap,
- editMessage,
focusInputOnPickerClose,
handleAttachButtonPress,
hasCameraPicker,
@@ -798,7 +705,6 @@ const ChannelWithContext = (props: PropsWithChildren) =
messageInputFloating,
messageInputHeightStore,
openPollCreationDialog,
- sendMessage,
setInputRef,
});
@@ -830,9 +736,6 @@ const ChannelWithContext = (props: PropsWithChildren) =
handleBlockUser,
hasCreatePoll:
hasCreatePoll === undefined ? pollCreationEnabled : hasCreatePoll && pollCreationEnabled,
- // A message is already targeted (by the prop or by the integrator's own jump), so first-unread
- // must not take the scroll off it.
- initialScrollToFirstUnreadMessage: !hasFocusTarget() && initialScrollToFirstUnreadMessage,
isAttachmentEqual,
isMessageAIGenerated,
markdownRules,
@@ -869,11 +772,6 @@ const ChannelWithContext = (props: PropsWithChildren) =
[channel, threadInstance],
);
- // TODO: replace the null view with appropriate message. Currently this is waiting a design decision.
- if (deleted) {
- return null;
- }
-
if (!channel || blockingError) {
// Retry re-runs the query that failed. A new failure lands in the paginator's `lastQueryError`,
// which is the `error` this indicator renders off — so the warn is all the handling needed.
@@ -940,7 +838,6 @@ export type ChannelProps = Partial) => {
const { client, isMessageAIGenerated } = useChatContext();
- const isOnline = useSettledWSConnectionHealth();
const { t } = useTranslationContext();
const channel = useSupersededChannelSwap(props.channel);
const notificationHostId =
@@ -971,7 +868,6 @@ export const Channel = (props: PropsWithChildren) => {
shouldSyncChannel={shouldSyncChannel}
{...{
isMessageAIGenerated,
- isOnline,
thread,
}}
/>
diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx
index 06aac55a07..7161c03a9f 100644
--- a/package/src/components/Channel/__tests__/Channel.test.tsx
+++ b/package/src/components/Channel/__tests__/Channel.test.tsx
@@ -1,5 +1,5 @@
import React, { type ComponentProps, useContext, useEffect } from 'react';
-import { View } from 'react-native';
+import { AppState, AppStateStatus, View } from 'react-native';
import { act, cleanup, render, waitFor } from '@testing-library/react-native';
import type { Channel as ChannelType, StreamChat as StreamChatType } from 'stream-chat';
@@ -13,13 +13,20 @@ import {
MessagesProvider,
} from '../../../contexts/messagesContext/MessagesContext';
+import {
+ OwnCapabilitiesContext,
+ type OwnCapabilitiesContextValue,
+} from '../../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext';
import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext';
import { ThreadContext, ThreadProvider } from '../../../contexts/threadContext/ThreadContext';
import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel';
import { useMockedApis } from '../../../mock-builders/api/useMockedApis';
+import dispatchChannelDeletedEvent from '../../../mock-builders/event/channelDeleted';
+import dispatchChannelUpdatedEvent from '../../../mock-builders/event/channelUpdated';
import dispatchConnectionChanged from '../../../mock-builders/event/connectionChanged';
import dispatchMessageNewEvent from '../../../mock-builders/event/messageNew';
+import dispatchNotificationRemovedFromChannel from '../../../mock-builders/event/notificationRemovedFromChannel';
import { generateChannelResponse } from '../../../mock-builders/generator/channel';
import { generateMember } from '../../../mock-builders/generator/member';
import { generateMessage } from '../../../mock-builders/generator/message';
@@ -28,6 +35,7 @@ import { generateUser } from '../../../mock-builders/generator/user';
import { getTestClientWithUser } from '../../../mock-builders/mock';
import { Attachment } from '../../Attachment/Attachment';
import { Chat } from '../../Chat/Chat';
+import { MessageList } from '../../MessageList/MessageList';
import { Thread as ThreadComponent } from '../../Thread/Thread';
import { Channel } from '../Channel';
import * as CreateChannelContext from '../hooks/useCreateChannelContext';
@@ -554,6 +562,127 @@ describe('Channel', () => {
expect(seen.length).toBe(rendersBefore);
expect(new Set(seen).size).toBe(1);
});
+
+ it.each([
+ ['deleted', () => dispatchChannelDeletedEvent(chatClient, { cid: channel.cid })],
+ [
+ 'left because the user was removed',
+ () => dispatchNotificationRemovedFromChannel(chatClient, { cid: channel.cid }),
+ ],
+ ])('keeps rendering its messages once the channel is %s', async (_label, dispose) => {
+ const { getByText } = renderComponent({ channel, children: });
+ await waitFor(() => expect(getByText(messages[0].text as string)).toBeTruthy());
+
+ act(() => dispose());
+
+ expect(channel.pendingDisposal).toBe(true);
+ expect(getByText(messages[0].text as string)).toBeTruthy();
+ });
+
+ it('marks the channel context disabled when channel.updated freezes the channel', async () => {
+ const seen: ChannelContextValue[] = [];
+ render(
+
+
+ }
+ fn={(ctx) => seen.push(ctx as ChannelContextValue)}
+ />
+
+ ,
+ );
+ await waitFor(() => expect(channel.initialized).toBe(true));
+ expect(seen.at(-1)?.disabled).toBe(false);
+
+ act(() => dispatchChannelUpdatedEvent(chatClient, { ...channel.data, frozen: true }));
+
+ await waitFor(() => expect(seen.at(-1)?.disabled).toBe(true));
+ });
+
+ it('does not re-render when channel.updated changes nothing it shows', async () => {
+ const renderSpy = jest.spyOn(CreateChannelContext, 'useCreateChannelContext');
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(channel.initialized).toBe(true));
+ await act(async () => {
+ await Promise.resolve();
+ });
+ const rendersBefore = renderSpy.mock.calls.length;
+ const capabilitiesBefore = channel.state.getLatestValue().ownCapabilities;
+
+ act(() => dispatchChannelUpdatedEvent(chatClient, { ...channel.data }));
+
+ // Guard against a vacuous pass: the update was applied and republished the capabilities.
+ expect(channel.state.getLatestValue().ownCapabilities).not.toBe(capabilitiesBefore);
+ expect(channel.state.getLatestValue().ownCapabilities).toEqual(capabilitiesBefore);
+ expect(renderSpy.mock.calls.length).toBe(rendersBefore);
+ });
+
+ it('rebuilds own capabilities when a different capability is overridden', async () => {
+ let capabilities: OwnCapabilitiesContextValue | undefined;
+ const renderChannel = (overrideOwnCapabilities: Partial) => (
+
+
+ }
+ fn={(ctx) => {
+ capabilities = ctx as OwnCapabilitiesContextValue;
+ }}
+ />
+
+
+ );
+
+ const { rerender } = render(renderChannel({ sendMessage: false }));
+ await waitFor(() => expect(capabilities?.sendMessage).toBe(false));
+ expect(capabilities?.uploadFile).toBe(true);
+
+ rerender(renderChannel({ uploadFile: false }));
+
+ await waitFor(() => expect(capabilities?.uploadFile).toBe(false));
+ expect(capabilities?.sendMessage).toBe(true);
+ });
+
+ it.each([
+ [true, 1],
+ [false, 0],
+ ])('when isTyping is %s, sends %i typing.stop on background', async (isTyping, sent) => {
+ const mockedChannel = generateChannelResponse({
+ channel: { cid: channelCid, own_capabilities: ['send-typing-events'] },
+ id: channelId,
+ members: [generateMember({ user })],
+ messages,
+ type: channelType,
+ });
+ useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
+ await channel.watch();
+ const sendEventSpy = jest.spyOn(channel, 'sendEvent').mockResolvedValue({} as never);
+ const addEventListenerSpy = jest.spyOn(AppState, 'addEventListener');
+
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(addEventListenerSpy).toHaveBeenCalled());
+
+ channel.isTyping = isTyping;
+ act(() => {
+ addEventListenerSpy.mock.calls.forEach(([, handler]) =>
+ (handler as (state: AppStateStatus) => void)('background'),
+ );
+ });
+
+ expect(sendEventSpy).toHaveBeenCalledTimes(sent);
+ if (sent) {
+ expect(sendEventSpy).toHaveBeenCalledWith({
+ event: { parent_id: undefined, type: 'typing.stop' },
+ });
+ }
+ });
});
describe('Channel initial load useEffect', () => {
diff --git a/package/src/components/Channel/__tests__/useCreateChannelContext.test.ts b/package/src/components/Channel/__tests__/useCreateChannelContext.test.ts
index a9d653e011..3299d6440b 100644
--- a/package/src/components/Channel/__tests__/useCreateChannelContext.test.ts
+++ b/package/src/components/Channel/__tests__/useCreateChannelContext.test.ts
@@ -17,9 +17,7 @@ const baseInput = (overrides: Partial = {}) =>
hasPendingInitialTargetLoad: () => false,
hideDateSeparators: false,
hideStickyDateHeader: false,
- isChannelActive: true,
maxTimeBetweenGroupedMessages: 1000,
- scrollToFirstUnreadThreshold: 4,
threadList: false,
...overrides,
}) as ChannelContextValue;
@@ -35,7 +33,7 @@ describe('useCreateChannelContext', () => {
);
const first = result.current;
- rerender(baseInput({ hideDateSeparators: true, scrollToFirstUnreadThreshold: 99 }));
+ rerender(baseInput({ hideDateSeparators: true, maxTimeBetweenGroupedMessages: 99 }));
expect(result.current).toBe(first);
});
@@ -45,7 +43,6 @@ describe('useCreateChannelContext', () => {
// a superseded instance shares its id with the instance that replaced it
['channel instance', { channel: { id: 'channel-1' } as unknown as Channel }],
['disabled', { disabled: true }],
- ['isChannelActive', { isChannelActive: false }],
['threadList', { threadList: true }],
])('builds a new object when %s changes', (_label, overrides) => {
const { rerender, result } = renderHook(
diff --git a/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx b/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx
deleted file mode 100644
index 1216c5f13d..0000000000
--- a/package/src/components/Channel/__tests__/useMessageListPagination.test.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import { act, cleanup, renderHook } from '@testing-library/react-native';
-import type { Channel, LocalMessage } from 'stream-chat';
-
-import { useMessageListPagination } from '../hooks/useMessageListPagination';
-
-// 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.
-
-type PaginatorStateValue = {
- hasMoreHead: boolean;
- hasMoreTail: boolean;
- isLoading: boolean;
- items?: LocalMessage[];
-};
-
-const makeStore = (value: T) => ({
- getLatestValue: () => value,
- subscribeWithSelector: () => () => {},
-});
-
-const makePaginator = (state: PaginatorStateValue) => ({
- hasMoreHead: state.hasMoreHead,
- hasMoreTail: state.hasMoreTail,
- messageFocusSignal: makeStore({ signal: null }),
- state: makeStore(state),
- toHead: jest.fn().mockResolvedValue(undefined),
- toTail: jest.fn().mockResolvedValue(undefined),
-});
-
-const makeChannel = (paginator: ReturnType) =>
- ({ messagePaginator: paginator }) as unknown as Channel;
-
-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('loadMore (older) delegates to paginator.toTail()', async () => {
- const paginator = makePaginator({
- hasMoreHead: true,
- hasMoreTail: true,
- isLoading: false,
- items: [],
- });
- const { result } = renderHook(() =>
- useMessageListPagination({ channel: makeChannel(paginator) }),
- );
- await act(async () => {
- await result.current.loadMore();
- });
- expect(paginator.toTail).toHaveBeenCalledTimes(1);
- expect(paginator.toHead).not.toHaveBeenCalled();
- });
-
- it('loadMoreRecent (newer) delegates to paginator.toHead()', async () => {
- const paginator = makePaginator({
- hasMoreHead: true,
- hasMoreTail: true,
- isLoading: false,
- items: [],
- });
- const { result } = renderHook(() =>
- useMessageListPagination({ channel: makeChannel(paginator) }),
- );
- await act(async () => {
- await result.current.loadMoreRecent();
- });
- expect(paginator.toHead).toHaveBeenCalledTimes(1);
- expect(paginator.toTail).not.toHaveBeenCalled();
- });
-
- 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: [],
- });
- 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();
- });
-});
diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts
index a65c6b1563..4f3063b417 100644
--- a/package/src/components/Channel/hooks/useCreateChannelContext.ts
+++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts
@@ -10,14 +10,10 @@ export const useCreateChannelContext = ({
allowDateSeparatorForSystemMessages,
hideDateSeparators,
hideStickyDateHeader,
- isChannelActive,
maxTimeBetweenGroupedMessages,
- scrollToFirstUnreadThreshold,
hasPendingInitialTargetLoad,
threadList,
}: ChannelContextValue) => {
- const channelId = channel?.id;
-
const channelContext: ChannelContextValue = useMemo(
() => ({
channel,
@@ -27,15 +23,13 @@ export const useCreateChannelContext = ({
allowDateSeparatorForSystemMessages,
hideDateSeparators,
hideStickyDateHeader,
- isChannelActive,
maxTimeBetweenGroupedMessages,
- scrollToFirstUnreadThreshold,
hasPendingInitialTargetLoad,
threadList,
}),
// Keyed on the instance as well, since a superseded instance shares its id with its successor
// eslint-disable-next-line react-hooks/exhaustive-deps
- [channel, channelId, disabled, isChannelActive, threadList],
+ [channel, disabled, threadList],
);
return channelContext;
diff --git a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts
index 520935f5b7..dca429b52a 100644
--- a/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts
+++ b/package/src/components/Channel/hooks/useCreateInputMessageInputContext.ts
@@ -14,7 +14,6 @@ export const useCreateInputMessageInputContext = ({
channelId,
compressImageQuality,
createPollOptionGap,
- editMessage,
focusInputOnPickerClose,
handleAttachButtonPress,
hasCameraPicker,
@@ -24,7 +23,6 @@ export const useCreateInputMessageInputContext = ({
messageInputFloating,
messageInputHeightStore,
openPollCreationDialog,
- sendMessage,
setInputRef,
showPollCreationDialog,
}: InputMessageInputContextValue & {
@@ -45,7 +43,6 @@ export const useCreateInputMessageInputContext = ({
audioRecordingEnabled,
compressImageQuality,
createPollOptionGap,
- editMessage,
focusInputOnPickerClose,
handleAttachButtonPress,
hasCameraPicker,
@@ -55,12 +52,11 @@ export const useCreateInputMessageInputContext = ({
messageInputFloating,
messageInputHeightStore,
openPollCreationDialog,
- sendMessage,
setInputRef,
showPollCreationDialog,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
- [compressImageQuality, channelId, showPollCreationDialog],
+ [channelId, compressImageQuality, hasCommands, showPollCreationDialog],
);
return inputMessageInputContext;
diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts
index aed765164a..e9f10f61d9 100644
--- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts
+++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts
@@ -29,7 +29,6 @@ export const useCreateMessagesContext = ({
handleThreadReply,
handleBlockUser,
hasCreatePoll,
- initialScrollToFirstUnreadMessage,
isAttachmentEqual,
isMessageAIGenerated,
markdownRules,
@@ -86,7 +85,6 @@ export const useCreateMessagesContext = ({
handleThreadReply,
handleBlockUser,
hasCreatePoll,
- initialScrollToFirstUnreadMessage,
isAttachmentEqual,
isMessageAIGenerated,
markdownRules,
@@ -112,7 +110,6 @@ export const useCreateMessagesContext = ({
channelId,
disableTypingIndicator,
dismissKeyboardOnMessageTouch,
- initialScrollToFirstUnreadMessage,
markdownRulesLength,
messageContentOrderValue,
messageOverlayTargetId,
diff --git a/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts b/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts
index 9f86ce3fb7..05d93f88b1 100644
--- a/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts
+++ b/package/src/components/Channel/hooks/useCreateOwnCapabilitiesContext.ts
@@ -1,6 +1,6 @@
import { useMemo } from 'react';
-import type { Channel } from 'stream-chat';
+import type { Channel, OwnCapabilitiesState } from 'stream-chat';
import {
allOwnCapabilities,
@@ -9,8 +9,10 @@ import {
} from '../../../contexts/ownCapabilitiesContext/OwnCapabilitiesContext';
import { useStateStore } from '../../../hooks/useStateStore';
-const selector = (state: { ownCapabilities: string[] }) => ({
- ownCapabilities: state.ownCapabilities,
+// TODO: Move own_capabilities as part of channel state rather than having to do
+// stuff like this.
+const selector = (state: OwnCapabilitiesState) => ({
+ ownCapabilities: state.ownCapabilities.join(','),
});
export const useCreateOwnCapabilitiesContext = ({
@@ -22,26 +24,25 @@ export const useCreateOwnCapabilitiesContext = ({
}) => {
// Sourced reactively from channel.state (kept up to date by the client
// on watch/query and `capabilities.changed`).
- const { ownCapabilities = [] } = useStateStore(channel.state, selector) ?? {};
+ const { ownCapabilities } = useStateStore(channel.state, selector);
- const overrideCapabilitiesStr = overrideCapabilities
- ? JSON.stringify(Object.values(overrideCapabilities))
- : null;
- const ownCapabilitiesStr = JSON.stringify(ownCapabilities);
+ const overrideCapabilitiesKey = overrideCapabilities
+ ? JSON.stringify(overrideCapabilities)
+ : undefined;
const ownCapabilitiesContext: OwnCapabilitiesContextValue = useMemo(() => {
- const capabilities = ownCapabilities as Array;
+ const capabilities = ownCapabilities.split(',');
return Object.keys(allOwnCapabilities).reduce(
(result, capability) => ({
...result,
[capability]:
overrideCapabilities?.[capability as OwnCapability] ??
- !!capabilities.includes(allOwnCapabilities[capability as OwnCapability]),
+ capabilities.includes(allOwnCapabilities[capability as OwnCapability]),
}),
{} as OwnCapabilitiesContextValue,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [channel.id, overrideCapabilitiesStr, ownCapabilitiesStr]);
+ }, [overrideCapabilitiesKey, ownCapabilities]);
return ownCapabilitiesContext;
};
diff --git a/package/src/components/Channel/hooks/useMessageListPagination.tsx b/package/src/components/Channel/hooks/useMessageListPagination.tsx
deleted file mode 100644
index 80e2e546e4..0000000000
--- a/package/src/components/Channel/hooks/useMessageListPagination.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { useState } from 'react';
-
-import { Channel, LocalMessage } from 'stream-chat';
-
-import { useStableCallback, useStateStore } from '../../../hooks';
-
-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,
-});
-
-/**
- * Pagination proper: the directional loads and their loading flags. Only the message-list components
- * use this — it subscribes to the paginator, so anything calling it re-renders per publish.
- */
-export const useMessageListPagination = ({ channel }: { channel: Channel }) => {
- const paginator = channel.messagePaginator;
-
- const { hasMore, hasMoreNewer, isLoading, messages } =
- useStateStore(paginator.state, selector) ?? {};
-
- const [loadingMore, setLoadingMore] = useState(false);
- const [loadingMoreRecent, setLoadingMoreRecent] = useState(false);
-
- /**
- * Loads older messages (before the oldest loaded message).
- */
- const loadMore = useStableCallback(async () => {
- if (!paginator.hasMoreTail || paginator.isLoading) {
- return;
- }
- setLoadingMore(true);
- try {
- await paginator.toTail();
- } catch (e) {
- console.warn('Message pagination(fetching old messages) request failed with error:', e);
- } finally {
- setLoadingMore(false);
- }
- });
-
- /**
- * Loads newer messages (after the most recent loaded message).
- */
- const loadMoreRecent = useStableCallback(async () => {
- if (!paginator.hasMoreHead || paginator.isLoading) {
- return;
- }
- setLoadingMoreRecent(true);
- try {
- await paginator.toHead();
- } catch (e) {
- console.warn('Message pagination(fetching new messages) request failed with error:', e);
- } finally {
- setLoadingMoreRecent(false);
- }
- });
-
- return {
- loadMore,
- loadMoreRecent,
- state: {
- hasMore,
- hasMoreNewer,
- loading: !!isLoading && !messages?.length,
- loadingMore,
- loadingMoreRecent,
- messages,
- },
- };
-};
diff --git a/package/src/components/ChannelList/ChannelList.tsx b/package/src/components/ChannelList/ChannelList.tsx
index 2e12c3ea04..b73db227f4 100644
--- a/package/src/components/ChannelList/ChannelList.tsx
+++ b/package/src/components/ChannelList/ChannelList.tsx
@@ -1,6 +1,6 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect } from 'react';
-import { StyleSheet, View } from 'react-native';
+import { FlatListProps, StyleSheet, View } from 'react-native';
import type { FlatList } from 'react-native-gesture-handler';
import { Channel, ChannelFilters, ChannelOptions, SortParamRequest } from 'stream-chat';
@@ -20,6 +20,7 @@ import { useChatContext } from '../../contexts/chatContext/ChatContext';
import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext';
import { SwipeRegistryProvider } from '../../contexts/swipeableContext/SwipeRegistryContext';
import { useLazyRef } from '../../hooks/useLazyRef';
+import { useStableCallback } from '../../hooks/useStableCallback';
import { generateRandomId } from '../../utils/utils';
import { NotificationTargetProvider } from '../Notifications/NotificationTargetContext';
@@ -75,6 +76,7 @@ export type ChannelListProps = Partial<
notificationHostId?: string;
};
+const DEFAULT_ADDITIONAL_FLAT_LIST_PROPS: Partial> = {};
const DEFAULT_FILTERS = {};
const DEFAULT_OPTIONS = {};
const DEFAULT_SORT: SortParamRequest[] = [];
@@ -87,7 +89,7 @@ const DEFAULT_SORT: SortParamRequest[] = [];
*/
export const ChannelList = (props: ChannelListProps) => {
const {
- additionalFlatListProps = {},
+ additionalFlatListProps = DEFAULT_ADDITIONAL_FLAT_LIST_PROPS,
channelRenderFilterFn,
filters = DEFAULT_FILTERS,
// https://stackoverflow.com/a/60666252/10826415
@@ -107,7 +109,6 @@ export const ChannelList = (props: ChannelListProps) => {
swipeActionsEnabled = true,
} = props;
- const [forceUpdate] = useState(0);
const fallbackNotificationHostIdRef = useLazyRef(() => `channel-list:${generateRandomId()}`);
const notificationHostId = notificationHostIdProp ?? fallbackNotificationHostIdRef.current;
const { client } = useChatContext();
@@ -125,6 +126,7 @@ export const ChannelList = (props: ChannelListProps) => {
loadingChannels,
loadingNextPage,
loadNextPage,
+ paginator,
refreshing,
refreshList,
reloadList,
@@ -136,30 +138,25 @@ export const ChannelList = (props: ChannelListProps) => {
sort,
});
+ const onFlatListRef = useStableCallback((ref: FlatList | null) => {
+ if (setFlatListRef) {
+ setFlatListRef(ref);
+ }
+ });
+
const channelsContext = useCreateChannelsContext({
additionalFlatListProps,
- channelListInitialized,
- channels: channelRenderFilterFn ? channelRenderFilterFn(channels ?? []) : (channels ?? null),
- error,
- forceUpdate,
- hasNextPage,
- loadingChannels,
- loadingNextPage,
loadMoreThreshold,
loadNextPage,
maxUnreadCount,
numberOfSkeletons,
onSelect,
getChannelActionItems,
+ paginator,
swipeActionsEnabled,
- refreshing,
refreshList,
reloadList,
- setFlatListRef: (ref: FlatList | null) => {
- if (setFlatListRef) {
- setFlatListRef(ref);
- }
- },
+ setFlatListRef: onFlatListRef,
mutedStatusPosition,
pinnedStatusPosition,
});
@@ -169,7 +166,17 @@ export const ChannelList = (props: ChannelListProps) => {
-
+
diff --git a/package/src/components/ChannelList/ChannelListView.tsx b/package/src/components/ChannelList/ChannelListView.tsx
index daa24c9498..38aa28ecdf 100644
--- a/package/src/components/ChannelList/ChannelListView.tsx
+++ b/package/src/components/ChannelList/ChannelListView.tsx
@@ -10,7 +10,6 @@ import {
useChannelsContext,
} from '../../contexts/channelsContext/ChannelsContext';
import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext';
-import { useDebugContext } from '../../contexts/debugContext/DebugContext';
import { useTheme } from '../../contexts/themeContext/ThemeContext';
import { useStableCallback } from '../../hooks';
@@ -18,16 +17,55 @@ import { ChannelPreview } from '../ChannelPreview/ChannelPreview';
import { useNetworkConnectionState } from '../Chat/hooks/useNetworkConnectionState';
import { useSettledWSConnectionHealth } from '../Chat/hooks/useWSConnectionState';
+/**
+ * The list's query state, passed down by `ChannelList`.
+ */
+export type ChannelListState = {
+ /**
+ * A control prop used to determine whether the first query of the channel list has succeeded.
+ */
+ channelListInitialized: boolean;
+ /**
+ * The channels to render.
+ */
+ channels: Channel[] | null;
+ /**
+ * Whether or not the FlatList has another page to render
+ */
+ hasNextPage: boolean;
+ /**
+ * Initial channels query loading state, triggers the LoadingIndicator
+ */
+ loadingChannels: boolean;
+ /**
+ * Whether or not additional channels are being loaded, triggers the
+ * ChannelListFooterLoadingIndicator
+ */
+ loadingNextPage: boolean;
+ /**
+ * Triggered when the channel list is refreshing, displays a loading spinner at the top of the list
+ */
+ refreshing: boolean;
+ /**
+ * Error in channels query, if any
+ */
+ error?: Error;
+};
+
export type ChannelListViewPropsWithContext = Omit<
ChannelsContextValue,
'maxUnreadCount' | 'numberOfSkeletons' | 'onSelect'
->;
-
-const StatusIndicator = () => {
+> &
+ ChannelListState;
+
+const StatusIndicator = ({
+ error,
+ loadingChannels,
+ refreshList,
+}: Pick) => {
const isNetworkOnline = useNetworkConnectionState()?.isOnline;
const isWSOnline = useSettledWSConnectionHealth();
const styles = useStyles();
- const { error, loadingChannels, refreshList } = useChannelsContext();
const { ChannelListHeaderErrorIndicator, ChannelListHeaderNetworkDownIndicator } =
useComponentsContext();
@@ -64,7 +102,6 @@ const ChannelListViewWithContext = (props: ChannelListViewPropsWithContext) => {
channelListInitialized,
channels,
error,
- forceUpdate,
hasNextPage,
loadingChannels,
loadingNextPage,
@@ -89,7 +126,6 @@ const ChannelListViewWithContext = (props: ChannelListViewPropsWithContext) => {
* change to loadingChannels is registered.
*/
const [loading, setLoading] = useState(true);
- const debugRef = useDebugContext();
const styles = useStyles();
useEffect(() => {
@@ -98,23 +134,6 @@ const ChannelListViewWithContext = (props: ChannelListViewPropsWithContext) => {
}
}, [loading, loadingChannels]);
- const isDebugModeEnabled = __DEV__ && debugRef && debugRef.current;
-
- if (isDebugModeEnabled) {
- if (debugRef.current.setEventType) {
- debugRef.current.setEventType('send');
- }
- if (debugRef.current.setSendEventParams) {
- debugRef.current.setSendEventParams({
- action: 'Channels',
- data: channels?.map((channel) => ({
- data: channel.data,
- members: channel.state.members,
- })),
- });
- }
- }
-
const onEndReached = useStableCallback(() => {
if (!onEndReachedCalledDuringCurrentScrollRef.current && hasNextPage) {
loadNextPage();
@@ -138,7 +157,6 @@ const ChannelListViewWithContext = (props: ChannelListViewPropsWithContext) => {
:
@@ -152,16 +170,21 @@ const ChannelListViewWithContext = (props: ChannelListViewPropsWithContext) => {
ref={setFlatListRef}
refreshing={refreshing}
renderItem={renderItem}
+ // Rows subscribe to their own channel, so a list update re-renders only the cells it changed.
+ strictMode
style={styles.flatList}
testID='channel-list-view'
{...additionalFlatListProps}
/>
-
+
>
);
};
-export type ChannelListViewProps = Partial;
+export type ChannelListViewProps = Partial<
+ Omit
+> &
+ ChannelListState;
/**
* This UI component displays the preview list of channels and handles Channel navigation. It
@@ -172,16 +195,9 @@ export type ChannelListViewProps = Partial;
export const ChannelListView = (props: ChannelListViewProps) => {
const {
additionalFlatListProps,
- channelListInitialized,
- channels,
- error,
- forceUpdate,
- hasNextPage,
- loadingChannels,
- loadingNextPage,
loadMoreThreshold,
loadNextPage,
- refreshing,
+ paginator,
refreshList,
reloadList,
setFlatListRef,
@@ -191,16 +207,9 @@ export const ChannelListView = (props: ChannelListViewProps) => {
({
* Custom ChannelPreview component used via WithComponents to verify channel rendering.
* Receives { channel, muted, unread, lastMessage } from ChannelPreview.
*/
-const ChannelPreviewComponent = ({ channel }: { channel: ChannelType }) => (
-
- {channel.data?.custom?.name}
- {channel.messagePaginator.headItems[0]?.text}
-
-);
+const ChannelPreviewComponent = ({ channel }: { channel: ChannelType }) => {
+ const name = useChannelName(channel);
+ return (
+
+ {name}
+ {channel.messagePaginator.headItems[0]?.text}
+
+ );
+};
/**
* Probe that reads swipeActionsEnabled from ChannelsContext.
@@ -68,12 +72,9 @@ const SwipeActionsProbe = () => {
};
/**
- * Probe that reads refreshing from ChannelsContext.
+ * Probe that renders once the list has loaded.
*/
-const RefreshingProbe = () => {
- const { refreshing } = useChannelsContext();
- return {`${refreshing}`};
-};
+const LoadedProbe = () => loaded;
/**
* Probe that captures the context `refreshList` (the public, non-forced pull-to-refresh handler) so a
@@ -81,11 +82,13 @@ const RefreshingProbe = () => {
*/
let capturedRefreshList: (() => void | Promise) | undefined;
const RefreshListProbe = () => {
- const { refreshing, refreshList } = useChannelsContext();
+ const { refreshList } = useChannelsContext();
capturedRefreshList = refreshList;
- return {`${refreshing}`};
+ return loaded;
};
+const isRefreshing = () => screen.getByTestId('channel-list-view').props.refreshing;
+
const ChannelPreviewContent = ({ unread }: { unread?: number }) => (
{`${unread}`}
);
@@ -860,7 +863,7 @@ describe('ChannelList', () => {
render(
-
+
,
@@ -868,8 +871,9 @@ describe('ChannelList', () => {
// The probe only renders once the mount query populates the list.
await waitFor(() => {
- expect(screen.getByTestId('refreshing').children[0]).toBe('false');
+ expect(screen.getByTestId('loaded')).toBeTruthy();
});
+ expect(isRefreshing()).toBe(false);
// Advance the clock 6s past mount so both reconnects observe t=6000.
dateNowSpy.mockReturnValue(6000);
@@ -902,7 +906,7 @@ describe('ChannelList', () => {
});
// Background reconnection refreshes never surface in the pull-to-refresh UI.
- expect(screen.getByTestId('refreshing').children[0]).toBe('false');
+ expect(isRefreshing()).toBe(false);
await waitFor(() => {
expect(
@@ -930,8 +934,9 @@ describe('ChannelList', () => {
);
await waitFor(() => {
- expect(screen.getByTestId('refreshing').children[0]).toBe('false');
+ expect(screen.getByTestId('loaded')).toBeTruthy();
});
+ expect(isRefreshing()).toBe(false);
const channelManager = chatClient.channelManager;
const querySpy = jest.spyOn(chatClient, 'queryChannels');
@@ -960,4 +965,99 @@ describe('ChannelList', () => {
});
});
});
+
+ describe('rows', () => {
+ const previewRenders: Record = {};
+ const CountingPreview = ({ channel }: { channel: ChannelType }) => {
+ const id = channel.id ?? '';
+ previewRenders[id] = (previewRenders[id] ?? 0) + 1;
+ return ;
+ };
+
+ it('re-renders only the row of the channel that received a message', async () => {
+ useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel2, testChannel3])]);
+
+ render(
+
+
+
+
+ ,
+ );
+
+ await waitFor(() => expect(screen.getAllByLabelText('list-item')).toHaveLength(3));
+ const [firstId, ...otherIds] = screen
+ .getAllByLabelText('list-item')
+ .map((item) => item.props.testID);
+ const firstChannel = [testChannel1, testChannel2, testChannel3].find(
+ ({ channel }) => channel.id === firstId,
+ )?.channel;
+ Object.keys(previewRenders).forEach((id) => {
+ previewRenders[id] = 0;
+ });
+
+ act(() =>
+ dispatchMessageNewEvent(
+ chatClient,
+ generateMessage({ cid: firstChannel?.cid, user: generateUser() }),
+ firstChannel,
+ ),
+ );
+
+ await waitFor(() => expect(previewRenders[firstId]).toBeGreaterThan(0));
+ otherIds.forEach((id) => expect(previewRenders[id]).toBe(0));
+ });
+
+ it('selects with the latest onSelect', async () => {
+ useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
+ const firstOnSelect = jest.fn();
+ const secondOnSelect = jest.fn();
+
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(screen.getByTestId('channel-preview-button')).toBeTruthy());
+
+ screen.rerender(
+
+
+ ,
+ );
+ fireEvent.press(screen.getByTestId('channel-preview-button'));
+
+ expect(firstOnSelect).not.toHaveBeenCalled();
+ expect(secondOnSelect).toHaveBeenCalledTimes(1);
+ });
+
+ it('loads the list again once a user connects after a disconnect', async () => {
+ useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
+
+ render(
+
+
+
+
+ ,
+ );
+ await waitFor(() => expect(screen.getByTestId(testChannel1.channel.id)).toBeTruthy());
+
+ const querySpy = jest.spyOn(chatClient, 'queryChannels');
+ await act(async () => {
+ await chatClient.disconnectUser();
+ // The mocked socket does not report its own close.
+ chatClient.wsConnection.state.partialNext({ isHealthy: false });
+ });
+
+ expect(screen.queryByTestId(testChannel1.channel.id)).toBeNull();
+ expect(querySpy).not.toHaveBeenCalled();
+
+ useMockedApis(chatClient, [queryChannelsApi([testChannel2])]);
+ await act(() => setUser(chatClient, { id: 'dan' }));
+
+ await waitFor(() => expect(screen.getByTestId(testChannel2.channel.id)).toBeTruthy());
+ expect(querySpy).toHaveBeenCalledTimes(1);
+ });
+ });
});
diff --git a/package/src/components/ChannelList/__tests__/ChannelListView.test.tsx b/package/src/components/ChannelList/__tests__/ChannelListView.test.tsx
index 4d3eb81666..57e5bbefc4 100644
--- a/package/src/components/ChannelList/__tests__/ChannelListView.test.tsx
+++ b/package/src/components/ChannelList/__tests__/ChannelListView.test.tsx
@@ -55,25 +55,25 @@ const ComponentWithContextOverrides = ({
value={
{
additionalFlatListProps: {},
- channelListInitialized: !loadingChannels && !error,
- channels: error ? null : [],
- error: error ? new Error('test error') : undefined,
- forceUpdate: 0,
- hasNextPage: false,
- loadingChannels,
- loadingNextPage: false,
loadMoreThreshold: 0.1,
loadNextPage: noop,
maxUnreadCount: 255,
numberOfSkeletons: 8,
- refreshing: false,
refreshList: noop,
reloadList: noop,
setFlatListRef: noop,
} as unknown as ChannelsContextValue
}
>
-
+
);
diff --git a/package/src/components/ChannelList/hooks/__tests__/useMutedChannels.test.tsx b/package/src/components/ChannelList/hooks/__tests__/useMutedChannels.test.tsx
new file mode 100644
index 0000000000..a4c85f86a8
--- /dev/null
+++ b/package/src/components/ChannelList/hooks/__tests__/useMutedChannels.test.tsx
@@ -0,0 +1,37 @@
+import { act, renderHook } from '@testing-library/react-native';
+import type { ChannelMute, StreamChat } from 'stream-chat';
+
+import type { ChatContextValue } from '../../../../contexts/chatContext/ChatContext';
+import * as ChatContext from '../../../../contexts/chatContext/ChatContext';
+import { getTestClientWithUser } from '../../../../mock-builders/mock';
+import { useMutedChannels } from '../useMutedChannels';
+
+describe('useMutedChannels', () => {
+ let client: StreamChat;
+
+ beforeEach(async () => {
+ client = await getTestClientWithUser({ id: 'me' });
+ jest
+ .spyOn(ChatContext, 'useChatContext')
+ .mockImplementation(() => ({ client }) as unknown as ChatContextValue);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('follows the client muted channels without being handed a channel', () => {
+ const { result } = renderHook(() => useMutedChannels());
+ expect(result.current).toEqual([]);
+
+ const channelMutes = [{ channel: { cid: 'messaging:muted' } }] as unknown as ChannelMute[];
+ act(() => {
+ client.dispatchEvent({
+ me: { channel_mutes: channelMutes },
+ type: 'notification.channel_mutes_updated',
+ } as unknown as Parameters[0]);
+ });
+
+ expect(result.current).toBe(channelMutes);
+ });
+});
diff --git a/package/src/components/ChannelList/hooks/useCreateChannelsContext.ts b/package/src/components/ChannelList/hooks/useCreateChannelsContext.ts
index 3edd23610e..2943c3d78e 100644
--- a/package/src/components/ChannelList/hooks/useCreateChannelsContext.ts
+++ b/package/src/components/ChannelList/hooks/useCreateChannelsContext.ts
@@ -4,76 +4,51 @@ import type { ChannelsContextValue } from '../../../contexts/channelsContext/Cha
export const useCreateChannelsContext = ({
additionalFlatListProps,
- channelListInitialized,
- channels,
- error,
- forceUpdate,
- hasNextPage,
- loadingChannels,
- loadingNextPage,
+ getChannelActionItems,
loadMoreThreshold,
loadNextPage,
maxUnreadCount,
+ mutedStatusPosition,
numberOfSkeletons,
onSelect,
- getChannelActionItems,
- swipeActionsEnabled,
- refreshing,
+ paginator,
+ pinnedStatusPosition,
refreshList,
reloadList,
setFlatListRef,
- mutedStatusPosition,
- pinnedStatusPosition,
-}: ChannelsContextValue) => {
- const channelValueString = channels
- ?.map(
- (channel) =>
- `${channel.data?.custom?.name ?? ''}${channel.id ?? ''}${
- channel?.state?.unreadCount ?? ''
- }${Object.values(channel.state.members)
- .map((member) => member.user?.online)
- .join()}`,
- )
- .join();
- const channelsContext: ChannelsContextValue = useMemo(
+ swipeActionsEnabled,
+}: ChannelsContextValue) =>
+ useMemo(
() => ({
additionalFlatListProps,
- channelListInitialized,
- channels,
- error,
- forceUpdate,
- hasNextPage,
- loadingChannels,
- loadingNextPage,
+ getChannelActionItems,
loadMoreThreshold,
loadNextPage,
maxUnreadCount,
+ mutedStatusPosition,
numberOfSkeletons,
onSelect,
- getChannelActionItems,
- swipeActionsEnabled,
- refreshing,
+ paginator,
+ pinnedStatusPosition,
refreshList,
reloadList,
setFlatListRef,
- mutedStatusPosition,
- pinnedStatusPosition,
+ swipeActionsEnabled,
}),
- // eslint-disable-next-line react-hooks/exhaustive-deps
[
- channelValueString,
- error,
- forceUpdate,
- hasNextPage,
- loadingChannels,
- loadingNextPage,
- channelListInitialized,
- swipeActionsEnabled,
- refreshing,
+ additionalFlatListProps,
+ getChannelActionItems,
+ loadMoreThreshold,
+ loadNextPage,
+ maxUnreadCount,
mutedStatusPosition,
+ numberOfSkeletons,
+ onSelect,
+ paginator,
pinnedStatusPosition,
+ refreshList,
+ reloadList,
+ setFlatListRef,
+ swipeActionsEnabled,
],
);
-
- return channelsContext;
-};
diff --git a/package/src/components/ChannelList/hooks/useMutedChannels.ts b/package/src/components/ChannelList/hooks/useMutedChannels.ts
index e1233affd1..ba6aeaf4d6 100644
--- a/package/src/components/ChannelList/hooks/useMutedChannels.ts
+++ b/package/src/components/ChannelList/hooks/useMutedChannels.ts
@@ -1,13 +1,15 @@
-import { Channel, ChannelMute, EventType, StreamChat } from 'stream-chat';
+import type { ChannelMute, EventType, StreamChat } from 'stream-chat';
import { useChatContext } from '../../../contexts';
-import { useSyncClientEventsToChannel } from '../../../hooks/useSyncClientEvents';
+import { useSyncClientEvents } from '../../../hooks/useSyncClientEvents';
-const selector = (_channel: Channel, client: StreamChat) => client.mutedChannels;
+const selector = (client: StreamChat) => client.mutedChannels;
const keys: EventType[] = ['health.check', 'notification.channel_mutes_updated'];
-export function useMutedChannels(channel: Channel): Array;
-export function useMutedChannels(channel?: Channel): Array | undefined;
-export function useMutedChannels(channel?: Channel) {
+
+/**
+ * Returns the current user's muted channels.
+ */
+export const useMutedChannels = (): Array => {
const { client } = useChatContext();
- return useSyncClientEventsToChannel({ channel, client, selector, stateChangeEventKeys: keys });
-}
+ return useSyncClientEvents({ client, selector, stateChangeEventKeys: keys });
+};
diff --git a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts
index 9bcbf33e35..1bcdd58b99 100644
--- a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts
+++ b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts
@@ -16,6 +16,7 @@ import { useStateStore } from '../../../hooks';
import { useLazyRef } from '../../../hooks/useLazyRef';
import { useStableCallback } from '../../../hooks/useStableCallback';
import { generateRandomId } from '../../../utils/utils';
+import { useClientUserId } from '../../Chat/hooks/useClientUserId';
/**
* Custom `queryChannels` implementation for a `ChannelList`. Mapped straight onto the paginator's
@@ -73,9 +74,6 @@ export const usePaginatedChannels = ({
}
const { limit, offset: _offset, ...requestOptions } = options;
return new ChannelPaginator({
- channelStateOptions: {
- skipInitialization: undefined,
- },
client,
filters,
id: paginatorIdRef.current,
@@ -131,18 +129,6 @@ export const usePaginatedChannels = ({
return;
}
- // Do NOT skip state initialization on the (re)query. `activeChannels.current` is
- // `Object.keys(channelsState)` — every channel ever MOUNTED, and it is never cleared on
- // navigate-back — so passing it as `skipInitialization` made `hydrateChannels` skip
- // `seedFirstPageSync`/`_initializeState` for every previously-opened channel on each reconnect.
- // Those channels' `messagePaginator.aggregateState` then never re-seeds on the fresh socket, so
- // their list-row preview (last message / unread, sourced from that aggregate) freezes while the
- // list still reorders. Re-initializing matches the offline-enabled path; the client still guards a
- // scrolled-up open channel from being clobbered via the `isActiveIntervalAtHead` check.
- paginator.channelStateOptions = {
- skipInitialization: undefined,
- };
-
setActiveQueryType(queryType);
try {
@@ -186,6 +172,8 @@ export const usePaginatedChannels = ({
const reloadList = useStableCallback(() => queryChannels('reload'));
+ const refreshListWithDefaults = useStableCallback(() => refreshList());
+
const loadNextPage = useStableCallback(() => queryChannels('loadChannels'));
/**
@@ -211,6 +199,15 @@ export const usePaginatedChannels = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterStr, optionsStr, sortStr, paginator]);
+ // A list reset to "never queried" while mounted (by `client.disconnectUser`) loads again once a
+ // user is connected. A reload already in flight is left alone.
+ const userId = useClientUserId(client);
+ const isUnloaded = channels === undefined;
+ useEffect(() => {
+ if (!isUnloaded || !userId || paginator.isLoading) return;
+ reloadList();
+ }, [isUnloaded, paginator, reloadList, userId]);
+
// Propagate runtime `lockChannelOrder` changes without a re-query (matches the legacy `setOptions`
// effect). Only affects how subsequent event-driven ingests reorder the list.
useEffect(() => {
@@ -231,8 +228,9 @@ export const usePaginatedChannels = ({
loadingChannels: channels === undefined && !error,
loadingNextPage: activeQueryType === 'loadChannels' && !!isLoading,
loadNextPage,
+ paginator,
refreshing: activeQueryType === 'refresh',
- refreshList: () => refreshList(),
+ refreshList: refreshListWithDefaults,
reloadList,
};
};
diff --git a/package/src/components/ChannelList/hooks/utils/index.ts b/package/src/components/ChannelList/hooks/utils/index.ts
deleted file mode 100644
index a451a5ef4d..0000000000
--- a/package/src/components/ChannelList/hooks/utils/index.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { Channel, SortParamRequest } from 'stream-chat';
-
-import { ChannelListProps } from '../../ChannelList';
-
-export const isChannelPinned = (channel: Channel) => {
- if (!channel) {
- return false;
- }
-
- const member = channel.state.membership;
-
- return member?.pinned_at != null;
-};
-
-export const isChannelArchived = (channel: Channel) => {
- if (!channel) {
- return false;
- }
-
- const member = channel.state.membership;
-
- return member?.archived_at != null;
-};
-
-export const shouldConsiderArchivedChannels = (filters: ChannelListProps['filters']) => {
- if (!filters) {
- return false;
- }
-
- return typeof filters.archived === 'boolean';
-};
-
-export const extractSortValue = ({
- atIndex,
- sort,
- targetKey,
-}: {
- atIndex: number;
- targetKey: string;
- sort?: ChannelListProps['sort'];
-}) => {
- if (!sort) {
- return null;
- }
-
- const option: SortParamRequest | null = sort[atIndex] ?? null;
-
- if (!option || option.field !== targetKey) {
- return null;
- }
-
- return option.direction ?? null;
-};
-
-/**
- * Returns true only if `{ pinned_at: -1 }` or `{ pinned_at: 1 }` option is first within the `sort` array.
- */
-export const shouldConsiderPinnedChannels = (sort: ChannelListProps['sort']) => {
- const value = extractSortValue({
- atIndex: 0,
- sort,
- targetKey: 'pinned_at',
- });
-
- if (typeof value !== 'number') {
- return false;
- }
-
- return Math.abs(value) === 1;
-};
-
-export function findPinnedAtSortOrder({ sort }: { sort: ChannelListProps['sort'] }) {
- if (!sort) {
- return null;
- }
-
- const [option] = sort;
-
- if (!option || option.field !== 'pinned_at') {
- return null;
- }
-
- return option.direction ?? null;
-}
-
-export function findLastPinnedChannelIndex({ channels }: { channels: Channel[] }) {
- let lastPinnedChannelIndex: number | null = null;
-
- for (const channel of channels) {
- if (!isChannelPinned(channel)) {
- break;
- }
-
- if (typeof lastPinnedChannelIndex === 'number') {
- lastPinnedChannelIndex++;
- } else {
- lastPinnedChannelIndex = 0;
- }
- }
-
- return lastPinnedChannelIndex;
-}
diff --git a/package/src/components/ChannelPreview/ChannelPreviewView.tsx b/package/src/components/ChannelPreview/ChannelPreviewView.tsx
index b4f0cb288f..16065e6bf8 100644
--- a/package/src/components/ChannelPreview/ChannelPreviewView.tsx
+++ b/package/src/components/ChannelPreview/ChannelPreviewView.tsx
@@ -165,12 +165,11 @@ const MemoizedChannelPreviewViewWithContext = React.memo(
* from the ChannelPreview component.
*/
export const ChannelPreviewView = (props: ChannelPreviewViewProps) => {
- const { forceUpdate, maxUnreadCount, onSelect, mutedStatusPosition, pinnedStatusPosition } =
+ const { maxUnreadCount, onSelect, mutedStatusPosition, pinnedStatusPosition } =
useChannelsContext();
return (
{
+ let channel: Channel;
+
+ beforeEach(async () => {
+ const { channels } = await initiateClientWithChannels();
+ channel = channels[0];
+ });
+
+ it('returns nothing for an empty composer', () => {
+ const { result } = renderHook(() => useChannelPreviewDraftMessage({ channel }));
+
+ expect(result.current).toBeUndefined();
+ });
+
+ it('returns a draft that only has attachments', () => {
+ const attachment = {
+ localMetadata: { id: 'local-image' },
+ type: 'image',
+ } as LocalAttachment;
+ const { result } = renderHook(() => useChannelPreviewDraftMessage({ channel }));
+
+ act(() => channel.messageComposer.attachmentManager.upsertAttachments([attachment]));
+
+ expect(result.current).toMatchObject({ attachments: [attachment], text: '' });
+ });
+});
diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewDraftMessage.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewDraftMessage.ts
index 306d73851e..72d8f6d6b5 100644
--- a/package/src/components/ChannelPreview/hooks/useChannelPreviewDraftMessage.ts
+++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewDraftMessage.ts
@@ -32,20 +32,22 @@ export const useChannelPreviewDraftMessage = ({ channel }: UseChannelPreviewDraf
stateSelector,
);
- const draftMessage: DraftMessage | undefined = useMemo(
- () =>
- !channel.messageComposer.compositionIsEmpty
- ? attachments && draftText
- ? {
- attachments,
- custom: {},
- id: channel.messageComposer.id,
- text: draftText,
- }
- : undefined
- : undefined,
- [channel.messageComposer, attachments, draftText],
- );
+ const draftMessage = useMemo(() => {
+ if (channel.messageComposer.compositionIsEmpty) {
+ return undefined;
+ }
+
+ if (!draftText && !attachments?.length) {
+ return undefined;
+ }
+
+ return {
+ attachments,
+ custom: {},
+ id: channel.messageComposer.id,
+ text: draftText ?? '',
+ };
+ }, [channel.messageComposer, attachments, draftText]);
return draftMessage;
};
diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx
index fb75ef7cff..72472dd8d8 100644
--- a/package/src/components/Chat/Chat.tsx
+++ b/package/src/components/Chat/Chat.tsx
@@ -1,18 +1,17 @@
import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react';
import { Platform } from 'react-native';
-import { Channel, NetworkConnectionState, OfflineDBState } from 'stream-chat';
+import type { OfflineDBState } from 'stream-chat';
-import { useClientMutedUsers } from './hooks';
-import { useAppSettings } from './hooks/useAppSettings';
+import { useAppSettingsGetter } from './hooks/useAppSettingsGetter';
+import { useClientUserId } from './hooks/useClientUserId';
+import { useConnectionLifecycle } from './hooks/useConnectionLifecycle';
import { useCreateChatContext } from './hooks/useCreateChatContext';
import { useInitializeOfflineDb } from './hooks/useInitializeOfflineDb';
-import { useIsOnline } from './hooks/useIsOnline';
import { usePendingUploadsDefault } from './hooks/usePendingUploadsDefault';
import { ChatContextValue, ChatProvider } from '../../contexts/chatContext/ChatContext';
import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext';
-import { useDebugContext } from '../../contexts/debugContext/DebugContext';
import { ThemeProvider, ThemeStyle, useTheme } from '../../contexts/themeContext/ThemeContext';
import {
DEFAULT_USER_LANGUAGE,
@@ -97,7 +96,7 @@ export type ChatProps = Pick &
* them to the offline DB. For a very large payload this replay is both costly
* on-device and unnecessary for what the user is looking at — the active
* channel list and any open channel are refreshed independently on reconnect
- * (via `queryChannels` + `channel.watch()`). When the payload exceeds this
+ * (by `client.connectionRecovery`). When the payload exceeds this
* limit the replay is skipped and that reconnect refresh covers the visible
* channels; inactive channels are hydrated on their next explicit query. The
* last-sync timestamp is still advanced so the same payload is not retried.
@@ -123,49 +122,17 @@ export type ChatProps = Pick &
/**
* Instance of Streami18n class should be provided to Chat component to enable internationalization.
*
- * Stream provides following list of in-built translations:
- * 1. English (en)
- * 2. Dutch (nl)
- * 3. ...
- * 4. ...
+ * The SDK ships English only. Add a language by registering a dictionary keyed by the SDK's
+ * dotted translation keys, then switching to it:
*
- * Simplest way to start using chat components in one of the in-built languages would be following:
- *
- * ```
- * const i18n = new Streami18n('nl');
- *
- * ...
- *
- * ```
- *
- * If you would like to override certain keys in in-built translation.
- * UI will be automatically updated in this case.
- *
- * ```
- * const i18n = new Streami18n('nl');
- *
- * i18n.registerTranslation('nl', {
- * 'Nothing yet...': 'Nog Niet ...',
- * '{{ firstUser }} and {{ secondUser }} are typing...': '{{ firstUser }} en {{ secondUser }} zijn aan het typen...',
- * });
- *
- *
- * ...
- *
* ```
- *
- * You can use the same function to add whole new language.
- *
- * ```
- * const i18n = new Streami18n('it');
+ * const i18n = new Streami18n();
*
* i18n.registerTranslation('it', {
- * 'Nothing yet...': 'Non ancora ...',
- * '{{ firstUser }} and {{ secondUser }} are typing...': '{{ firstUser }} a {{ secondUser }} stanno scrivendo...',
+ * 'channelPreview.noMessages.text': 'Ancora nessun messaggio',
* });
- *
- * // Make sure to call setLanguage to reflect new language in UI.
* i18n.setLanguage('it');
+ *
*
* ...
*
@@ -203,10 +170,6 @@ export type ChatProps = Pick &
style?: ThemeStyle;
};
-const networkSelector = (nextValue: NetworkConnectionState) => ({
- isOnline: nextValue.isOnline,
-});
-
const selector = (nextValue: OfflineDBState) =>
({
initialized: nextValue.initialized,
@@ -228,8 +191,6 @@ const ChatWithContext = (props: PropsWithChildren) => {
} = props;
const { ChatLoadingIndicator } = useComponentsContext();
- const [channel, setChannel] = useState();
-
// Setup translators
const translators = useStreami18n(i18nInstance);
@@ -256,28 +217,10 @@ const ChatWithContext = (props: PropsWithChildren) => {
[translators, userLanguage],
);
- /**
- * Setup connection event listeners
- */
- useIsOnline(client, closeConnectionOnBackground);
+ useConnectionLifecycle(client, closeConnectionOnBackground);
usePendingUploadsDefault(client, enableOfflineSupport);
- // The device's network, for the one consumer that needs it before the context exists.
- const isNetworkOnline = useStateStore(client.networkConnection?.state, networkSelector)?.isOnline;
-
- const { initialized: offlineDbInitialized, userId: offlineDbUserId } =
- useStateStore(client.offlineDb?.state, selector) ?? {};
-
- /**
- * Setup muted user listener
- * TODO: reimplement
- */
- const mutedUsers = useClientMutedUsers(client);
-
- const debugRef = useDebugContext();
- const isDebugModeEnabled = __DEV__ && debugRef && debugRef.current;
-
- const userID = client.userID;
+ const userId = useClientUserId(client);
useEffect(() => {
if (client) {
@@ -291,29 +234,16 @@ const ChatWithContext = (props: PropsWithChildren) => {
client.deviceIdentifier = { os: `${Platform.OS} ${Platform.Version}` };
client.persistUserOnConnectionFailure = enableOfflineSupport;
}
-
- if (isDebugModeEnabled) {
- if (debugRef.current.setEventType) {
- debugRef.current.setEventType('send');
- }
- if (debugRef.current.setSendEventParams) {
- debugRef.current.setSendEventParams({
- action: 'Client',
- data: client.user,
- });
- }
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [client, enableOfflineSupport]);
- const setActiveChannel = (newChannel?: Channel) => setChannel(newChannel);
-
- useInitializeOfflineDb({
+ const offlineDb = useInitializeOfflineDb({
client,
enabled: enableOfflineSupport,
options: { getEncryptionKey: getOfflineDbEncryptionKey, maxSyncEventsLimit },
- userID,
+ userID: userId,
});
+ const { initialized: offlineDbInitialized, userId: offlineDbUserId } =
+ useStateStore(offlineDb?.state, selector) ?? {};
useEffect(() => {
if (!client) {
@@ -341,26 +271,17 @@ const ChatWithContext = (props: PropsWithChildren) => {
installNativeMultipartAdapter(client);
}, [client, useNativeMultipartUpload]);
- const initialisedDatabase = !!offlineDbInitialized && userID === offlineDbUserId;
+ const initialisedDatabase = !!offlineDbInitialized && userId === offlineDbUserId;
- const appSettings = useAppSettings(
+ const getAppSettings = useAppSettingsGetter({
client,
- isNetworkOnline,
- enableOfflineSupport,
- initialisedDatabase,
- );
-
- const chatContext = useCreateChatContext({
- appSettings,
- channel,
- client,
- enableOfflineSupport,
- isMessageAIGenerated,
- mutedUsers,
- setActiveChannel,
+ ready: !enableOfflineSupport || initialisedDatabase,
+ userId,
});
- if (userID && enableOfflineSupport && !initialisedDatabase) {
+ const chatContext = useCreateChatContext({ client, getAppSettings, isMessageAIGenerated });
+
+ if (userId && enableOfflineSupport && !initialisedDatabase) {
// if user id has been set and offline support is enabled, we need to wait for database to be initialised
return ChatLoadingIndicator ? : null;
}
@@ -380,12 +301,12 @@ const ChatWithContext = (props: PropsWithChildren) => {
*
* The ChatContext provides the following props:
*
- * - channel - currently active channel
* - client - client connection
+ * - getAppSettings - resolves the app's settings
+ * - isMessageAIGenerated - decides whether a message renders as AI generated
*
* Connection status is NOT on this context. Read it with `useWSConnectionState()` (our socket)
* or `useNetworkConnectionState()` (the device's network) — they are separate facts.
- * - setActiveChannel - function to set the currently active channel
*/
export const Chat = (props: PropsWithChildren) => {
const { theme } = useTheme();
diff --git a/package/src/components/Chat/__tests__/Chat.test.tsx b/package/src/components/Chat/__tests__/Chat.test.tsx
index 5964b7be64..124ed7ac90 100644
--- a/package/src/components/Chat/__tests__/Chat.test.tsx
+++ b/package/src/components/Chat/__tests__/Chat.test.tsx
@@ -215,36 +215,70 @@ describe('ChatContext', () => {
);
await waitFor(() => {
- expect(context).toBeInstanceOf(Object);
- expect(context.channel).toBeUndefined();
expect(context.client).toBe(chatClient);
- expect(context.setActiveChannel).toBeInstanceOf(Function);
+ expect(Object.keys(context).sort()).toEqual([
+ 'client',
+ 'getAppSettings',
+ 'isMessageAIGenerated',
+ ]);
});
});
- it('calls setActiveChannel to set a new channel in context', async () => {
- let context: ChatContextValue = {} as ChatContextValue;
+ // The context used to be rebuilt by every mute, every new connection id and a listener count, and
+ // everything reading it re-rendered with it.
+ it('keeps the same value through mutes and reconnects', async () => {
+ const client = await getTestClientWithUser({ id: 'me' });
+ const contexts: ChatContextValue[] = [];
render(
-
- {
- context = ctx;
- }}
- />
+
+ contexts.push(ctx)} />
,
);
+ await waitFor(() => expect(client.getAppSettings).toHaveBeenCalled());
+ const first = contexts[contexts.length - 1];
+
+ act(() => {
+ client.mutedUsersStore.next({
+ mutedUsers: [{ target: { id: 'other' }, user: { id: 'me' } }],
+ } as unknown as Parameters[0]);
+ client.mutedChannels = [
+ { channel: { cid: 'messaging:muted' } },
+ ] as typeof client.mutedChannels;
+ client.clientID = 'a-new-connection';
+ dispatchConnectionChangedEvent(client, false);
+ dispatchConnectionChangedEvent(client, true);
+ });
+
+ expect(new Set(contexts).size).toBe(1);
+ expect(contexts[contexts.length - 1]).toBe(first);
+ });
- const channel = { cid: 'cid', id: 'cid', query: jest.fn() };
+ it('rebuilds the value when isMessageAIGenerated changes', async () => {
+ let context: ChatContextValue = {} as ChatContextValue;
+ const consumer = (
+ {
+ context = ctx;
+ }}
+ />
+ );
+ const first = jest.fn(() => false);
+ const second = jest.fn(() => true);
- await waitFor(() => expect(context.channel).toBeUndefined());
- act(() =>
- context.setActiveChannel(
- channel as unknown as Parameters[0],
- ),
+ const { rerender } = render(
+
+ {consumer}
+ ,
);
+ await waitFor(() => expect(context.isMessageAIGenerated).toBe(first));
- await waitFor(() => expect(context.channel).toStrictEqual(channel));
+ rerender(
+
+ {consumer}
+ ,
+ );
+ await waitFor(() => expect(context.isMessageAIGenerated).toBe(second));
});
});
@@ -458,6 +492,38 @@ describe('TranslationContext', () => {
});
});
+describe('Chat with a user connected after it mounted', () => {
+ afterEach(cleanup);
+
+ // Nothing told that a user had connected: it read `client.userID` during render, so the
+ // offline database was only set up if something unrelated happened to re-render it afterwards.
+ it('sets up the offline database for that user and then renders its children', async () => {
+ const client = getTestClient();
+
+ render(
+
+
+ ,
+ );
+ // Let everything starts on mount (translations, NetInfo) settle first, so the user
+ // connecting is the only thing left that could make it render again.
+ await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy());
+ await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
+ expect(client.offlineDb).toBeUndefined();
+
+ act(() => {
+ const user = { id: 'late-user', mutes: [] };
+ Object.assign(client, { _user: { ...user }, user });
+ // The socket coming up is the next thing connectUser() does after setting the user.
+ // eslint-disable-next-line no-underscore-dangle -- internal status setter, no public way to flip it
+ client.wsConnection._setStatus({ isHealthy: true });
+ });
+
+ await waitFor(() => expect(client.offlineDb?.state.getLatestValue().userId).toBe('late-user'));
+ await waitFor(() => expect(screen.getByTestId('children')).toBeTruthy());
+ });
+});
+
describe('Chat offline DB encryption', () => {
const installedSpies: jest.SpyInstance[] = [];
@@ -473,7 +539,7 @@ describe('Chat offline DB encryption', () => {
};
/**
- * Chat mounts useIsOnline, which opens the websocket whenever the app comes to the
+ * Chat mounts useConnectionLifecycle, which opens the websocket whenever the app comes to the
* foreground. Left real, that connection attempt outlives the test and rejects
* asynchronously. Nothing in this block needs a connection.
*/
diff --git a/package/src/components/Chat/hooks/__tests__/useAppSettings.test.tsx b/package/src/components/Chat/hooks/__tests__/useAppSettings.test.tsx
deleted file mode 100644
index 3f96bdab75..0000000000
--- a/package/src/components/Chat/hooks/__tests__/useAppSettings.test.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import React from 'react';
-import { Text } from 'react-native';
-
-import { render, waitFor } from '@testing-library/react-native';
-
-import type { StreamChat } from 'stream-chat';
-
-import { useAppSettings } from '../useAppSettings';
-
-describe('useAppSettings', () => {
- it('will return a settings object if the backend call is successful', async () => {
- const TestComponent = () => {
- const isOnline = true;
- const appSettings = useAppSettings(
- {
- getAppSettings: jest.fn().mockReturnValue(
- Promise.resolve({
- auto_translation_enabled: true,
- }),
- ),
- userID: 'some-user-id',
- } as unknown as StreamChat,
- isOnline,
- false,
- false,
- );
-
- return {JSON.stringify(appSettings)};
- };
-
- const { getByText } = render();
-
- await waitFor(() => {
- expect(getByText(JSON.stringify({ auto_translation_enabled: true }))).toBeTruthy();
- });
- });
-});
diff --git a/package/src/components/Chat/hooks/__tests__/useAppSettingsGetter.test.tsx b/package/src/components/Chat/hooks/__tests__/useAppSettingsGetter.test.tsx
new file mode 100644
index 0000000000..3d4ba9fd60
--- /dev/null
+++ b/package/src/components/Chat/hooks/__tests__/useAppSettingsGetter.test.tsx
@@ -0,0 +1,104 @@
+import { act, cleanup, renderHook } from '@testing-library/react-native';
+import type { GetApplicationResponse, StreamChat } from 'stream-chat';
+
+import { useAppSettingsGetter } from '../useAppSettingsGetter';
+
+const fetched = { app: { name: 'fetched' } } as unknown as GetApplicationResponse;
+const stored = { app: { name: 'stored' } } as unknown as GetApplicationResponse;
+
+const makeClient = () => {
+ const offlineDb = {
+ executeQuerySafely: jest.fn(),
+ getAppSettings: jest.fn().mockResolvedValue(stored),
+ };
+ const client = { getAppSettings: jest.fn().mockResolvedValue(fetched), offlineDb };
+ return { client, offlineDb };
+};
+
+const render = (client: unknown, props: { ready?: boolean; userId?: string } = {}) =>
+ renderHook(
+ ({ ready, userId }: { ready: boolean; userId: string | undefined }) =>
+ useAppSettingsGetter({ client: client as StreamChat, ready, userId }),
+ { initialProps: { ready: props.ready ?? true, userId: props.userId ?? 'me' } },
+ );
+
+describe('useAppSettingsGetter', () => {
+ afterEach(() => {
+ cleanup();
+ jest.restoreAllMocks();
+ });
+
+ it('requests the settings once a user is connected and reuses them for every call', async () => {
+ const { client, offlineDb } = makeClient();
+ const { result } = render(client);
+
+ await act(async () => {
+ await expect(result.current()).resolves.toBe(fetched);
+ await expect(result.current()).resolves.toBe(fetched);
+ });
+
+ expect(client.getAppSettings).toHaveBeenCalledTimes(1);
+ expect(offlineDb.executeQuerySafely).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not request them before it is ready', async () => {
+ const { client } = makeClient();
+ const { rerender } = render(client, { ready: false });
+ await act(() => Promise.resolve());
+ expect(client.getAppSettings).not.toHaveBeenCalled();
+
+ rerender({ ready: true, userId: 'me' });
+ await act(() => Promise.resolve());
+ expect(client.getAppSettings).toHaveBeenCalledTimes(1);
+ });
+
+ it('falls back to the offline copy when the request fails, and fetches again next time', async () => {
+ const { client } = makeClient();
+ jest.spyOn(console, 'error').mockImplementation(() => undefined);
+ client.getAppSettings.mockRejectedValueOnce(new Error('network'));
+ const { result } = render(client, { ready: false });
+
+ await act(async () => {
+ await expect(result.current()).resolves.toBe(stored);
+ await expect(result.current()).resolves.toBe(fetched);
+ });
+ expect(client.getAppSettings).toHaveBeenCalledTimes(2);
+ });
+
+ it('rejects when the request fails and there is no offline copy', async () => {
+ const { client, offlineDb } = makeClient();
+ offlineDb.getAppSettings.mockResolvedValue(null);
+ client.getAppSettings.mockRejectedValueOnce(new Error('network'));
+ const { result } = render(client, { ready: false });
+
+ await act(async () => {
+ await expect(result.current()).rejects.toThrow('network');
+ });
+ });
+
+ it('fetches again for a different user, so their offline copy is written too', async () => {
+ const { client, offlineDb } = makeClient();
+ const { rerender, result } = render(client);
+ await act(async () => {
+ await result.current();
+ });
+
+ rerender({ ready: true, userId: 'someone-else' });
+ await act(async () => {
+ await result.current();
+ });
+
+ expect(client.getAppSettings).toHaveBeenCalledTimes(2);
+ expect(offlineDb.executeQuerySafely).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps the same function across renders', () => {
+ const { client } = makeClient();
+ const { rerender, result } = render(client, { ready: false });
+ const first = result.current;
+
+ rerender({ ready: true, userId: 'me' });
+
+ expect(result.current).toBe(first);
+ });
+});
diff --git a/package/src/components/Chat/hooks/__tests__/useCreateChatClient.netinfo.test.ts b/package/src/components/Chat/hooks/__tests__/useCreateChatClient.netinfo.test.ts
index 607a880e2f..8588b00de1 100644
--- a/package/src/components/Chat/hooks/__tests__/useCreateChatClient.netinfo.test.ts
+++ b/package/src/components/Chat/hooks/__tests__/useCreateChatClient.netinfo.test.ts
@@ -2,7 +2,7 @@ import NetInfo from '@react-native-community/netinfo';
import { StreamChat } from 'stream-chat';
-import { netInfoStatusReporter } from '../useIsOnline';
+import { netInfoStatusReporter } from '../useConnectionLifecycle';
/**
* The reporter has to be named in the client's OWN options, not installed from an effect: until
diff --git a/package/src/components/Chat/hooks/index.ts b/package/src/components/Chat/hooks/index.ts
index dd79af3a6c..cb0a387a42 100644
--- a/package/src/components/Chat/hooks/index.ts
+++ b/package/src/components/Chat/hooks/index.ts
@@ -1,7 +1,5 @@
export * from './useCreateChatClient';
-export * from './useIsOnline';
-export * from './useAppSettings';
-export * from './useClientMutedUsers';
+export * from './useConnectionLifecycle';
export * from './useCreateChatContext';
export * from './useNetworkConnectionState';
export * from './useWSConnectionState';
diff --git a/package/src/components/Chat/hooks/useAppSettings.ts b/package/src/components/Chat/hooks/useAppSettings.ts
deleted file mode 100644
index b5759bb005..0000000000
--- a/package/src/components/Chat/hooks/useAppSettings.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { useEffect, useRef, useState } from 'react';
-
-import type { GetApplicationResponse, StreamChat } from 'stream-chat';
-
-import { useIsMountedRef } from '../../../hooks/useIsMountedRef';
-
-export const useAppSettings = (
- client: StreamChat,
- isNetworkOnline: boolean | undefined,
- enableOfflineSupport: boolean,
- initialisedDatabase: boolean,
-): GetApplicationResponse | null => {
- const [appSettings, setAppSettings] = useState(null);
- const appSettingsPromise = useRef>(null);
- const fetchedAppSettings = useRef(false);
- const isMounted = useIsMountedRef();
-
- useEffect(() => {
- if (fetchedAppSettings.current) {
- return;
- }
-
- const fetchAppSettings = () => {
- if (appSettingsPromise.current) {
- return appSettingsPromise.current;
- }
- appSettingsPromise.current = client.getAppSettings();
- return appSettingsPromise.current;
- };
-
- const enforceAppSettings = async () => {
- if (!client.userID) return;
-
- if (enableOfflineSupport && !initialisedDatabase) return;
-
- const userId = client.userID as string;
-
- if (isNetworkOnline === false && client.offlineDb) {
- const appSettings = await client.offlineDb.getAppSettings({ userId });
- setAppSettings(appSettings);
- return;
- }
-
- try {
- const appSettings = await fetchAppSettings();
- if (isMounted.current && appSettings) {
- setAppSettings(appSettings);
- fetchedAppSettings.current = true;
- client.offlineDb?.executeQuerySafely(
- (db) => db.upsertAppSettings({ appSettings, userId }),
- { method: 'upsertAppSettings' },
- );
- }
- } catch (error: unknown) {
- if (error instanceof Error) {
- console.error(`An error occurred while getting app settings: ${error}`);
- }
- }
- };
-
- enforceAppSettings();
- }, [client, isNetworkOnline, initialisedDatabase, isMounted, enableOfflineSupport]);
-
- return appSettings;
-};
diff --git a/package/src/components/Chat/hooks/useAppSettingsGetter.ts b/package/src/components/Chat/hooks/useAppSettingsGetter.ts
new file mode 100644
index 0000000000..c74b1882b9
--- /dev/null
+++ b/package/src/components/Chat/hooks/useAppSettingsGetter.ts
@@ -0,0 +1,80 @@
+import { useEffect, useRef } from 'react';
+
+import type { GetApplicationResponse, StreamChat } from 'stream-chat';
+
+import { useStableCallback } from '../../../hooks/useStableCallback';
+
+type AppSettingsRequest = {
+ client: StreamChat;
+ promise: Promise;
+ token: object;
+ userId: string | undefined;
+};
+
+/**
+ * A stable `getAppSettings()` for the chat context. The first call for a user fetches the app
+ * settings and later calls reuse them. With offline support, a successful fetch is copied to the offline
+ * database and a failed one falls back to that copy. A failed fetch is not kept, so the next call
+ * fetches again.
+ *
+ * The settings are requested once `ready` and a user is connected, so they are usually loaded by
+ * the time anything asks for them.
+ */
+export const useAppSettingsGetter = ({
+ client,
+ ready,
+ userId,
+}: {
+ client: StreamChat;
+ ready: boolean;
+ userId: string | undefined;
+}) => {
+ const requestRef = useRef(undefined);
+
+ const getAppSettings = useStableCallback(() => {
+ if (requestRef.current?.client === client && requestRef.current.userId === userId) {
+ return requestRef.current.promise;
+ }
+
+ const token = {};
+ const load = async () => {
+ try {
+ const appSettings = await client.getAppSettings();
+ if (userId) {
+ client.offlineDb?.executeQuerySafely(
+ (db) => db.upsertAppSettings({ appSettings, userId }),
+ { method: 'upsertAppSettings' },
+ );
+ }
+ return appSettings;
+ } catch (error) {
+ if (requestRef.current?.token === token) {
+ requestRef.current = undefined;
+ }
+ const storedAppSettings = userId
+ ? await client.offlineDb?.getAppSettings({ userId }).catch(() => null)
+ : null;
+ if (storedAppSettings) {
+ return storedAppSettings;
+ }
+ throw error;
+ }
+ };
+ const request = { client, promise: load(), token, userId };
+ requestRef.current = request;
+ return request.promise;
+ });
+
+ useEffect(() => {
+ if (!ready || !userId) {
+ return;
+ }
+ getAppSettings().catch((error: unknown) => {
+ if (error instanceof Error) {
+ console.error(`An error occurred while getting app settings: ${error}`);
+ }
+ });
+ }, [getAppSettings, ready, userId]);
+
+ return getAppSettings;
+};
diff --git a/package/src/components/Chat/hooks/useClientMutedUsers.ts b/package/src/components/Chat/hooks/useClientMutedUsers.ts
deleted file mode 100644
index ca7dbc2594..0000000000
--- a/package/src/components/Chat/hooks/useClientMutedUsers.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import type { StreamChat, UserMuteResponse } from 'stream-chat';
-
-import { useStateStore } from '../../../hooks/useStateStore';
-
-const selector = (state: { mutedUsers: UserMuteResponse[] }) => ({ mutedUsers: state.mutedUsers });
-
-/**
- * Returns the client's muted users, sourced reactively from `client.mutedUsersStore`.
- */
-export const useClientMutedUsers = (client: StreamChat): UserMuteResponse[] =>
- useStateStore(client?.mutedUsersStore, selector)?.mutedUsers ?? [];
diff --git a/package/src/components/Chat/hooks/useClientUserId.ts b/package/src/components/Chat/hooks/useClientUserId.ts
new file mode 100644
index 0000000000..afb21bc86c
--- /dev/null
+++ b/package/src/components/Chat/hooks/useClientUserId.ts
@@ -0,0 +1,31 @@
+import { useCallback } from 'react';
+
+import type { StreamChat } from 'stream-chat';
+import { useSyncExternalStore } from 'use-sync-external-store/shim';
+
+/**
+ * The id of the user connected to `client`, kept current as users connect and disconnect.
+ *
+ * This hook is TEMPORARY. It goes away once the client keeps its state (the connected user and the
+ * app settings) in a store of its own, which components can subscribe to directly.
+ *
+ * The client has no store for its own user. `connectUser()` sets it right before opening the
+ * socket and `disconnectUser()` clears it while closing it, so the socket and network stores
+ * publishing around those calls are the signal to read it again. Re-renders only when the id
+ * itself changes.
+ */
+export const useClientUserId = (client: StreamChat) => {
+ const subscribe = useCallback(
+ (onChange: () => void) => {
+ const unsubscribeFunctions = [
+ client.wsConnection?.state.subscribe(onChange),
+ client.networkConnection?.state.subscribe(onChange),
+ ];
+ return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe?.());
+ },
+ [client],
+ );
+ const getUserId = useCallback(() => client.userId, [client]);
+
+ return useSyncExternalStore(subscribe, getUserId);
+};
diff --git a/package/src/components/Chat/hooks/useIsOnline.ts b/package/src/components/Chat/hooks/useConnectionLifecycle.ts
similarity index 98%
rename from package/src/components/Chat/hooks/useIsOnline.ts
rename to package/src/components/Chat/hooks/useConnectionLifecycle.ts
index c87e6b12c6..1f7ba0971f 100644
--- a/package/src/components/Chat/hooks/useIsOnline.ts
+++ b/package/src/components/Chat/hooks/useConnectionLifecycle.ts
@@ -20,7 +20,7 @@ import { useAppStateListener } from '../../../hooks/useAppStateListener';
* 2. **Background/foreground.** Close the socket when the app backgrounds and reopen it on
* foreground, because push notifications are only delivered while no socket is active.
*/
-export const useIsOnline = (client: StreamChat, closeConnectionOnBackground = true) => {
+export const useConnectionLifecycle = (client: StreamChat, closeConnectionOnBackground = true) => {
const clientExists = !!client;
const onBackground = useCallback(() => {
diff --git a/package/src/components/Chat/hooks/useCreateChatClient.ts b/package/src/components/Chat/hooks/useCreateChatClient.ts
index b84eeb6c30..aa25df1626 100644
--- a/package/src/components/Chat/hooks/useCreateChatClient.ts
+++ b/package/src/components/Chat/hooks/useCreateChatClient.ts
@@ -9,7 +9,7 @@ import type {
UserResponse,
} from 'stream-chat';
-import { netInfoStatusReporter } from './useIsOnline';
+import { netInfoStatusReporter } from './useConnectionLifecycle';
/**
* Names the NetInfo reporter in the client's own options, so the device's network status is reported
diff --git a/package/src/components/Chat/hooks/useCreateChatContext.ts b/package/src/components/Chat/hooks/useCreateChatContext.ts
index d56727e162..e53f0dd1aa 100644
--- a/package/src/components/Chat/hooks/useCreateChatContext.ts
+++ b/package/src/components/Chat/hooks/useCreateChatContext.ts
@@ -3,33 +3,11 @@ import { useMemo } from 'react';
import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext';
export const useCreateChatContext = ({
- appSettings,
- channel,
client,
- enableOfflineSupport,
+ getAppSettings,
isMessageAIGenerated,
- mutedUsers,
- setActiveChannel,
-}: ChatContextValue) => {
- const channelId = channel?.id;
- const clientValues = client
- ? `${client.clientID}${Object.keys(client.listeners).length}${client.mutedChannels.length}`
- : 'Offline';
- const mutedUsersLength = mutedUsers.length;
-
- const chatContext: ChatContextValue = useMemo(
- () => ({
- appSettings,
- channel,
- client,
- enableOfflineSupport,
- isMessageAIGenerated,
- mutedUsers,
- setActiveChannel,
- }),
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [appSettings, channelId, clientValues, mutedUsersLength],
+}: ChatContextValue) =>
+ useMemo(
+ () => ({ client, getAppSettings, isMessageAIGenerated }),
+ [client, getAppSettings, isMessageAIGenerated],
);
-
- return chatContext;
-};
diff --git a/package/src/components/Chat/hooks/useInitializeOfflineDb.ts b/package/src/components/Chat/hooks/useInitializeOfflineDb.ts
index 04860a28e2..51f0f45002 100644
--- a/package/src/components/Chat/hooks/useInitializeOfflineDb.ts
+++ b/package/src/components/Chat/hooks/useInitializeOfflineDb.ts
@@ -31,7 +31,8 @@ export type UseInitializeOfflineDbParams = {
};
/**
- * Attaches an offline database to the client and initializes it for a user.
+ * Attaches an offline database to the client and initializes it for a user. Returns the attached
+ * database, so the caller can subscribe to its state: it is attached after the first render.
*
* **Raises** whatever prevented the database from opening, from render, so an error
* boundary above the caller can decide what to do. The offline database is never
@@ -53,6 +54,7 @@ export const useInitializeOfflineDb = ({
* retry would loop forever.
*/
const [initializationError, setInitializationError] = useState();
+ const [offlineDb, setOfflineDb] = useState(() => client.offlineDb);
const { getEncryptionKey, maxSyncEventsLimit } = options ?? {};
@@ -66,6 +68,7 @@ export const useInitializeOfflineDb = ({
const initialize = useCallback(async () => {
if (!(userID && enabled)) {
+ setOfflineDb(client.offlineDb);
return;
}
@@ -93,16 +96,17 @@ export const useInitializeOfflineDb = ({
);
}
- const { offlineDb } = client;
- if (offlineDb) {
- await offlineDb.init(userID);
+ const attachedOfflineDb = client.offlineDb;
+ setOfflineDb(attachedOfflineDb);
+ if (attachedOfflineDb) {
+ await attachedOfflineDb.init(userID);
// Note: Since `init()` currently swallows errors by design, we have to rely
// on consuming the error later in order to be able to still rethrow without
// introducing a breaking change.
// TODO: The DB API should be changed in the next major to always throw upwards
// and let integrators handle it if necessary.
setInitializationError(
- offlineDb instanceof OfflineDB ? offlineDb.initializationError : undefined,
+ attachedOfflineDb instanceof OfflineDB ? attachedOfflineDb.initializationError : undefined,
);
}
}, [client, enabled, isEncryptionEnabled, maxSyncEventsLimit, resolveEncryptionKey, userID]);
@@ -114,4 +118,6 @@ export const useInitializeOfflineDb = ({
if (initializationError) {
throw initializationError;
}
+
+ return offlineDb;
};
diff --git a/package/src/components/Chat/hooks/usePendingUploadsDefault.ts b/package/src/components/Chat/hooks/usePendingUploadsDefault.ts
index dbe35edcc3..eb42e6a1f5 100644
--- a/package/src/components/Chat/hooks/usePendingUploadsDefault.ts
+++ b/package/src/components/Chat/hooks/usePendingUploadsDefault.ts
@@ -37,7 +37,7 @@ export const usePendingUploadsDefault = (client: StreamChat, enableOfflineSuppor
attachments: { pendingUploadsEnabled: true },
});
- // Deliberately no teardown, for the same reason as the network reporter in `useIsOnline`: the
- // configuration lives as long as the client, which outlives ``.
+ // Deliberately no teardown, for the same reason as the network reporter in
+ // `useConnectionLifecycle`: the configuration lives as long as the client, which outlives ``.
}, [client, enableOfflineSupport]);
};
diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx
index 00ac5b90c8..d61e3a69ab 100644
--- a/package/src/components/Message/Message.tsx
+++ b/package/src/components/Message/Message.tsx
@@ -33,7 +33,7 @@ import {
ChannelContextValue,
useChannelContext,
} from '../../contexts/channelContext/ChannelContext';
-import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext';
+import { useChatContext } from '../../contexts/chatContext/ChatContext';
import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext';
import {
KeyboardContextValue,
@@ -253,7 +253,6 @@ export type MessagePropsWithContext = Pick<
| 'supportedReactions'
> &
Pick & {
- chatContext: ChatContextValue;
messagesContext: MessagesContextValue;
/**
* Whether or not users are able to long press messages.
@@ -280,10 +279,10 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
const [showMessageReactions, setShowMessageReactions] = useState(false);
const [selectedReaction, setSelectedReaction] = useState(undefined);
const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false);
+ const { client } = useChatContext();
const {
channel,
- chatContext,
deleteMessage: deleteMessageFromContext,
deleteReaction,
dismissKeyboardOnMessageTouch,
@@ -348,7 +347,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
);
const messageOverlayId = useMemo(() => createMessageOverlayId(message.id), [message.id]);
const isMessageTypeDeleted = message.type === 'deleted';
- const { client } = chatContext;
const rectRef = useRef(undefined);
const bubbleRect = useRef(undefined);
@@ -956,7 +954,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWithContext) => {
const {
- chatContext: { mutedUsers: prevMutedUsers },
goToMessage: prevGoToMessage,
groupStyles: prevGroupStyles,
isAttachmentEqual,
@@ -968,7 +965,6 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit
t: prevT,
} = prevProps;
const {
- chatContext: { mutedUsers: nextMutedUsers },
goToMessage: nextGoToMessage,
groupStyles: nextGroupStyles,
isTargetedMessage: nextIsTargetedMessage,
@@ -1066,14 +1062,6 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit
return false;
}
- const mutedUserSame =
- prevMutedUsers.length === nextMutedUsers.length ||
- prevMutedUsers.some((mutedUser) => mutedUser.target?.id === prevMessage.user?.id) ===
- nextMutedUsers.some((mutedUser) => mutedUser.target?.id === nextMessage.user?.id);
- if (!mutedUserSame) {
- return false;
- }
-
const showUnreadUnderlayEqual = prevShowUnreadUnderlay === nextShowUnreadUnderlay;
if (!showUnreadUnderlayEqual) {
return false;
@@ -1126,7 +1114,6 @@ export type MessageProps = Partial<
*/
export const Message = (props: MessageProps) => {
const { channel, enforceUniqueReaction } = useChannelContext();
- const chatContext = useChatContext();
const { dismissKeyboard } = useKeyboardContext();
const messagesContext = useMessagesContext();
const messageOperations = useMessageOperations();
@@ -1139,7 +1126,6 @@ export const Message = (props: MessageProps) => {
{...messageOperations}
{...{
channel,
- chatContext,
dismissKeyboard,
enforceUniqueReaction,
messagesContext,
diff --git a/package/src/components/Message/MessageItemView/MessageWrapper.tsx b/package/src/components/Message/MessageItemView/MessageWrapper.tsx
index 6eae61b815..212c40031f 100644
--- a/package/src/components/Message/MessageItemView/MessageWrapper.tsx
+++ b/package/src/components/Message/MessageItemView/MessageWrapper.tsx
@@ -125,9 +125,6 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW
},
} = useTheme();
const styles = useStyles();
- if (!channel || channel.pendingDisposal) {
- return null;
- }
const showUnreadUnderlay = !!shouldShowUnreadUnderlay && showUnreadSeparator;
diff --git a/package/src/components/MessageInput/MessageComposer.tsx b/package/src/components/MessageInput/MessageComposer.tsx
index 55a8ac5ab1..775eb63e80 100644
--- a/package/src/components/MessageInput/MessageComposer.tsx
+++ b/package/src/components/MessageInput/MessageComposer.tsx
@@ -10,7 +10,12 @@ import Animated, {
import { useSafeAreaInsets } from 'react-native-safe-area-context';
-import { type ChannelWatchState, type MembersState, type UserResponse } from 'stream-chat';
+import {
+ type ChannelDataState,
+ type ChannelWatchState,
+ type MembersState,
+ type UserResponse,
+} from 'stream-chat';
import { MicPositionProvider } from './contexts/MicPositionContext';
@@ -175,6 +180,11 @@ const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({
height: state.height,
});
+// Capabilities arrive with the channel's data, from the server or the offline database.
+const capabilitiesKnownSelector = (state: ChannelDataState) => ({
+ capabilitiesKnown: Array.isArray(state.data?.own_capabilities),
+});
+
const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => {
const {
additionalTextInputProps,
@@ -616,6 +626,7 @@ export const MessageComposer = (props: MessageComposerProps) => {
const ownCapabilities = useOwnCapabilitiesContext();
const { channel } = useChannelContext();
+ const { capabilitiesKnown } = useStateStore(channel.state, capabilitiesKnownSelector);
const {
audioRecorderManager,
@@ -655,7 +666,7 @@ export const MessageComposer = (props: MessageComposerProps) => {
*
*/
if (
- channel.initialized &&
+ capabilitiesKnown &&
!ownCapabilities.sendMessage &&
!editing &&
SendMessageDisallowedIndicator
diff --git a/package/src/components/MessageInput/__tests__/InputButtons.test.tsx b/package/src/components/MessageInput/__tests__/InputButtons.test.tsx
index 8b5066a4f6..367bccecf6 100644
--- a/package/src/components/MessageInput/__tests__/InputButtons.test.tsx
+++ b/package/src/components/MessageInput/__tests__/InputButtons.test.tsx
@@ -87,6 +87,24 @@ describe('InputButtons', () => {
});
});
+ it('shows the attach button once the upload capability arrives after mount', async () => {
+ const capabilities = channel.state.getLatestValue().ownCapabilities;
+ act(() => {
+ channel.state.partialNext({
+ ownCapabilities: capabilities.filter((capability) => capability !== 'upload-file'),
+ });
+ });
+
+ renderComponent({ channelProps: { channel, hasCommands: false }, client, props: {} });
+ await waitFor(() => expect(screen.queryByTestId('attach-button')).toBeFalsy());
+
+ act(() => {
+ channel.state.partialNext({ ownCapabilities: [...capabilities, 'upload-file'] });
+ });
+
+ await waitFor(() => expect(screen.queryByTestId('attach-button')).toBeTruthy());
+ });
+
it('should not show commands buttons when there is text in the textComposer', async () => {
const props = {};
const channelProps = {
diff --git a/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.tsx b/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.tsx
index 34bd5da1d2..c8f26e4876 100644
--- a/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.tsx
+++ b/package/src/components/MessageInput/__tests__/SendMessageDisallowedIndicator.test.tsx
@@ -130,6 +130,34 @@ describe('SendMessageDisallowedIndicator', () => {
});
});
+ it('renders the SendMessageDisallowedIndicator once capabilities without send-message arrive', async () => {
+ // A channel opened before its query landed: neither initialized nor holding capabilities yet.
+ const data = channel.data!;
+ act(() => {
+ channel.initialized = false;
+ channel.data = { ...data, own_capabilities: undefined };
+ });
+
+ renderComponent({ channelProps: { channel, initializeOnMount: false }, client, props: {} });
+ await waitFor(() =>
+ expect(screen.queryByTestId('send-message-disallowed-indicator')).toBeNull(),
+ );
+
+ // The query response publishes the capabilities before the channel is marked initialized.
+ act(() => {
+ channel.data = {
+ ...data,
+ own_capabilities: data.own_capabilities!.filter(
+ (capability) => capability !== 'send-message',
+ ),
+ };
+ });
+
+ await waitFor(() =>
+ expect(screen.queryByTestId('send-message-disallowed-indicator')).toBeTruthy(),
+ );
+ });
+
it('should not render the SendMessageDisallowedIndicator if the channel is frozen and the send-message capability is present', async () => {
const props = {};
const channelProps = { channel };
diff --git a/package/src/components/MessageInput/components/InputButtons/index.tsx b/package/src/components/MessageInput/components/InputButtons/index.tsx
index e9f3cb7739..447c90b1fd 100644
--- a/package/src/components/MessageInput/components/InputButtons/index.tsx
+++ b/package/src/components/MessageInput/components/InputButtons/index.tsx
@@ -77,16 +77,24 @@ const areEqual = (
) => {
const {
hasCameraPicker: prevHasCameraPicker,
+ hasCommands: prevHasCommands,
hasFilePicker: prevHasFilePicker,
hasImagePicker: prevHasImagePicker,
+ uploadFile: prevUploadFile,
} = prevProps;
const {
hasCameraPicker: nextHasCameraPicker,
+ hasCommands: nextHasCommands,
hasFilePicker: nextHasFilePicker,
hasImagePicker: nextHasImagePicker,
+ uploadFile: nextUploadFile,
} = nextProps;
+ if (prevHasCommands !== nextHasCommands || prevUploadFile !== nextUploadFile) {
+ return false;
+ }
+
if (prevHasCameraPicker !== nextHasCameraPicker) {
return false;
}
diff --git a/package/src/components/MessageList/InlineLoadingMoreRecentThreadIndicator.tsx b/package/src/components/MessageList/InlineLoadingMoreRecentThreadIndicator.tsx
deleted file mode 100644
index 74408a5945..0000000000
--- a/package/src/components/MessageList/InlineLoadingMoreRecentThreadIndicator.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import React from 'react';
-import { ActivityIndicator, StyleSheet, View } from 'react-native';
-
-import { useTheme } from '../../contexts/themeContext/ThemeContext';
-import { primitives } from '../../theme';
-
-const styles = StyleSheet.create({
- activityIndicatorContainer: {
- padding: primitives.spacingSm,
- width: '100%',
- },
-});
-
-export type InlineLoadingMoreRecentThreadIndicatorPropsWithContext = {
- loadingMoreRecent?: boolean;
-};
-
-export const InlineLoadingMoreRecentIndicatorWithContext = ({
- loadingMoreRecent,
-}: InlineLoadingMoreRecentThreadIndicatorPropsWithContext) => {
- const { theme } = useTheme();
-
- const { semantics } = theme;
-
- if (!loadingMoreRecent) {
- return null;
- }
-
- return (
-
-
-
- );
-};
-
-const areEqual = (
- prevProps: InlineLoadingMoreRecentThreadIndicatorPropsWithContext,
- nextProps: InlineLoadingMoreRecentThreadIndicatorPropsWithContext,
-) => {
- const { loadingMoreRecent: prevLoadingMoreRecent } = prevProps;
- const { loadingMoreRecent: nextLoadingMoreRecent } = nextProps;
-
- const loadingMoreRecentEqual = prevLoadingMoreRecent === nextLoadingMoreRecent;
- if (!loadingMoreRecentEqual) {
- return false;
- }
-
- return true;
-};
-
-const MemoizedInlineLoadingMoreRecentIndicator = React.memo(
- InlineLoadingMoreRecentIndicatorWithContext,
- areEqual,
-) as typeof InlineLoadingMoreRecentIndicatorWithContext;
-
-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 d09acdd5d1..23ee827e6e 100644
--- a/package/src/components/MessageList/MessageFlashList.tsx
+++ b/package/src/components/MessageList/MessageFlashList.tsx
@@ -1,29 +1,26 @@
import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import {
- AppState,
- LayoutChangeEvent,
- ScrollViewProps,
- StyleSheet,
- View,
- useColorScheme,
-} from 'react-native';
+import { LayoutChangeEvent, ScrollViewProps, StyleSheet, View, useColorScheme } from 'react-native';
import Animated from 'react-native-reanimated';
import type { FlashListProps, FlashListRef } from '@shopify/flash-list';
-import type { Channel, EventPayload, LocalMessage } from 'stream-chat';
+import type { LocalMessage } from 'stream-chat';
-import { convertTimestampToDate, getAttachmentPreviewUrl } from 'stream-chat';
+import { getAttachmentPreviewUrl } from 'stream-chat';
import { useMarkRead } from './hooks/useMarkRead';
import { useMessageList } from './hooks/useMessageList';
-
+import { useMessageListFocus } from './hooks/useMessageListFocus';
+import { useMessageListLiveState } from './hooks/useMessageListLiveState';
+import { useMessageListPagination } from './hooks/useMessageListPagination';
+import { useOwnUnreadCount } from './hooks/useOwnUnreadCount';
import { useScrollToBottomAccessibilityAction } from './hooks/useScrollToBottomAccessibilityAction';
import { useShouldScrollToRecentOnNewOwnMessage } from './hooks/useShouldScrollToRecentOnNewOwnMessage';
+import { useStickyHeaderDate } from './hooks/useStickyHeaderDate';
import { useTypingUsers } from './hooks/useTypingUsers';
+import { useUnreadNotificationVisibility } from './hooks/useUnreadNotificationVisibility';
import { InlineLoadingMoreIndicator } from './InlineLoadingMoreIndicator';
import { InlineLoadingMoreRecentIndicator } from './InlineLoadingMoreRecentIndicator';
-import { InlineLoadingMoreRecentThreadIndicator } from './InlineLoadingMoreRecentThreadIndicator';
import { getMessageListItemCacheKey } from './utils/buildMessageListWithNeighbours';
import {
@@ -56,7 +53,7 @@ import {
import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext';
import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext';
-import { useStableCallback, useStateStore, useActiveMessagePaginator } from '../../hooks';
+import { useStableCallback, useStateStore } from '../../hooks';
import { isVideoPlayerAvailable } from '../../native';
import { bumpOverlayLayoutRevision, useHasActiveId } from '../../state-store';
import { MessageInputHeightState } from '../../state-store/message-input-height-store';
@@ -64,12 +61,7 @@ import { primitives } from '../../theme';
import type { ScrollViewRef, ViewabilityConfig, ViewToken } from '../../types/react-native-compat';
import { FileTypes } from '../../types/types';
import { transitions } from '../../utils/animations/transitions';
-import { getChannelUnreadState } from '../../utils/getChannelUnreadState';
import { MarkReadFunctionOptions } from '../Channel/Channel';
-import {
- DEFAULT_HIGHLIGHT_DURATION,
- useMessageListPagination,
-} from '../Channel/hooks/useMessageListPagination';
import { MessageWrapper } from '../Message/MessageItemView/MessageWrapper';
import { excludeCanceledUploadNotifications } from '../Notifications/notificationFilters';
import { PortalWhileClosingView } from '../UIComponents/PortalWhileClosingView';
@@ -94,14 +86,6 @@ const flatListViewabilityConfig: ViewabilityConfig = {
viewAreaCoveragePercentThreshold: 1,
};
-const hasReadLastMessage = (channel: Channel, userId: string) => {
- const latestMessageIdInChannel = channel.messagePaginator.state
- .getLatestValue()
- .items?.at(-1)?.id;
- const lastReadMessageIdServer = channel.state.read[userId]?.last_read_message_id;
- return latestMessageIdInChannel === lastReadMessageIdServer;
-};
-
const messageInputHeightStoreSelector = (state: MessageInputHeightState) => ({
height: state.height,
});
@@ -113,12 +97,7 @@ type MessageFlashListPropsWithContext = Pick<
Pick &
Pick<
ChannelContextValue,
- | 'channel'
- | 'disabled'
- | 'hideStickyDateHeader'
- | 'scrollToFirstUnreadThreshold'
- | 'hasPendingInitialTargetLoad'
- | 'threadList'
+ 'channel' | 'disabled' | 'hideStickyDateHeader' | 'hasPendingInitialTargetLoad' | 'threadList'
> &
Pick &
Pick & {
@@ -127,15 +106,8 @@ type MessageFlashListPropsWithContext = Pick<
* (`messageComposer.attachments.pendingUploadsEnabled`). Read from the composer by default.
*/
pendingUploadsEnabled: boolean;
- loadMore: () => Promise;
- loadMoreRecent: () => Promise;
markRead: (options?: MarkReadFunctionOptions) => void;
- loadingMore?: boolean;
- loadingMoreRecent?: boolean;
- } & Pick<
- MessagesContextValue,
- 'disableTypingIndicator' | 'FlatList' | 'myMessageTheme' | 'shouldShowUnreadUnderlay'
- > &
+ } & Pick &
Pick & {
/**
* Besides existing (default) UX behavior of underlying FlatList of MessageList component, if you want
@@ -293,22 +265,7 @@ const getItemTypeInternal = (message: LocalMessage) => {
return 'generic-message';
};
-const messageListLoadingSelector = (state: { isLoading: boolean; items?: unknown[] }) => ({
- hasMessages: !!state.items?.length,
- isLoading: state.isLoading,
-});
-
-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
- : InlineLoadingMoreRecentIndicator;
const {
attachmentPickerStore,
additionalFlashListProps,
@@ -317,15 +274,10 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
closePicker,
disabled,
disableTypingIndicator,
- // FlatList,
FooterComponent,
HeaderComponent = InlineLoadingMoreIndicator,
hideStickyDateHeader,
isLiveStreaming = false,
- loadingMore,
- loadingMoreRecent,
- loadMore,
- loadMoreRecent,
markRead,
messageInputFloating,
messageInputHeightStore,
@@ -358,32 +310,16 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
messageInputHeightStore.store,
messageInputHeightStoreSelector,
);
+ const paginator = threadList ? threadInstance?.messagePaginator : channel.messagePaginator;
- 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);
- const stickyHeaderDateRef = useRef(undefined);
- /**
- * We want to call onEndReached and onStartReached only once, per content length.
- * We keep track of calls to these functions per content length, with following trackers.
- */
- const onStartReachedTracker = useRef>({});
- const onEndReachedTracker = useRef>({});
-
- const onStartReachedInPromise = useRef | null>(null);
- const onEndReachedInPromise = useRef | null>(null);
-
/**
* The timeout id used to debounce our scrollToIndex calls on messageList updates
*/
const scrollToDebounceTimeoutRef = useRef>(undefined);
- const channelResyncScrollSet = useRef(true);
const { theme } = useTheme();
const styles = useStyles();
@@ -396,11 +332,29 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
[myMessageThemeString, scheme, theme],
);
- const { maxLoadedItems, processedMessageList, rawMessageList, viewabilityChangedCallback } =
- useMessageList({
- isFlashList: true,
- isLiveStreaming,
- threadList,
+ const { processedMessageList, rawMessageList, viewabilityChangedCallback } = useMessageList({
+ isFlashList: true,
+ isLiveStreaming,
+ paginator,
+ });
+ const { loading, loadingMore, loadingMoreRecent, loadMore, loadMoreRecent } =
+ useMessageListPagination(paginator);
+ const { focusedMessageId, focusToken, goToMessage } = useMessageListFocus(paginator);
+ const reportViewableMessages = useMessageListLiveState({
+ channel,
+ markRead,
+ paginator,
+ threadList,
+ userId: client.user?.id,
+ });
+ const { stickyHeaderDate, updateStickyHeaderDate } = useStickyHeaderDate();
+ const { isUnreadNotificationOpen, onUnreadNotificationClose, updateUnreadNotification } =
+ useUnreadNotificationVisibility({
+ attachmentPickerStore,
+ channel,
+ markRead,
+ readEvents,
+ userId: client.userID,
});
const renderItem = useCallback(
@@ -418,18 +372,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
[processedMessageList],
);
- /**
- * We need topMessage and channelLastRead values to set the initial scroll position.
- * So these values only get used if `initialScrollToFirstUnreadMessage` prop is true.
- */
- const topMessageBeforeUpdate = useRef(undefined);
- const topMessageAfterUpdate: LocalMessage | undefined = rawMessageList[0];
-
const latestNonCurrentMessageBeforeUpdateRef = useRef(undefined);
- const messageListLengthBeforeUpdate = useRef(0);
- const messageListLengthAfterUpdate = processedMessageList.length;
-
const shouldScrollToRecentOnNewOwnMessageRef = useShouldScrollToRecentOnNewOwnMessage(
rawMessageList,
client.userID,
@@ -468,28 +412,8 @@ 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 = useActiveMessagePaginator();
- // `loading` means "querying with nothing to show yet" — selected here rather than handed down, so
- // a message publish does not re-render anything above this component.
- const { hasMessages, isLoading } =
- useStateStore(focusPaginator?.state, messageListLoadingSelector) ?? {};
- const loading = !!isLoading && !hasMessages;
- 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]);
-
/**
* 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
@@ -537,80 +461,17 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
// 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 });
+ paginator?.scheduleMessageFocusSignalClear({ token: focusToken });
}, WAIT_FOR_SCROLL_TIMEOUT);
- }, [focusPaginator, focusToken, focusedMessageId, processedMessageList]);
-
- const goToMessage = useStableCallback(async (messageId: string) => {
- // jumpToMessage loads-around + emits messageFocusSignal → the effect scrolls and highlights.
- await focusPaginator?.jumpToMessage(messageId, {
- focusReason: 'jump-to-message',
- focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
- });
- });
-
- useEffect(() => {
- /**
- * Condition to check if a message is removed from MessageList.
- * Eg: This would happen when giphy search is cancelled, message is deleted with visibility "never" etc.
- * If such a case arises, we scroll to bottom.
- */
- const isMessageRemovedFromMessageList =
- messageListLengthBeforeUpdate.current - messageListLengthAfterUpdate === 1;
-
- /**
- * Scroll down when
- * created_at timestamp of top message before update is lesser than created_at timestamp of top message after update - channel has resynced
- */
- const scrollToBottomIfNeeded = () => {
- if (!client || !channel || processedMessageList.length === 0) {
- return;
- }
-
- if (
- isMessageRemovedFromMessageList ||
- (topMessageBeforeUpdate.current?.created_at != null &&
- topMessageAfterUpdate?.created_at != null &&
- topMessageBeforeUpdate.current.created_at < topMessageAfterUpdate.created_at)
- ) {
- channelResyncScrollSet.current = false;
- setScrollToBottomButtonVisible(false);
- resetPaginationTrackersRef.current();
-
- setTimeout(() => {
- channelResyncScrollSet.current = true;
- if (channel.countUnread() > 0) {
- markRead();
- }
- }, WAIT_FOR_SCROLL_TIMEOUT);
- }
- };
-
- if (isMessageRemovedFromMessageList) {
- if (maxLoadedItems) {
- // The list shrank while a window cap is configured, so a prune is the likely cause. The
- // trackers are keyed by list length, and a prune returns the length to a value already
- // marked as consumed — leaving them would permanently wedge back-pagination.
- resetPaginationTrackersRef.current();
- } else {
- scrollToBottomIfNeeded();
- }
- }
-
- messageListLengthBeforeUpdate.current = messageListLengthAfterUpdate;
- topMessageBeforeUpdate.current = topMessageAfterUpdate;
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [messageListLengthAfterUpdate, topMessageAfterUpdate?.id, maxLoadedItems]);
+ }, [paginator, focusToken, focusedMessageId, processedMessageList]);
useEffect(() => {
- if (!processedMessageList.length) {
+ if (!processedMessageList.length || !paginator) {
return;
}
- const notLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead;
- if (notLatestSet) {
- latestNonCurrentMessageBeforeUpdateRef.current =
- channel.messagePaginator.lastMessage ?? undefined;
+ if (paginator.hasMoreHead) {
+ latestNonCurrentMessageBeforeUpdateRef.current = paginator.lastMessage ?? undefined;
setAutoscrollToRecent(false);
setScrollToBottomButtonVisible(true);
return;
@@ -637,158 +498,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
});
}
}
- }, [channel, processedMessageList, shouldScrollToRecentOnNewOwnMessageRef, threadList]);
-
- /**
- * 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 = getChannelUnreadState(channel);
- return (
- channel.messagePaginator.isViewingLive &&
- !channelUnreadState?.first_unread_message_id &&
- client.user?.id &&
- !hasReadLastMessage(channel, client.user?.id)
- );
- };
-
- const handleEvent = (event: EventPayload<'message.new'>) => {
- const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel;
- if (mainChannelUpdated && shouldMarkRead()) {
- markRead();
- }
- };
-
- const listener: ReturnType = channel.on('message.new', handleEvent);
-
- return () => {
- listener?.unsubscribe();
- };
- }, [channel, client.user?.id, markRead]);
-
- const updateStickyHeaderDateIfNeeded = useStableCallback(
- (viewableItems: ViewToken[]) => {
- if (!viewableItems.length) {
- return;
- }
-
- const lastItem = viewableItems[0];
-
- if (!lastItem) return;
-
- if (
- !channel.messagePaginator.hasMoreTail &&
- processedMessageList[0].id === lastItem.item.id
- ) {
- setStickyHeaderDate(undefined);
- return;
- }
- const isMessageTypeDeleted = lastItem.item.type === 'deleted';
-
- if (
- !isMessageTypeDeleted &&
- lastItem.item.created_at != null &&
- convertTimestampToDate(lastItem.item.created_at)?.toDateString() !==
- stickyHeaderDateRef.current?.toDateString()
- ) {
- stickyHeaderDateRef.current = convertTimestampToDate(lastItem.item.created_at);
- setStickyHeaderDate(convertTimestampToDate(lastItem.item.created_at));
- }
- },
- );
-
- /**
- * This function should show or hide the unread indicator depending on the
- */
- const updateStickyUnreadIndicator = useStableCallback(
- (viewableItems: ViewToken[]) => {
- 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).
- const lastReadMessageId = channelUnreadState?.last_read_message_id;
- const lastReadMessageVisible = viewableItems.some(
- (item) => item.item.id === lastReadMessageId,
- );
-
- // Channels with disabled `read-events` (i.e livestreams) still surface the unread
- // notification when the channel opted into a local unread count, so the gate accepts
- // either source.
- const unreadNotificationSupported =
- readEvents || channel.config.readEvents.localUnreadCountEnabled;
-
- if (
- !viewableItems.length ||
- !unreadNotificationSupported ||
- lastReadMessageVisible ||
- attachmentPickerStore.state.getLatestValue().selectedPicker === 'images'
- ) {
- setIsUnreadNotificationOpen(false);
- return;
- }
-
- const lastItem = viewableItems[0];
-
- if (!lastItem) return;
-
- const lastItemMessage = lastItem.item;
- const lastItemCreatedAt = lastItemMessage.created_at;
-
- const unreadIndicatorDate = channelUnreadState?.last_read;
- const lastItemDate = lastItemCreatedAt;
-
- if (
- !channel.messagePaginator.hasMoreTail &&
- processedMessageList[0].id === lastItemMessage.id
- ) {
- setIsUnreadNotificationOpen(false);
- return;
- }
- /**
- * This is a special case where there is a single long message by the sender.
- * When a message is sent, we mark it as read before it actually has a `created_at` timestamp.
- * This is a workaround to prevent the unread indicator from showing when the message is sent.
- */
- if (
- viewableItems.length === 1 &&
- channel.countUnread() === 0 &&
- lastItemMessage.user?.id === client.userID
- ) {
- setIsUnreadNotificationOpen(false);
- return;
- }
- if (unreadIndicatorDate != null && lastItemDate > unreadIndicatorDate) {
- setIsUnreadNotificationOpen(true);
- } else {
- setIsUnreadNotificationOpen(false);
- }
- },
- );
+ }, [paginator, processedMessageList, shouldScrollToRecentOnNewOwnMessageRef]);
/**
* FlatList doesn't accept changeable function for onViewableItemsChanged prop.
@@ -803,27 +513,24 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
return;
}
viewabilityChangedCallback({ inverted: false, viewableItems });
- if (!hideStickyDateHeader) {
- updateStickyHeaderDateIfNeeded(viewableItems);
+
+ const viewableMessages = viewableItems.map((viewable) => viewable.item);
+ // Not inverted: the first viewable item is the topmost one on screen.
+ const topVisibleMessage = viewableMessages[0];
+ const isAtOldestMessage =
+ topVisibleMessage !== undefined &&
+ !paginator?.hasMoreTail &&
+ processedMessageList[0]?.id === topVisibleMessage.id;
+
+ if (!hideStickyDateHeader && topVisibleMessage) {
+ updateStickyHeaderDate({ isAtOldestMessage, topVisibleMessage });
}
- 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?.id === newestMessageId);
- channel.messagePaginator.setViewingLive(isAppActive && isNewestMessageVisibleRef.current);
+ if (!threadList) {
+ updateUnreadNotification({ isAtOldestMessage, topVisibleMessage, viewableMessages });
+ }
+ // Viewability reflects the real layout, so this is right even at mount: a channel opened at its
+ // first unread has the newest message off screen and is not viewing live.
+ reportViewableMessages(viewableMessages);
};
const onViewableItemsChanged = useRef(unstableOnViewableItemsChanged);
@@ -854,150 +561,35 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
);
/**
- * We are keeping full control on message pagination, and not relying on react-native for it.
- * The reasons being,
- * 1. FlatList doesn't support onStartReached prop
- * 2. `onEndReached` function prop available on react-native, gets executed
- * once per content length (and thats actually a nice optimization strategy).
- * But it also means, we always need to prioritize onEndReached above our
- * logic for `onStartReached`.
- * 3. `onEndReachedThreshold` prop decides - at which scroll position to call `onEndReached`.
- * Its a factor of content length (which is necessary for "real" infinite scroll). But on
- * the other hand, it also makes calls to `onEndReached` (and this `channel.query`) way
- * too early during scroll, which we don't really need. So we are going to instead
- * keep some fixed offset distance, to decide when to call `loadMore` or `loadMoreRecent`.
- *
- * We are still gonna keep the optimization, which react-native does - only call onEndReached
- * once per content length.
- */
-
- /**
- * 1. Makes a call to `loadMore` function, which queries more older messages.
- * 2. Ensures that we call `loadMore`, once per content length
- * 3. If the call to `loadMoreRecent` is in progress, we wait for it to finish to make sure scroll doesn't jump.
- */
- const maybeCallOnStartReached = useStableCallback(async () => {
- // If onEndReached has already been called for given messageList length, then ignore.
- if (
- processedMessageList?.length &&
- onStartReachedTracker.current[processedMessageList.length]
- ) {
- return;
- }
-
- if (processedMessageList?.length) {
- onStartReachedTracker.current[processedMessageList.length] = true;
- }
-
- const callback = () => {
- onStartReachedInPromise.current = null;
- return Promise.resolve();
- };
-
- const onError = () => {
- /** Release the onEndReachedTracker trigger after 2 seconds, to try again */
- setTimeout(() => {
- onStartReachedTracker.current = {};
- }, 2000);
- };
-
- // If onStartReached is in progress, better to wait for it to finish for smooth UX
- if (onEndReachedInPromise.current) {
- await onEndReachedInPromise.current;
- }
- onStartReachedInPromise.current = (
- threadList && threadInstance ? threadInstance.messagePaginator.toHead() : loadMoreRecent()
- )
- .then(callback)
- .catch(onError);
- });
-
- /**
- * 1. Makes a call to `loadMoreRecent` function, which queries more recent messages.
- * 2. Ensures that we call `loadMoreRecent`, once per content length
- * 3. If the call to `loadMore` is in progress, we wait for it to finish to make sure scroll doesn't jump.
+ * Pagination is driven from the scroll position rather than the list's `onEndReached`, from a
+ * fixed distance to either edge. Not inverted: the top is the oldest end.
*/
- const maybeCallOnEndReached = useStableCallback(async () => {
- // If onStartReached has already been called for given data length, then ignore.
- if (processedMessageList?.length && onEndReachedTracker.current[processedMessageList.length]) {
- return;
- }
-
- if (processedMessageList?.length) {
- onEndReachedTracker.current[processedMessageList.length] = true;
- }
-
- const callback = () => {
- onEndReachedInPromise.current = null;
-
- return Promise.resolve();
- };
-
- const onError = () => {
- /** Release the onStartReached trigger after 2 seconds, to try again */
- setTimeout(() => {
- onEndReachedTracker.current = {};
- }, 2000);
- };
-
- // If onEndReached is in progress, better to wait for it to finish for smooth UX
- if (onStartReachedInPromise.current) {
- await onStartReachedInPromise.current;
- }
-
- onEndReachedInPromise.current = (
- threadList ? (threadInstance?.messagePaginator.toTail() ?? Promise.resolve()) : loadMore()
- )
- .then(callback)
- .catch(onError);
- });
-
const onUserScrollEvent: NonNullable = useStableCallback((event) => {
const nativeEvent = event.nativeEvent;
const offset = nativeEvent.contentOffset.y;
const visibleLength = nativeEvent.layoutMeasurement.height;
const contentLength = nativeEvent.contentSize.height;
- if (!channel || !channelResyncScrollSet.current) {
- return;
- }
- // Check if scroll has reached either start of end of list.
- const isScrollAtEnd = offset < 100;
- const isScrollAtStart = contentLength - visibleLength - offset < 100;
+ const isScrollAtTop = offset < 100;
+ const isScrollAtBottom = contentLength - visibleLength - offset < 100;
- if (isScrollAtEnd) {
- maybeCallOnEndReached();
+ if (isScrollAtTop) {
+ loadMore();
}
- if (isScrollAtStart) {
- maybeCallOnStartReached();
+ if (isScrollAtBottom) {
+ loadMoreRecent();
}
});
- /**
- * Resets the pagination trackers, doing so cancels currently scheduled loading more calls
- */
- const resetPaginationTrackersRef = useRef(() => {
- onStartReachedTracker.current = {};
- onEndReachedTracker.current = {};
- });
-
- const currentScrollOffsetRef = useRef(0);
-
const handleScroll: ScrollViewProps['onScroll'] = useStableCallback((event) => {
const messageListHasMessages = processedMessageList.length > 0;
const nativeEvent = event.nativeEvent;
const offset = nativeEvent.contentOffset.y;
- currentScrollOffsetRef.current = offset;
const visibleLength = nativeEvent.layoutMeasurement.height;
const contentLength = nativeEvent.contentSize.height;
- const isScrollAtStart = contentLength - visibleLength - offset < messageInputHeight;
-
- const notLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead;
-
- const showScrollToBottomButton =
- messageListHasMessages && ((!threadList && notLatestSet) || !isScrollAtStart);
+ const isScrollAtBottom = contentLength - visibleLength - offset < messageInputHeight;
/**
* 1. If I scroll up -> show scrollToBottom button.
@@ -1005,7 +597,9 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
* |-> hide scrollToBottom button.
* |-> if channel is unread, call markRead().
*/
- setScrollToBottomButtonVisible(showScrollToBottomButton);
+ setScrollToBottomButtonVisible(
+ messageListHasMessages && (paginator?.hasMoreHead || !isScrollAtBottom),
+ );
if (onListScroll) {
onListScroll(event);
@@ -1013,11 +607,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
});
const goToNewMessages = useStableCallback(async () => {
- const isNotLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead;
-
- if (isNotLatestSet) {
- resetPaginationTrackersRef.current();
- await channel.messagePaginator.jumpToTheLatestMessage();
+ if (paginator?.hasMoreHead) {
+ await paginator.jumpToTheLatestMessage();
} else if (flashListRef.current) {
flashListRef.current.scrollToEnd({
animated: true,
@@ -1034,10 +625,9 @@ 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 scrollToBottomUnreadCount = useOwnUnreadCount(
+ scrollToBottomButtonVisible && !threadList ? channel : undefined,
+ );
const {
accessibilityActions: messageListAccessibilityActions,
onAccessibilityAction: messageListOnAccessibilityAction,
@@ -1056,16 +646,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
}
});
- const onScrollBeginDrag: ScrollViewProps['onScrollBeginDrag'] = useStableCallback((event) => {
- !hasMoved && attachmentPickerStore.state.getLatestValue().selectedPicker && setHasMoved(true);
- onUserScrollEvent(event);
- });
-
- const onScrollEndDrag: ScrollViewProps['onScrollEndDrag'] = useStableCallback((event) => {
- hasMoved && attachmentPickerStore.state.getLatestValue().selectedPicker && setHasMoved(false);
- onUserScrollEvent(event);
- });
-
const refCallback = useStableCallback((ref: FlashListRef) => {
flashListRef.current = ref;
@@ -1074,11 +654,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
}
});
- const onUnreadNotificationClose = useStableCallback(async () => {
- await markRead();
- setIsUnreadNotificationOpen(false);
- });
-
// We need to omit the style related props from the additionalFlatListProps and add them directly instead of spreading
let additionalFlashListPropsExcludingStyle:
| Omit, 'style' | 'contentContainerStyle'>
@@ -1150,7 +725,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
return (
-
+
{!disableTypingIndicator && TypingIndicator && (
@@ -1160,7 +735,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
);
}, [
FooterComponent,
- LoadingMoreRecentIndicator,
loadingMoreRecent,
TypingIndicator,
TypingIndicatorContainer,
@@ -1183,7 +757,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
return (
- {processedMessageList.length === 0 && !threadInstance ? (
+ {processedMessageList.length === 0 && !threadList ? (
{EmptyStateIndicator ? : null}
@@ -1201,8 +775,8 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
maintainVisibleContentPosition={maintainVisibleContentPosition}
onMomentumScrollEnd={onUserScrollEvent}
onScroll={handleScroll}
- onScrollBeginDrag={onScrollBeginDrag}
- onScrollEndDrag={onScrollEndDrag}
+ onScrollBeginDrag={onUserScrollEvent}
+ onScrollEndDrag={onUserScrollEvent}
onTouchEnd={dismissImagePicker}
onViewableItemsChanged={stableOnViewableItemsChanged}
ref={refCallback}
@@ -1225,7 +799,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
importantForAccessibility='no-hide-descendants'
style={styles.stickyHeaderContainer}
>
- {messageListLengthAfterUpdate && StickyHeader ? (
+ {processedMessageList.length > 0 && StickyHeader ? (
) : null}
@@ -1336,20 +910,12 @@ export const MessageFlashList = (props: MessageFlashListProps) => {
disabled,
enableMessageGroupingByUser,
hideStickyDateHeader,
- isChannelActive,
- scrollToFirstUnreadThreshold,
hasPendingInitialTargetLoad,
threadList,
} = useChannelContext();
const markRead = useMarkRead(channel);
const { client } = useChatContext();
- const { disableTypingIndicator, FlatList, myMessageTheme, shouldShowUnreadUnderlay } =
- useMessagesContext();
- const {
- loadMore,
- loadMoreRecent,
- state: { loadingMore, loadingMoreRecent },
- } = useMessageListPagination({ channel });
+ const { disableTypingIndicator, myMessageTheme } = useMessagesContext();
const { threadInstance } = useThreadContext();
const { readEvents } = useOwnCapabilitiesContext();
const pendingUploadsEnabled = usePendingUploadsEnabled();
@@ -1364,23 +930,14 @@ export const MessageFlashList = (props: MessageFlashListProps) => {
closePicker,
disabled,
disableTypingIndicator,
- enableMessageGroupingByUser,
- FlatList,
hideStickyDateHeader,
- isListActive: isChannelActive,
- loadMore,
- loadMoreRecent,
- loadingMore,
- loadingMoreRecent,
markRead,
messageInputFloating,
messageInputHeightStore,
myMessageTheme,
pendingUploadsEnabled,
readEvents,
- scrollToFirstUnreadThreshold,
hasPendingInitialTargetLoad,
- shouldShowUnreadUnderlay,
threadInstance,
threadList,
}}
diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx
index 8c7a9b2d19..de44bd744b 100644
--- a/package/src/components/MessageList/MessageList.tsx
+++ b/package/src/components/MessageList/MessageList.tsx
@@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
- AppState,
FlatList,
FlatListProps,
FlatList as FlatListType,
@@ -15,19 +14,21 @@ import Animated from 'react-native-reanimated';
import debounce from 'lodash/debounce';
-import type { Channel, EventPayload, LocalMessage } from 'stream-chat';
-
-import { convertTimestampToDate } from 'stream-chat';
+import type { LocalMessage } from 'stream-chat';
import { useMarkRead } from './hooks/useMarkRead';
import { useMessageList } from './hooks/useMessageList';
-
+import { useMessageListFocus } from './hooks/useMessageListFocus';
+import { useMessageListLiveState } from './hooks/useMessageListLiveState';
+import { useMessageListPagination } from './hooks/useMessageListPagination';
+import { useOwnUnreadCount } from './hooks/useOwnUnreadCount';
import { useScrollToBottomAccessibilityAction } from './hooks/useScrollToBottomAccessibilityAction';
import { useShouldScrollToRecentOnNewOwnMessage } from './hooks/useShouldScrollToRecentOnNewOwnMessage';
+import { useStickyHeaderDate } from './hooks/useStickyHeaderDate';
+import { useUnreadNotificationVisibility } from './hooks/useUnreadNotificationVisibility';
import { InlineLoadingMoreIndicator } from './InlineLoadingMoreIndicator';
import { InlineLoadingMoreRecentIndicator } from './InlineLoadingMoreRecentIndicator';
-import { InlineLoadingMoreRecentThreadIndicator } from './InlineLoadingMoreRecentThreadIndicator';
import {
buildMessageListWithNeighbours,
@@ -46,7 +47,6 @@ import {
import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext';
import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext';
-import { useDebugContext } from '../../contexts/debugContext/DebugContext';
import { usePendingUploadsEnabled } from '../../contexts/messageInputContext/hooks/usePendingUploadsEnabled';
import {
@@ -68,20 +68,15 @@ import {
import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext';
import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext';
-import { useStableCallback, useActiveMessagePaginator } from '../../hooks';
+import { useStableCallback } from '../../hooks';
import { useStateStore } from '../../hooks/useStateStore';
import { bumpOverlayLayoutRevision, useHasActiveId } from '../../state-store';
import { MessageInputHeightState } from '../../state-store/message-input-height-store';
import { primitives } from '../../theme';
import type { ViewabilityConfig, ViewToken } from '../../types/react-native-compat';
import { transitions } from '../../utils/animations/transitions';
-import { getChannelUnreadState } from '../../utils/getChannelUnreadState';
import { useIncomingMessageAnnouncements } from '../Accessibility/hooks/useIncomingMessageAnnouncements';
import { MarkReadFunctionOptions } from '../Channel/Channel';
-import {
- DEFAULT_HIGHLIGHT_DURATION,
- useMessageListPagination,
-} from '../Channel/hooks/useMessageListPagination';
import { MessageWrapper } from '../Message/MessageItemView/MessageWrapper';
import { excludeCanceledUploadNotifications } from '../Notifications/notificationFilters';
import { PortalWhileClosingView } from '../UIComponents';
@@ -184,34 +179,15 @@ const flatListViewabilityConfig: ViewabilityConfig = {
viewAreaCoveragePercentThreshold: 1,
};
-const hasReadLastMessage = (channel: Channel, userId: string) => {
- const latestMessageIdInChannel = channel.messagePaginator.state
- .getLatestValue()
- .items?.at(-1)?.id;
- const lastReadMessageIdServer = channel.state.read[userId]?.last_read_message_id;
- return latestMessageIdInChannel === lastReadMessageIdServer;
-};
-
type MessageListPropsWithContext = Pick<
AttachmentPickerContextValue,
'closePicker' | 'attachmentPickerStore'
> &
Pick &
- Pick<
- ChannelContextValue,
- 'channel' | 'disabled' | 'hideStickyDateHeader' | 'scrollToFirstUnreadThreshold' | 'threadList'
- > &
+ Pick &
Pick & {
- loadMore: () => Promise;
- loadMoreRecent: () => Promise;
markRead: (options?: MarkReadFunctionOptions) => void;
- hasMore?: boolean;
- loadingMore?: boolean;
- loadingMoreRecent?: boolean;
- } & Pick<
- MessagesContextValue,
- 'disableTypingIndicator' | 'FlatList' | 'myMessageTheme' | 'shouldShowUnreadUnderlay'
- > &
+ } & Pick &
Pick &
Pick & {
/**
@@ -300,22 +276,7 @@ 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 messageListLoadingSelector = (state: { isLoading: boolean; items?: unknown[] }) => ({
- hasMessages: !!state.items?.length,
- isLoading: state.isLoading,
-});
-
-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
- : InlineLoadingMoreRecentIndicator;
const {
animateLayout = true,
attachmentPickerStore,
@@ -331,10 +292,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
hideStickyDateHeader,
inverted = true,
isLiveStreaming = false,
- loadingMore,
- loadingMoreRecent,
- loadMore,
- loadMoreRecent,
markRead,
messageInputFloating,
messageInputHeightStore,
@@ -347,7 +304,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
setFlatListRef,
threadInstance,
threadList = false,
- hasMore,
} = props;
const {
EmptyStateIndicator,
@@ -361,13 +317,13 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
UnreadMessagesNotification,
AutoCompleteSuggestionList,
} = useComponentsContext();
- const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false);
const { theme } = useTheme();
const styles = useStyles();
const { height: messageInputHeight } = useStateStore(
messageInputHeightStore.store,
messageInputHeightStoreSelector,
);
+ const paginator = threadList ? threadInstance?.messagePaginator : channel.messagePaginator;
useIncomingMessageAnnouncements({
activeThreadId: threadInstance?.id,
@@ -385,14 +341,26 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
[myMessageThemeString, scheme, theme],
);
- /**
- * NOTE: rawMessageList changes only when messages array state changes
- * processedMessageList changes on any state change
- */
const { maxLoadedItems, processedMessageList, rawMessageList, viewabilityChangedCallback } =
- useMessageList({
- isLiveStreaming,
- threadList,
+ useMessageList({ isLiveStreaming, paginator });
+ const { loading, loadingMore, loadingMoreRecent, loadMore, loadMoreRecent } =
+ useMessageListPagination(paginator);
+ const { focusedMessageId, focusToken, goToMessage } = useMessageListFocus(paginator);
+ const reportViewableMessages = useMessageListLiveState({
+ channel,
+ markRead,
+ paginator,
+ threadList,
+ userId: client.user?.id,
+ });
+ const { stickyHeaderDate, updateStickyHeaderDate } = useStickyHeaderDate();
+ const { isUnreadNotificationOpen, onUnreadNotificationClose, updateUnreadNotification } =
+ useUnreadNotificationVisibility({
+ attachmentPickerStore,
+ channel,
+ markRead,
+ readEvents,
+ userId: client.userID,
});
const previousDerivedItemsRef = useRef