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>(undefined); @@ -422,16 +390,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { ); }); - const messageListLengthBeforeUpdate = useRef(0); - const messageListLengthAfterUpdate = processedMessageList.length; - - /** - * 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 latestNonCurrentMessageBeforeUpdateRef = useRef(undefined); - const topMessageAfterUpdate: LocalMessage | undefined = rawMessageList[0]; const shouldScrollToRecentOnNewOwnMessageRef = useShouldScrollToRecentOnNewOwnMessage( rawMessageList, @@ -458,141 +417,14 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { [autoscrollToTopThreshold, minIndexForVisible], ); - /** - * 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); - const flatListRef = useRef | null>(null); - const channelResyncScrollSet = useRef(true); - /** * The timeout id used to debounce our scrollToIndex calls on messageList updates */ const scrollToDebounceTimeoutRef = useRef>(undefined); - /** - * The timeout id used to temporarily load the initial scroll set flag - */ - const onScrollEventTimeoutRef = useRef>(undefined); - - const [hasMoved, setHasMoved] = useState(false); - const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); - const [isAppActive, setIsAppActive] = useState(() => AppState.currentState === 'active'); - const isNewestMessageVisibleRef = useRef(false); - - const [stickyHeaderDate, setStickyHeaderDate] = useState(); - const stickyHeaderDateRef = useRef(undefined); - - // ref for channel to use in useEffect without triggering it on channel change - const channelRef = useRef(channel); - channelRef.current = channel; - - const updateStickyHeaderDateIfNeeded = useStableCallback( - (viewableItems: ViewToken[]) => { - if (!viewableItems.length) { - return; - } - - const lastMessage = viewableItems[viewableItems.length - 1].item?.message; - - if (lastMessage) { - if ( - !channel.messagePaginator.hasMoreTail && - processedMessageList[processedMessageList.length - 1].id === lastMessage.id - ) { - setStickyHeaderDate(undefined); - return; - } - const isMessageTypeDeleted = lastMessage.type === 'deleted'; - - if ( - !isMessageTypeDeleted && - lastMessage.created_at != null && - convertTimestampToDate(lastMessage.created_at)?.toDateString() !== - stickyHeaderDateRef.current?.toDateString() - ) { - stickyHeaderDateRef.current = convertTimestampToDate(lastMessage.created_at); - setStickyHeaderDate(convertTimestampToDate(lastMessage.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.message.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[viewableItems.length - 1].item; - - if (lastItem) { - const lastItemMessage = lastItem.message; - const lastItemCreatedAt = lastItemMessage.created_at; - - const unreadIndicatorDate = channelUnreadState?.last_read; - const lastItemDate = lastItemCreatedAt; - - if ( - !channel.messagePaginator.hasMoreTail && - processedMessageList[processedMessageList.length - 1].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); - } - } - }, - ); /** * FlatList doesn't accept changeable function for onViewableItemsChanged prop. @@ -608,20 +440,21 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { if (!viewableItems) { return; } - if (!hideStickyDateHeader) { - updateStickyHeaderDateIfNeeded(viewableItems); + const viewableMessages = viewableItems.map((viewable) => viewable.item.message); + // Inverted: the last viewable item is the topmost one on screen. + const topVisibleMessage = viewableMessages[viewableMessages.length - 1]; + const isAtOldestMessage = + topVisibleMessage !== undefined && + !paginator?.hasMoreTail && + processedMessageList[processedMessageList.length - 1]?.id === topVisibleMessage.id; + + if (!hideStickyDateHeader && topVisibleMessage) { + updateStickyHeaderDate({ isAtOldestMessage, topVisibleMessage }); } - updateStickyUnreadIndicator(viewableItems); - - const paginatorState = channel.messagePaginator.state.getLatestValue(); - const loadedItems = paginatorState.items ?? []; - const newestMessageId = paginatorState.hasMoreHead - ? undefined - : loadedItems[loadedItems.length - 1]?.id; - isNewestMessageVisibleRef.current = - !!newestMessageId && - viewableItems.some((viewable) => viewable.item.message?.id === newestMessageId); - channel.messagePaginator.setViewingLive(isAppActive && isNewestMessageVisibleRef.current); + if (!threadList) { + updateUnreadNotification({ isAtOldestMessage, topVisibleMessage, viewableMessages }); + } + reportViewableMessages(viewableMessages); }; const onViewableItemsChanged = useRef(unstableOnViewableItemsChanged); @@ -638,141 +471,24 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { [], ); - /** - * Resets the pagination trackers, doing so cancels currently scheduled loading more calls - */ - const resetPaginationTrackersRef = useRef(() => { - onStartReachedTracker.current = {}; - onEndReachedTracker.current = {}; - }); - useEffect(() => { if (disabled) { setScrollToBottomButtonVisible(false); } }, [disabled]); - /** - * 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]); - - 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 = - messageListLengthAfterUpdate < messageListLengthBeforeUpdate.current; - /** - * 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 || rawMessageList.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(() => { - flatListRef.current?.scrollToOffset({ - offset: 0, - }); - }, WAIT_FOR_SCROLL_TIMEOUT); - setTimeout(() => { - channelResyncScrollSet.current = true; - if (channel.countUnread() > 0) { - markRead(); - } - }, 500); - } - }; - - if (threadList || isMessageRemovedFromMessageList) { - if (maxLoadedItems) { - // pruning has happened, reset the trackers - resetPaginationTrackersRef.current(); - } else { - scrollToBottomIfNeeded(); - } - } - - messageListLengthBeforeUpdate.current = messageListLengthAfterUpdate; - topMessageBeforeUpdate.current = topMessageAfterUpdate; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [threadList, messageListLengthAfterUpdate, topMessageAfterUpdate?.id, maxLoadedItems]); - useEffect(() => { if (threadList) { setAutoscrollToRecent(true); return; } - 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; @@ -784,13 +500,10 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { setAutoscrollToRecent(true); return; } - const didMergeMessageSetsWithNoUpdates = - latestNonCurrentMessageBeforeUpdate?.id === latestCurrentMessageAfterUpdate.id; - // if didMergeMessageSetsWithNoUpdates=false, we got new messages - // so we should scroll to bottom if we are near the bottom already + // When the newest message did not change, jumping back to the latest set merged into the + // window without anything new; otherwise new messages arrived, so keep following them. const shouldForceScrollToRecent = - !didMergeMessageSetsWithNoUpdates || - processedMessageList.length - messageListLengthBeforeUpdate.current > 0; + latestNonCurrentMessageBeforeUpdate?.id !== latestCurrentMessageAfterUpdate.id; // we don't want this behaviour while pruning, as it may scroll unnecessarily in // certain scenarios @@ -798,7 +511,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { setAutoscrollToRecent(shouldForceScrollToRecent); } - if (!didMergeMessageSetsWithNoUpdates) { + if (shouldForceScrollToRecent) { const shouldScrollToRecentOnNewOwnMessage = shouldScrollToRecentOnNewOwnMessageRef.current(); // we should scroll to bottom where ever we are now // as we have sent a new own message @@ -812,44 +525,15 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { } } }, [ - channel, + paginator, threadList, processedMessageList, shouldScrollToRecentOnNewOwnMessageRef, maxLoadedItems, ]); - // Scroll-to-target is driven by the paginator's messageFocusSignal (thread-aware): a jump - // (jumpToMessage / jumpToTheFirstUnreadMessage / emitMessageFocusSignal) emits it, and the effect - // below scrolls to it. `token` re-fires the effect on every jump, even to the same message id. - const focusPaginator = 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]); - - const goToMessage = useStableCallback(async (messageId: string) => { - // jumpToMessage loads-around the target + emits messageFocusSignal → the effect scrolls and the - // message highlights (mirrors stream-chat-react — no bespoke scroll/highlight bookkeeping). - await focusPaginator?.jumpToMessage(messageId, { - focusReason: 'jump-to-message', - focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION, - }); - }); - /** * Scrolls to the focused message (messageFocusSignal) once it's rendered. Keyed on the focus * token so it re-fires on every jump (even to the same id); also re-attempts when the list @@ -881,9 +565,9 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { // 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]); + }, [paginator, focusToken, focusedMessageId, processedMessageList]); const setNativeScrollability = useStableCallback((value: boolean) => { if (flatListRef.current) { @@ -903,128 +587,26 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { ); /** - * 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 `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. - */ - const maybeCallOnStartReached = useStableCallback(async () => { - // If onStartReached has already been called for given data 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 onStartReached trigger after 2 seconds, to try again */ - setTimeout(() => { - onStartReachedTracker.current = {}; - }, 2000); - }; - - // If onEndReached 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 `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. + * Pagination is driven from the scroll position rather than FlatList's `onEndReached`: FlatList + * has no `onStartReached`, and `onEndReachedThreshold` is a fraction of the content length, which + * fires far too early in a long list. A fixed distance from either edge is used instead. */ - const maybeCallOnEndReached = useStableCallback(async () => { - const shouldQuery = - (threadList && !!threadInstance?.messagePaginator.hasMoreTail) || (!threadList && hasMore); - // If onEndReached has already been called for given messageList length, then ignore. - if ( - (processedMessageList?.length && onEndReachedTracker.current[processedMessageList.length]) || - !shouldQuery - ) { - return; - } - - if (processedMessageList?.length) { - onEndReachedTracker.current[processedMessageList.length] = true; - } - - const callback = () => { - onEndReachedInPromise.current = null; - return Promise.resolve(); - }; - - const onError = () => { - /** Release the onEndReachedTracker trigger after 2 seconds, to try again */ - setTimeout(() => { - onEndReachedTracker.current = {}; - }, 2000); - }; - - // If onStartReached 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; - clearTimeout(onScrollEventTimeoutRef.current); 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. + // Inverted: the start of the list is the newest end. const isScrollAtStart = offset < 100; const isScrollAtEnd = contentLength - visibleLength - offset < 100; if (isScrollAtStart) { - maybeCallOnStartReached(); + loadMoreRecent(); } if (isScrollAtEnd) { - maybeCallOnEndReached(); + loadMore(); } }); @@ -1033,18 +615,15 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const offset = event.nativeEvent.contentOffset.y; const isScrollAtBottom = offset <= messageInputHeight; - const notLatestSet = channel.messagePaginator.state.getLatestValue().hasMoreHead; - - const showScrollToBottomButton = - messageListHasMessages && ((!threadList && notLatestSet) || !isScrollAtBottom); - /** * 1. If I scroll up -> show scrollToBottom button. * 2. If I scroll to bottom of screen * |-> hide scrollToBottom button. * |-> if channel is unread, call markRead(). */ - setScrollToBottomButtonVisible(showScrollToBottomButton); + setScrollToBottomButtonVisible( + messageListHasMessages && (paginator?.hasMoreHead || !isScrollAtBottom), + ); if (onListScroll) { onListScroll(event); @@ -1052,11 +631,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }); 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 (flatListRef.current) { flatListRef.current.scrollToOffset({ animated: true, @@ -1074,10 +650,9 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }); }); - // Non-reactive read for the accessibility action label only (the button owns its own reactive - // count). Refreshes on the list's normal re-renders (e.g. scroll), which is sufficient for a11y. - const scrollToBottomUnreadCount = - scrollToBottomButtonVisible && !threadList ? channel?.countUnread() : undefined; + const scrollToBottomUnreadCount = useOwnUnreadCount( + scrollToBottomButtonVisible && !threadList ? channel : undefined, + ); const { accessibilityActions: messageListAccessibilityActions, onAccessibilityAction: messageListOnAccessibilityAction, @@ -1139,16 +714,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { } }); - 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: FlatListType) => { flatListRef.current = ref; @@ -1157,27 +722,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { } }); - const onUnreadNotificationClose = useStableCallback(async () => { - await markRead(); - setIsUnreadNotificationOpen(false); - }); - - const debugRef = useDebugContext(); - - const isDebugModeEnabled = __DEV__ && debugRef && debugRef.current; - - if (isDebugModeEnabled) { - if (debugRef.current.setEventType) { - debugRef.current.setEventType('send'); - } - if (debugRef.current.setSendEventParams) { - debugRef.current.setSendEventParams({ - action: threadInstance ? 'ThreadList' : 'Messages', - data: processedMessageList, - }); - } - } - // We need to omit the style related props from the additionalFlatListProps and add them directly instead of spreading let additionalFlatListPropsExcludingStyle: | Omit, 'style' | 'contentContainerStyle'> @@ -1232,7 +776,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { debounce( (viewportHeight: number, contentHeight: number) => { if (viewportHeight >= contentHeight) { - maybeCallOnEndReached(); + loadMore(); } }, 500, @@ -1241,7 +785,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { trailing: true, }, ), - [maybeCallOnEndReached], + [loadMore], ); const onContentSizeChange = useStableCallback((width: number, height: number) => { @@ -1277,7 +821,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { return ( <> - + {!disableTypingIndicator && TypingIndicator && ( @@ -1287,7 +831,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { ); }, [ HeaderComponent, - LoadingMoreRecentIndicator, loadingMoreRecent, TypingIndicator, TypingIndicatorContainer, @@ -1310,7 +853,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { return ( {/* Don't show the empty list indicator for Thread messages */} - {processedMessageList.length === 0 && !threadInstance ? ( + {processedMessageList.length === 0 && !threadList ? ( {EmptyStateIndicator ? : null} @@ -1322,7 +865,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { contentContainerStyle={flatListContentContainerStyle} /** Disables the MessageList UI. Which means, message actions, reactions won't work. */ data={processedMessageListWithNeighbors} - extraData={disabled} inverted={inverted} keyboardShouldPersistTaps='handled' keyExtractor={keyExtractor} @@ -1340,8 +882,8 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { onLayout={onLayout} onMomentumScrollEnd={onUserScrollEvent} onScroll={handleScroll} - onScrollBeginDrag={onScrollBeginDrag} - onScrollEndDrag={onScrollEndDrag} + onScrollBeginDrag={onUserScrollEvent} + onScrollEndDrag={onUserScrollEvent} onScrollToIndexFailed={onScrollToIndexFailedRef.current} onTouchEnd={dismissImagePicker} onViewableItemsChanged={stableOnViewableItemsChanged} @@ -1365,7 +907,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { importantForAccessibility='no-hide-descendants' style={styles.stickyHeaderContainer} > - {messageListLengthAfterUpdate && StickyHeader ? ( + {processedMessageList.length > 0 && StickyHeader ? ( ) : null} @@ -1425,26 +967,14 @@ export type MessageListProps = Partial; export const MessageList = (props: MessageListProps) => { const { closePicker, attachmentPickerStore } = useAttachmentPickerContext(); - const { - channel, - disabled, - enableMessageGroupingByUser, - hideStickyDateHeader, - scrollToFirstUnreadThreshold, - threadList, - } = useChannelContext(); + const { channel, disabled, enableMessageGroupingByUser, hideStickyDateHeader, threadList } = + useChannelContext(); const markRead = useMarkRead(channel); const { client } = useChatContext(); const { readEvents } = useOwnCapabilitiesContext(); - const { disableTypingIndicator, FlatList, myMessageTheme, shouldShowUnreadUnderlay } = - useMessagesContext(); + const { disableTypingIndicator, FlatList, myMessageTheme } = useMessagesContext(); const pendingUploadsEnabled = usePendingUploadsEnabled(); const { messageInputFloating, messageInputHeightStore } = useMessageInputContext(); - const { - loadMore, - loadMoreRecent, - state: { hasMore, loadingMore, loadingMoreRecent }, - } = useMessageListPagination({ channel }); const { threadInstance } = useThreadContext(); return ( @@ -1456,24 +986,16 @@ export const MessageList = (props: MessageListProps) => { closePicker, disabled, disableTypingIndicator, - enableMessageGroupingByUser, FlatList, hideStickyDateHeader, - loadMore, - loadMoreRecent, markRead, messageInputFloating, messageInputHeightStore, myMessageTheme, pendingUploadsEnabled, readEvents, - scrollToFirstUnreadThreshold, - shouldShowUnreadUnderlay, threadInstance, threadList, - hasMore, - loadingMore, - loadingMoreRecent, }} {...props} noGroupByUser={!enableMessageGroupingByUser || props.noGroupByUser} diff --git a/package/src/components/MessageList/ScrollToBottomButton.tsx b/package/src/components/MessageList/ScrollToBottomButton.tsx index f74603474a..948482566a 100644 --- a/package/src/components/MessageList/ScrollToBottomButton.tsx +++ b/package/src/components/MessageList/ScrollToBottomButton.tsx @@ -1,15 +1,13 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; import Animated, { ZoomIn, ZoomOut } from 'react-native-reanimated'; -import type { ReadState } from 'stream-chat'; +import { useOwnUnreadCount } from './hooks/useOwnUnreadCount'; import { useChannelContext } from '../../contexts/channelContext/ChannelContext'; -import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { useStateStore } from '../../hooks/useStateStore'; import { primitives } from '../../theme'; import { BadgeNotification } from '../ui'; import { Button } from '../ui/Button'; @@ -29,20 +27,11 @@ export type ScrollToBottomButtonProps = { export const ScrollToBottomButton = (props: ScrollToBottomButtonProps) => { const { onPress, showNotification = true } = props; const { channel, threadList } = useChannelContext(); - const { client } = useChatContext(); const { theme: { semantics }, } = useTheme(); const { icons } = useComponentsContext(); - const userId = client?.userID; - const ownUnreadSelector = useCallback( - (state: ReadState) => ({ - unreadCount: userId ? (state.read[userId]?.unread_messages ?? 0) : 0, - }), - [userId], - ); - const ownRead = useStateStore(channel?.state, ownUnreadSelector); - const unreadCount = threadList ? undefined : ownRead?.unreadCount; + const unreadCount = useOwnUnreadCount(threadList ? undefined : channel); const accessibilityLabelParams = useMemo( () => (unreadCount ? { count: unreadCount } : undefined), [unreadCount], diff --git a/package/src/components/MessageList/UnreadMessagesNotification.tsx b/package/src/components/MessageList/UnreadMessagesNotification.tsx index e9e19d2302..70a2ee7ad9 100644 --- a/package/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/package/src/components/MessageList/UnreadMessagesNotification.tsx @@ -3,6 +3,8 @@ import { StyleSheet, View } from 'react-native'; import type { UnreadSnapshotState } from 'stream-chat'; +import { DEFAULT_HIGHLIGHT_DURATION } from './hooks/useMessageListFocus'; + import { useChannelContext } from '../../contexts/channelContext/ChannelContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; @@ -10,7 +12,6 @@ import { useTranslationContext } from '../../contexts/translationContext/Transla import { useStateStore } from '../../hooks/useStateStore'; import { primitives } from '../../theme'; import { MarkReadFunctionOptions } from '../Channel/Channel'; -import { DEFAULT_HIGHLIGHT_DURATION } from '../Channel/hooks/useMessageListPagination'; import { useNotificationApi } from '../Notifications'; import { Button } from '../ui'; @@ -28,29 +29,23 @@ export type UnreadMessagesNotificationProps = { * Callback to handle the press event */ onPressHandler?: () => Promise; - /** - * Unread count - */ - unreadCount?: number; }; const unreadCountSelector = (snapshot: UnreadSnapshotState) => ({ - unread_messages: snapshot.unreadCount, + count: snapshot.unreadCount, }); export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProps) => { - const { markRead, onCloseHandler, onPressHandler, unreadCount } = props; + const { markRead, onCloseHandler, onPressHandler } = props; const { t } = useTranslationContext(); const { icons } = useComponentsContext(); const { channel } = useChannelContext(); const { addNotification } = useNotificationApi(); - const { unread_messages } = useStateStore( + const { count } = useStateStore( channel.messagePaginator.unreadStateSnapshot, unreadCountSelector, ); - const count = unread_messages ?? unreadCount; - const handleOnPress = async () => { if (onPressHandler) { await onPressHandler(); diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index 5395e9967e..e4febdd8ff 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -5,7 +5,7 @@ import { FlatList } from 'react-native'; import { act, cleanup, fireEvent, render, waitFor, within } from '@testing-library/react-native'; import type { LocalMessage, StreamChat as StreamChatClient, UserResponse } from 'stream-chat'; -import { msToNs } from 'stream-chat'; +import { msToNs, Thread } from 'stream-chat'; import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; @@ -20,29 +20,11 @@ import { generateMessage } from '../../../mock-builders/generator/message'; import { generateUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; import { Channel } from '../../Channel/Channel'; -import * as MessageListPaginationHook from '../../Channel/hooks/useMessageListPagination'; import { Chat } from '../../Chat/Chat'; import { SCROLL_TO_BOTTOM_ACCESSIBILITY_ACTION_NAME } from '../hooks/useScrollToBottomAccessibilityAction'; import { MessageList } from '../MessageList'; -// Local test fixture (was previously imported from the now-removed useChannelDataState hook). -const channelInitialState = { - hasMore: true, - hasMoreNewer: false, - loading: false, - loadingMore: false, - loadingMoreRecent: false, - members: {}, - messages: [], - pinnedMessages: [], - read: {}, - targetedMessageId: undefined, - typing: {}, - watcherCount: 0, - watchers: {}, -}; - describe('MessageList', () => { afterEach(() => { cleanup(); @@ -831,22 +813,6 @@ describe('MessageList pagination', () => { jest.clearAllMocks(); }); - const mockedHook = ( - values: Partial>, - ) => { - const messages = Array.from({ length: 100 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - return jest - .spyOn(MessageListPaginationHook, 'useMessageListPagination') - .mockImplementation(() => ({ - loadMore: jest.fn(), - loadMoreRecent: jest.fn(), - state: { ...channelInitialState, messages }, - ...values, - })); - }; - const renderMessageListForScrollToBottom = async ({ additionalFlatListProps, accessibility = { enabled: true }, @@ -914,8 +880,9 @@ describe('MessageList pagination', () => { const channel = chatClient.channel('messaging', mockedChannel.channel.id); await channel.watch(); - const loadMoreRecent = jest.fn(() => Promise.resolve()); - mockedHook({ loadMoreRecent }); + // Newer messages exist past the loaded window, so the newest edge has something to load. + act(() => channel.messagePaginator.state.partialNext({ hasMoreHead: true })); + const toHead = jest.spyOn(channel.messagePaginator, 'toHead').mockResolvedValue(undefined); const { getByTestId } = render( @@ -940,7 +907,7 @@ describe('MessageList pagination', () => { }); await waitFor(() => { - expect(loadMoreRecent).toHaveBeenCalledTimes(1); + expect(toHead).toHaveBeenCalledTimes(1); }); }); @@ -956,8 +923,8 @@ describe('MessageList pagination', () => { const channel = chatClient.channel('messaging', mockedChannel.channel.id); await channel.watch(); - const loadMore = jest.fn(() => Promise.resolve()); - mockedHook({ loadMore }); + act(() => channel.messagePaginator.state.partialNext({ hasMoreTail: true })); + const toTail = jest.spyOn(channel.messagePaginator, 'toTail').mockResolvedValue(undefined); const { getByTestId } = render( @@ -982,8 +949,55 @@ describe('MessageList pagination', () => { }); await waitFor(() => { - expect(loadMore).toHaveBeenCalledTimes(1); + expect(toTail).toHaveBeenCalledTimes(1); + }); + }); + + // The list used to remember each list length it had requested older messages at, so a page that + // failed left the top edge dead until a new message changed the length. + it('requests older messages again at the same list length after a failed page', async () => { + const mockedChannel = generateChannelResponse({ + messages: Array.from({ length: 20 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); + const chatClient = await getTestClientWithUser({ id: 'testID' } as UserResponse); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + act(() => channel.messagePaginator.state.partialNext({ hasMoreTail: true })); + const toTail = jest + .spyOn(channel.messagePaginator, 'toTail') + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValue(undefined); + + const { getByTestId } = render( + + + + + + + , + ); + + const scrollToOldestEnd = () => + fireEvent(getByTestId('message-flat-list'), 'momentumScrollEnd', { + nativeEvent: { + contentOffset: { y: 1900 }, + contentSize: { height: 2000, width: 200 }, + layoutMeasurement: { height: 400, width: 200 }, + }, + }); + + act(scrollToOldestEnd); + await waitFor(() => expect(toTail).toHaveBeenCalledTimes(1)); + // Let the failed request settle before reaching the edge again. + await act(async () => { + await Promise.resolve(); }); + + act(scrollToOldestEnd); + await waitFor(() => expect(toTail).toHaveBeenCalledTimes(2)); }); // FLAKY (~20% of runs, pre-existing on V10 — this body is byte-identical there). Fails as @@ -1198,3 +1212,88 @@ describe('MessageList pagination', () => { expect(onAccessibilityAction).toHaveBeenCalledTimes(1); }); }); + +// A thread list renders the thread's replies, so everything it reads or drives has to be the +// thread's paginator. It used to drive the channel's, moving the hidden channel list and leaving the +// channel's unread state wrong after the thread closed. +describe('MessageList in a thread', () => { + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + const setup = async () => { + const mockedChannel = generateChannelResponse({ + messages: Array.from({ length: 5 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); + const chatClient = await getTestClientWithUser({ id: 'testID' } as UserResponse); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + const parentMessage = channel.state.formatMessage(generateMessage({ text: 'parent' })); + const threadInstance = new Thread({ channel, client: chatClient, parentMessage }); + const replies = Array.from({ length: 5 }, (_, i) => + channel.state.formatMessage( + generateMessage({ parent_id: parentMessage.id, text: `reply-${i}` }), + ), + ) as LocalMessage[]; + act(() => + threadInstance.messagePaginator.state.partialNext({ + hasMoreHead: false, + hasMoreTail: false, + isLoading: false, + items: replies, + }), + ); + + const utils = render( + + + + + + + , + ); + await waitFor(() => expect(utils.getByText('reply-4')).toBeTruthy()); + + return { ...utils, channel, threadInstance }; + }; + + it("leaves the channel's viewing-live flag as the channel list reported it", async () => { + const { channel, unmount } = await setup(); + act(() => channel.messagePaginator.setViewingLive(true)); + + unmount(); + + expect(channel.messagePaginator.isViewingLive).toBe(true); + }); + + it('jumps the thread, not the channel, to its latest replies from the scroll to bottom button', async () => { + const { channel, getByTestId, threadInstance } = await setup(); + // The thread was opened at an older reply, so newer replies exist past its loaded window. + act(() => threadInstance.messagePaginator.state.partialNext({ hasMoreHead: true })); + const jumpThread = jest + .spyOn(threadInstance.messagePaginator, 'jumpToTheLatestMessage') + .mockResolvedValue(true); + const jumpChannel = jest + .spyOn(channel.messagePaginator, 'jumpToTheLatestMessage') + .mockResolvedValue(true); + + act(() => { + fireEvent(getByTestId('message-flat-list'), 'scroll', { + nativeEvent: { + contentOffset: { y: 1900 }, + contentSize: { height: 2000, width: 200 }, + layoutMeasurement: { height: 400, width: 200 }, + }, + }); + }); + const button = await waitFor(() => getByTestId('scroll-to-bottom-button')); + act(() => fireEvent.press(button)); + + await waitFor(() => expect(jumpThread).toHaveBeenCalledTimes(1)); + expect(jumpChannel).not.toHaveBeenCalled(); + }); +}); diff --git a/package/src/components/MessageList/__tests__/useMessageList.test.tsx b/package/src/components/MessageList/__tests__/useMessageList.test.tsx index 50882b8920..ba8d1aee61 100644 --- a/package/src/components/MessageList/__tests__/useMessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/useMessageList.test.tsx @@ -1,10 +1,7 @@ -import React from 'react'; - import { renderHook } from '@testing-library/react-native'; import type { Channel, LocalMessage } from 'stream-chat'; -import { ChannelProvider } from '../../../contexts/channelContext/ChannelContext'; import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; import { generateMessage } from '../../../mock-builders/generator/message'; import { useMessageList } from '../hooks/useMessageList'; @@ -28,11 +25,7 @@ describe('useMessageList', () => { }); it('should always return a list of reversed messages', () => { - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - - const { result } = renderHook(() => useMessageList({ threadList: false }), { wrapper }); + const { result } = renderHook(() => useMessageList({ paginator: channel.messagePaginator })); const reversedMessages = [...messages].reverse(); expect(result.current.processedMessageList.map(({ id }) => id)).toEqual( diff --git a/package/src/components/MessageList/hooks/__tests__/useMessageListLiveState.test.tsx b/package/src/components/MessageList/hooks/__tests__/useMessageListLiveState.test.tsx new file mode 100644 index 0000000000..8aee8b9ced --- /dev/null +++ b/package/src/components/MessageList/hooks/__tests__/useMessageListLiveState.test.tsx @@ -0,0 +1,157 @@ +import { AppState } from 'react-native'; + +import { act, cleanup, renderHook } from '@testing-library/react-native'; +import type { Channel, LocalMessage, MessagePaginator, StreamChat } from 'stream-chat'; + +import { getOrCreateChannelApi } from '../../../../mock-builders/api/getOrCreateChannel'; +import { useMockedApis } from '../../../../mock-builders/api/useMockedApis'; +import dispatchMessageNewEvent from '../../../../mock-builders/event/messageNew'; +import { generateChannelResponse } from '../../../../mock-builders/generator/channel'; +import { generateMessage } from '../../../../mock-builders/generator/message'; +import { generateUser } from '../../../../mock-builders/generator/user'; +import { getTestClientWithUser } from '../../../../mock-builders/mock'; +import { useMessageListLiveState } from '../useMessageListLiveState'; + +const asMessages = (ids: string[]) => ids.map((id) => ({ id })) as LocalMessage[]; + +// A thread's reply paginator, reduced to what the hook touches. +const makeThreadPaginator = () => ({ + setViewingLive: jest.fn(), + state: { getLatestValue: () => ({ hasMoreHead: false, items: asMessages(['r1', 'r2']) }) }, +}); + +describe('useMessageListLiveState', () => { + let client: StreamChat; + let channel: Channel; + + // The react-native jest preset leaves `currentState` a mock function rather than a state string. + const originalAppState = AppState.currentState; + + beforeEach(async () => { + Object.assign(AppState, { currentState: 'active' }); + client = await getTestClientWithUser({ id: 'me' }); + const response = generateChannelResponse({ + messages: [generateMessage({ id: 'm1', user: generateUser({ id: 'other' }) })], + }); + useMockedApis(client, [getOrCreateChannelApi(response)]); + channel = client.channel('messaging', response.channel.id); + await channel.watch(); + }); + + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + Object.assign(AppState, { currentState: originalAppState }); + }); + + it('reports whether the newest message is on screen to the channel paginator', () => { + const { result } = renderHook(() => + useMessageListLiveState({ + channel, + markRead: jest.fn(), + paginator: channel.messagePaginator, + threadList: false, + userId: 'me', + }), + ); + const newestId = channel.messagePaginator.state.getLatestValue().items?.at(-1)?.id as string; + + act(() => result.current(asMessages([newestId]))); + expect(channel.messagePaginator.isViewingLive).toBe(true); + + act(() => result.current(asMessages(['not-the-newest']))); + expect(channel.messagePaginator.isViewingLive).toBe(false); + }); + + it('stops reporting the channel as viewed live when the list unmounts', () => { + const { result, unmount } = renderHook(() => + useMessageListLiveState({ + channel, + markRead: jest.fn(), + paginator: channel.messagePaginator, + threadList: false, + userId: 'me', + }), + ); + const newestId = channel.messagePaginator.state.getLatestValue().items?.at(-1)?.id as string; + act(() => result.current(asMessages([newestId]))); + + unmount(); + + expect(channel.messagePaginator.isViewingLive).toBe(false); + }); + + // A thread opened over the channel used to write the channel's flag, so the channel stayed "not + // live" after the thread closed and counted the next message as unread while the user sat at the + // bottom. + it("reports a thread list to the thread's paginator and leaves the channel's flag alone", () => { + act(() => channel.messagePaginator.setViewingLive(true)); + const threadPaginator = makeThreadPaginator(); + + const { result, unmount } = renderHook(() => + useMessageListLiveState({ + channel, + markRead: jest.fn(), + paginator: threadPaginator as unknown as MessagePaginator, + threadList: true, + userId: 'me', + }), + ); + act(() => result.current(asMessages(['r2']))); + expect(threadPaginator.setViewingLive).toHaveBeenLastCalledWith(true); + + act(() => result.current(asMessages(['r1']))); + expect(threadPaginator.setViewingLive).toHaveBeenLastCalledWith(false); + + unmount(); + expect(channel.messagePaginator.isViewingLive).toBe(true); + }); + + it('marks the channel read when a message arrives while it is viewed live', () => { + const markRead = jest.fn(); + renderHook(() => + useMessageListLiveState({ + channel, + markRead, + paginator: channel.messagePaginator, + threadList: false, + userId: 'me', + }), + ); + act(() => channel.messagePaginator.setViewingLive(true)); + + act(() => + dispatchMessageNewEvent( + client, + generateMessage({ cid: channel.cid, user: generateUser({ id: 'other' }) }), + channel.data as never, + ), + ); + + expect(markRead).toHaveBeenCalledTimes(1); + }); + + it('leaves marking the channel read to the channel list when it is a thread list', () => { + const markRead = jest.fn(); + renderHook(() => + useMessageListLiveState({ + channel, + markRead, + paginator: makeThreadPaginator() as unknown as MessagePaginator, + threadList: true, + userId: 'me', + }), + ); + act(() => channel.messagePaginator.setViewingLive(true)); + + act(() => + dispatchMessageNewEvent( + client, + generateMessage({ cid: channel.cid, user: generateUser({ id: 'other' }) }), + channel.data as never, + ), + ); + + expect(markRead).not.toHaveBeenCalled(); + }); +}); diff --git a/package/src/components/MessageList/hooks/__tests__/useMessageListPagination.test.tsx b/package/src/components/MessageList/hooks/__tests__/useMessageListPagination.test.tsx new file mode 100644 index 0000000000..4a973de8b9 --- /dev/null +++ b/package/src/components/MessageList/hooks/__tests__/useMessageListPagination.test.tsx @@ -0,0 +1,184 @@ +import { act, cleanup, renderHook } from '@testing-library/react-native'; +import type { MessagePaginator } from 'stream-chat'; + +import { useMessageListPagination } from '../useMessageListPagination'; + +type Deferred = { promise: Promise; reject: (error: Error) => void; resolve: () => void }; + +const deferred = (): Deferred => { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, reject, resolve }; +}; + +// The hook only reads the paging flags and calls toHead/toTail, so a plain object stands in for the +// paginator; `state` only has to satisfy the loading selector. +const makePaginator = ({ hasMoreHead = true, hasMoreTail = true, isLoading = false } = {}) => { + const paginator = { + hasMoreHead, + hasMoreTail, + isLoading, + state: { + getLatestValue: (): { isLoading: boolean; items: unknown[] } => ({ + isLoading: false, + items: [], + }), + subscribeWithSelector: () => () => undefined, + }, + toHead: jest.fn(() => Promise.resolve()), + toTail: jest.fn(() => Promise.resolve()), + }; + return paginator; +}; + +const render = (paginator: ReturnType) => + renderHook(() => useMessageListPagination(paginator as unknown as MessagePaginator)); + +describe('useMessageListPagination', () => { + afterEach(cleanup); + + it('loads older messages and flags loadingMore while the request runs', async () => { + const paginator = makePaginator(); + const request = deferred(); + paginator.toTail.mockReturnValueOnce(request.promise); + const { result } = render(paginator); + + let loadMore: Promise | undefined; + act(() => { + loadMore = result.current.loadMore(); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(paginator.toTail).toHaveBeenCalledTimes(1); + expect(paginator.toHead).not.toHaveBeenCalled(); + expect(result.current.loadingMore).toBe(true); + + await act(async () => { + request.resolve(); + await loadMore; + }); + expect(result.current.loadingMore).toBe(false); + }); + + it('loads newer messages and flags loadingMoreRecent while the request runs', async () => { + const paginator = makePaginator(); + const request = deferred(); + paginator.toHead.mockReturnValueOnce(request.promise); + const { result } = render(paginator); + + let loadMoreRecent: Promise | undefined; + act(() => { + loadMoreRecent = result.current.loadMoreRecent(); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(paginator.toHead).toHaveBeenCalledTimes(1); + expect(result.current.loadingMoreRecent).toBe(true); + + await act(async () => { + request.resolve(); + await loadMoreRecent; + }); + expect(result.current.loadingMoreRecent).toBe(false); + }); + + it('does not request an end that has nothing more, or while the paginator is loading', async () => { + const exhausted = makePaginator({ hasMoreHead: false, hasMoreTail: false }); + const { result: exhaustedResult } = render(exhausted); + const busy = makePaginator({ isLoading: true }); + const { result: busyResult } = render(busy); + + await act(async () => { + await exhaustedResult.current.loadMore(); + await exhaustedResult.current.loadMoreRecent(); + await busyResult.current.loadMore(); + await busyResult.current.loadMoreRecent(); + }); + + expect(exhausted.toTail).not.toHaveBeenCalled(); + expect(exhausted.toHead).not.toHaveBeenCalled(); + expect(busy.toTail).not.toHaveBeenCalled(); + expect(busy.toHead).not.toHaveBeenCalled(); + expect(exhaustedResult.current.loadingMore).toBe(false); + }); + + it('makes one request for repeated calls in the same direction while one is running', async () => { + const paginator = makePaginator(); + const request = deferred(); + paginator.toTail.mockReturnValueOnce(request.promise); + const { result } = render(paginator); + + let first: Promise | undefined; + act(() => { + first = result.current.loadMore(); + result.current.loadMore(); + result.current.loadMore(); + }); + await act(async () => { + request.resolve(); + await first; + }); + + expect(paginator.toTail).toHaveBeenCalledTimes(1); + }); + + it('starts a newer request only once the older one in flight has settled', async () => { + const paginator = makePaginator(); + const older = deferred(); + paginator.toTail.mockReturnValueOnce(older.promise); + const { result } = render(paginator); + + let newer: Promise | undefined; + act(() => { + result.current.loadMore(); + newer = result.current.loadMoreRecent(); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(paginator.toTail).toHaveBeenCalledTimes(1); + expect(paginator.toHead).not.toHaveBeenCalled(); + + await act(async () => { + older.resolve(); + await newer; + }); + expect(paginator.toHead).toHaveBeenCalledTimes(1); + }); + + // The lists used to remember every list length they had requested at, so a page that failed left + // that edge dead until the list length changed. + it('requests the same end again after a failed request', async () => { + const paginator = makePaginator(); + paginator.toTail.mockRejectedValueOnce(new Error('network')); + const { result } = render(paginator); + + await act(async () => { + await result.current.loadMore(); + }); + expect(result.current.loadingMore).toBe(false); + + await act(async () => { + await result.current.loadMore(); + }); + expect(paginator.toTail).toHaveBeenCalledTimes(2); + }); + + it('reports loading only while the first page has nothing to show', () => { + const paginator = makePaginator(); + let value = { isLoading: true, items: [] as unknown[] }; + paginator.state.getLatestValue = () => value; + const { rerender, result } = render(paginator); + expect(result.current.loading).toBe(true); + + value = { isLoading: true, items: [{ id: 'a' }] }; + rerender({}); + expect(result.current.loading).toBe(false); + }); +}); diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index 8dd2a8df18..2d895ea285 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -1,15 +1,13 @@ import { useMemo } from 'react'; -import type { LocalMessage } from 'stream-chat'; - -import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; -import { useThreadContext } from '../../../contexts/threadContext/ThreadContext'; +import type { LocalMessage, MessagePaginator } from 'stream-chat'; import { useRAFCoalescedValue, useStateStore } from '../../../hooks'; import { usePrunableMessageList } from '../../../hooks/usePrunableMessageList'; export type UseMessageListParams = { - threadList?: boolean; + /** The paginator the list renders: the channel's, or the open thread's in a thread list. */ + paginator: MessagePaginator | undefined; isLiveStreaming?: boolean; isFlashList?: boolean; }; @@ -28,18 +26,13 @@ const EMPTY_MESSAGES: LocalMessage[] = []; const messageListSelector = (state: { items?: LocalMessage[] }) => ({ messages: state.items }); export const useMessageList = (params: UseMessageListParams) => { - const { threadList, isLiveStreaming, isFlashList = false } = params; - const { channel } = useChannelContext(); - const { threadInstance } = useThreadContext(); - const messagePaginator = threadList ? threadInstance?.messagePaginator : channel.messagePaginator; - const { messages } = useStateStore(messagePaginator?.state, messageListSelector) ?? {}; - const { maxLoadedItems, viewabilityChangedCallback } = usePrunableMessageList({ - paginator: messagePaginator, - }); + const { isLiveStreaming, isFlashList = false, paginator } = params; + const { messages } = useStateStore(paginator?.state, messageListSelector) ?? {}; + const { maxLoadedItems, viewabilityChangedCallback } = usePrunableMessageList({ paginator }); const messageList = messages ?? EMPTY_MESSAGES; const processedMessageList = useMemo( - () => (isFlashList ? messageList.slice() : messageList.slice().reverse()), + () => (isFlashList ? messageList : messageList.slice().reverse()), [messageList, isFlashList], ); @@ -52,9 +45,9 @@ export const useMessageList = (params: UseMessageListParams) => { * the paginator rather than a prop — it is state-layer configuration. */ maxLoadedItems, - /** Messages enriched with dates/readby/groups and also reversed in order */ + /** Render order: newest first for the inverted FlatList, oldest first for FlashList. */ processedMessageList: data, - /** Raw messages from the channel state */ + /** The paginator's messages, oldest first. */ rawMessageList: messageList, viewabilityChangedCallback, }), diff --git a/package/src/components/MessageList/hooks/useMessageListFocus.ts b/package/src/components/MessageList/hooks/useMessageListFocus.ts new file mode 100644 index 0000000000..54bc0dec89 --- /dev/null +++ b/package/src/components/MessageList/hooks/useMessageListFocus.ts @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; + +import type { MessagePaginator } from 'stream-chat'; + +import { useStableCallback, useStateStore } from '../../../hooks'; + +/** How long, in milliseconds, a message the list jumped to stays highlighted. */ +export const DEFAULT_HIGHLIGHT_DURATION = 3000; + +const messageFocusSelector = (state: { + signal: { messageId?: string; token?: number } | null; +}) => ({ + focusedMessageId: state.signal?.messageId, + focusToken: state.signal?.token, +}); + +/** + * The message `paginator` wants the list to scroll to and highlight, and `goToMessage` to request + * one. A jump (`jumpToMessage`, `jumpToTheFirstUnreadMessage`, …) emits the paginator's + * `messageFocusSignal`; the list scrolls when it sees a new `focusToken`, which changes on every jump + * even to the same message. + * + * The signal lives on the paginator, which outlives the list, so it is cleared on unmount. Otherwise a + * highlight still running when the user navigates away would scroll and highlight again on return. + */ +export const useMessageListFocus = (paginator?: MessagePaginator) => { + const { focusedMessageId, focusToken } = + useStateStore(paginator?.messageFocusSignal, messageFocusSelector) ?? {}; + + useEffect(() => () => paginator?.clearMessageFocusSignal(), [paginator]); + + const goToMessage = useStableCallback(async (messageId: string) => { + await paginator?.jumpToMessage(messageId, { + focusReason: 'jump-to-message', + focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION, + }); + }); + + return { focusedMessageId, focusToken, goToMessage }; +}; diff --git a/package/src/components/MessageList/hooks/useMessageListLiveState.ts b/package/src/components/MessageList/hooks/useMessageListLiveState.ts new file mode 100644 index 0000000000..9d47392055 --- /dev/null +++ b/package/src/components/MessageList/hooks/useMessageListLiveState.ts @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from 'react'; +import { AppState } from 'react-native'; + +import type { Channel, EventPayload, LocalMessage, MessagePaginator } from 'stream-chat'; + +import { useStableCallback } from '../../../hooks'; +import type { MarkReadFunctionOptions } from '../../Channel/Channel'; + +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 UseMessageListLiveStateParams = { + channel: Channel; + markRead: (options?: MarkReadFunctionOptions) => void; + /** The list's paginator: the channel's, or the open thread's in a thread list. */ + paginator: MessagePaginator | undefined; + threadList: boolean; + userId: string | undefined; +}; + +/** + * Tells `paginator` whether the user is viewing its latest messages: the app is in the foreground, + * the newest messages are loaded, and the newest one is on screen. While that holds, `stream-chat` + * does not count an incoming message as unread (`messagePaginator.isViewingLive`). + * + * A channel list also marks the channel read when a message arrives in that state. A thread list + * leaves the channel's flags alone: the channel list underneath it keeps reporting its own. + * + * Returns the callback the list calls with the messages currently on screen. + */ +export const useMessageListLiveState = ({ + channel, + markRead, + paginator, + threadList, + userId, +}: UseMessageListLiveStateParams) => { + const [isAppActive, setIsAppActive] = useState(() => AppState.currentState === 'active'); + const isNewestMessageVisibleRef = useRef(false); + + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextAppState) => + setIsAppActive(nextAppState === 'active'), + ); + return () => subscription.remove(); + }, []); + + // Viewability reports on scroll; this reports when the app goes to or comes from the background. + // Reset on unmount, so a list that is gone never keeps unread counting switched off. + useEffect(() => { + if (!paginator) { + return; + } + paginator.setViewingLive(isAppActive && isNewestMessageVisibleRef.current); + return () => paginator.setViewingLive(false); + }, [isAppActive, paginator]); + + useEffect(() => { + if (threadList) { + return; + } + + const handleEvent = (event: EventPayload<'message.new'>) => { + const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; + if (!mainChannelUpdated || !userId) { + return; + } + const { firstUnreadMessageId } = + channel.messagePaginator.unreadStateSnapshot.getLatestValue(); + if ( + channel.messagePaginator.isViewingLive && + !firstUnreadMessageId && + !hasReadLastMessage(channel, userId) + ) { + markRead(); + } + }; + + const listener = channel.on('message.new', handleEvent); + return () => listener.unsubscribe(); + }, [channel, markRead, threadList, userId]); + + return useStableCallback((viewableMessages: LocalMessage[]) => { + if (!paginator) { + return; + } + // The last loaded message is the newest one only when nothing newer is left to load. + const { hasMoreHead, items } = paginator.state.getLatestValue(); + const newestMessageId = hasMoreHead ? undefined : items?.at(-1)?.id; + isNewestMessageVisibleRef.current = + newestMessageId !== undefined && + viewableMessages.some((message) => message.id === newestMessageId); + paginator.setViewingLive(isAppActive && isNewestMessageVisibleRef.current); + }); +}; diff --git a/package/src/components/MessageList/hooks/useMessageListPagination.ts b/package/src/components/MessageList/hooks/useMessageListPagination.ts new file mode 100644 index 0000000000..710496f210 --- /dev/null +++ b/package/src/components/MessageList/hooks/useMessageListPagination.ts @@ -0,0 +1,71 @@ +import { useRef, useState } from 'react'; + +import type { MessagePaginator } from 'stream-chat'; + +import { useStableCallback, useStateStore } from '../../../hooks'; + +const loadingSelector = (state: { isLoading: boolean; items?: unknown[] }) => ({ + hasMessages: (state.items?.length ?? 0) > 0, + isLoading: state.isLoading, +}); + +/** + * Paging for a message list over `paginator`, the channel's or the open thread's. + * + * `loadMore` fetches older messages and `loadMoreRecent` newer ones. The paginator ignores a request + * while it is already loading or when that end has nothing more, so the list can call these on every + * scroll event that reaches an edge. A request first waits for one in flight in the other direction, + * so the two never shift the scroll position under each other. + */ +export const useMessageListPagination = (paginator?: MessagePaginator) => { + const { hasMessages, isLoading } = useStateStore(paginator?.state, loadingSelector) ?? {}; + const [loadingMore, setLoadingMore] = useState(false); + const [loadingMoreRecent, setLoadingMoreRecent] = useState(false); + const olderRequestRef = useRef>(undefined); + const newerRequestRef = useRef>(undefined); + + const loadMore = useStableCallback(() => { + olderRequestRef.current ??= (async () => { + await newerRequestRef.current; + if (paginator?.hasMoreTail && !paginator.isLoading) { + setLoadingMore(true); + try { + await paginator.toTail(); + } catch { + // The paginator records the failure in `state.lastQueryError`. + } finally { + setLoadingMore(false); + } + } + olderRequestRef.current = undefined; + })(); + return olderRequestRef.current; + }); + + const loadMoreRecent = useStableCallback(() => { + newerRequestRef.current ??= (async () => { + await olderRequestRef.current; + if (paginator?.hasMoreHead && !paginator.isLoading) { + setLoadingMoreRecent(true); + try { + await paginator.toHead(); + } catch { + // The paginator records the failure in `state.lastQueryError`. + } finally { + setLoadingMoreRecent(false); + } + } + newerRequestRef.current = undefined; + })(); + return newerRequestRef.current; + }); + + return { + /** The first page is loading and there is nothing to show yet. */ + loading: isLoading === true && !hasMessages, + loadingMore, + loadingMoreRecent, + loadMore, + loadMoreRecent, + }; +}; diff --git a/package/src/components/MessageList/hooks/useOwnUnreadCount.ts b/package/src/components/MessageList/hooks/useOwnUnreadCount.ts new file mode 100644 index 0000000000..81913d8ae0 --- /dev/null +++ b/package/src/components/MessageList/hooks/useOwnUnreadCount.ts @@ -0,0 +1,22 @@ +import { useMemo } from 'react'; + +import type { Channel, ReadState } from 'stream-chat'; + +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useStateStore } from '../../../hooks/useStateStore'; + +/** + * The current user's unread message count in `channel`, kept current as reads arrive. `undefined` + * when no channel is given, which also leaves the channel's state unsubscribed. + */ +export const useOwnUnreadCount = (channel: Channel | undefined) => { + const { client } = useChatContext(); + const userId = client?.userID; + const selector = useMemo( + () => (state: ReadState) => ({ + unreadCount: userId ? (state.read[userId]?.unread_messages ?? 0) : 0, + }), + [userId], + ); + return useStateStore(channel?.state, selector)?.unreadCount; +}; diff --git a/package/src/components/MessageList/hooks/useStickyHeaderDate.ts b/package/src/components/MessageList/hooks/useStickyHeaderDate.ts new file mode 100644 index 0000000000..ba491476b5 --- /dev/null +++ b/package/src/components/MessageList/hooks/useStickyHeaderDate.ts @@ -0,0 +1,40 @@ +import { useRef, useState } from 'react'; + +import type { LocalMessage } from 'stream-chat'; +import { convertTimestampToDate } from 'stream-chat'; + +import { useStableCallback } from '../../../hooks'; + +/** + * The date the list's sticky header shows: the day of the topmost message on screen. Hidden once the + * oldest message of the whole list is on screen, where the inline date separator says the same. + */ +export const useStickyHeaderDate = () => { + const [stickyHeaderDate, setStickyHeaderDate] = useState(); + const stickyHeaderDateRef = useRef(undefined); + + const updateStickyHeaderDate = useStableCallback( + ({ + isAtOldestMessage, + topVisibleMessage, + }: { + isAtOldestMessage: boolean; + topVisibleMessage: LocalMessage; + }) => { + if (isAtOldestMessage) { + setStickyHeaderDate(undefined); + return; + } + if (topVisibleMessage.type === 'deleted' || topVisibleMessage.created_at == null) { + return; + } + const date = convertTimestampToDate(topVisibleMessage.created_at); + if (date?.toDateString() !== stickyHeaderDateRef.current?.toDateString()) { + stickyHeaderDateRef.current = date; + setStickyHeaderDate(date); + } + }, + ); + + return { stickyHeaderDate, updateStickyHeaderDate }; +}; diff --git a/package/src/components/MessageList/hooks/useUnreadNotificationVisibility.ts b/package/src/components/MessageList/hooks/useUnreadNotificationVisibility.ts new file mode 100644 index 0000000000..7d3d4b9e1c --- /dev/null +++ b/package/src/components/MessageList/hooks/useUnreadNotificationVisibility.ts @@ -0,0 +1,86 @@ +import { useState } from 'react'; + +import type { Channel, LocalMessage } from 'stream-chat'; + +import { useStableCallback } from '../../../hooks'; +import type { AttachmentPickerStore } from '../../../state-store/attachment-picker-store'; +import type { MarkReadFunctionOptions } from '../../Channel/Channel'; + +type UseUnreadNotificationVisibilityParams = { + attachmentPickerStore: AttachmentPickerStore; + channel: Channel; + markRead: (options?: MarkReadFunctionOptions) => Promise | void; + readEvents: boolean | undefined; + userId: string | undefined; +}; + +/** + * Whether the "N new messages" notification is shown at the top of a channel list: the user has + * scrolled up past the last message they read, and that message is no longer on screen. + */ +export const useUnreadNotificationVisibility = ({ + attachmentPickerStore, + channel, + markRead, + readEvents, + userId, +}: UseUnreadNotificationVisibilityParams) => { + const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); + + const updateUnreadNotification = useStableCallback( + ({ + isAtOldestMessage, + topVisibleMessage, + viewableMessages, + }: { + isAtOldestMessage: boolean; + topVisibleMessage: LocalMessage | undefined; + viewableMessages: LocalMessage[]; + }) => { + const { firstUnreadMessageId, lastReadAt, lastReadMessageId, unreadCount } = + channel.messagePaginator.unreadStateSnapshot.getLatestValue(); + // Only the last read message going off screen opens it, not ordinary scrolling past old ones. + const lastReadMessageVisible = viewableMessages.some( + (message) => message.id === lastReadMessageId, + ); + // Channels without read events (livestreams) still show it when they count unread locally. + const unreadNotificationSupported = + readEvents || channel.config.readEvents.localUnreadCountEnabled; + + if ( + !topVisibleMessage || + !unreadNotificationSupported || + lastReadMessageVisible || + attachmentPickerStore.state.getLatestValue().selectedPicker === 'images' || + isAtOldestMessage + ) { + setIsUnreadNotificationOpen(false); + return; + } + + // A single long own message is marked read before it has a `created_at`, which would + // otherwise open the notification right after sending it. + if ( + viewableMessages.length === 1 && + channel.countUnread() === 0 && + topVisibleMessage.user?.id === userId + ) { + setIsUnreadNotificationOpen(false); + return; + } + + // With no read state at all there is no boundary to compare against. + const hasUnreadState = firstUnreadMessageId || lastReadMessageId || unreadCount; + setIsUnreadNotificationOpen( + Boolean(hasUnreadState) && topVisibleMessage.created_at > (lastReadAt ?? 0), + ); + }, + ); + + const onUnreadNotificationClose = useStableCallback(async () => { + await markRead(); + setIsUnreadNotificationOpen(false); + }); + + return { isUnreadNotificationOpen, onUnreadNotificationClose, updateUnreadNotification }; +}; diff --git a/package/src/components/MessageList/utils/getLastReceivedMessage.ts b/package/src/components/MessageList/utils/getLastReceivedMessage.ts deleted file mode 100644 index d53e15c7d8..0000000000 --- a/package/src/components/MessageList/utils/getLastReceivedMessage.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { LocalMessage } from 'stream-chat'; - -import { MessageStatusTypes } from '../../../utils/utils'; - -export const getLastReceivedMessage = (messages: LocalMessage[]) => { - /** - * There are no status on dates so they will be skipped - */ - for (const message of messages) { - if (message?.status !== MessageStatusTypes.FAILED) { - return message; - } - } - - return; -}; diff --git a/package/src/components/Notifications/notificationTranslations.ts b/package/src/components/Notifications/notificationTranslations.ts index b8df1b0416..7f2c8c288a 100644 --- a/package/src/components/Notifications/notificationTranslations.ts +++ b/package/src/components/Notifications/notificationTranslations.ts @@ -157,10 +157,11 @@ const coreNotificationTranslators: Record t('notifications.messageJumpToLatestFailed.error', 'Failed to jump to the latest message'), + // The composer's send and update failures, which the SDK used to report itself with this copy. [CORE_NOTIFICATION_TYPE.messageSendFailed]: ({ t }) => - t('notifications.messageSendFailed.error', 'Failed to send the message'), + t('common.sendMessageFailed.error', 'Send message request failed'), [CORE_NOTIFICATION_TYPE.messageUpdateFailed]: ({ t }) => - t('notifications.messageUpdateFailed.error', 'Failed to update the message'), + t('common.editMessageFailed.error', 'Edit message request failed'), [CORE_NOTIFICATION_TYPE.pollCastVoteLimit]: ({ t }) => t( 'notifications.voteLimitReached.error', diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index fc09a5d5fe..cdbe781fb7 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -16,6 +16,7 @@ import { isFileAttachment, isImageAttachment, isVideoAttachment, + LocalMessage, MessageComposerState, } from 'stream-chat'; @@ -29,7 +30,6 @@ import { useMessageContext, } from '../../contexts/messageContext/MessageContext'; import { useMessageComposer } from '../../contexts/messageInputContext/hooks/useMessageComposer'; -import { MessagesContextValue } from '../../contexts/messagesContext/MessagesContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; import { useStateStore } from '../../hooks'; @@ -97,8 +97,8 @@ const RightContent = React.memo( export type ReplyPropsWithContext = { ImageComponent: React.ComponentType } & Pick< MessageContextValue, 'message' -> & - Pick & { +> & { + quotedMessage?: LocalMessage | null; isMyMessage: boolean; isParentMessageMine?: boolean; onDismiss?: () => void; @@ -307,7 +307,7 @@ export const Reply = (props: ReplyProps) => { ); const quotedMessage = messageFromContext - ? (messageFromContext.quoted_message as MessagesContextValue['quotedMessage']) + ? (messageFromContext.quoted_message as ReplyPropsWithContext['quotedMessage']) : quotedMessageFromComposer; // `mode='edit'` supplies the edited message via `props.quotedMessage` and leaves diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index 57861a1d20..2c77ae5b90 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -2,7 +2,10 @@ import React, { useCallback, useEffect, useMemo } from 'react'; import type { LocalMessage } from 'stream-chat'; -import { ThreadFooterComponent } from './components/ThreadFooterComponent'; +import { + ThreadFooterComponent, + ThreadFooterComponentProps, +} from './components/ThreadFooterComponent'; import { useChannelContext } from '../../contexts/channelContext/ChannelContext'; import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; @@ -26,7 +29,8 @@ try { } type ThreadPropsWithContext = Pick & - Pick & { + Pick & + Pick & { /** * Additional props for underlying MessageComposer component. * Available props - https://getstream.io/chat/docs/sdk/reactnative/ui-components/message-input/#props diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap index 0ac947ce44..9dec8da839 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap @@ -249,7 +249,6 @@ exports[`Thread should match thread snapshot 1`] = ` }, ] } - extraData={false} getItem={[Function]} getItemCount={[Function]} invertStickyHeaders={true} diff --git a/package/src/components/Thread/components/ThreadFooterComponent.tsx b/package/src/components/Thread/components/ThreadFooterComponent.tsx index 48c897d164..f7500d32e7 100644 --- a/package/src/components/Thread/components/ThreadFooterComponent.tsx +++ b/package/src/components/Thread/components/ThreadFooterComponent.tsx @@ -13,10 +13,12 @@ import { useTranslationContext } from '../../../contexts/translationContext/Tran import { useStateStore } from '../../../hooks'; import { primitives } from '../../../theme'; -type ThreadFooterComponentPropsWithContext = Pick< - ThreadContextValue, - 'parentMessagePreventPress' | 'threadInstance' ->; +type ThreadFooterComponentPropsWithContext = Pick & { + /** + * Boolean to enable/disable parent message press + */ + parentMessagePreventPress?: boolean; +}; const loadingSelector = (state: { isLoading: boolean }) => ({ isLoading: state.isLoading }); @@ -113,21 +115,13 @@ const MemoizedThreadFooter = React.memo( ) as typeof ThreadFooterComponentWithContext; export type ThreadFooterComponentProps = Partial< - Pick + Pick >; export const ThreadFooterComponent = (props: ThreadFooterComponentProps) => { - const { parentMessagePreventPress, threadInstance } = useThreadContext(); + const { threadInstance } = useThreadContext(); - return ( - - ); + return ; }; const useStyles = () => { diff --git a/package/src/components/ThreadList/ThreadListItem.tsx b/package/src/components/ThreadList/ThreadListItem.tsx index 7fc51724c5..179db9395d 100644 --- a/package/src/components/ThreadList/ThreadListItem.tsx +++ b/package/src/components/ThreadList/ThreadListItem.tsx @@ -6,6 +6,7 @@ import { convertTimestampToDate, DraftMessage, LocalMessage, + MessagePaginatorAggregateState, TextComposerState, Thread, ThreadState, @@ -47,11 +48,9 @@ const attachmentManagerStateSelector = (state: AttachmentManagerState) => ({ attachments: state.attachments, }); -type ThreadReplyPaginatorState = { items?: LocalMessage[] }; - -// The messagePaginator is the sole reply source (Thread.state.replies was removed). -const lastReplySelector = (state: ThreadReplyPaginatorState) => ({ - lastReply: state.items?.at(-1), +// The newest reply, also when the loaded page of replies is not the newest one. +const lastReplySelector = (state: MessagePaginatorAggregateState) => ({ + lastReply: state.lastMessage ?? undefined, }); export const ThreadListItemComponent = () => { @@ -173,7 +172,7 @@ export const ThreadListItem = (props: ThreadListItemProps) => { selector, ); - const { lastReply } = useStateStore(thread.messagePaginator.state, lastReplySelector) ?? {}; + const { lastReply } = useStateStore(thread.messagePaginator.aggregateState, lastReplySelector); const timestamp = lastReply?.created_at; diff --git a/package/src/components/ThreadList/__tests__/ThreadList.test.tsx b/package/src/components/ThreadList/__tests__/ThreadList.test.tsx index 96b03f4897..2c5adaebe6 100644 --- a/package/src/components/ThreadList/__tests__/ThreadList.test.tsx +++ b/package/src/components/ThreadList/__tests__/ThreadList.test.tsx @@ -111,6 +111,36 @@ describe('ThreadList loading states', () => { await waitFor(() => expect(rows()).toHaveLength(2)); expect(footer()).toBeUndefined(); }); + it('shows the newest reply when the loaded replies are an older page', async () => { + const thread = makeThread(); + const reply = (text: string, timestamp: Date) => + channel.state.formatMessage( + generateMessage({ + cid: channel.cid, + timestamp, + parent_id: thread.id, + text, + }), + ); + const olderReply = reply('older reply', new Date('2024-03-05T10:00:00.000Z')); + thread.messagePaginator.ingestItem(olderReply); + thread.messagePaginator.ingestItem(reply('newest reply', new Date('2025-07-09T10:00:00.000Z'))); + // The thread was scrolled back to an older page of replies. + thread.messagePaginator.state.partialNext({ items: [olderReply] }); + jest + .spyOn(chatClient, 'queryThreadsAndHydrate') + .mockResolvedValueOnce({ next: undefined, threads: [thread] }); + + render( + + + , + ); + + await waitFor(() => expect(screen.getByText('07/09/2025')).toBeTruthy()); + expect(screen.queryByText('03/05/2024')).toBeNull(); + }); + it('pluralizes the unseen-threads banner', async () => { jest .spyOn(chatClient, 'queryThreadsAndHydrate') diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 116c37eef7..6fca050ae0 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -31,9 +31,6 @@ export * from './AutoCompleteInput/mentionItems'; export * from './Channel/Channel'; export * from './Channel/hooks/useCreateChannelContext'; -// Only the constant, not the module: `useMessageListPagination` is internal, but integrators doing -// their own `jumpToMessage` need the TTL the SDK's own highlight uses, or they hardcode 3000 and drift. -export { DEFAULT_HIGHLIGHT_DURATION } from './Channel/hooks/useMessageListPagination'; export * from './Channel/hooks/useCreateInputMessageInputContext'; export * from './Channel/hooks/useCreateMessagesContext'; export * from './Channel/hooks/useCreateThreadContext'; @@ -153,6 +150,8 @@ export * from './MessageInput/hooks/useCooldownRemaining'; export * from './MessageList/DateHeader'; export * from './MessageList/hooks/useMarkRead'; +// Only the constant: integrators running their own `jumpToMessage` need the TTL the SDK's highlight uses. +export { DEFAULT_HIGHLIGHT_DURATION } from './MessageList/hooks/useMessageListFocus'; export * from './MessageList/hooks/useMessageList'; export * from './MessageList/hooks/useTypingString'; export * from './MessageList/InlineDateSeparator'; @@ -167,7 +166,6 @@ export * from './MessageList/ScrollToBottomButton'; export * from './MessageList/TypingIndicator'; export * from './MessageList/TypingIndicatorContainer'; export * from './MessageList/utils/getGroupStyles'; -export * from './MessageList/utils/getLastReceivedMessage'; export * from './MessageList/hooks/useMessageDateSeparator'; export * from './MessageList/hooks/useMessageGroupStyles'; diff --git a/package/src/contexts/channelContext/ChannelContext.tsx b/package/src/contexts/channelContext/ChannelContext.tsx index 4863ab34e0..cd68de5c86 100644 --- a/package/src/contexts/channelContext/ChannelContext.tsx +++ b/package/src/contexts/channelContext/ChannelContext.tsx @@ -48,19 +48,6 @@ export type ChannelContextValue = { */ hideDateSeparators: boolean; hideStickyDateHeader: boolean; - /** - * Loads channel around a specific message. Emits `messageFocusSignal` on the paginator, which - * drives the highlight + scroll-to-target. - * @param limit - The number of messages to load around the message - * @param messageId - The message around which to load messages - */ - - /** - * Loads channel at first unread message. Emits `messageFocusSignal` on the paginator. - * @param limit - The number of messages to load around the first unread message - */ - - scrollToFirstUnreadThreshold: number; /** * Returns true when Channel is about to load an initial targeted message. * @@ -69,10 +56,6 @@ export type ChannelContextValue = { hasPendingInitialTargetLoad?: () => boolean; disabled?: boolean; enableMessageGroupingByUser?: boolean; - /** - * Id of message, which is highlighted in the channel. - */ - isChannelActive?: boolean; /** * Maximum time in milliseconds that should occur between messages * to still consider them grouped together diff --git a/package/src/contexts/channelContext/__tests__/publicSurface.test.ts b/package/src/contexts/channelContext/__tests__/publicSurface.test.ts index b53356fbe2..74c37fedf0 100644 --- a/package/src/contexts/channelContext/__tests__/publicSurface.test.ts +++ b/package/src/contexts/channelContext/__tests__/publicSurface.test.ts @@ -3,7 +3,7 @@ import * as sdk from '../../../index'; import type { ChannelContextValue } from '../../../index'; // Type-only half: these fail to COMPILE if the hooks stop being exported, which a runtime // check cannot catch. -import type { useActiveMessagePaginator, useIsTargetedMessage } from '../../../index'; +import type { useMessagePaginator, useIsTargetedMessage } from '../../../index'; /** * `ChannelContextValue`'s shape, pinned. @@ -24,13 +24,13 @@ import type { useActiveMessagePaginator, useIsTargetedMessage } from '../../../i describe('ChannelContext public surface', () => { it('exports the replacements the migration guide points at', () => { // Runtime half — the guide's §8 table is unusable without these. - expect(typeof sdk.useActiveMessagePaginator).toBe('function'); + expect(typeof sdk.useMessagePaginator).toBe('function'); expect(typeof sdk.useIsTargetedMessage).toBe('function'); expect(typeof sdk.useChannelContext).toBe('function'); expect(sdk.DEFAULT_HIGHLIGHT_DURATION).toBe(3000); // Type half — asserted by construction (this file IS typechecked, via tsconfig.test.json). - type Paginator = typeof useActiveMessagePaginator; + type Paginator = typeof useMessagePaginator; type Targeted = typeof useIsTargetedMessage; const types: [Paginator, Targeted] | undefined = undefined; expect(types).toBeUndefined(); @@ -65,14 +65,12 @@ describe('ChannelContext public surface', () => { 'hasPendingInitialTargetLoad', 'hideDateSeparators', 'hideStickyDateHeader', - 'isChannelActive', 'maxTimeBetweenGroupedMessages', - 'scrollToFirstUnreadThreshold', 'threadList', ]; // An assertion by construction: a removed member fails to compile in the array above. - // 11 since the V10 merge: message pruning removed `maximumMessageLimit` in favour of - // `maxLoadedItems`, derived inside `useMessageList`. - expect(expected).toHaveLength(11); + // 9 since the state cleanup removed the never-read `isChannelActive` and + // `scrollToFirstUnreadThreshold`. + expect(expected).toHaveLength(9); }); }); diff --git a/package/src/contexts/channelsContext/ChannelsContext.tsx b/package/src/contexts/channelsContext/ChannelsContext.tsx index 851da09a99..ea729dd77d 100644 --- a/package/src/contexts/channelsContext/ChannelsContext.tsx +++ b/package/src/contexts/channelsContext/ChannelsContext.tsx @@ -3,7 +3,7 @@ import React, { PropsWithChildren, useContext } from 'react'; import type { FlatListProps } from 'react-native'; import type { FlatList } from 'react-native-gesture-handler'; -import type { Channel } from 'stream-chat'; +import type { Channel, ChannelPaginator } from 'stream-chat'; import type { GetChannelActionItems } from '../../hooks/actions/useChannelActionItems'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; @@ -29,31 +29,6 @@ export type ChannelsContextValue = { * **Note:** Don't use `additionalFlatListProps` to access the FlatList ref, use `setFlatListRef` */ additionalFlatListProps: Partial>; - /** - * A control prop used to determine whether the first query of the channel list has succeeded. - */ - channelListInitialized: boolean; - /** - * Channels can be either an array of channels or a promise which resolves to an array of channels - */ - channels: Channel[] | null; - /** - * Incremental number change to force update the FlatList - */ - forceUpdate: number; - /** - * 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; /** * The React Native FlatList threshold to fetch more data * @see See loadMoreThreshold [doc](https://facebook.github.io/react-native/docs/flatlist#onendreachedthreshold) @@ -72,9 +47,10 @@ export type ChannelsContextValue = { */ numberOfSkeletons: number; /** - * Triggered when the channel list is refreshing, displays a loading spinner at the top of the list + * The list's `ChannelPaginator`. Read the loaded channels and the query state from + * `paginator.state`, e.g. `useStateStore(paginator.state, selector)`. */ - refreshing: boolean; + paginator: ChannelPaginator; /** * Function to refresh the channel list that is similar to `reloadList`, but it doesn't wipe out existing channels * from UI before loading the new ones @@ -84,12 +60,6 @@ export type ChannelsContextValue = { * Removes all the existing channels from UI and loads fresh channels * */ reloadList: () => Promise; - // /** - // * Function to set the currently active channel, acts as a bridge between ChannelList and Channel components - // * - // * @param channel A channel object - // */ - // setActiveChannel?: (channel: Channel) => void; /** * Function to gain access to the inner FlatList ref * @@ -103,10 +73,6 @@ export type ChannelsContextValue = { * ``` */ setFlatListRef: (ref: FlatList | null) => void; - /** - * Error in channels query, if any - */ - error?: Error; /** * Function to set the currently active channel, acts as a bridge between ChannelList and Channel components * diff --git a/package/src/contexts/chatContext/ChatContext.tsx b/package/src/contexts/chatContext/ChatContext.tsx index 5c2aaa78b7..dde7834a47 100644 --- a/package/src/contexts/chatContext/ChatContext.tsx +++ b/package/src/contexts/chatContext/ChatContext.tsx @@ -1,6 +1,6 @@ import React, { PropsWithChildren, useContext } from 'react'; -import type { Channel, GetApplicationResponse, StreamChat, UserMuteResponse } from 'stream-chat'; +import type { GetApplicationResponse, StreamChat } from 'stream-chat'; import { MessageContextValue } from '../messageContext/MessageContext'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; @@ -9,9 +9,11 @@ import { isTestEnvironment } from '../utils/isTestEnvironment'; export type ChatContextValue = { /** - * Object of application settings returned from Stream. - * */ - appSettings: GetApplicationResponse | null; + * Resolves the application settings returned from Stream. The first call fetches them and later + * calls reuse the result. With offline support enabled, it falls back to the copy stored in the + * offline database while the request cannot be made. + */ + getAppSettings: () => Promise; /** * The StreamChat client object * @@ -29,32 +31,6 @@ export type ChatContextValue = { * @overrideType StreamChat * */ client: StreamChat; - enableOfflineSupport: boolean; - mutedUsers: UserMuteResponse[]; - /** - * @param newChannel Channel to set as active. - * - * @overrideType Function - */ - setActiveChannel: (newChannel?: Channel) => void; - /** - * Instance of channel object from stream-chat package. - * - * Please check the docs around how to create or query channel - https://getstream.io/chat/docs/javascript/creating_channels/?language=javascript - * - * ``` - * import { StreamChat, Channel } from 'stream-chat'; - * import { Chat, Channel} from 'stream-chat-react-native'; - * - * const client = StreamChat.getInstance('api_key'); - * await client.connectUser('user_id', 'user_token'); - * const channel = client.channel('messaging', 'channel_id'); - * await channel.watch(); - * ``` - * - * @overrideType Channel - */ - channel?: Channel; } & Partial>; export const ChatContext = React.createContext(DEFAULT_BASE_CONTEXT_VALUE as ChatContextValue); diff --git a/package/src/contexts/debugContext/DebugContext.tsx b/package/src/contexts/debugContext/DebugContext.tsx deleted file mode 100644 index 069c9af1af..0000000000 --- a/package/src/contexts/debugContext/DebugContext.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React, { PropsWithChildren, useContext, useRef } from 'react'; - -import type { Channel, ChannelState, LocalMessage, StreamChat } from 'stream-chat'; - -import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; - -export type DebugDataType = - | StreamChat['user'] - | { - data: Channel['data']; - members: ChannelState['members']; - }[] - | LocalMessage[]; - -export type DebugContextValue = { - eventType?: string; - sendEventParams?: { - action: string; - data: DebugDataType; - }; - setEventType?: (data: string) => void; - setSendEventParams?: (data: { action: string; data: DebugDataType }) => void; -}; - -export const DebugContext = React.createContext( - DEFAULT_BASE_CONTEXT_VALUE as React.MutableRefObject, -); - -export const DebugContextProvider = ({ - children, - useFlipper, -}: PropsWithChildren<{ - useFlipper: () => { - updateData: (ref: React.RefObject) => void; - }; -}>) => { - const debugRef = useRef({ - eventType: undefined, - sendEventParams: undefined, - }); - - const { updateData } = useFlipper(); - - const ref = useRef({ - setEventType: (data: string) => { - debugRef.current.eventType = data; - updateData(debugRef); - }, - setSendEventParams: (data: { action: string; data: DebugDataType }) => { - debugRef.current.sendEventParams = data; - updateData(debugRef); - }, - }); - - return ( - }> - {children} - - ); -}; - -export const useDebugContext = () => { - const contextValue = useContext( - DebugContext, - ) as unknown as React.MutableRefObject; - - return contextValue; -}; diff --git a/package/src/contexts/index.ts b/package/src/contexts/index.ts index 9e2b31f371..02ac8631ed 100644 --- a/package/src/contexts/index.ts +++ b/package/src/contexts/index.ts @@ -6,7 +6,6 @@ export * from './channelContext/ChannelContext'; export * from './channelsContext/ChannelsContext'; export * from './chatConfigContext/ChatConfigContext'; export * from './chatContext/ChatContext'; -export * from './debugContext/DebugContext'; export * from './imageGalleryContext/ImageGalleryContext'; export * from './keyboardContext/KeyboardContext'; export * from './messageContext/MessageContext'; diff --git a/package/src/contexts/messageComposerContext/MessageComposerContext.tsx b/package/src/contexts/messageComposerContext/MessageComposerContext.tsx index f8d6adf7ff..380b9901b9 100644 --- a/package/src/contexts/messageComposerContext/MessageComposerContext.tsx +++ b/package/src/contexts/messageComposerContext/MessageComposerContext.tsx @@ -1,6 +1,6 @@ -import React, { useContext, useMemo, useState } from 'react'; +import React, { useContext, useEffect, useMemo, useState } from 'react'; -import { LocalMessage } from 'stream-chat'; +import { LocalMessage, MessageComposer, MessageComposerState } from 'stream-chat'; import { MessageComposerAPIContextValue, @@ -9,7 +9,8 @@ import { import { ChannelProps } from '../../components'; import { useStableCallback } from '../../hooks/useStableCallback'; -import { useCreateMessageComposer } from '../messageInputContext/hooks/useCreateMessageComposer'; +import { useStateStore } from '../../hooks/useStateStore'; +import { useChatContext } from '../chatContext/ChatContext'; import { ThreadContextValue } from '../threadContext/ThreadContext'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; @@ -18,8 +19,12 @@ export type MessageComposerContextValue = { channel: ChannelProps['channel']; threadInstance: ThreadContextValue['threadInstance']; /** - * Variable that tracks the editing state. - * It is defined with message type if the editing state is true, else its undefined. + * The composer in use: the edit composer while a message is being edited, otherwise the thread's + * or the channel's own. + */ + messageComposer: MessageComposer; + /** + * The message being edited, if any. */ editing?: LocalMessage; }; @@ -32,27 +37,66 @@ type Props = React.PropsWithChildren<{ value: Pick; }>; +const editedMessageSelector = (state: MessageComposerState) => ({ + editedMessage: state.editedMessage, +}); + export const MessageComposerProvider = ({ children, value }: Props) => { - const [editing, setEditing] = useState(undefined); + const { client } = useChatContext(); + const { channel, threadInstance } = value; + const [editComposer, setEditComposer] = useState(undefined); + + const messageComposer = + editComposer ?? threadInstance?.messageComposer ?? channel.messageComposer; + + // Submitting or clearing the edit composer clears its edited message, which ends the edit. + const { editedMessage } = useStateStore(editComposer?.state, editedMessageSelector) ?? {}; + useEffect(() => { + if (editComposer && !editedMessage) { + setEditComposer(undefined); + } + }, [editComposer, editedMessage]); + + useEffect(() => messageComposer.registerSubscriptions(), [messageComposer]); const setEditingState: MessageComposerAPIContextValue['setEditingState'] = useStableCallback( (message) => { - setEditing(message); + if (!message) { + setEditComposer(undefined); + return; + } + const tag = MessageComposer.constructTag(message); + const cachedComposer = client.messageComposerCache.get(tag); + if (cachedComposer) { + // Starts from the message itself, not from whatever an earlier, abandoned edit left behind. + cachedComposer.initState({ composition: message }); + setEditComposer(cachedComposer); + return; + } + const composer = new MessageComposer({ + client, + composition: message, + compositionContext: message, + }); + // The cache also keeps the composer's channel in the client's channel store. + client.messageComposerCache.add(tag, composer); + setEditComposer(composer); }, ); const clearEditingState: MessageComposerAPIContextValue['clearEditingState'] = useStableCallback( - () => setEditing(undefined), + () => setEditComposer(undefined), ); - const messageComposerContextValue = useMemo(() => ({ editing, ...value }), [editing, value]); - - const messageComposer = useCreateMessageComposer(messageComposerContextValue); - const setQuotedMessage = useStableCallback((message: LocalMessage | null) => messageComposer.setQuotedMessage(message), ); + const messageComposerContextValue = useMemo( + () => ({ channel, editing: editedMessage ?? undefined, messageComposer, threadInstance }), + [channel, editedMessage, messageComposer, threadInstance], + ); + const messageComposerAPIContextValue = useMemo( () => ({ clearEditingState, setEditingState, setQuotedMessage }), [clearEditingState, setEditingState, setQuotedMessage], diff --git a/package/src/contexts/messageComposerContext/__tests__/MessageComposerContext.test.tsx b/package/src/contexts/messageComposerContext/__tests__/MessageComposerContext.test.tsx new file mode 100644 index 0000000000..f1fa553241 --- /dev/null +++ b/package/src/contexts/messageComposerContext/__tests__/MessageComposerContext.test.tsx @@ -0,0 +1,77 @@ +import React, { PropsWithChildren } from 'react'; + +import { act, cleanup, render, renderHook } from '@testing-library/react-native'; +import type { Channel, LocalMessage, StreamChat } from 'stream-chat'; + +import { Chat } from '../../../components'; +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { useMessageComposer } from '../../messageInputContext/hooks/useMessageComposer'; +import { useMessageComposerAPIContext } from '../MessageComposerAPIContext'; +import { MessageComposerProvider } from '../MessageComposerContext'; + +describe('MessageComposerProvider', () => { + let channel: Channel; + let chatClient: StreamChat; + + beforeEach(async () => { + const { client, channels } = await initiateClientWithChannels(); + channel = channels[0]; + chatClient = client; + }); + + afterEach(() => { + jest.restoreAllMocks(); + cleanup(); + }); + + const wrapper = ({ children }: PropsWithChildren) => ( + + + {children} + + + ); + + it('starts a repeated edit from the message, not from where an abandoned edit left off', async () => { + const message = generateMessage({ cid: channel.cid, text: 'original' }) as LocalMessage; + const { result } = renderHook( + () => ({ api: useMessageComposerAPIContext(), composer: useMessageComposer() }), + { wrapper }, + ); + + act(() => result.current.api.setEditingState(message)); + await act(async () => { + await result.current.composer.textComposer.handleChange({ + selection: { end: 14, start: 14 }, + text: 'abandoned edit', + }); + }); + act(() => result.current.api.clearEditingState()); + expect(result.current.composer).toBe(channel.messageComposer); + + act(() => result.current.api.setEditingState(message)); + + expect(result.current.composer.textComposer.text).toBe('original'); + }); + + it('subscribes the composer once, however many components read it', () => { + const registerSubscriptions = jest.spyOn(channel.messageComposer, 'registerSubscriptions'); + const Reader = () => { + useMessageComposer(); + return null; + }; + + render( + + + {Array.from({ length: 5 }, (_, i) => ( + + ))} + + , + ); + + expect(registerSubscriptions).toHaveBeenCalledTimes(1); + }); +}); diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 5e53af729b..37f70198f8 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -10,13 +10,6 @@ import React, { import { Alert, Linking, TextInputProps } from 'react-native'; import { lookup as lookupMimeType } from 'mime-types'; -import { - LocalMessage, - MessageComposer, - MessageRequest as StreamMessage, - SendMessageOptions, - UpdateMessageOptions, -} from 'stream-chat'; import { useCreateMessageInputContext } from './hooks/useCreateMessageInputContext'; import { useMessageComposer } from './hooks/useMessageComposer'; @@ -24,7 +17,6 @@ import { useMessageComposer } from './hooks/useMessageComposer'; import { dismissKeyboard } from '../../components/KeyboardCompatibleView/KeyboardCompatibleView'; import { parseLinksFromText } from '../../components/Message/MessageItemView/utils/parseLinks'; import { useAudioRecorder } from '../../components/MessageInput/hooks/useAudioRecorder'; -import { useNotificationApi } from '../../components/Notifications'; import { useStableCallback } from '../../hooks/useStableCallback'; import { setupVideoAttachmentPreviewMiddleware } from '../../middlewares/attachments'; @@ -35,7 +27,6 @@ import { TextInputRef } from '../../types/react-native-compat'; import { File } from '../../types/types'; import { compressedImageURI } from '../../utils/compressImage'; import { useAttachmentPickerContext } from '../attachmentPickerContext/AttachmentPickerContext'; -import { useMessageComposerAPIContext } from '../messageComposerContext/MessageComposerAPIContext'; import { useOwnCapabilitiesContext } from '../ownCapabilitiesContext/OwnCapabilitiesContext'; import { useThreadContext } from '../threadContext/ThreadContext'; import { useTranslationContext } from '../translationContext/TranslationContext'; @@ -116,11 +107,6 @@ export type InputMessageInputContextValue = { */ attachmentSelectionBarHeight: number; - editMessage: (params: { - localMessage: LocalMessage; - options?: UpdateMessageOptions; - }) => Promise; - /** * Controls what happens when the attach button is pressed while the attachment picker is open. * When true (default), the message input is focused, toggling from the picker to the keyboard. @@ -140,11 +126,6 @@ export type InputMessageInputContextValue = { /** When false, ImageSelectorIcon will be hidden */ hasImagePicker: boolean; - sendMessage: (params: { - localMessage: LocalMessage; - message: StreamMessage; - options?: SendMessageOptions; - }) => Promise; /** * Additional props for underlying TextInput component. These props will be forwarded as it is to TextInput component. * @@ -190,8 +171,7 @@ export type InputMessageInputContextValue = { messageInputHeightStore: MessageInputHeightStore; }; -export type MessageInputContextValue = LocalMessageInputContext & - Omit; +export type MessageInputContextValue = LocalMessageInputContext & InputMessageInputContextValue; export const MessageInputContext = React.createContext( DEFAULT_BASE_CONTEXT_VALUE as MessageInputContextValue, @@ -213,10 +193,8 @@ export const MessageInputProvider = ({ const channelCapabilities = useOwnCapabilitiesContext(); const [audioRecorderManager] = useState(new AudioRecorderManager()); - const { clearEditingState } = useMessageComposerAPIContext(); const { threadInstance } = useThreadContext(); const { t } = useTranslationContext(); - const { addNotification } = useNotificationApi(); const inputBoxRef = useRef(null); const [showPollCreationDialog, setShowPollCreationDialog] = useState(false); @@ -227,7 +205,7 @@ export const MessageInputProvider = ({ const { openPollCreationDialog: openPollCreationDialogFromContext } = value; const messageComposer = useMessageComposer(); - const { attachmentManager, editedMessage } = messageComposer; + const { attachmentManager } = messageComposer; /** * Composer middleware this SDK owns. @@ -369,107 +347,32 @@ export const MessageInputProvider = ({ }, [closePicker, attachmentPickerStore]); const sendMessage = useStableCallback(async () => { - const textToRestore = messageComposer.textComposer.text; - let compositionAccepted = false; + const text = messageComposer.textComposer.text; + + if (!channelCapabilities.sendLinks && parseLinksFromText(text).length > 0) { + Alert.alert( + t('common.linksDisabled.title', 'Links are disabled'), + t('common.linksDisabled.text', 'Sending links is not allowed in this conversation'), + ); + return; + } inputBoxRef.current?.clearState(); try { - const composition = await messageComposer.compose(); - - if (!composition || !composition.message) { - inputBoxRef.current?.restoreState(textToRestore); - return; - } - - const { localMessage, message, sendOptions } = composition; - const linkInfos = parseLinksFromText(localMessage.text); - - if (!channelCapabilities.sendLinks && linkInfos.length > 0) { - Alert.alert( - t('common.linksDisabled.title', 'Links are disabled'), - t('common.linksDisabled.text', 'Sending links is not allowed in this conversation'), - ); - - inputBoxRef.current?.restoreState(textToRestore); - return; - } - - compositionAccepted = true; - - // MODERATION: This is for the case where the message is of type 'error' and if you try to edit it, it will throw an error. - if (editedMessage && editedMessage.type !== 'error') { - try { - clearEditingState(); - await value.editMessage({ localMessage, options: sendOptions }); - } catch (error) { - addNotification( - { - message: t('common.editMessageFailed.error', 'Edit message request failed'), - options: { - ...(error instanceof Error ? { originalError: error } : {}), - severity: 'error', - }, - origin: { emitter: 'MessageComposer' }, - }, - { - incident: { - domain: 'api', - entity: 'message', - operation: 'edit', - }, - }, - ); - throw new Error('Error while editing message'); - } - } else { - try { - // Since the message id does not get cleared, we have to handle this manually - // and let the poll creation dialog handle clearing the rest of the state. Once - // sending a message has been moved to the composer as an API, this will be - // redundant and can be removed. - if (localMessage.poll_id) { - messageComposer.state.partialNext({ - id: MessageComposer.generateId(), - pollId: null, - }); - } else { - messageComposer.clear(); - } - // Even though we edit, but we eventually send the message as a regular message, so we need to clear the editing state. - if (editedMessage) { - clearEditingState(); - } - await value.sendMessage({ - localMessage, - message: message as StreamMessage, - options: sendOptions, - }); - } catch (error) { - addNotification( - { - message: t('common.sendMessageFailed.error', 'Send message request failed'), - options: { - ...(error instanceof Error ? { originalError: error } : {}), - severity: 'error', - }, - origin: { emitter: 'MessageComposer' }, - }, - { - incident: { - domain: 'api', - entity: 'message', - operation: 'send', - }, - }, - ); - throw new Error('Error while sending message'); - } + // A bounced message (type 'error') is never updated: editing it sends it again as a new message. + const { editedMessage } = messageComposer; + const result = + editedMessage && editedMessage.type !== 'error' + ? await messageComposer.update() + : await messageComposer.send(); + + if (result === 'nothing-to-send') { + inputBoxRef.current?.restoreState(text); } } catch (error) { - if (!compositionAccepted) { - inputBoxRef.current?.restoreState(textToRestore); - } + // Only composing can throw; a failed request is reported by the composer itself. + inputBoxRef.current?.restoreState(text); console.error('Error while sending message:', error); } }); @@ -547,7 +450,7 @@ export const MessageInputProvider = ({ ...value, closePollCreationDialog, openPollCreationDialog, - sendMessage, // overriding the originally passed in sendMessage + sendMessage, showPollCreationDialog, audioRecorderManager, startVoiceRecording, diff --git a/package/src/contexts/messageInputContext/__tests__/sendMessage.test.tsx b/package/src/contexts/messageInputContext/__tests__/sendMessage.test.tsx index 165f9deb88..bdc6f957b1 100644 --- a/package/src/contexts/messageInputContext/__tests__/sendMessage.test.tsx +++ b/package/src/contexts/messageInputContext/__tests__/sendMessage.test.tsx @@ -1,22 +1,21 @@ import React, { PropsWithChildren } from 'react'; +import { Alert } from 'react-native'; import { act, cleanup, renderHook, waitFor } from '@testing-library/react-native'; -import type { Attachment, Channel, StreamChat } from 'stream-chat'; +import type { Channel, LocalMessage, StreamChat } from 'stream-chat'; import { Chat } from '../../../components'; +import { getNotificationDisplayMessage } from '../../../components/Notifications/notificationTranslations'; +import type { StreamTFunction } from '../../../i18n/types'; import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; - -import { generateLocalFileUploadAttachmentData } from '../../../mock-builders/attachments'; import { generateMessage } from '../../../mock-builders/generator/message'; -import * as UseMessageComposerAPIContext from '../../messageComposerContext/MessageComposerAPIContext'; - -import { MessageComposerAPIContextValue } from '../../messageComposerContext/MessageComposerAPIContext'; -import type { MessageComposerContextValue } from '../../messageComposerContext/MessageComposerContext'; +import { useMessageComposerAPIContext } from '../../messageComposerContext/MessageComposerAPIContext'; import { MessageComposerProvider } from '../../messageComposerContext/MessageComposerContext'; import { OwnCapabilitiesContextValue, OwnCapabilitiesProvider, } from '../../ownCapabilitiesContext/OwnCapabilitiesContext'; +import { useMessageComposer } from '../hooks/useMessageComposer'; import { InputMessageInputContextValue, MessageInputProvider, @@ -24,33 +23,13 @@ import { } from '../MessageInputContext'; import type { InputBoxRef } from '../MessageInputContext'; -const Wrapper = ({ - messageComposerContextValue, - client, - props, -}: { - client: StreamChat; - messageComposerContextValue: Partial; - props: PropsWithChildren>; -}) => { - return ( - - - - - {props.children} - - - - - ); -}; +const t = ((key: string, defaultValue?: string) => defaultValue ?? key) as StreamTFunction; + +const useComposerHarness = () => ({ + api: useMessageComposerAPIContext(), + composer: useMessageComposer(), + input: useMessageInputContext(), +}); describe("MessageInputContext's sendMessage", () => { let channel: Channel; @@ -63,266 +42,146 @@ describe("MessageInputContext's sendMessage", () => { }); afterEach(() => { - jest.clearAllMocks(); + jest.restoreAllMocks(); cleanup(); channel.messageComposer.clear(); }); - it('should get into the catch block if the message composer compose throws an error', async () => { - const sendMessageMock = jest.fn(); - const initialProps = { - sendMessage: sendMessageMock, - }; - const consoleErrorMock = jest.spyOn(console, 'error'); - - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - - const composerComposeMock = jest.spyOn(channel.messageComposer, 'compose'); - composerComposeMock.mockRejectedValue(new Error('Error composing message')); - - await waitFor(() => { - result.current.sendMessage(); - }); - - await waitFor(() => { - expect(sendMessageMock).not.toHaveBeenCalled(); - expect(consoleErrorMock).toHaveBeenCalled(); - }); - }); - - it('should restore input state if composition is discarded', async () => { - const sendMessageMock = jest.fn(); - const clearState = jest.fn(); - const restoreState = jest.fn(); - const initialProps = { - sendMessage: sendMessageMock, - }; - - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - + const renderComposer = (capabilities: Partial = {}) => { + const wrapper = ({ children }: PropsWithChildren) => ( + + + + + {children} + + + + + ); + const rendered = renderHook(useComposerHarness, { wrapper }); + const inputBox = { clearState: jest.fn(), restoreState: jest.fn() }; + act(() => + (rendered.result.current.input.setInputBoxRef as (ref: InputBoxRef | null) => void)( + inputBox as unknown as InputBoxRef, ), - }); - - const text = 'Hello there'; - const inputRef = { - clearState, - restoreState, - } as unknown as InputBoxRef; - (result.current.setInputBoxRef as (ref: InputBoxRef | null) => void)(inputRef); + ); + return { ...rendered, inputBox }; + }; + const typeText = async (text: string, composer = channel.messageComposer) => { await act(async () => { - await channel.messageComposer.textComposer.handleChange({ - selection: { - end: text.length, - start: text.length, - }, + await composer.textComposer.handleChange({ + selection: { end: text.length, start: text.length }, text, }); }); + }; - jest.spyOn(channel.messageComposer, 'compose').mockResolvedValue(undefined); + it('sends the composition through the composer and clears the input', async () => { + const send = jest.spyOn(channel.messageOperations, 'send').mockResolvedValue(undefined); + const { inputBox, result } = renderComposer(); + await typeText('Hello there'); - await act(async () => { - await result.current.sendMessage(); - }); + await act(() => result.current.input.sendMessage()); - expect(clearState).toHaveBeenCalledTimes(1); - expect(restoreState).toHaveBeenCalledWith(text); - expect(sendMessageMock).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0].localMessage.text).toBe('Hello there'); + expect(channel.messageComposer.textComposer.text).toBe(''); + expect(inputBox.clearState).toHaveBeenCalledTimes(1); + expect(inputBox.restoreState).not.toHaveBeenCalled(); }); - it('should get into the catch block if the sendMessage throws an error', async () => { - const sendMessageMock = jest.fn(); - sendMessageMock.mockRejectedValue(new Error('Error sending message')); - - const initialProps = { - sendMessage: sendMessageMock, - }; - const consoleErrorMock = jest.spyOn(console, 'error'); - - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - - await act(async () => { - const text = 'Hello there'; - await channel.messageComposer.textComposer.handleChange({ - selection: { - end: text.length, - start: text.length, - }, - text, - }); - }); + it('restores the text when there is nothing to send', async () => { + const send = jest.spyOn(channel.messageOperations, 'send').mockResolvedValue(undefined); + const { inputBox, result } = renderComposer(); + await typeText('Hello there'); + jest.spyOn(channel.messageComposer, 'compose').mockResolvedValue(undefined); - await waitFor(() => { - result.current.sendMessage(); - }); + await act(() => result.current.input.sendMessage()); - await waitFor(() => { - expect(sendMessageMock).toHaveBeenCalled(); - expect(consoleErrorMock).toHaveBeenCalled(); - }); + expect(send).not.toHaveBeenCalled(); + expect(inputBox.clearState).toHaveBeenCalledTimes(1); + expect(inputBox.restoreState).toHaveBeenCalledWith('Hello there'); }); - it('should not call composer clear if composition has poll id in it', async () => { - const sendMessageMock = jest.fn(); - const clearSpy = jest.spyOn(channel.messageComposer, 'clear'); - const initialProps = { - sendMessage: sendMessageMock, - }; - const { pollComposer } = channel.messageComposer; + it('restores the text when composing throws', async () => { + const send = jest.spyOn(channel.messageOperations, 'send').mockResolvedValue(undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + const { inputBox, result } = renderComposer(); + await typeText('Hello there'); jest - .spyOn(chatClient, 'createPoll') - .mockResolvedValue({ poll: { id: 'test-poll-id' } } as unknown as Awaited< - ReturnType - >); + .spyOn(channel.messageComposer, 'compose') + .mockRejectedValue(new Error('middleware failed')); - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); + await act(() => result.current.input.sendMessage()); - await act(async () => { - await pollComposer.updateFields({ - id: 'test-poll', - name: 'Test Poll', - options: [ - { id: 1, text: '1' }, - { id: 2, text: '2' }, - ], - } as unknown as Parameters[0]); - await channel.messageComposer.createPoll(); - }); - - await waitFor(() => { - result.current.sendMessage(); - }); - - await waitFor(() => { - expect(clearSpy).not.toHaveBeenCalled(); - expect(sendMessageMock).toHaveBeenCalledTimes(1); - }); + expect(send).not.toHaveBeenCalled(); + expect(inputBox.clearState).toHaveBeenCalledTimes(1); + expect(inputBox.restoreState).toHaveBeenCalledWith('Hello there'); }); - it('should send message', async () => { - const sendMessageMock = jest.fn(); - const clearSpy = jest.spyOn(channel.messageComposer, 'clear'); - const initialProps = { - sendMessage: sendMessageMock, - }; + it('does not send a link where links are not allowed', async () => { + const send = jest.spyOn(channel.messageOperations, 'send').mockResolvedValue(undefined); + const alert = jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); + const { inputBox, result } = renderComposer({ sendLinks: false }); + await typeText('see https://getstream.io'); - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); + await act(() => result.current.input.sendMessage()); - await act(async () => { - const text = 'Hello there'; - await channel.messageComposer.textComposer.handleChange({ - selection: { - end: text.length, - start: text.length, - }, - text, - }); - }); - - await waitFor(() => { - result.current.sendMessage(); - }); - - await waitFor(() => { - expect(clearSpy).toHaveBeenCalled(); - expect(sendMessageMock).toHaveBeenCalledTimes(1); - }); + expect(alert).toHaveBeenCalledTimes(1); + expect(send).not.toHaveBeenCalled(); + expect(inputBox.clearState).not.toHaveBeenCalled(); + expect(channel.messageComposer.textComposer.text).toBe('see https://getstream.io'); }); -}); -describe("MessageInputContext's editMessage", () => { - let channel: Channel; - let chatClient: StreamChat; + it('reports a failed send with the send-failure copy', async () => { + jest.spyOn(channel.messageOperations, 'send').mockRejectedValue(new Error('offline')); + const { result } = renderComposer(); + await typeText('Hello there'); - beforeAll(async () => { - const { client, channels } = await initiateClientWithChannels(); - channel = channels[0]; - chatClient = client; - }); + await act(() => result.current.input.sendMessage()); - afterEach(() => { - jest.clearAllMocks(); - cleanup(); + const [notification] = chatClient.notifications.notifications; + expect(getNotificationDisplayMessage({ notification, t })).toBe('Send message request failed'); }); - it('should clear the edited state when the composition is empty', async () => { - const editMessageMock = jest.fn(); - const initialProps = { - editMessage: editMessageMock, - }; + it('saves an edit through the composer, then leaves edit mode', async () => { + const update = jest.spyOn(channel.messageOperations, 'update').mockResolvedValue(undefined); + const message = generateMessage({ cid: channel.cid, text: 'before' }) as LocalMessage; + const { result } = renderComposer(); - const clearEditingStateMock = jest.fn(); + act(() => result.current.api.setEditingState(message)); + const editComposer = result.current.composer; + expect(editComposer).not.toBe(channel.messageComposer); + await typeText('after', editComposer); - jest.spyOn(UseMessageComposerAPIContext, 'useMessageComposerAPIContext').mockReturnValue({ - clearEditingState: clearEditingStateMock, - } as unknown as MessageComposerAPIContextValue); + await act(() => result.current.input.sendMessage()); - const message = generateMessage({ - attachments: [generateLocalFileUploadAttachmentData() as unknown as Attachment], - cid: 'messaging:channel-id', - text: 'test', - }); - - const { result } = renderHook(() => useMessageInputContext(), { - initialProps, - wrapper: (props) => ( - - ), - }); - - await waitFor(() => { - result.current.sendMessage(); - }); + expect(update).toHaveBeenCalledTimes(1); + expect(update.mock.calls[0][0].localMessage.text).toBe('after'); + await waitFor(() => expect(result.current.composer).toBe(channel.messageComposer)); + }); - await waitFor(() => { - expect(clearEditingStateMock).toHaveBeenCalled(); - }); + it('sends a bounced message again as a new message, then leaves edit mode', async () => { + const send = jest.spyOn(channel.messageOperations, 'send').mockResolvedValue(undefined); + const update = jest.spyOn(channel.messageOperations, 'update').mockResolvedValue(undefined); + const message = generateMessage({ + cid: channel.cid, + text: 'bounced', + type: 'error', + }) as LocalMessage; + const { result } = renderComposer(); + + act(() => result.current.api.setEditingState(message)); + await act(() => result.current.input.sendMessage()); + + expect(send).toHaveBeenCalledTimes(1); + expect(update).not.toHaveBeenCalled(); + await waitFor(() => expect(result.current.composer).toBe(channel.messageComposer)); }); }); diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts deleted file mode 100644 index e1e61cc625..0000000000 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageComposer.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useEffect, useMemo } from 'react'; - -import { MessageComposer } from 'stream-chat'; - -import { useChatContext } from '../../chatContext/ChatContext'; -import { MessageComposerContextValue } from '../../messageComposerContext/MessageComposerContext'; - -export const useCreateMessageComposer = ({ - editing: editedMessage, - threadInstance, - channel, -}: Pick) => { - const { client } = useChatContext(); - const { messageComposerCache: queueCache } = client; - - const cachedEditedMessage = useMemo(() => { - if (!editedMessage) return undefined; - - return editedMessage; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editedMessage?.id]); - - // composer hierarchy: edited message (always new) -> thread instance (own) -> channel (own) - // editedMessage ?? threadInstance ?? channel - const messageComposer = useMemo(() => { - if (cachedEditedMessage) { - const tag = MessageComposer.constructTag(cachedEditedMessage); - - const cachedComposer = queueCache.get(tag); - if (cachedComposer) { - cachedComposer.editedMessage = cachedEditedMessage; - return cachedComposer; - } - - return new MessageComposer({ - client, - composition: cachedEditedMessage, - compositionContext: cachedEditedMessage, - }); - } else if (threadInstance) { - return threadInstance.messageComposer; - } else { - return channel.messageComposer; - } - }, [cachedEditedMessage, channel.messageComposer, client, queueCache, threadInstance]); - - if ( - (['message'] as MessageComposer['contextType'][]).includes(messageComposer.contextType) && - !queueCache.peek(messageComposer.tag) - ) { - queueCache.add(messageComposer.tag, messageComposer); - } - - useEffect(() => { - const unsubscribe = messageComposer.registerSubscriptions(); - return () => { - unsubscribe(); - }; - }, [messageComposer]); - - return messageComposer; -}; diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index ae30d44802..828c43c360 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts @@ -16,7 +16,6 @@ export const useCreateMessageInputContext = ({ closePollCreationDialog, compressImageQuality, createPollOptionGap, - editMessage, focusInputOnPickerClose, handleAttachButtonPress, hasCameraPicker, @@ -59,7 +58,6 @@ export const useCreateMessageInputContext = ({ closePollCreationDialog, compressImageQuality, createPollOptionGap, - editMessage, focusInputOnPickerClose, handleAttachButtonPress, hasCameraPicker, @@ -86,7 +84,7 @@ export const useCreateMessageInputContext = ({ stopVoiceRecording, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [threadId, showPollCreationDialog], + [hasCommands, threadId, showPollCreationDialog], ); return messageInputContext; diff --git a/package/src/contexts/messageInputContext/hooks/useMessageComposer.ts b/package/src/contexts/messageInputContext/hooks/useMessageComposer.ts index 68acbc6ba7..92ae4a8c66 100644 --- a/package/src/contexts/messageInputContext/hooks/useMessageComposer.ts +++ b/package/src/contexts/messageInputContext/hooks/useMessageComposer.ts @@ -1,9 +1,7 @@ -import { useCreateMessageComposer } from './useCreateMessageComposer'; - import { useMessageComposerContext } from '../../messageComposerContext/MessageComposerContext'; -export const useMessageComposer = () => { - const messageComposerContext = useMessageComposerContext(); - - return useCreateMessageComposer(messageComposerContext); -}; +/** + * The composer in use: the edit composer while a message is being edited, otherwise the thread's or + * the channel's own. + */ +export const useMessageComposer = () => useMessageComposerContext().messageComposer; diff --git a/package/src/contexts/messageInputContext/hooks/useMessageCompositionIsEmpty.ts b/package/src/contexts/messageInputContext/hooks/useMessageCompositionIsEmpty.ts deleted file mode 100644 index 15f49d65ad..0000000000 --- a/package/src/contexts/messageInputContext/hooks/useMessageCompositionIsEmpty.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { EditingAuditState } from 'stream-chat'; - -import { useMessageComposer } from './useMessageComposer'; - -import { useStateStore } from '../../../hooks/useStateStore'; - -const editingAuditStateStateSelector = (state: EditingAuditState) => state; - -export const useMessageCompositionIsEmpty = () => { - const messageComposer = useMessageComposer(); - useStateStore(messageComposer.editingAuditState, editingAuditStateStateSelector); - return messageComposer.compositionIsEmpty; -}; diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 8ad088ceb5..46435b79bd 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -54,10 +54,6 @@ export type MessagesContextValue = Pick; - /** - * When true, messageList will be scrolled at first unread message, when opened. - */ - initialScrollToFirstUnreadMessage: boolean; /** Order to render the message content */ messageContentOrder: MessageContentType[]; /** @@ -299,7 +295,6 @@ export type MessagesContextValue = Pick void; - quotedMessage?: LocalMessage | null; /** * The position of the reaction list in the message */ diff --git a/package/src/contexts/threadContext/ThreadContext.tsx b/package/src/contexts/threadContext/ThreadContext.tsx index 2741b87032..1fd4dfbf12 100644 --- a/package/src/contexts/threadContext/ThreadContext.tsx +++ b/package/src/contexts/threadContext/ThreadContext.tsx @@ -14,10 +14,6 @@ export type AlsoSentToChannelHeaderPressPayload = { export type ThreadContextValue = { allowThreadMessagesInChannel: boolean; - /** - * Boolean to enable/disable parent message press - */ - parentMessagePreventPress?: boolean; threadInstance?: Thread | null; /** * Function to handle press on the "Also sent to channel" header action. diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts index 79c6bcbbde..9a5187800c 100644 --- a/package/src/hooks/index.ts +++ b/package/src/hooks/index.ts @@ -1,4 +1,3 @@ -export * from './useActiveMessagePaginator'; export * from './useIsTargetedMessage'; export * from '../a11y'; export * from './actions'; @@ -13,7 +12,6 @@ export * from './useCanAddMembersToChannel'; export * from './useChannelMuteActive'; export * from './useIsDirectChat'; export * from './useIsChannelMember'; -export * from './useSelectedChannelState'; export * from './useStreami18n'; export * from './useViewport'; export * from './useScreenDimensions'; diff --git a/package/src/hooks/useActiveMessagePaginator.ts b/package/src/hooks/useActiveMessagePaginator.ts deleted file mode 100644 index 026b348a81..0000000000 --- a/package/src/hooks/useActiveMessagePaginator.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useChannelContext } from '../contexts/channelContext/ChannelContext'; -import { useThreadContext } from '../contexts/threadContext/ThreadContext'; - -/** - * The message paginator this subtree is rendering: the open thread's reply list when `threadList` is - * set, the channel's main list otherwise. - * - * One place, because the SDK previously answered this question four times in three mutually - * inconsistent ways — `threadList ? … : …`, `threadInstance ?? channel`, and `if (thread)`. Those - * only agree while no one passes `thread` without `threadList`, which `ChannelProps` allows. - * `threadList` is the rule: it states which list this subtree renders, which is the actual question. - */ -export const useActiveMessagePaginator = () => { - const { channel, threadList } = useChannelContext(); - const { threadInstance } = useThreadContext(); - - return threadList ? threadInstance?.messagePaginator : channel.messagePaginator; -}; diff --git a/package/src/hooks/useIsTargetedMessage.ts b/package/src/hooks/useIsTargetedMessage.ts index 79ec8cc66e..f50bf5db5a 100644 --- a/package/src/hooks/useIsTargetedMessage.ts +++ b/package/src/hooks/useIsTargetedMessage.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { useActiveMessagePaginator } from './useActiveMessagePaginator'; +import { useMessagePaginator } from './useMessagePaginator'; import { useStateStore } from './useStateStore'; type MessageFocusSignalState = { signal: { messageId?: string } | null }; @@ -16,7 +16,7 @@ type MessageFocusSignalState = { signal: { messageId?: string } | null }; * and the row gaining it. */ export const useIsTargetedMessage = (messageId: string) => { - const paginator = useActiveMessagePaginator(); + const paginator = useMessagePaginator(); const selector = useCallback( (state: MessageFocusSignalState) => ({ diff --git a/package/src/hooks/useMessagePaginator.ts b/package/src/hooks/useMessagePaginator.ts index 8a751491ec..6db8fbd990 100644 --- a/package/src/hooks/useMessagePaginator.ts +++ b/package/src/hooks/useMessagePaginator.ts @@ -1,17 +1,18 @@ -import type { MessagePaginator } from 'stream-chat'; - import { useChannelContext } from '../contexts/channelContext/ChannelContext'; import { useThreadContext } from '../contexts/threadContext/ThreadContext'; /** - * Resolves the active `MessagePaginator` from `stream-chat`: the thread's paginator when - * rendered inside a thread, otherwise the channel's main-list paginator. + * The message paginator this subtree is rendering: the open thread's reply list when `threadList` is + * set, the channel's main list otherwise. * - * Must be called within a `` subtree (and optionally a `` subtree). + * One place, because the SDK previously answered this question four times in three mutually + * inconsistent ways — `threadList ? … : …`, `threadInstance ?? channel`, and `if (thread)`. Those + * only agree while no one passes `thread` without `threadList`, which `ChannelProps` allows. + * `threadList` is the rule: it states which list this subtree renders, which is the actual question. */ -export const useMessagePaginator = (): MessagePaginator => { - const { channel } = useChannelContext(); +export const useMessagePaginator = () => { + const { channel, threadList } = useChannelContext(); const { threadInstance } = useThreadContext(); - return threadInstance?.messagePaginator ?? channel.messagePaginator; + return threadList ? threadInstance?.messagePaginator : channel.messagePaginator; }; diff --git a/package/src/hooks/useSelectedChannelState.ts b/package/src/hooks/useSelectedChannelState.ts deleted file mode 100644 index 35eb21b012..0000000000 --- a/package/src/hooks/useSelectedChannelState.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useCallback } from 'react'; - -import type { Channel, EventType } from 'stream-chat'; -import { useSyncExternalStore } from 'use-sync-external-store/shim'; - -const noop = () => {}; - -export function useSelectedChannelState(_: { - channel: Channel; - selector: (channel: Channel) => O; - stateChangeEventKeys?: EventType[]; -}): O; -export function useSelectedChannelState(_: { - selector: (channel: Channel) => O; - channel?: Channel | undefined; - stateChangeEventKeys?: EventType[]; -}): O | undefined; -export function useSelectedChannelState({ - channel, - selector, - stateChangeEventKeys = ['all'], -}: { - selector: (channel: Channel) => O; - channel?: Channel; - stateChangeEventKeys?: EventType[]; -}): O | undefined { - const subscribe = useCallback( - (onStoreChange: (value: O) => void) => { - if (!channel) { - return noop; - } - - const subscriptions = stateChangeEventKeys.map((et) => - channel.on(et, () => { - onStoreChange(selector(channel)); - }), - ); - - return () => subscriptions.forEach((subscription) => subscription.unsubscribe()); - }, - [channel, selector, stateChangeEventKeys], - ); - - const getSnapshot = useCallback(() => { - if (!channel) { - return undefined; - } - - return selector(channel); - }, [channel, selector]); - - return useSyncExternalStore(subscribe, getSnapshot); -} diff --git a/package/src/i18n/__tests__/catalog.fixture.json b/package/src/i18n/__tests__/catalog.fixture.json index acd5a11a5b..f916f6c4b6 100644 --- a/package/src/i18n/__tests__/catalog.fixture.json +++ b/package/src/i18n/__tests__/catalog.fixture.json @@ -335,8 +335,6 @@ "notifications.locationShareFailed.error": "Failed to share location", "notifications.messageJumpFailed.error": "Failed to jump to the message", "notifications.messageJumpToLatestFailed.error": "Failed to jump to the latest message", - "notifications.messageSendFailed.error": "Failed to send the message", - "notifications.messageUpdateFailed.error": "Failed to update the message", "notifications.pollCreateFailed.error": "Failed to create the poll", "notifications.pollCreateFailed.withReason.error": "Failed to create the poll due to {{reason}}", "notifications.pollEndFailed.withReason.error": "Failed to end the poll due to {{reason}}", diff --git a/package/src/i18n/keys.ts b/package/src/i18n/keys.ts index 5cc59b9295..2178970fbd 100644 --- a/package/src/i18n/keys.ts +++ b/package/src/i18n/keys.ts @@ -346,8 +346,6 @@ export type TranslationCatalog = { 'notifications.locationShareFailed.error': 'Failed to share location'; 'notifications.messageJumpFailed.error': 'Failed to jump to the message'; 'notifications.messageJumpToLatestFailed.error': 'Failed to jump to the latest message'; - 'notifications.messageSendFailed.error': 'Failed to send the message'; - 'notifications.messageUpdateFailed.error': 'Failed to update the message'; 'notifications.pollCreateFailed.error': 'Failed to create the poll'; 'notifications.pollCreateFailed.withReason.error': 'Failed to create the poll due to {{reason}}'; 'notifications.pollEndFailed.withReason.error': 'Failed to end the poll due to {{reason}}'; diff --git a/package/src/index.ts b/package/src/index.ts index 8aa3184b17..a373f461d7 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -21,7 +21,6 @@ export * from './types/react-native-compat'; export * from './types/types'; export * from './utils/attachmentUrls'; -export * from './utils/patchMessageTextCommand'; export * from './utils/i18n/Streami18n'; export * from './i18n'; export * from './utils/setupCommandUIMiddlewares'; diff --git a/package/src/mock-builders/mock.ts b/package/src/mock-builders/mock.ts index d5226effe8..700f693199 100644 --- a/package/src/mock-builders/mock.ts +++ b/package/src/mock-builders/mock.ts @@ -27,16 +27,17 @@ type MockableStreamChat = StreamChat & { export const setUser = (client: StreamChat, user: MockUser): Promise => new Promise((resolve) => { const c = client as MockableStreamChat; + // `userID` is now a read-only getter derived from `user.id`, so setting `user` is enough. Set + // before the socket opens, as `connectUser()` does. + c.user = { ...user, mutes: [] } as unknown as OwnUserResponse; + c._user = { ...c.user }; + c.userToken = token; // A connected client means a live socket with a connection id. The id lives on its own manager // now, and `channel.watch()` / `client.queryChannels()` await it rather than degrading to // `watch: false` — with no timeout — so a fixture that leaves it unset hangs every one of them // until the test itself times out. The socket status goes with it: both halves of "connected". client.connectionIdManager.resolveConnectionId('dummy_connection_id'); client.wsConnection._setStatus({ isHealthy: true }); - // `userID` is now a read-only getter derived from `user.id`, so setting `user` is enough. - c.user = { ...user, mutes: [] } as unknown as OwnUserResponse; - c._user = { ...c.user }; - c.userToken = token; resolve(); }); diff --git a/package/src/state-store/message-list-prev-next-state.ts b/package/src/state-store/message-list-prev-next-state.ts deleted file mode 100644 index d2198c1062..0000000000 --- a/package/src/state-store/message-list-prev-next-state.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { StateStore } from '@stream-io/state-store'; -import { LocalMessage } from 'stream-chat'; - -export type MessagePreviousAndNextMessageStoreType = { - messageList: Record< - string, - { - previousMessage: LocalMessage; - nextMessage: LocalMessage; - } - >; -}; - -const INITIAL_STATE: MessagePreviousAndNextMessageStoreType = { - messageList: {}, -}; - -export class MessagePreviousAndNextMessageStore { - public state: StateStore; - - constructor() { - this.state = new StateStore(INITIAL_STATE); - } - - // The default value of isFlashList is true as the logic in the function makes more sense when the list is not reversed. - public setMessageListPreviousAndNextMessage({ - messages, - isFlashList = true, - }: { - messages: LocalMessage[]; - isFlashList?: boolean; - }) { - const currentValue = this.state.getLatestValue(); - const prevMessageList: MessagePreviousAndNextMessageStoreType['messageList'] = - currentValue.messageList; - const newMessageList: MessagePreviousAndNextMessageStoreType['messageList'] = {}; - for (let i = 0; i < messages.length; i++) { - const message = messages[i]; - const previousMessage = isFlashList ? messages[i - 1] : messages[i + 1]; - const nextMessage = isFlashList ? messages[i + 1] : messages[i - 1]; - - const existing = prevMessageList[message.id]; - - if ( - existing && - existing.previousMessage === previousMessage && - existing.nextMessage === nextMessage - ) { - newMessageList[message.id] = existing; - } else { - newMessageList[message.id] = { - nextMessage, - previousMessage, - }; - } - } - this.state.partialNext({ messageList: newMessageList }); - } -} diff --git a/package/src/store/OfflineDB.ts b/package/src/store/OfflineDB.ts index 36717b47f9..975e620125 100644 --- a/package/src/store/OfflineDB.ts +++ b/package/src/store/OfflineDB.ts @@ -39,13 +39,11 @@ export class OfflineDB extends AbstractOfflineDB { upsertChannels = api.upsertChannels; - // TODO: Rename currentUserId -> userId in the next major version as it is technically breaking. upsertUserSyncStatus = ({ userId, lastSyncedAt, execute }: DBUpsertUserSyncStatusType) => - api.upsertUserSyncStatus({ currentUserId: userId, execute, lastSyncedAt }); + api.upsertUserSyncStatus({ execute, lastSyncedAt, userId }); - // TODO: Rename currentUserId -> userId in the next major version as it is technically breaking. upsertAppSettings = ({ appSettings, userId, execute }: DBUpsertAppSettingsType) => - api.upsertAppSettings({ appSettings, currentUserId: userId, execute }); + api.upsertAppSettings({ appSettings, execute, userId }); upsertPoll = api.upsertPoll; @@ -65,27 +63,22 @@ export class OfflineDB extends AbstractOfflineDB { updateMessage = api.updateMessage; - // TODO: Rename currentUserId -> userId in the next major version as it is technically breaking. getChannels = ({ cids, userId }: DBGetChannelsType) => - api.getChannels({ channelIds: cids, currentUserId: userId }); + api.getChannels({ channelIds: cids, userId }); - // TODO: Rename currentUserId -> userId in the next major version as it is technically breaking. getChannelsForQuery = ({ userId, options }: DBGetChannelsForQueryType) => api.getChannelsForFilterSort({ - currentUserId: userId, filters: options?.filter_conditions, options, sort: options?.sort, + userId, }); getAllChannelCids = api.getAllChannelIds; - // TODO: Rename currentUserId -> userId in the next major version as it is technically breaking. - getLastSyncedAt = ({ userId }: DBGetLastSyncedAtType) => - api.getLastSyncedAt({ currentUserId: userId }); + getLastSyncedAt = ({ userId }: DBGetLastSyncedAtType) => api.getLastSyncedAt({ userId }); - getAppSettings = ({ userId }: DBGetAppSettingsType) => - api.getAppSettings({ currentUserId: userId }); + getAppSettings = ({ userId }: DBGetAppSettingsType) => api.getAppSettings({ userId }); getReactions = api.getReactionsForFilterSort; diff --git a/package/src/store/__tests__/mapperRequiredTimestamps.test.ts b/package/src/store/__tests__/mapperRequiredTimestamps.test.ts index 80137d8157..a9309943bd 100644 --- a/package/src/store/__tests__/mapperRequiredTimestamps.test.ts +++ b/package/src/store/__tests__/mapperRequiredTimestamps.test.ts @@ -16,7 +16,7 @@ describe('row -> model mappers keep required timestamps numeric', () => { it('mapStorableToMessage', () => { const message = mapStorableToMessage({ - currentUserId: 'u1', + userId: 'u1', // eslint-disable-next-line @typescript-eslint/no-explicit-any messageRow: { ...nullDates, id: 'm1', type: 'regular', user: userRow } as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/package/src/store/apis/deleteReactions.ts b/package/src/store/apis/deleteReactions.ts deleted file mode 100644 index 0cd5684870..0000000000 --- a/package/src/store/apis/deleteReactions.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createDeleteQuery } from '../sqlite-utils/createDeleteQuery'; -import { SqliteClient } from '../SqliteClient'; - -export const deleteReactionsForMessage = async ({ - execute = true, - messageId, -}: { - messageId: string; - execute?: boolean; -}) => { - const query = createDeleteQuery('reactions', { - messageId, - }); - SqliteClient.logger?.('info', 'deleteReactionsForMessage', { - execute, - messageId, - }); - if (execute) { - await SqliteClient.executeSql.apply(null, query); - } - - return [query]; -}; diff --git a/package/src/store/apis/getAppSettings.ts b/package/src/store/apis/getAppSettings.ts index 5d048571f9..576bb51384 100644 --- a/package/src/store/apis/getAppSettings.ts +++ b/package/src/store/apis/getAppSettings.ts @@ -4,17 +4,17 @@ import { createSelectQuery } from '../sqlite-utils/createSelectQuery'; import { SqliteClient } from '../SqliteClient'; export const getAppSettings = async ({ - currentUserId, + userId, }: { - currentUserId: string; + userId: string; }): Promise => { SqliteClient.logger?.('info', 'getAppSettings', { - currentUserId, + userId, }); const result = await SqliteClient.executeSql.apply( null, createSelectQuery('userSyncStatus', ['*'], { - userId: currentUserId, + userId, }), ); diff --git a/package/src/store/apis/getChannelMessages.ts b/package/src/store/apis/getChannelMessages.ts index 87ae9641ed..a279629f16 100644 --- a/package/src/store/apis/getChannelMessages.ts +++ b/package/src/store/apis/getChannelMessages.ts @@ -11,14 +11,14 @@ import type { TableRow, TableRowJoinedUser } from '../types'; export const getChannelMessages = async ({ channelIds, - currentUserId, + userId, }: { channelIds: string[]; - currentUserId: string; + userId: string; }) => { SqliteClient.logger?.('info', 'getChannelMessages', { channelIds, - currentUserId, + userId, }); const messageRows = await selectMessagesForChannels(channelIds); const messageIds = messageRows.map(({ id }) => id); @@ -83,7 +83,7 @@ export const getChannelMessages = async ({ cidVsMessages[m.cid].push( mapStorableToMessage({ - currentUserId, + userId, messageRow: m, pollRow: messageIdsVsPolls[m.poll_id], reactionRows: messageIdVsReactions[m.id], diff --git a/package/src/store/apis/getChannels.ts b/package/src/store/apis/getChannels.ts index e032621b72..a08db1cd18 100644 --- a/package/src/store/apis/getChannels.ts +++ b/package/src/store/apis/getChannels.ts @@ -15,28 +15,28 @@ import { SqliteClient } from '../SqliteClient'; * * @param {Object} param * @param {Array} param.channelIds List of channel ids to fetch. - * @param {Array} param.currentUserId Id of the current logged in user. + * @param {Array} param.userId Id of the current logged in user. * * @returns {Array} Channels with enriched state. */ export const getChannels = async ({ channelIds, - currentUserId, + userId, }: { channelIds: string[]; - currentUserId: string; + userId: string; }): Promise[]> => { - SqliteClient.logger?.('info', 'getChannels', { channelIds, currentUserId }); + SqliteClient.logger?.('info', 'getChannels', { channelIds, userId }); const [channels, cidVsDraft, cidVsMembers, cidVsReads, cidVsMessages, cidVsActiveLocations] = await Promise.all([ selectChannels({ channelIds }), - getDraftForChannels({ channelIds, currentUserId }), + getDraftForChannels({ channelIds, userId }), getMembers({ channelIds }), getReads({ channelIds }), getChannelMessages({ channelIds, - currentUserId, + userId, }), getChannelActiveLocations({ channelIds }), ]); @@ -47,7 +47,7 @@ export const getChannels = async ({ active_live_locations: cidVsActiveLocations[c.cid] || [], draft: cidVsDraft[c.cid], members: cidVsMembers[c.cid] || [], - membership: (cidVsMembers[c.cid] || []).find((member) => member.user_id === currentUserId), + membership: (cidVsMembers[c.cid] || []).find((member) => member.user_id === userId), messages: cidVsMessages[c.cid] || [], pinned_messages: [], read: cidVsReads[c.cid] || [], diff --git a/package/src/store/apis/getChannelsForFilterSort.ts b/package/src/store/apis/getChannelsForFilterSort.ts index 1ef07c14c8..a34e5bb611 100644 --- a/package/src/store/apis/getChannelsForFilterSort.ts +++ b/package/src/store/apis/getChannelsForFilterSort.ts @@ -14,7 +14,7 @@ import { SqliteClient } from '../SqliteClient'; * Gets the channels from database for given filter and sort query. * * @param {Object} param - * @param {string} param.currentUserId Id of current logged in user + * @param {string} param.userId Id of current logged in user * @param {Object} param.filters Filters for channels https://getstream.io/chat/docs/javascript/query_channels/?language=javascript&q=su#query-parameters * @param {Object} param.sort Sort for channels https://getstream.io/chat/docs/javascript/query_channels/?language=javascript&q=su#query-parameters * @@ -22,12 +22,12 @@ import { SqliteClient } from '../SqliteClient'; * they were cached with. Returns null if filters + sort query doesn't exist in "channelQueries" table. */ export const getChannelsForFilterSort = async ({ - currentUserId, + userId, filters, options, sort, }: { - currentUserId: string; + userId: string; filters?: ChannelFilters; options?: ChannelOptions; sort?: SortParamRequest[]; @@ -56,7 +56,7 @@ export const getChannelsForFilterSort = async ({ return { channels: await getChannels({ channelIds: cids, - currentUserId, + userId, }), predefinedFilter, }; diff --git a/package/src/store/apis/getDraft.ts b/package/src/store/apis/getDraft.ts index 83ef191352..a043f62153 100644 --- a/package/src/store/apis/getDraft.ts +++ b/package/src/store/apis/getDraft.ts @@ -45,7 +45,7 @@ export const getDraft = async ({ return mapStorableToDraft({ channelRow: channelRows[0] as unknown as TableRow<'channels'>, - currentUserId: userId, + userId, draftRow: draftRowWithMessage, pollRow: polls[0], quotedMessageRow: quotedMessageRows, diff --git a/package/src/store/apis/getDraftsForChannels.ts b/package/src/store/apis/getDraftsForChannels.ts index f2d497fe94..91d538f649 100644 --- a/package/src/store/apis/getDraftsForChannels.ts +++ b/package/src/store/apis/getDraftsForChannels.ts @@ -10,10 +10,10 @@ import { TableRow } from '../types'; export const getDraftForChannels = async ({ channelIds, - currentUserId, + userId, }: { channelIds: string[]; - currentUserId: string; + userId: string; }) => { SqliteClient.logger?.('info', 'getDraftsForChannel', { channelIds }); @@ -43,7 +43,7 @@ export const getDraftForChannels = async ({ cidVsDrafts[row.cid] = mapStorableToDraft({ channelRow: channelRows[0] as unknown as TableRow<'channels'>, - currentUserId, + userId, draftRow: row, pollRow: polls[0], quotedMessageRow, diff --git a/package/src/store/apis/getLastSyncedAt.ts b/package/src/store/apis/getLastSyncedAt.ts index 27fbb1e412..0f91ecae50 100644 --- a/package/src/store/apis/getLastSyncedAt.ts +++ b/package/src/store/apis/getLastSyncedAt.ts @@ -2,15 +2,15 @@ import { createSelectQuery } from '../sqlite-utils/createSelectQuery'; import { SqliteClient } from '../SqliteClient'; export const getLastSyncedAt = async ({ - currentUserId, + userId, }: { - currentUserId: string; + userId: string; }): Promise => { - SqliteClient.logger?.('info', 'getLastSyncedAt', { currentUserId }); + SqliteClient.logger?.('info', 'getLastSyncedAt', { userId }); const result = await SqliteClient.executeSql.apply( null, createSelectQuery('userSyncStatus', ['*'], { - userId: currentUserId, + userId, }), ); diff --git a/package/src/store/apis/index.ts b/package/src/store/apis/index.ts index 5bd625feda..d628db3dd4 100644 --- a/package/src/store/apis/index.ts +++ b/package/src/store/apis/index.ts @@ -2,7 +2,6 @@ export * from './deleteChannel'; export * from './deleteMember'; export * from './deleteMessage'; export * from './deleteMessagesForChannel'; -export * from './deleteReactions'; export * from './getAllChannelIds'; export * from './getAppSettings'; export * from './getChannelMessages'; diff --git a/package/src/store/apis/upsertAppSettings.ts b/package/src/store/apis/upsertAppSettings.ts index f60de901bc..d08741608d 100644 --- a/package/src/store/apis/upsertAppSettings.ts +++ b/package/src/store/apis/upsertAppSettings.ts @@ -5,25 +5,25 @@ import { SqliteClient } from '../SqliteClient'; export const upsertAppSettings = async ({ appSettings, - currentUserId, + userId, execute = true, }: { appSettings: GetApplicationResponse; - currentUserId: string; + userId: string; execute?: boolean; }) => { const storableAppSettings = JSON.stringify(appSettings); const queries = [ createUpsertQuery('userSyncStatus', { appSettings: storableAppSettings, - userId: currentUserId, + userId, }), ]; SqliteClient.logger?.('info', 'upsertAppSettings', { appSettings: storableAppSettings, execute, - userId: currentUserId, + userId, }); if (execute) { diff --git a/package/src/store/apis/upsertChannelDataFromChannel.ts b/package/src/store/apis/upsertChannelDataFromChannel.ts deleted file mode 100644 index 002395ed1f..0000000000 --- a/package/src/store/apis/upsertChannelDataFromChannel.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Channel } from 'stream-chat'; - -import { mapChannelToStorable } from '../mappers/mapChannelToStorable'; -import { createUpsertQuery } from '../sqlite-utils/createUpsertQuery'; -import { SqliteClient } from '../SqliteClient'; - -export const upsertChannelDataFromChannel = async ({ - channel, - execute = true, -}: { - channel: Channel; - execute?: boolean; -}) => { - const storableChannel = mapChannelToStorable(channel); - if (!storableChannel) { - return; - } - const query = createUpsertQuery('channels', storableChannel); - if (execute) { - await SqliteClient.executeSqlBatch([query]); - } - - return [query]; -}; diff --git a/package/src/store/apis/upsertUserSyncStatus.ts b/package/src/store/apis/upsertUserSyncStatus.ts index 9e19cfc531..b077ddc947 100644 --- a/package/src/store/apis/upsertUserSyncStatus.ts +++ b/package/src/store/apis/upsertUserSyncStatus.ts @@ -2,24 +2,24 @@ import { createUpsertQuery } from '../sqlite-utils/createUpsertQuery'; import { SqliteClient } from '../SqliteClient'; export const upsertUserSyncStatus = async ({ - currentUserId, + userId, lastSyncedAt, execute = true, }: { - currentUserId: string; + userId: string; lastSyncedAt: string; execute?: boolean; }) => { const queries = [ createUpsertQuery('userSyncStatus', { lastSyncedAt, - userId: currentUserId, + userId, }), ]; SqliteClient.logger?.('info', 'upsertUserSyncStatus', { lastSyncedAt, - userId: currentUserId, + userId, }); if (execute) { diff --git a/package/src/store/mappers/mapStorableToDraft.ts b/package/src/store/mappers/mapStorableToDraft.ts index f748d5649b..d88eb6ab00 100644 --- a/package/src/store/mappers/mapStorableToDraft.ts +++ b/package/src/store/mappers/mapStorableToDraft.ts @@ -9,13 +9,13 @@ import { mapStorableToRequiredTimestamp } from './mapStorableToTimestamp'; import type { TableRow, TableRowJoinedDraftMessage, TableRowJoinedUser } from '../types'; export const mapStorableToDraft = ({ - currentUserId, + userId, draftRow, channelRow, pollRow, quotedMessageRow, }: { - currentUserId: string; + userId: string; draftRow: TableRowJoinedDraftMessage<'draft'>; channelRow: TableRow<'channels'>; pollRow: TableRow<'poll'>; @@ -28,7 +28,7 @@ export const mapStorableToDraft = ({ const channel = mapStorableToChannel(channelRow); const quotedMessage = quotedMessageRow - ? mapStorableToMessage({ currentUserId, messageRow: quotedMessageRow, pollRow }) + ? mapStorableToMessage({ userId, messageRow: quotedMessageRow, pollRow }) : undefined; return { diff --git a/package/src/store/mappers/mapStorableToMessage.ts b/package/src/store/mappers/mapStorableToMessage.ts index e72e8e7c20..8f95440195 100644 --- a/package/src/store/mappers/mapStorableToMessage.ts +++ b/package/src/store/mappers/mapStorableToMessage.ts @@ -10,13 +10,13 @@ import { mapStorableToUser } from './mapStorableToUser'; import type { TableRow, TableRowJoinedUser } from '../types'; export const mapStorableToMessage = ({ - currentUserId, + userId, messageRow, pollRow, reactionRows, reminderRow, }: { - currentUserId: string; + userId: string; messageRow: TableRowJoinedUser<'messages'>; pollRow: TableRow<'poll'>; reactionRows?: TableRowJoinedUser<'reactions'>[]; @@ -37,7 +37,7 @@ export const mapStorableToMessage = ({ } = messageRow; const latestReactions = reactionRows?.map((reaction) => mapStorableToReaction(reaction)) || []; - const ownReactions = latestReactions.filter((reaction) => reaction.user?.id === currentUserId); + const ownReactions = latestReactions.filter((reaction) => reaction.user?.id === userId); return { ...rest, diff --git a/package/src/store/sqlite-utils/printRow.ts b/package/src/store/sqlite-utils/printRow.ts deleted file mode 100644 index 3fe8fb24f3..0000000000 --- a/package/src/store/sqlite-utils/printRow.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { TableRow } from '../types'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const printRow = (row: TableRow) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const prettyRow: Record = {}; - for (const key in row) { - // @ts-ignore - const value = row[key]; - - try { - prettyRow[key] = JSON.parse(value); - } catch (e) { - prettyRow[key] = value; - } - } - - console.log(JSON.stringify(prettyRow, null, 2)); -}; diff --git a/package/src/types/types.ts b/package/src/types/types.ts index 05d9fde427..e0fbc21819 100644 --- a/package/src/types/types.ts +++ b/package/src/types/types.ts @@ -1,7 +1,6 @@ import type { ChannelFilters, SortParamRequest, - ChannelState, FileReference, LocalAudioAttachment, LocalUploadAttachment, @@ -100,8 +99,6 @@ export type UnknownType = Record; export type ValueOf = T[keyof T]; -export type ChannelUnreadState = Omit, 'user'>; - // ASYNC AUDIO EXPO: export enum AndroidOutputFormat { DEFAULT = 0, diff --git a/package/src/utils/__tests__/patchMessageTextCommand.test.ts b/package/src/utils/__tests__/patchMessageTextCommand.test.ts deleted file mode 100644 index 2deab150a2..0000000000 --- a/package/src/utils/__tests__/patchMessageTextCommand.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { patchMessageTextCommand } from '../patchMessageTextCommand'; - -describe('patchMessageTextCommand', () => { - test.each<[string, string, string[]]>([ - ['/ban @santhosh vaiyapuri reason', '/ban @santhosh reason', ['santhosh']], - ['/ban @santhosh vaiyapuri', '/ban @santhosh vaiyapuri', ['santhosh']], - ['/ban @santhosh', '/ban @santhosh @santhosh', ['santhosh']], - ['/ban @santhosh vaiyapuri @khushal reason', '/ban @santhosh reason', ['santhosh', 'khushal']], - ['/mute @santhosh vaiyapuri', '/mute @santhosh', ['santhosh']], - ['/mute @santhosh vaiyapuri @khushal reason', '/mute @santhosh', ['santhosh', 'khushal']], - ])('Patch message with text:"%s" to "%s")', (input, expectedOutput, mentionedUserIds) => { - expect(patchMessageTextCommand(input, mentionedUserIds)).toBe(expectedOutput); - }); -}); diff --git a/package/src/utils/getChannelUnreadState.ts b/package/src/utils/getChannelUnreadState.ts deleted file mode 100644 index 999b8b1005..0000000000 --- a/package/src/utils/getChannelUnreadState.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { asTimestampNS } from 'stream-chat'; -import type { Channel } from 'stream-chat'; - -import type { ChannelUnreadState } from '../types/types'; - -/** - * Maps the LLC's `channel.messagePaginator.unreadStateSnapshot` (the single source of truth for - * unread state) into the SDK's `ChannelUnreadState` shape used across the message list. - * - * Returns `undefined` when there is nothing unread to represent (no first-unread marker, no - * last-read message, no unread count) — mirroring the previous store's "empty means undefined" - * semantics so downstream `channelUnreadState?.x` checks behave identically. `last_read` falls back - * to the epoch (whole channel unread) exactly as before. - */ -export const getChannelUnreadState = (channel: Channel): ChannelUnreadState | undefined => { - const snapshot = channel.messagePaginator.unreadStateSnapshot.getLatestValue(); - - if (!snapshot.firstUnreadMessageId && !snapshot.lastReadMessageId && !snapshot.unreadCount) { - return undefined; - } - - return { - first_unread_message_id: snapshot.firstUnreadMessageId ?? undefined, - last_read: snapshot.lastReadAt ?? asTimestampNS(0), - last_read_message_id: snapshot.lastReadMessageId ?? undefined, - unread_messages: snapshot.unreadCount, - }; -}; diff --git a/package/src/utils/patchMessageTextCommand.ts b/package/src/utils/patchMessageTextCommand.ts deleted file mode 100644 index 58807b434f..0000000000 --- a/package/src/utils/patchMessageTextCommand.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Converts a message text command to proper format - * Example: "/mute @username" to "/mute @userId" - * Supports "/ban", "/unban", "/mute", "/unmute" - * @param messageText - * @param mentionedUsers - * @returns - */ -export function patchMessageTextCommand(messageText: string, mentionedUserIds: string[]): string { - if (mentionedUserIds.length === 0) { - return messageText; - } - const trimmedMessageText = messageText.trim(); - - /** - * The required formats are "/unban @userid" or "/mute @userid" or "/unmute @userid" - */ - if ( - trimmedMessageText.startsWith('/mute ') || - trimmedMessageText.startsWith('/unmute ') || - trimmedMessageText.startsWith('/unban ') - ) { - return trimmedMessageText.replace(/@.+/, `@${mentionedUserIds[0]}`); - } - - /** - * The required format is "/ban @userid reason" - */ - if (trimmedMessageText.startsWith('/ban ')) { - // TODO: There is a bug here. If the name has two words, the reason will be the surname of the user. - const reasonText = trimmedMessageText.split(' ').pop() ?? ''; - return `/ban @${mentionedUserIds[0]} ${reasonText}`.trim(); - } - - return messageText; -} diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index ca62b50dea..e3efb00219 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -2,12 +2,7 @@ import type React from 'react'; import dayjs from 'dayjs'; import EmojiRegex from 'emoji-regex'; -import type { - AttachmentLoadingState, - LocalMessage, - MessageResponse, - TimestampNS, -} from 'stream-chat'; +import type { AttachmentLoadingState, LocalMessage, MessageResponse } from 'stream-chat'; import { IconProps } from '../../src/icons/utils/base'; import { ValueOf } from '../types/types'; @@ -190,20 +185,6 @@ export const stringifyMessage = ({ }${baseFieldsString}`; }; -/** - * Reduces a list of messages to strings that are used in useEffect & useMemo - * @param {messages} messages - the array of messages to be compared - * @returns {string} The mapped message string - */ -export const reduceMessagesToString = (messages: LocalMessage[]): string => - messages - .map((message) => - message?.quoted_message - ? `${stringifyMessage({ message })}_${message.quoted_message.type}_${message.quoted_message.deleted_at}_${message.quoted_message.text}_${message.quoted_message.updated_at}` - : stringifyMessage({ message }), - ) - .join(); - /** * Utility to get the file name from the path using regex. * `[^/]+` matches one or more characters that are not a slash (/), ensuring we capture the filename part. @@ -266,54 +247,6 @@ export function escapeRegExp(text: string) { return text.replace(/[-[\]{}()*+?.,/\\^$|#]/g, '\\$&'); } -/** - * Utility to find the index of a message in the messages array by id. - * @param messages - * @param targetId - * @returns number - */ -export const findInMessagesById = (messages: LocalMessage[], targetId: string) => { - const idx = messages.findIndex((message) => message.id === targetId); - return idx; -}; - -/** - * Utility to find the index of a message in the messages array by timestamp. - * @param messages - * @param targetTimestamp Unix nanoseconds, the same unit the messages' `created_at` carries. - * @returns an object with the index and the message object - */ -export const findInMessagesByDate = ( - messages: MessageResponse[] | LocalMessage[], - targetTimestamp: TimestampNS, -) => { - // Binary search - let left = 0; - let right = messages.length - 1; - let middle = 0; - while (left <= right) { - middle = Math.floor(left + (right - left) / 2); - const middleTimestamp = messages[middle].created_at; - const middleLeftTimestamp = messages[middle - 1]?.created_at; - const middleRightTimestamp = messages[middle + 1]?.created_at; - if ( - middleTimestamp === targetTimestamp || - (middleLeftTimestamp != null && - middleRightTimestamp != null && - middleLeftTimestamp < targetTimestamp && - middleRightTimestamp > targetTimestamp) - ) { - return { index: middle, message: messages[middle] }; - } else if (middleTimestamp < targetTimestamp) { - left = middle + 1; - } else { - right = middle - 1; - } - } - - return { index: -1 }; -}; - /** * The purpose of this function is to compare two messages and determine if they are equal. * It checks various properties of the messages, such as status, type, text, pinned state, updated_at timestamp, i18n data, and reply count.