diff --git a/AGENTS.md b/AGENTS.md index da178117c6..4680de6a44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,9 +142,12 @@ knows it is not rendering a thread. Resolve the list's data with `useMessagePagi `` is the entry point. It sets SDK metadata on the `stream-chat` client (identifier, device info), registers subscriptions for threads/polls/reminders (cleaned up on unmount), initializes `OfflineDB` when `enableOfflineSupport` is set, and wraps children in `ChatProvider` → `TranslationProvider` → `ThemeProvider`. -**Reconnect recovery is owned by the JS client, not this SDK.** `client.connectionRecovery` re-runs -each channel list's own first-page query and reloads whichever channels are `active` (`` -marks its channel active on mount), then dispatches `connection.recovered`. So do not add a +**Reconnect recovery is owned by the JS client, not this SDK.** `client.connectionRecovery` re-queries +every page each registered channel list has loaded, through the list's own query (up to 150 channels, +`ChannelPaginator.recoverLoadedItems()`), drops the loaded channels no page returned, and reloads +whichever channels are `active` (`` marks its channel active on mount), then dispatches +`connection.recovered`. `ChannelList`'s pull-to-refresh is separate and still re-queries only the first +page. So do not add a `connection.changed` listener that re-queries a list or re-watches a channel — that duplicates it. Recovery is on by default and configured declaratively (`client.config.set({ client: { connectionRecovery: { enabled: false } } })` turns it off); the old diff --git a/ai-docs/ai-migration-v9-to-v10.md b/ai-docs/ai-migration-v9-to-v10.md index 5450049742..de8b9eca49 100644 --- a/ai-docs/ai-migration-v9-to-v10.md +++ b/ai-docs/ai-migration-v9-to-v10.md @@ -214,6 +214,7 @@ means changed. Details in the linked section. | `` | ``, no `` around it | §10.1 | | `threadList` (prop or context field) | `useThreadContext().thread` | §10.2 | | `ThreadMessageComposer` slot / `additionalMessageComposerProps` | a `MessageComposer` child of `` with its own props | §10.2 | +| `MessageList` slot | a `MessageList` (or `MessageFlashList`) child of ``, or your own list component | §10.2 | | `useMessageComposerContext().thread` (parent message; deep import only) | `thread` is now the `Thread`; parent on `thread.state` | §10.2 | | `editMessage()` on the message input context | removed: `useMessageInputContext().sendMessage()` saves an edit; intercept with `updateMessageRequest` | P.17 | | `` props | removed — list reads the `Thread` from `ThreadContext` + paginator internally; `thread` stays, retyped to `Thread \| null` | §12 | @@ -819,6 +820,8 @@ registers a new one. `parentMessagePreventPress` to `ThreadFooterComponent`. - **`ThreadMessageComposer` slot**: removed from `WithComponents`. Render the composer you want as a child of ``. +- **`MessageList` slot**: removed from `WithComponents`. In v9 only `` read it, to render the + thread's list; `` now renders its children, so render the list you want as a child. - **`threadList`**: removed everywhere - `ChannelContextValue.threadList`, `MessageContextValue.threadList`, the `threadList` prop of `Message`, `MessageList`, `MessageFlashList` and `MessageComposer`, and `useIncomingMessageAnnouncements({ threadList })`. diff --git a/ai-docs/i18n-v10-key-map.json b/ai-docs/i18n-v10-key-map.json index 6a4e3edbee..ad1bc60fd5 100644 --- a/ai-docs/i18n-v10-key-map.json +++ b/ai-docs/i18n-v10-key-map.json @@ -1,5 +1,5 @@ { - "$comment": "v9 -> v10 translation key map for stream-chat-react-native. The left-hand side is the old key, which was the English copy itself; the right-hand side is the stable dotted key that replaces it. `plural` entries live in the catalog as _one / _other, while call sites use the bare key and pass `count`. Keys not listed here have no v10 equivalent -- either they were dead in v9, or the component/notification that rendered them is gone; see the \"Removed keys\" table in i18n-v10-migration.md. Generated keys and their English copy: `yarn i18n:export`.", + "$comment": "v9 -> v10 translation key map for stream-chat-react-native. The left-hand side is the old key, which was the English copy itself; the right-hand side is the stable dotted key that replaces it. `plural` entries live in the catalog as _one / _other, while call sites use the bare key and pass `count`. An entry with `alsoKeys` was one v9 key that v10 split into several: apply the old translation to `key` and to each of `alsoKeys`. Keys not listed here have no v10 equivalent -- either they were dead in v9, or the component/notification that rendered them is gone; see the \"Removed keys\" table in i18n-v10-migration.md. Generated keys and their English copy: `yarn i18n:export`.", "count": 389, "keys": { "+{{count}}": { @@ -1429,6 +1429,7 @@ }, "a11y/Double tap to open": { "key": "attachment.gallery.doubleTapToOpen.accessibilityLabel", + "alsoKeys": ["channelPreview.doubleTapToOpen.accessibilityLabel"], "prose": true, "plural": false }, diff --git a/ai-docs/i18n-v10-migration.md b/ai-docs/i18n-v10-migration.md index 4e82a46651..03c03691d9 100644 --- a/ai-docs/i18n-v10-migration.md +++ b/ai-docs/i18n-v10-migration.md @@ -371,7 +371,9 @@ timestampFormatter(...) }}` breaks date rendering outright. Pass `--all` for the ## The old→new key table -Every old key maps to exactly one new key. The full table (389 rows) is +Every old key maps to one new key, except `a11y/Double tap to open`, which v10 split between the gallery and the +channel list row: its entry lists the second key under `alsoKeys`, and an old translation applies to both. The full +table (389 rows) is [`i18n-v10-key-map.json`](./i18n-v10-key-map.json). Keys absent from it were dead in v9 and have been removed. The exception is four formatter expressions the SDK never rendered but shipped in `en.json`, so apps called them directly: `duration/Location end at`, `duration/Remind Me`, `timestamp/ReminderNotification` and diff --git a/ai-docs/v10-breaking-changes.md b/ai-docs/v10-breaking-changes.md index da23cdf479..32c210a417 100644 --- a/ai-docs/v10-breaking-changes.md +++ b/ai-docs/v10-breaking-changes.md @@ -30,7 +30,7 @@ Docs impact (which getstream.io pages change) is in [`v10-docs-impact.md`](./v10 4. the `examples/` diff (the migration the team already did on its own apps), the deleted/rewritten tests, and a cross-check against the `stream-chat` and `stream-chat-react` v10 breaking-change lists. - Not breaking (checked): theme shape (`Theme` is identical), the `icons` map, `DEFAULT_COMPONENTS` slot names except - the removed `ThreadMessageComposer`, native modules, podspecs, the Expo config plugin, `registerNativeHandlers` and + the removed `ThreadMessageComposer` and `MessageList`, native modules, podspecs, the Expo config plugin, `registerNativeHandlers` and the native handler interfaces. --- @@ -420,7 +420,7 @@ message input). ## 9. Component slots, icons and theme -- One slot was removed: `ThreadMessageComposer`. Render the composer you want as a child of ``. — §10.2 · +- Two slots were removed: `ThreadMessageComposer` and `MessageList`, the ones `` read in v9. Render the composer and the list you want as children of ``. — §10.2 · #3867 - No other `DEFAULT_COMPONENTS` slot or icon was added, removed or renamed, and the `Theme` type is identical. What changed is the **props and context** those slots receive (sections 3–4) — a custom slot written against v9 contexts diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 4a27feb1e7..99762f9b2e 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -2453,7 +2453,7 @@ PODS: - ReactNativeDependencies - RNFBApp - Yoga - - RNGestureHandler (3.3.0): + - RNGestureHandler (3.4.0): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2815,7 +2815,7 @@ PODS: - SDWebImage/Core (~> 5.17) - SocketRocket (0.7.1): - ReactNativeDependencies - - stream-chat-react-native (9.10.0): + - stream-chat-react-native (9.10.1): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3392,7 +3392,7 @@ SPEC CHECKSUMS: RNFastImage: 25f87dde869e11156daeef98b4cee2371023407e RNFBApp: 21e4d8c20656c236a818ee2eff3f859557ceff33 RNFBMessaging: e3e31aec89cd0eb986c87ec8247f598190b61c36 - RNGestureHandler: ddbdf3f293a498368803f8181ff157271ac05c48 + RNGestureHandler: abdc2d49ac3970908acd127980b429d377927ee1 RNNotifee: 5e3b271e8ea7456a36eec994085543c9adca9168 RNReactNativeHapticFeedback: a1bba076645e8fedcb65c8e70461b77cd5d6dd32 RNReanimated: 5c641cc430b7255eae87c8039204eb0f816ea1b8 @@ -3405,10 +3405,10 @@ SPEC CHECKSUMS: SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 SocketRocket: 37aec555668fb852ec12e3c0de59a86ca58f0871 - stream-chat-react-native: dc4a04f6903f9616ed0d58757e813e96bda1972f + stream-chat-react-native: 291a3f91fef6922e89ed97bf7a790bf4a786bab9 Teleport: 8a97ac5c0d76c04e20c36843d4d8f8fe5aa1aa73 Yoga: d1c536142c5ff8ec8cd856ab2a7c227a1d875c8e PODFILE CHECKSUM: ac9682db83e42d63d399a8de4586161e86b3e751 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/package/src/__tests__/offline-support/offline-feature.tsx b/package/src/__tests__/offline-support/offline-feature.tsx index dfbf86d768..11f73340d1 100644 --- a/package/src/__tests__/offline-support/offline-feature.tsx +++ b/package/src/__tests__/offline-support/offline-feature.tsx @@ -482,7 +482,7 @@ export const Generic = () => { const readRows = await BetterSqlite.selectFromTable('reads'); const matchingMessageRows = messagesRows.filter((m) => m.id === newMessage.id); const matchingReadRows = readRows.filter( - (r) => targetChannel.cid === r.cid && chatClient.userID === r.userId, + (r) => targetChannel.cid === r.cid && chatClient.userId === r.userId, ); expect(matchingMessageRows.length).toBe(1); @@ -505,7 +505,7 @@ export const Generic = () => { await waitFor(async () => { const readRows = await BetterSqlite.selectFromTable('reads'); const matchingReadRows = readRows.filter( - (r) => targetChannel.cid === r.cid && chatClient.userID === r.userId, + (r) => targetChannel.cid === r.cid && chatClient.userId === r.userId, ); expect(matchingReadRows.length).toBe(1); @@ -538,7 +538,7 @@ export const Generic = () => { newMessages.some((newMessage) => newMessage.id === m.id), ); const matchingReadRows = readRows.filter( - (r) => targetChannel.cid === r.cid && chatClient.userID === r.userId, + (r) => targetChannel.cid === r.cid && chatClient.userId === r.userId, ); expect(matchingMessageRows.length).toBe(3); @@ -567,7 +567,7 @@ export const Generic = () => { await waitFor(async () => { const readRows = await BetterSqlite.selectFromTable('reads'); const matchingReadRows = readRows.filter( - (r) => targetChannel.cid === r.cid && chatClient.userID === r.userId, + (r) => targetChannel.cid === r.cid && chatClient.userId === r.userId, ); expect(matchingReadRows.length).toBe(1); @@ -600,7 +600,7 @@ export const Generic = () => { newMessages.some((newMessage) => newMessage.id === m.id), ); const matchingReadRows = readRows.filter( - (r) => targetChannel.cid === r.cid && chatClient.userID === r.userId, + (r) => targetChannel.cid === r.cid && chatClient.userId === r.userId, ); expect(matchingMessageRows.length).toBe(3); @@ -955,7 +955,7 @@ export const Generic = () => { const readsRows = await BetterSqlite.selectFromTable('reads'); const matchingReadRows = readsRows.filter( - (r) => r.userId === chatClient.userID && r.cid === channelToTruncate.cid, + (r) => r.userId === chatClient.userId && r.cid === channelToTruncate.cid, ); expect(matchingMessagesRows.length).toBe(0); @@ -1006,7 +1006,7 @@ export const Generic = () => { const readsRows = await BetterSqlite.selectFromTable('reads'); const matchingReadRows = readsRows.filter( - (r) => r.userId === chatClient.userID && r.cid === channelToTruncate.cid, + (r) => r.userId === chatClient.userId && r.cid === channelToTruncate.cid, ); const messagesLeft = messages.length / 2 - 1; diff --git a/package/src/__tests__/offline-support/optimistic-update.tsx b/package/src/__tests__/offline-support/optimistic-update.tsx index 35d34a308e..bad6e0c3c4 100644 --- a/package/src/__tests__/offline-support/optimistic-update.tsx +++ b/package/src/__tests__/offline-support/optimistic-update.tsx @@ -1000,7 +1000,7 @@ export const OptimisticUpdates = () => { status: MessageStatusTypes.SENDING, text: 'unsent across a restart', user: chatClient.user as UserResponse, - user_id: chatClient.userID, + user_id: chatClient.userId, }); jest @@ -1069,7 +1069,7 @@ export const OptimisticUpdates = () => { status: MessageStatusTypes.SENDING, text: 'no guard probe please', user: chatClient.user as UserResponse, - user_id: chatClient.userID, + user_id: chatClient.userId, }); jest @@ -1297,14 +1297,14 @@ export const OptimisticUpdates = () => { status: MessageStatusTypes.SENDING, text: 'offline resend', user: chatClient.user as UserResponse, - user_id: chatClient.userID, + user_id: chatClient.userId, }); const serverMessage = generateMessage({ cid: channel.cid, id: localMessage.id, text: localMessage.text, user: chatClient.user as UserResponse, - user_id: chatClient.userID, + user_id: chatClient.userId, }); jest @@ -1412,7 +1412,7 @@ export const OptimisticUpdates = () => { status: MessageStatusTypes.SENDING, text: 'offline resend unresolved', user: chatClient.user as UserResponse, - user_id: chatClient.userID, + user_id: chatClient.userId, }); jest diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index c72485f758..a4cc13291e 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -3,7 +3,7 @@ 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'; -import { asTimestampNS, StreamChat } from 'stream-chat'; +import { asTimestampNS, ChannelWatchStatus, StreamChat } from 'stream-chat'; import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; import { ChannelContext, ChannelProvider } from '../../../contexts/channelContext/ChannelContext'; @@ -153,7 +153,7 @@ describe('Channel', () => { } as never); draft.initialized = true; const watchSpy = jest.spyOn(draft, 'watch'); - const reloadSpy = jest.spyOn(draft.messagePaginator, 'reload'); + const seedSpy = jest.spyOn(draft.messagePaginator, 'reload'); renderComponent({ channel: draft }); // let the mount effect run to its end @@ -163,7 +163,7 @@ describe('Channel', () => { expect(draft.isProvisional).toBe(true); expect(watchSpy).not.toHaveBeenCalled(); - expect(reloadSpy).not.toHaveBeenCalled(); + expect(seedSpy).not.toHaveBeenCalled(); }); it('should set an error if channel watch fails and render a LoadingErrorIndicator', async () => { @@ -757,6 +757,45 @@ describe('Channel initial load useEffect', () => { await waitFor(() => expect(Object.keys(channel.state.members)).toHaveLength(10)); }); + describe('watch on mount for an initialized channel at the head', () => { + const mountLoadedChannel = async (watchStatus: ChannelWatchStatus) => { + const messages = Array.from({ length: 10 }, (_, i) => generateMessage({ id: String(i) })); + const mockedChannel = generateChannelResponse({ messages }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + jest.spyOn(channel.messagePaginator, 'isActiveIntervalAtHead', 'get').mockReturnValue(true); + channel.watchStatus = watchStatus; + const watchSpy = jest.fn(); + channel.watch = watchSpy; + const seedSpy = jest.spyOn(channel.messagePaginator, 'seedUnreadSnapshot'); + + renderComponent({ channel, markReadOnMount: false }); + // The seed runs after the watch decision, so the mount effect has made it by then. + await waitFor(() => expect(seedSpy).toHaveBeenCalled()); + + return watchSpy; + }; + + it('watches a channel that was loaded without a watch, such as a search result', async () => { + const watchSpy = await mountLoadedChannel(ChannelWatchStatus.NotWatching); + + expect(watchSpy).toHaveBeenCalledTimes(1); + }); + + it('does not watch a channel that is already watched', async () => { + const watchSpy = await mountLoadedChannel(ChannelWatchStatus.Watching); + + expect(watchSpy).not.toHaveBeenCalled(); + }); + + it('leaves a channel whose watch dropped with the connection to the connection recovery', async () => { + const watchSpy = await mountLoadedChannel(ChannelWatchStatus.WasWatching); + + expect(watchSpy).not.toHaveBeenCalled(); + }); + }); + describe('message page size', () => { const mountChannel = async () => { const mockedChannel = generateChannelResponse({ messages: [] }); @@ -979,7 +1018,7 @@ describe('Channel initial load useEffect', () => { await channel.watch(); // Call through to the real reload so the resync actually reconciles (and we can assert it ran). - const reloadSpy = jest.spyOn(channel, 'reload'); + const seedSpy = jest.spyOn(channel, 'reload'); renderComponent({ channel }); @@ -1007,7 +1046,7 @@ describe('Channel initial load useEffect', () => { // The reload re-watches and reconciles, but preserves the failed (locally-unsent) messages — // the 10 originals + 10 failed remain. await waitFor(() => { - expect(reloadSpy).toHaveBeenCalled(); + expect(seedSpy).toHaveBeenCalled(); expect(channel.messagePaginator.anchoredHeadItems.length).toBe(20); }); }); diff --git a/package/src/components/Channel/hooks/useChannelLifecycle.ts b/package/src/components/Channel/hooks/useChannelLifecycle.ts index a00c73251d..c266ef5f16 100644 --- a/package/src/components/Channel/hooks/useChannelLifecycle.ts +++ b/package/src/components/Channel/hooks/useChannelLifecycle.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react'; +import { ChannelWatchStatus } from 'stream-chat'; import type { Channel, StreamChat } from 'stream-chat'; import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext'; @@ -107,7 +108,9 @@ export const useChannelLifecycle = ({ } if ( - (!channel.initialized || !channel.messagePaginator.isActiveIntervalAtHead) && + (!channel.initialized || + !channel.messagePaginator.isActiveIntervalAtHead || + channel.watchStatus === ChannelWatchStatus.NotWatching) && initializeOnMount ) { try { diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetails.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetails.test.tsx index a8f13e4817..7ee9f56580 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetails.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetails.test.tsx @@ -28,7 +28,7 @@ const Providers = ({ children }: PropsWithChildren) => ( }} > {children} diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsActionsSection.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsActionsSection.test.tsx index 77577aba8b..ff4aa8de1d 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetailsActionsSection.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsActionsSection.test.tsx @@ -69,7 +69,7 @@ const sectionElement = () => ( mutedUsers: [], mutedUsersStore, on: () => ({ unsubscribe: () => undefined }), - userID: 'me', + userId: 'me', }, } as never } diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx index 5ceeb24562..30cd91dbb0 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsEditButton.test.tsx @@ -63,7 +63,7 @@ const Providers = ({ children }: PropsWithChildren) => ( }} > {children} diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsMemberSection.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsMemberSection.test.tsx index 1ad1ca580d..64be26f910 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetailsMemberSection.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsMemberSection.test.tsx @@ -99,7 +99,7 @@ const renderSection = ({ mutedUsers: [], notifications: new NotificationManager(), on: () => ({ unsubscribe: () => undefined }), - userID: 'me', + userId: 'me', }, } as never } diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavHeader.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavHeader.test.tsx index 4d6c0161a0..c663c8c639 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavHeader.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsNavHeader.test.tsx @@ -39,7 +39,7 @@ const Providers = ({ children }: PropsWithChildren) => ( }} > {children} diff --git a/package/src/components/ChannelDetails/__tests__/ChannelDetailsProfile.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelDetailsProfile.test.tsx index cd839d2b14..8b08cbbaa6 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelDetailsProfile.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelDetailsProfile.test.tsx @@ -31,7 +31,7 @@ const buildChannel = () => ({ cid: 'messaging:test', data: {}, - getClient: () => ({ userID: OWN_USER_ID }), + getClient: () => ({ userId: OWN_USER_ID }), on: () => ({ unsubscribe: () => undefined }), }) as unknown as Channel; @@ -45,7 +45,7 @@ const renderProfile = ({ channel = buildChannel() }: { channel?: Channel } = {}) userLanguage: 'en', }} > - + diff --git a/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsForm.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsForm.test.tsx index f5dec06e21..840fc2a716 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsForm.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsForm.test.tsx @@ -73,7 +73,7 @@ const renderForm = ({ channel, onClose = jest.fn() }: { channel: Channel; onClos }} > diff --git a/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsFormContent.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsFormContent.test.tsx index 25b0270821..301c7f8e50 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsFormContent.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelEditDetailsFormContent.test.tsx @@ -80,7 +80,7 @@ const renderComponent = ({ channel }: { channel: Channel }) => { > ({ unsubscribe: () => undefined }), userID: 'me' } } as never + { client: { on: () => ({ unsubscribe: () => undefined }), userId: 'me' } } as never } > @@ -128,7 +128,7 @@ describe('ChannelEditDetailsFormContent', () => { > ({ unsubscribe: () => undefined }), userID: 'me' } } as never + { client: { on: () => ({ unsubscribe: () => undefined }), userId: 'me' } } as never } > diff --git a/package/src/components/ChannelDetails/__tests__/ChannelEditName.test.tsx b/package/src/components/ChannelDetails/__tests__/ChannelEditName.test.tsx index 42ca9cf72f..7daa53979b 100644 --- a/package/src/components/ChannelDetails/__tests__/ChannelEditName.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/ChannelEditName.test.tsx @@ -35,7 +35,7 @@ const renderComponent = ({ channel }: { channel: Channel }) => { > ({ unsubscribe: () => undefined }), userID: 'me' } } as never + { client: { on: () => ({ unsubscribe: () => undefined }), userId: 'me' } } as never } > diff --git a/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersForm.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersForm.test.tsx index e996b2b935..c11d91b8ad 100644 --- a/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersForm.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/members/ChannelAddMembersForm.test.tsx @@ -102,7 +102,7 @@ const renderForm = ({ }} > diff --git a/package/src/components/ChannelDetails/__tests__/members/ChannelMemberActionsSheet.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberActionsSheet.test.tsx index ee5086687e..c8459adfe8 100644 --- a/package/src/components/ChannelDetails/__tests__/members/ChannelMemberActionsSheet.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberActionsSheet.test.tsx @@ -76,7 +76,7 @@ const renderSheet = ({ client: { mutedUsers: [], on: () => ({ unsubscribe: () => undefined }), - userID: 'me', + userId: 'me', }, } as never } @@ -156,7 +156,7 @@ describe('ChannelMemberActionsSheet', () => { ({ unsubscribe: () => undefined }) }, + client: { userId: 'me', mutedUsers: [], on: () => ({ unsubscribe: () => undefined }) }, } as never } > diff --git a/package/src/components/ChannelDetails/__tests__/members/ChannelMemberItem.test.tsx b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberItem.test.tsx index f2a77b6f7d..24cc8656e6 100644 --- a/package/src/components/ChannelDetails/__tests__/members/ChannelMemberItem.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/members/ChannelMemberItem.test.tsx @@ -75,7 +75,7 @@ const renderRow = ({ // `useMutedUsers` reads the mutes reactively off `client.mutedUsersStore`. mutedUsersStore: new StateStore({ mutedUsers }), on: () => ({ unsubscribe: () => undefined }), - userID: currentUserId, + userId: currentUserId, }, } as never } diff --git a/package/src/components/ChannelDetails/__tests__/useChannelDetailsMemberStatusText.test.tsx b/package/src/components/ChannelDetails/__tests__/useChannelDetailsMemberStatusText.test.tsx index 1de601e36a..ce92c83431 100644 --- a/package/src/components/ChannelDetails/__tests__/useChannelDetailsMemberStatusText.test.tsx +++ b/package/src/components/ChannelDetails/__tests__/useChannelDetailsMemberStatusText.test.tsx @@ -55,7 +55,7 @@ const t = (( const OWN_USER_ID = 'own-user'; const channel = { cid: 'messaging:test', - getClient: () => ({ userID: OWN_USER_ID }), + getClient: () => ({ userId: OWN_USER_ID }), } as unknown as Channel; const renderStatusText = () => { diff --git a/package/src/components/ChannelDetails/components/members/ChannelMemberItem.tsx b/package/src/components/ChannelDetails/components/members/ChannelMemberItem.tsx index c012ae4d3d..e80a0d0137 100644 --- a/package/src/components/ChannelDetails/components/members/ChannelMemberItem.tsx +++ b/package/src/components/ChannelDetails/components/members/ChannelMemberItem.tsx @@ -60,7 +60,7 @@ export const ChannelMemberItem = ({ if (!user) return null; const isLarge = size === 'lg'; - const isCurrentUser = !!client?.userID && user.id === client.userID; + const isCurrentUser = !!client?.userId && user.id === client.userId; const displayName = isCurrentUser ? t('common.you.label', 'You') : (user.name ?? user.id); const accessibilityLabel = composeAccessibilityLabel( displayName, diff --git a/package/src/components/ChannelDetails/hooks/useChannelDetailsMemberStatusText.ts b/package/src/components/ChannelDetails/hooks/useChannelDetailsMemberStatusText.ts index 31042ec5c1..93576a822c 100644 --- a/package/src/components/ChannelDetails/hooks/useChannelDetailsMemberStatusText.ts +++ b/package/src/components/ChannelDetails/hooks/useChannelDetailsMemberStatusText.ts @@ -24,7 +24,7 @@ export const useChannelDetailsMemberStatusText = (channel: Channel): string => { return useMemo(() => { if (isDirect) { - const ownUserId = channel.getClient().userID; + const ownUserId = channel.getClient().userId; const otherMember = Object.values(members).find((member) => member.user?.id !== ownUserId); return otherMember?.user?.online ? t('common.presence.online.label', 'Online') : ''; } diff --git a/package/src/components/ChannelList/ChannelListView.tsx b/package/src/components/ChannelList/ChannelListView.tsx index f2a70b796f..f2affbe6bf 100644 --- a/package/src/components/ChannelList/ChannelListView.tsx +++ b/package/src/components/ChannelList/ChannelListView.tsx @@ -36,7 +36,7 @@ export type ChannelListState = { export type ChannelListViewPropsWithContext = Omit< ChannelsContextValue, - 'maxUnreadCount' | 'numberOfSkeletons' | 'onSelect' + 'maxUnreadCount' | 'numberOfSkeletons' | 'onSelect' | 'paginator' > & ChannelListState; @@ -163,7 +163,6 @@ export const ChannelListView = (props: ChannelListViewProps) => { additionalFlatListProps, loadMoreThreshold, loadNextPage, - paginator, refreshList, reloadList, setFlatListRef, @@ -175,7 +174,6 @@ export const ChannelListView = (props: ChannelListViewProps) => { additionalFlatListProps, loadMoreThreshold, loadNextPage, - paginator, refreshList, reloadList, setFlatListRef, diff --git a/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx b/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx index dfcabd70c3..54018a5905 100644 --- a/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx +++ b/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx @@ -45,7 +45,7 @@ export const ChannelDetailsHeader = ({ channel }: ChannelDetailsHeaderProps) => const isOtherMemberOnline = useMemo( () => Object.values(members).some( - (member) => member.user?.id !== client.userID && member.user?.online, + (member) => member.user?.id !== client.userId && member.user?.online, ), [client, members], ); diff --git a/package/src/components/ChannelPreview/ChannelPreviewView.tsx b/package/src/components/ChannelPreview/ChannelPreviewView.tsx index 88ed182d08..9f7d104d51 100644 --- a/package/src/components/ChannelPreview/ChannelPreviewView.tsx +++ b/package/src/components/ChannelPreview/ChannelPreviewView.tsx @@ -77,7 +77,7 @@ const ChannelPreviewViewWithContext = (props: ChannelPreviewViewPropsWithContext } = useTheme(); const styles = useStyles(); const swipeRegistry = useSwipeRegistryContext(); - const accessibilityHint = useA11yLabel('attachment.gallery.doubleTapToOpen.accessibilityLabel'); + const accessibilityHint = useA11yLabel('channelPreview.doubleTapToOpen.accessibilityLabel'); const onPress = useStableCallback(() => { if (swipeRegistry?.hasOpen()) { diff --git a/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx b/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx index 2de0c4ddaf..59fb964502 100644 --- a/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx +++ b/package/src/components/ChannelPreview/__tests__/ChannelPreview.test.tsx @@ -145,7 +145,7 @@ describe('ChannelPreview', () => { describe('notification.mark_read event', () => { it("should not update the unread count if the event's cid does not match the channel's cid", async () => { channel = await initChannelFromData(chatClient); - seedUnread(channel, chatClient.userID as string, 10); + seedUnread(channel, chatClient.userId as string, 10); const { getByTestId } = render(); @@ -167,7 +167,7 @@ describe('ChannelPreview', () => { it('should update the unread count to 0', async () => { channel = await initChannelFromData(chatClient); - seedUnread(channel, chatClient.userID as string, 10); + seedUnread(channel, chatClient.userId as string, 10); const { getByTestId } = render(); @@ -327,7 +327,7 @@ describe('ChannelPreview', () => { muteStatus: { createdAt: null, expiresAt: null, muted: true }, }); }); - seedUnread(channel, chatClient.userID as string, 5); + seedUnread(channel, chatClient.userId as string, 5); const { getByTestId } = render(); @@ -360,7 +360,7 @@ describe('ChannelPreview', () => { it('should update the unread count on "message.new" event', async () => { const someOtherUser = generateUser({ id: 'not-me' }); channel = await initChannelFromData(chatClient); - seedUnread(channel, chatClient.userID as string, 0); + seedUnread(channel, chatClient.userId as string, 0); const { getByTestId } = render(); diff --git a/package/src/components/ChannelPreview/__tests__/ChannelPreviewView.test.tsx b/package/src/components/ChannelPreview/__tests__/ChannelPreviewView.test.tsx index e3440fe925..7aef8a498d 100644 --- a/package/src/components/ChannelPreview/__tests__/ChannelPreviewView.test.tsx +++ b/package/src/components/ChannelPreview/__tests__/ChannelPreviewView.test.tsx @@ -4,6 +4,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react-nativ import truncate from 'lodash/truncate'; import type { Channel as ChannelType, StreamChat } from 'stream-chat'; +import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; +import type { TranslationDictionary } from '../../../i18n/types'; import { getOrCreateChannelApi } from '../../../mock-builders/api/getOrCreateChannel'; import { useMockedApis } from '../../../mock-builders/api/useMockedApis'; import { generateChannelResponse } from '../../../mock-builders/generator/channel'; @@ -11,6 +13,7 @@ import { generateMember } from '../../../mock-builders/generator/member'; import { generateMessage } from '../../../mock-builders/generator/message'; import { generateUser } from '../../../mock-builders/generator/user'; import { getTestClientWithUser } from '../../../mock-builders/mock'; +import { Streami18n } from '../../../utils/i18n/Streami18n'; import { Chat } from '../../Chat/Chat'; import { ChannelPreviewView } from '../ChannelPreviewView'; @@ -56,6 +59,43 @@ describe('ChannelPreviewView', () => { }); }); + it("takes its accessibility hint from its own key, not the gallery's", async () => { + await initializeChannel(generateChannelResponse()); + const i18nInstance = new Streami18n({ logger: () => {} }); + i18nInstance.registerTranslation('en', { + 'attachment.gallery.doubleTapToOpen.accessibilityLabel': 'Double tap to view the photo', + } as TranslationDictionary); + + const { rerender } = render( + + + + + , + ); + await waitFor(() => + expect(screen.getByTestId('channel-preview-button').props.accessibilityHint).toBe( + 'Double tap to open', + ), + ); + + i18nInstance.registerTranslation('en', { + 'channelPreview.doubleTapToOpen.accessibilityLabel': 'Double tap to open the channel', + } as TranslationDictionary); + rerender( + + + + + , + ); + await waitFor(() => + expect(screen.getByTestId('channel-preview-button').props.accessibilityHint).toBe( + 'Double tap to open the channel', + ), + ); + }); + it('should render name of channel', async () => { const channelName = Date.now().toString(); await initializeChannel( diff --git a/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewDisplayName.test.tsx b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewDisplayName.test.tsx index dbe3a1cb2d..f53f7e42b7 100644 --- a/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewDisplayName.test.tsx +++ b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewDisplayName.test.tsx @@ -24,7 +24,7 @@ describe('useChannelPreviewDisplayName', () => { jest .spyOn(ChatContext, 'useChatContext') .mockImplementation( - () => ({ client: { userID: OWN_USER_ID } }) as unknown as ChatContextValue, + () => ({ client: { userId: OWN_USER_ID } }) as unknown as ChatContextValue, ); jest.spyOn(useChannelNameModule, 'useChannelName').mockReturnValue(undefined); }); diff --git a/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewDisplayPresence.test.tsx b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewDisplayPresence.test.tsx index e818e72aaa..4853fc181a 100644 --- a/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewDisplayPresence.test.tsx +++ b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewDisplayPresence.test.tsx @@ -18,7 +18,7 @@ describe('useChannelPreviewDisplayPresence', () => { jest.clearAllMocks(); chatClient = await getTestClientWithUser({ id: currentUserId, - userID: currentUserId, + userId: currentUserId, } as unknown as Parameters[0]); // Create mock channel diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts index c9bc87520f..128f41d6ec 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewData.ts @@ -22,7 +22,7 @@ const lastMessageSelector = (state: MessagePaginatorAggregateState) => ({ }); export const useChannelPreviewData = (channel: Channel, client: StreamChat) => { - const userId = client.userID; + const userId = client.userId; const { lastMessage } = useStateStore(channel.messagePaginator.aggregateState, lastMessageSelector) ?? {}; diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts index a470ceafeb..388160366d 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts @@ -18,7 +18,7 @@ const pickName = (member: ChannelMemberResponse) => ({ export const useChannelPreviewDisplayName = (channel?: Channel) => { const { client } = useChatContext(); const { t } = useTranslationContext(); - const currentUserId = client?.userID; + const currentUserId = client?.userId; const members = useChannelMembersState(channel, pickName); const channelName = useChannelName(channel); diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayPresence.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayPresence.ts index d2d09b5b38..b4f760ffcb 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayPresence.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayPresence.ts @@ -11,7 +11,7 @@ import { useSyncClientEventsToChannel } from '../../../hooks/useSyncClientEvents const selector = (channel: Channel, client: StreamChat) => { const members = channel.state.members; const membersCount = Object.keys(members).length; - const otherMember = Object.values(members).find((member) => member.user?.id !== client.userID); + const otherMember = Object.values(members).find((member) => member.user?.id !== client.userId); if (membersCount !== 2) return false; return otherMember?.user?.online ?? false; diff --git a/package/src/components/Chat/__tests__/Chat.test.tsx b/package/src/components/Chat/__tests__/Chat.test.tsx index 94820ac7d2..7eca942761 100644 --- a/package/src/components/Chat/__tests__/Chat.test.tsx +++ b/package/src/components/Chat/__tests__/Chat.test.tsx @@ -4,7 +4,7 @@ import { View } from 'react-native'; import NetInfo from '@react-native-community/netinfo'; import { act, cleanup, render, screen, waitFor } from '@testing-library/react-native'; -import type { StreamChat } from 'stream-chat'; +import { StreamChat } from 'stream-chat'; import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; @@ -136,6 +136,36 @@ describe('Chat', () => { expect(chatClient.networkConnection.isOnline).toBe(true); }); + it('keeps a reporter the app registered before mounting', async () => { + const statusReporter = jest.fn(() => () => undefined); + chatClient.config.set({ client: { networkConnection: { statusReporter } } }); + + const { getByTestId } = render( + + + , + ); + await waitFor(() => expect(getByTestId('children')).toBeTruthy()); + + expect(chatClient.networkConnection.config.statusReporter).toBe(statusReporter); + expect(NetInfo.addEventListener).not.toHaveBeenCalled(); + }); + + it('keeps a reporter passed to the client constructor', async () => { + const statusReporter = jest.fn(() => () => undefined); + const client = new StreamChat('key', { client: { networkConnection: { statusReporter } } }); + + const { getByTestId } = render( + + + , + ); + await waitFor(() => expect(getByTestId('children')).toBeTruthy()); + + expect(client.networkConnection.config.statusReporter).toBe(statusReporter); + expect(NetInfo.addEventListener).not.toHaveBeenCalled(); + }); + it('keeps the NetInfo listener alive after unmount, because the client outlives ', async () => { // The reporter's lifetime is the CLIENT's, not this component's. Releasing it here would leave // `isOnline` frozen at a stale value (`setStatusReporter(null)` keeps the last status by design), diff --git a/package/src/components/Chat/hooks/useConnectionLifecycle.ts b/package/src/components/Chat/hooks/useConnectionLifecycle.ts index bb8c61cced..8925fe5096 100644 --- a/package/src/components/Chat/hooks/useConnectionLifecycle.ts +++ b/package/src/components/Chat/hooks/useConnectionLifecycle.ts @@ -9,9 +9,9 @@ import type { NetworkStatusReporter, StreamChat } from 'stream-chat'; import { useAppStateListener } from '../../../hooks/useAppStateListener'; /** - * Installs the NetInfo network status reporter on the client and closes the WebSocket while the app - * is in the background, reopening it in the foreground (push notifications are only delivered while - * no socket is active). + * Installs the NetInfo network status reporter on the client, unless the app registered one, and + * closes the WebSocket while the app is in the background, reopening it in the foreground (push + * notifications are only delivered while no socket is active). * * Read the results with `useNetworkConnectionState()` and `useWSConnectionState()`. * @@ -32,7 +32,7 @@ export const useConnectionLifecycle = (client: StreamChat, closeConnectionOnBack const onForeground = useCallback(() => { // Without a user ID, opening the connection would raise an unneeded error. - if (!clientExists || !client.userID) { + if (!clientExists || !client.userId) { return; } @@ -44,7 +44,7 @@ export const useConnectionLifecycle = (client: StreamChat, closeConnectionOnBack // A `` mounted while the app is already active (Android recreating the activity) never sees // the background-to-foreground change that reopens a socket its predecessor closed. useEffect(() => { - if (!closeConnectionOnBackground || !clientExists || !client.userID) return; + if (!closeConnectionOnBackground || !clientExists || !client.userId) return; if (AppState.currentState === 'active' && client.wsConnection.connection?.isDisconnected) { client.openConnection(); } @@ -55,6 +55,11 @@ export const useConnectionLifecycle = (client: StreamChat, closeConnectionOnBack return; } + // a reporter the app registered, through `client.config` or the client's constructor wins + if (client.config.getConfig('client')?.networkConnection?.statusReporter) { + return; + } + // Set through the client config so `client.config.get('client')` shows the installed reporter. // Required on React Native: the client's fallback mirrors its own WebSocket and cannot report // the network returning before the socket notices. @@ -74,7 +79,8 @@ export const useConnectionLifecycle = (client: StreamChat, closeConnectionOnBack }; /** - * Subscribes to NetInfo and reports every change to the client. `` installs it in an effect. + * Subscribes to NetInfo and reports every change to the client. `` installs it in an effect, + * unless a reporter is registered in `client.config`. * * When you build the client yourself, install it at construction instead: * diff --git a/package/src/components/Indicators/LoadingErrorIndicator.tsx b/package/src/components/Indicators/LoadingErrorIndicator.tsx index cd00204c72..f0fa45d963 100644 --- a/package/src/components/Indicators/LoadingErrorIndicator.tsx +++ b/package/src/components/Indicators/LoadingErrorIndicator.tsx @@ -33,7 +33,7 @@ const LoadingErrorWrapper = (props: React.PropsWithChildren Promise; retry?: () => void; }; @@ -64,6 +64,13 @@ export const LoadingErrorIndicator = (props: LoadingErrorProps) => { )} /> ); + case 'thread': + return ( + + ); default: return ; } diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 209e4b9744..1d46ab4c69 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -343,7 +343,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const actionsEnabled = message.type === 'regular' && message.status === MessageStatusTypes.RECEIVED; - const isMyMessage = client && message && client.userID === message.user?.id; + const isMyMessage = client && message && client.userId === message.user?.id; const handleAction = async (name: string, value: string) => { if (message.id) { diff --git a/package/src/components/Message/MessageItemView/MessageWrapper.tsx b/package/src/components/Message/MessageItemView/MessageWrapper.tsx index 14ea99e5b7..f9d9921325 100644 --- a/package/src/components/Message/MessageItemView/MessageWrapper.tsx +++ b/package/src/components/Message/MessageItemView/MessageWrapper.tsx @@ -57,8 +57,9 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW const createdAtTimestamp = message.created_at; const messageId = message.id; const nextMessageId = nextMessage?.id; - const nextMessageIsOwn = nextMessage?.user?.id === client.userID; + const nextMessageIsOwn = nextMessage?.user?.id === client.userId; const nextMessageCreatedAt = nextMessage?.created_at ?? undefined; + const isThreadList = !!messagePaginator.parentMessageId; // The unread separator goes on the row above the first unread message from another user. It is // located per message inside the selector, so the flag stays `false` for every other row and a @@ -66,7 +67,9 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW const showUnreadSeparatorSelector = useCallback( (snapshot: UnreadSnapshotState) => { let showUnreadSeparator: boolean; - if (snapshot.firstUnreadMessageId) { + if (isThreadList) { + showUnreadSeparator = false; + } else if (snapshot.firstUnreadMessageId) { // Frozen boundary (channel open or mark-unread): anchor to the known first unread ID. showUnreadSeparator = nextMessageId === snapshot.firstUnreadMessageId; } else if (snapshot.unreadCount) { @@ -103,7 +106,14 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW unreadCount: showUnreadSeparator ? snapshot.unreadCount : undefined, }; }, - [createdAtTimestamp, messageId, nextMessageCreatedAt, nextMessageId, nextMessageIsOwn], + [ + createdAtTimestamp, + isThreadList, + messageId, + nextMessageCreatedAt, + nextMessageId, + nextMessageIsOwn, + ], ); const { showUnreadSeparator, unreadCount } = useStateStore( messagePaginator.unreadStateSnapshot, @@ -119,7 +129,7 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW const showUnreadUnderlay = !!shouldShowUnreadUnderlay && showUnreadSeparator; - const wrapMessageInTheme = client.userID === message.user?.id && !!myMessageTheme; + const wrapMessageInTheme = client.userId === message.user?.id && !!myMessageTheme; const renderDateSeperator = dateSeparatorDate ? ( diff --git a/package/src/components/Message/hooks/__tests__/useCreateMessageActionHandlers.test.tsx b/package/src/components/Message/hooks/__tests__/useCreateMessageActionHandlers.test.tsx index 553def408e..464f69eb81 100644 --- a/package/src/components/Message/hooks/__tests__/useCreateMessageActionHandlers.test.tsx +++ b/package/src/components/Message/hooks/__tests__/useCreateMessageActionHandlers.test.tsx @@ -22,7 +22,7 @@ const createClient = (mutedUsers: UserMuteResponse[] = []) => on: jest.fn(() => ({ unsubscribe: jest.fn() })), pinMessage: jest.fn(), unpinMessage: jest.fn(), - userID: 'current-user-id', + userId: 'current-user-id', }) as unknown as StreamChat & { moderation: { mute: jest.Mock; unmute: jest.Mock }; notifications: { add: jest.Mock; remove: jest.Mock; startTimeout: jest.Mock }; diff --git a/package/src/components/Message/hooks/__tests__/useUserMuteActive.test.tsx b/package/src/components/Message/hooks/__tests__/useUserMuteActive.test.tsx index b33aa6a819..b357b1088a 100644 --- a/package/src/components/Message/hooks/__tests__/useUserMuteActive.test.tsx +++ b/package/src/components/Message/hooks/__tests__/useUserMuteActive.test.tsx @@ -13,7 +13,7 @@ const muteOf = (targetId: string) => const createClient = () => ({ mutedUsersStore: new StateStore<{ mutedUsers: UserMuteResponse[] }>({ mutedUsers: [] }), - userID: 'current-user-id', + userId: 'current-user-id', }) as unknown as StreamChat; describe('useUserMuteActive', () => { diff --git a/package/src/components/Message/hooks/useCreateMessageActionHandlers.ts b/package/src/components/Message/hooks/useCreateMessageActionHandlers.ts index 665fc1b77a..32dd44ce09 100644 --- a/package/src/components/Message/hooks/useCreateMessageActionHandlers.ts +++ b/package/src/components/Message/hooks/useCreateMessageActionHandlers.ts @@ -197,7 +197,7 @@ export const useCreateMessageActionHandlers = ({ const isMuted = isUserMuted( client.mutedUsersStore.getLatestValue().mutedUsers, - client.userID, + client.userId, message.user.id, ); @@ -371,9 +371,9 @@ export const useCreateMessageActionHandlers = ({ const userExistingReaction = own_reactions.find((reaction) => { // `own_reactions` should only hold the current user's reactions; checked anyway so a bad // message update cannot break toggling. - if (reaction.user && client.userID === reaction.user.id && reaction.type === reactionType) { + if (reaction.user && client.userId === reaction.user.id && reaction.type === reactionType) { return true; - } else if (reaction.user && client.userID !== reaction.user.id) { + } else if (reaction.user && client.userId !== reaction.user.id) { console.warn( 'message.own_reactions contained reactions from a different user, this indicates a bug', ); diff --git a/package/src/components/Message/hooks/useProcessReactions.ts b/package/src/components/Message/hooks/useProcessReactions.ts index 29722cf7f7..a5547b65e8 100644 --- a/package/src/components/Message/hooks/useProcessReactions.ts +++ b/package/src/components/Message/hooks/useProcessReactions.ts @@ -113,7 +113,7 @@ export const useProcessReactions = (props: UseProcessReactionsParams) => { Icon: getEmojiByReactionType(reactionType, supportedReactions), lastReactionAt: convertTimestampToDate(last_reaction_at) ?? null, latestReactedUserNames, - own: isOwnReaction(reactionType, own_reactions, latest_reactions, client.userID), + own: isOwnReaction(reactionType, own_reactions, latest_reactions, client.userId), type: reactionType, unlistedReactedUserCount: count - latestReactedUserNames.length, }; @@ -126,7 +126,7 @@ export const useProcessReactions = (props: UseProcessReactionsParams) => { totalReactionCount: unsortedReactions.reduce((total, { count }) => total + count, 0), }; }, [ - client.userID, + client.userId, reaction_groups, own_reactions, latest_reactions, diff --git a/package/src/components/Message/hooks/useUserMuteActive.ts b/package/src/components/Message/hooks/useUserMuteActive.ts index 38e5af455b..8cd2f7f240 100644 --- a/package/src/components/Message/hooks/useUserMuteActive.ts +++ b/package/src/components/Message/hooks/useUserMuteActive.ts @@ -32,7 +32,7 @@ export const isUserMuted = ( */ export const useUserMuteActive = (user: UserResponse | null | undefined) => { const { client } = useChatContext(); - const currentUserId = client?.userID; + const currentUserId = client?.userId; const userId = user?.id; const selector = useCallback( diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 7230f22772..3b49b4e72f 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -321,7 +321,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => channel, markRead, readEvents, - userId: client.userID, + userId: client.userId, }); const renderItem = useCallback( @@ -343,7 +343,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const shouldScrollToRecentOnNewOwnMessageRef = useShouldScrollToRecentOnNewOwnMessage( rawMessageList, - client.userID, + client.userId, ); const [autoscrollToRecent, setAutoscrollToRecent] = useState(true); diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index a2713c0734..4e37f55ecc 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -321,7 +321,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { channel, markRead, readEvents, - userId: client.userID, + userId: client.userId, }); const previousDerivedItemsRef = useRef>(undefined); @@ -355,7 +355,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const shouldScrollToRecentOnNewOwnMessageRef = useShouldScrollToRecentOnNewOwnMessage( rawMessageList, - client.userID, + client.userId, ); const [autoscrollToRecent, setAutoscrollToRecent] = useState(false); diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index b01e7daeac..ef64b197bc 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -1375,7 +1375,9 @@ describe('MessageList in a thread', () => { expect(markReadLocally).not.toHaveBeenCalled(); }); - it("places the unread separator from the thread's unread state, not the channel's", async () => { + // The LLC seeds the thread's snapshot from the channel's read state, which says nothing about the + // replies, so a thread draws no separator from either snapshot. + it('draws no unread separator in a thread', async () => { const { channel, queryAllByLabelText, replies, threadInstance } = await setup(); act(() => @@ -1386,12 +1388,28 @@ describe('MessageList in a thread', () => { ); expect(queryAllByLabelText('Inline unread indicator')).toHaveLength(0); - act(() => - threadInstance.messagePaginator.unreadStateSnapshot.partialNext({ - firstUnreadMessageId: replies[3].id, - unreadCount: 2, - }), - ); - await waitFor(() => expect(queryAllByLabelText('Inline unread indicator')).toHaveLength(1)); + // The first reply too, whose separator row would be the parent message. + for (const firstUnread of [replies[0], replies[3]]) { + await act(async () => { + threadInstance.messagePaginator.unreadStateSnapshot.partialNext({ + firstUnreadMessageId: firstUnread.id, + unreadCount: 2, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(queryAllByLabelText('Inline unread indicator')).toHaveLength(0); + } + + // The live boundary, from the read time rather than a frozen first-unread ID. + await act(async () => { + threadInstance.messagePaginator.unreadStateSnapshot.next({ + firstUnreadMessageId: null, + lastReadAt: replies[1].created_at, + lastReadMessageId: replies[1].id, + unreadCount: 3, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(queryAllByLabelText('Inline unread indicator')).toHaveLength(0); }); }); diff --git a/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx b/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx index 2898d2b903..8a7e7e253b 100644 --- a/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx +++ b/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx @@ -75,7 +75,7 @@ describe('ScrollToBottomButton', () => { const i18nInstance = new Streami18n(); const translators = await i18nInstance.init(); // The button reads OUR user's count from `channel.state` (a v10 reactive StateStore) via - // `useStateStore`, keyed by the chat client's userID. Seed the `read` slice with 3 unread + // `useStateStore`, keyed by the chat client's `userId`. Seed the `read` slice with 3 unread // for that user. const channel = { state: generateChannelState({ @@ -91,7 +91,7 @@ describe('ScrollToBottomButton', () => { const { getByTestId, getByText } = render( - + null} showNotification={true} /> @@ -139,7 +139,7 @@ describe('ScrollToBottomButton', () => { - + null} showNotification={true} /> diff --git a/package/src/components/MessageList/hooks/useOwnUnreadCount.ts b/package/src/components/MessageList/hooks/useOwnUnreadCount.ts index faa2f39ae5..36c6c39719 100644 --- a/package/src/components/MessageList/hooks/useOwnUnreadCount.ts +++ b/package/src/components/MessageList/hooks/useOwnUnreadCount.ts @@ -14,7 +14,7 @@ import { useStateStore } from '../../../hooks/useStateStore'; */ export const useOwnUnreadCount = (channel: Channel | undefined) => { const { client } = useChatContext(); - const userId = client?.userID; + const userId = client?.userId; const selector = useMemo( () => (state: ReadState) => ({ unreadCount: userId ? (state.read[userId]?.unread_messages ?? 0) : 0, diff --git a/package/src/components/MessageMenu/MessageUserReactionsItem.tsx b/package/src/components/MessageMenu/MessageUserReactionsItem.tsx index 58a18a501c..a6299d60e5 100644 --- a/package/src/components/MessageMenu/MessageUserReactionsItem.tsx +++ b/package/src/components/MessageMenu/MessageUserReactionsItem.tsx @@ -36,7 +36,7 @@ export const MessageUserReactionsItem = ({ const styles = useStyles(); const { client } = useChatContext(); - const isOwnReaction = client.userID === id; + const isOwnReaction = client.userId === id; const { t } = useTranslationContext(); const { handleReaction } = useMessageContext(); diff --git a/package/src/components/MessageMenu/hooks/useMessageOwnReactions.ts b/package/src/components/MessageMenu/hooks/useMessageOwnReactions.ts index c8c38bb73e..ce149d71aa 100644 --- a/package/src/components/MessageMenu/hooks/useMessageOwnReactions.ts +++ b/package/src/components/MessageMenu/hooks/useMessageOwnReactions.ts @@ -10,9 +10,9 @@ export const useMessageOwnReactions = () => { const ownReactionTypes = message?.own_reactions?.map((reaction) => reaction.type) || []; const latestReactionTypesByCurrentUser = message?.latest_reactions - ?.filter((reaction) => reaction.user?.id === client.userID) + ?.filter((reaction) => reaction.user?.id === client.userId) .map((reaction) => reaction.type) || []; return Array.from(new Set([...ownReactionTypes, ...latestReactionTypesByCurrentUser])); - }, [client.userID, message?.latest_reactions, message?.own_reactions]); + }, [client.userId, message?.latest_reactions, message?.own_reactions]); }; diff --git a/package/src/components/Poll/components/PollAnswersList.tsx b/package/src/components/Poll/components/PollAnswersList.tsx index 3f77e67b0a..71cec569b9 100644 --- a/package/src/components/Poll/components/PollAnswersList.tsx +++ b/package/src/components/Poll/components/PollAnswersList.tsx @@ -96,7 +96,7 @@ export const PollAnswerListItem = ({ answer }: { answer: PollVoteResponseData }) [answer.updated_at, t, tDateTimeParser], ); - const isMyAnswer = client.userID === answer.user?.id; + const isMyAnswer = client.userId === answer.user?.id; const isAnonymous = useMemo( () => votingVisibility === 'anonymous' && !isMyAnswer, diff --git a/package/src/components/Poll/components/PollButtons.tsx b/package/src/components/Poll/components/PollButtons.tsx index 7d1b1874f0..7fd91d2ca2 100644 --- a/package/src/components/Poll/components/PollButtons.tsx +++ b/package/src/components/Poll/components/PollButtons.tsx @@ -229,7 +229,7 @@ export const EndVoteButton = () => { const { client } = useChatContext(); const styles = useStyles(); - return !isClosed && createdBy?.id === client.userID ? ( + return !isClosed && createdBy?.id === client.userId ? ( { const { createdBy } = usePollState(); const { client } = useChatContext(); - return createdBy?.id === client.userID; + return createdBy?.id === client.userId; }; diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index a801090743..2cfc099ae4 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -138,7 +138,7 @@ export const Thread = (props: PropsWithChildren) => { .toTail() .catch((err: unknown) => console.warn('Reloading the thread replies failed:', err)); - return ; + return ; } return ( diff --git a/package/src/components/Thread/__tests__/Thread.test.tsx b/package/src/components/Thread/__tests__/Thread.test.tsx index 2f2a91e760..319d60b754 100644 --- a/package/src/components/Thread/__tests__/Thread.test.tsx +++ b/package/src/components/Thread/__tests__/Thread.test.tsx @@ -328,6 +328,8 @@ describe('Thread', () => { await waitFor(() => { expect(screen.getByTestId('loading-error')).toBeTruthy(); }); + // The thread's own copy, not the channel's "Error loading messages for this channel...". + expect(screen.getByTestId('loading-error')).toHaveTextContent('Error loading replies...'); }); it('retries the reply query — and only the reply query — from the indicator', async () => { @@ -511,10 +513,10 @@ describe('Thread lifecycle', () => { generateMessage({ reply_count: 3, user }), ); const readState = (unreadMessageCount: number) => ({ - [chatClient.userID as string]: { + [chatClient.userId as string]: { lastReadAt: 1, unreadMessageCount, - user: { id: chatClient.userID as string }, + user: { id: chatClient.userId as string }, }, }); return { markRead, parentMessage, readState, testChannel }; diff --git a/package/src/components/ThreadList/ThreadListItem.tsx b/package/src/components/ThreadList/ThreadListItem.tsx index f318dbf075..49f0434e69 100644 --- a/package/src/components/ThreadList/ThreadListItem.tsx +++ b/package/src/components/ThreadList/ThreadListItem.tsx @@ -158,7 +158,7 @@ export const ThreadListItem = (props: ThreadListItemProps) => { channel: nextValue.channel, deletedAt: nextValue.deletedAt, ownUnreadMessageCount: - (client.userID && nextValue.read[client.userID]?.unreadMessageCount) || 0, + (client.userId && nextValue.read[client.userId]?.unreadMessageCount) || 0, parentMessage: nextValue.parentMessage, }) as const, [client], diff --git a/package/src/components/ThreadList/ThreadListUnreadBanner.tsx b/package/src/components/ThreadList/ThreadListUnreadBanner.tsx index c1963ac5fd..ae5e8fbdae 100644 --- a/package/src/components/ThreadList/ThreadListUnreadBanner.tsx +++ b/package/src/components/ThreadList/ThreadListUnreadBanner.tsx @@ -11,13 +11,15 @@ import { primitives } from '../../theme'; const selector = (nextValue: ThreadManagerState) => ({ unseenThreadIds: nextValue.unseenThreadIds }) as const; -// The paginator records a failed reload and clears it on the next successful query. +// The paginator records a failed query and clears it on the next successful one. const lastQueryErrorSelector = (nextValue: { lastQueryError?: Error }) => ({ lastQueryError: nextValue.lastQueryError, }); export const ThreadListUnreadBanner = () => { + // we need the two extra states so we can tell the client's states apart const [loading, setLoading] = useState(false); + const [reloadFailed, setReloadFailed] = useState(false); const { client } = useChatContext(); const { icons } = useComponentsContext(); const { t } = useTranslationContext(); @@ -48,8 +50,10 @@ export const ThreadListUnreadBanner = () => { setLoading(true); try { await client.threads.reload(); + setReloadFailed(!!client.threads.paginator.lastQueryError); } catch { // Only with the paginator's `throwErrors`; the error is already in its state. + setReloadFailed(true); } finally { setLoading(false); } @@ -64,7 +68,7 @@ export const ThreadListUnreadBanner = () => { ); } - if (lastQueryError) { + if (reloadFailed && lastQueryError) { return ( diff --git a/package/src/components/ThreadList/__tests__/ThreadList.test.tsx b/package/src/components/ThreadList/__tests__/ThreadList.test.tsx index 6f9c3e1dd8..ba171f9b50 100644 --- a/package/src/components/ThreadList/__tests__/ThreadList.test.tsx +++ b/package/src/components/ThreadList/__tests__/ThreadList.test.tsx @@ -179,4 +179,25 @@ describe('ThreadList loading states', () => { await waitFor(() => expect(rows()).toHaveLength(2)); expect(screen.queryByText("Couldn't load new threads. Tap to retry")).toBeNull(); }); + + it('keeps the new threads count when a different thread query failed', async () => { + jest + .spyOn(chatClient, 'queryThreadsAndHydrate') + .mockResolvedValueOnce({ next: undefined, threads: [makeThread()] }); + render( + + + , + ); + await waitFor(() => expect(rows()).toHaveLength(1)); + + // A failed next page, for example: the paginator records it the same way as a failed reload. + act(() => { + chatClient.threads.paginator.state.partialNext({ lastQueryError: new Error('offline') }); + chatClient.threads.state.partialNext({ unseenThreadIds: ['a'] }); + }); + + await waitFor(() => expect(screen.getByText('1 new thread')).toBeTruthy()); + expect(screen.queryByText("Couldn't load new threads. Tap to retry")).toBeNull(); + }); }); diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 6c5fd974b7..24ea0a7ea2 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -150,7 +150,9 @@ export * from './MessageList/hooks/useMarkRead'; // Only the constant, for integrators running their own `jumpToMessage`. export { DEFAULT_HIGHLIGHT_DURATION } from './MessageList/hooks/useMessageListFocus'; export * from './MessageList/hooks/useMessageList'; +export * from './MessageList/hooks/useOwnUnreadCount'; export * from './MessageList/hooks/useTypingString'; +export * from './MessageList/hooks/useTypingUsers'; export * from './MessageList/InlineDateSeparator'; export * from './MessageList/InlineLoadingMoreIndicator'; export * from './MessageList/InlineLoadingMoreRecentIndicator'; diff --git a/package/src/contexts/componentsContext/defaultComponents.ts b/package/src/contexts/componentsContext/defaultComponents.ts index 3850ba1f5c..cb1120516d 100644 --- a/package/src/contexts/componentsContext/defaultComponents.ts +++ b/package/src/contexts/componentsContext/defaultComponents.ts @@ -149,7 +149,6 @@ import { StopMessageStreamingButton } from '../../components/MessageInput/StopMe import { DateHeader } from '../../components/MessageList/DateHeader'; import { InlineDateSeparator } from '../../components/MessageList/InlineDateSeparator'; import { InlineUnreadIndicator } from '../../components/MessageList/InlineUnreadIndicator'; -import { MessageList } from '../../components/MessageList/MessageList'; import { MessageSystem } from '../../components/MessageList/MessageSystem'; import { NetworkDownIndicator } from '../../components/MessageList/NetworkDownIndicator'; import { ScrollToBottomButton } from '../../components/MessageList/ScrollToBottomButton'; @@ -448,7 +447,6 @@ const components = { MessageInputLeadingView, MessageInputTrailingView, MessageItemView, - MessageList, MessagePinnedHeader, MessageReactionPicker, MessageReminderHeader, diff --git a/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx b/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx index 0a69c8fa4d..5c2d9c1179 100644 --- a/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx +++ b/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx @@ -35,7 +35,7 @@ export const LiveLocationManagerProvider = ( } return new LiveLocationManager({ client, - getDeviceId: getDeviceId ?? (() => `react-native-${Platform.OS}-${client.userID}`), + getDeviceId: getDeviceId ?? (() => `react-native-${Platform.OS}-${client.userId}`), watchLocation, }); }, [client, getDeviceId, watchLocation]); diff --git a/package/src/hooks/__tests__/useChannelMuteActive.test.tsx b/package/src/hooks/__tests__/useChannelMuteActive.test.tsx index e5c64ed3be..c3b8ff72f9 100644 --- a/package/src/hooks/__tests__/useChannelMuteActive.test.tsx +++ b/package/src/hooks/__tests__/useChannelMuteActive.test.tsx @@ -21,7 +21,7 @@ const createClient = ({ // muted USERS stay client-global — useMutedUsers reads them reactively from mutedUsersStore. mutedUsersStore: new StateStore({ mutedUsers }), on: jest.fn(() => ({ unsubscribe: jest.fn() })), - userID: CURRENT_USER_ID, + userId: CURRENT_USER_ID, }) as never; // Back the mock channel with a real StateStore (so `useStateStore` can subscribe to `members` and diff --git a/package/src/hooks/__tests__/useIsDirectChat.test.tsx b/package/src/hooks/__tests__/useIsDirectChat.test.tsx index c8354428a3..4ee1704a0e 100644 --- a/package/src/hooks/__tests__/useIsDirectChat.test.tsx +++ b/package/src/hooks/__tests__/useIsDirectChat.test.tsx @@ -23,7 +23,7 @@ describe('useIsDirectChat', () => { jest .spyOn(ChatContext, 'useChatContext') .mockImplementation( - () => ({ client: { userID: OWN_USER_ID } }) as unknown as ChatContextValue, + () => ({ client: { userId: OWN_USER_ID } }) as unknown as ChatContextValue, ); }); diff --git a/package/src/hooks/actions/__tests__/useChannelActionItems.test.tsx b/package/src/hooks/actions/__tests__/useChannelActionItems.test.tsx index e9cf68a164..5f3c9a0a05 100644 --- a/package/src/hooks/actions/__tests__/useChannelActionItems.test.tsx +++ b/package/src/hooks/actions/__tests__/useChannelActionItems.test.tsx @@ -58,7 +58,7 @@ const createChannelMock = (params?: { getLatestValue: () => ({ userIds: blockedUserIds }), subscribeWithSelector: () => () => {}, }, - userID: ownUserId, + userId: ownUserId, }), state: { members: { diff --git a/package/src/hooks/actions/__tests__/useChannelActions.test.tsx b/package/src/hooks/actions/__tests__/useChannelActions.test.tsx index afbf85ad89..78276510a8 100644 --- a/package/src/hooks/actions/__tests__/useChannelActions.test.tsx +++ b/package/src/hooks/actions/__tests__/useChannelActions.test.tsx @@ -58,7 +58,7 @@ const createClient = () => ({ unblockUser: jest.fn(), unmuteUser: jest.fn(), uploadImage: jest.fn().mockResolvedValue({ file: 'https://cdn.example.com/uploaded.png' }), - userID: 'current-user-id', + userId: 'current-user-id', }); const createChannel = (client: ReturnType) => diff --git a/package/src/hooks/actions/__tests__/useChannelMemberActionItems.test.tsx b/package/src/hooks/actions/__tests__/useChannelMemberActionItems.test.tsx index 2fd6dc24b3..eb58cbeac2 100644 --- a/package/src/hooks/actions/__tests__/useChannelMemberActionItems.test.tsx +++ b/package/src/hooks/actions/__tests__/useChannelMemberActionItems.test.tsx @@ -35,15 +35,15 @@ const createMemberMock = (userId = 'target-user-id'): ChannelMemberResponse => user_id: userId, }) as ChannelMemberResponse; -const createChannelMock = (params?: { blockedUserIds?: string[]; userID?: string }): Channel => { - const { blockedUserIds = [], userID = 'current-user-id' } = params ?? {}; +const createChannelMock = (params?: { blockedUserIds?: string[]; userId?: string }): Channel => { + const { blockedUserIds = [], userId = 'current-user-id' } = params ?? {}; return { getClient: () => ({ blockedUsers: { getLatestValue: () => ({ userIds: blockedUserIds }), subscribeWithSelector: () => () => {}, }, - userID, + userId, }), } as unknown as Channel; }; @@ -84,7 +84,7 @@ describe('useChannelMemberActionItems', () => { }); it('returns no action items when the member is the current user', () => { - const currentUserChannel = createChannelMock({ userID: 'target-user-id' }); + const currentUserChannel = createChannelMock({ userId: 'target-user-id' }); const { result } = renderHook(() => useChannelMemberActionItems({ channel: currentUserChannel, member }), @@ -101,7 +101,7 @@ describe('useChannelMemberActionItems', () => { }); const wrapper = ({ children }: PropsWithChildren) => ( - + {children} ); @@ -166,7 +166,7 @@ describe('useChannelMemberActionItems', () => { jest .spyOn(useChannelOwnCapabilitiesModule, 'useChannelOwnCapabilities') .mockReturnValue(['update-channel-members']); - const currentUserChannel = createChannelMock({ userID: 'target-user-id' }); + const currentUserChannel = createChannelMock({ userId: 'target-user-id' }); const { result } = renderHook(() => useChannelMemberActionItems({ channel: currentUserChannel, member }), diff --git a/package/src/hooks/actions/useChannelActionItems.tsx b/package/src/hooks/actions/useChannelActionItems.tsx index d7407c417b..8708bf34f1 100644 --- a/package/src/hooks/actions/useChannelActionItems.tsx +++ b/package/src/hooks/actions/useChannelActionItems.tsx @@ -112,7 +112,7 @@ export const buildDefaultChannelActionItems: BuildDefaultChannelActionItems = ( t, channel, } = channelActionItemsParams; - const ownUserId = channel.getClient().userID; + const ownUserId = channel.getClient().userId; const actionItems: ChannelActionItem[] = [ { diff --git a/package/src/hooks/actions/useChannelActions.ts b/package/src/hooks/actions/useChannelActions.ts index 499ee25ddb..7f9641def8 100644 --- a/package/src/hooks/actions/useChannelActions.ts +++ b/package/src/hooks/actions/useChannelActions.ts @@ -39,7 +39,7 @@ export type ChannelActions = { export const getOtherUserInDirectChannel = (channel: Channel) => { const members = Object.values(channel.state.members); return members.length === 2 - ? members.find((member) => member.user?.id !== channel.getClient().userID) + ? members.find((member) => member.user?.id !== channel.getClient().userId) : undefined; }; @@ -62,7 +62,7 @@ export const useChannelActions = (channel: Channel) => { const { client } = useChatContext(); const { addNotification } = useNotificationApi(); const { t } = useTranslationContext(); - const ownUserId = client.userID; + const ownUserId = client.userId; const pin = useStableCallback(async (options?: ChannelActionOptions) => { try { diff --git a/package/src/hooks/actions/useChannelMemberActionItems.tsx b/package/src/hooks/actions/useChannelMemberActionItems.tsx index 51fc2834a8..16b9ae81e8 100644 --- a/package/src/hooks/actions/useChannelMemberActionItems.tsx +++ b/package/src/hooks/actions/useChannelMemberActionItems.tsx @@ -211,7 +211,7 @@ export const useChannelMemberActionItems = ({ const isBlocked = blockedUserIds.includes(member.user?.id ?? ''); - const isCurrentUser = member.user?.id === channel.getClient().userID; + const isCurrentUser = member.user?.id === channel.getClient().userId; const channelMemberActionItemsParams = useMemo( () => ({ diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts index 9a5187800c..5702f9c5bc 100644 --- a/package/src/hooks/index.ts +++ b/package/src/hooks/index.ts @@ -13,6 +13,7 @@ export * from './useChannelMuteActive'; export * from './useIsDirectChat'; export * from './useIsChannelMember'; export * from './useStreami18n'; +export * from './useTranslatedMessage'; export * from './useViewport'; export * from './useScreenDimensions'; export * from './useScreenOrientation'; diff --git a/package/src/hooks/messagePreview/__tests__/useMessageDeliveryStatus.test.tsx b/package/src/hooks/messagePreview/__tests__/useMessageDeliveryStatus.test.tsx index 7a347a6885..85927d1af4 100644 --- a/package/src/hooks/messagePreview/__tests__/useMessageDeliveryStatus.test.tsx +++ b/package/src/hooks/messagePreview/__tests__/useMessageDeliveryStatus.test.tsx @@ -1,9 +1,8 @@ import React, { PropsWithChildren } from 'react'; import { act, renderHook } from '@testing-library/react-native'; -import { fromPartial } from '@total-typescript/shoehorn'; - -import type { Channel, UserResponse } from 'stream-chat'; +import { asTimestampNS } from 'stream-chat'; +import type { Channel, MsgRef } from 'stream-chat'; import { Chat } from '../../../components/Chat/Chat'; import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; @@ -12,7 +11,16 @@ import { generateUser } from '../../../mock-builders/generator/user'; import { MessageDeliveryStatus, useMessageDeliveryStatus } from '../useMessageDeliveryStatus'; describe('useMessageDeliveryStatus', () => { - const ownMessage = generateMessage({ user: generateUser({ id: 'me' }) }); + const ownMessage = generateMessage({ + timestamp: new Date('2026-01-01T10:00:00Z'), + user: generateUser({ id: 'me' }), + }); + + // A cursor `offsetNs` after (or, negative, before) `ownMessage`, on a message of its own. + const cursorAt = (offsetNs: number, msgId = `cursor-${offsetNs}`): MsgRef => ({ + msgId, + timestamp: asTimestampNS(ownMessage.created_at + offsetNs), + }); const setup = async ({ isReadEventsEnabled = true, @@ -32,14 +40,11 @@ describe('useMessageDeliveryStatus', () => { const setReceipts = ( channel: Channel, - { - delivered = {}, - readers = {}, - }: { delivered?: Record; readers?: Record }, + { delivered = null, read = null }: { delivered?: MsgRef | null; read?: MsgRef | null }, ) => channel.messageReceiptsTracker.snapshotStore.partialNext({ - deliveredByMessageId: delivered, - readersByMessageId: readers, + lastDeliveredRefByOthers: delivered, + lastReadRefByOthers: read, }); it('returns NOT_SENT_BY_CURRENT_USER when read events are disabled', async () => { @@ -58,22 +63,55 @@ describe('useMessageDeliveryStatus', () => { const { channel, result } = await setup(); expect(result.current.status).toBe(MessageDeliveryStatus.SENT); - act(() => - setReceipts(channel, { - delivered: { [ownMessage.id]: [fromPartial({ id: 'other' })] }, - }), - ); + act(() => setReceipts(channel, { delivered: cursorAt(0, ownMessage.id) })); expect(result.current.status).toBe(MessageDeliveryStatus.DELIVERED); act(() => setReceipts(channel, { - delivered: { [ownMessage.id]: [fromPartial({ id: 'other' })] }, - readers: { [ownMessage.id]: [fromPartial({ id: 'other' })] }, + delivered: cursorAt(0, ownMessage.id), + read: cursorAt(0, ownMessage.id), }), ); expect(result.current.status).toBe(MessageDeliveryStatus.READ); }); + it('returns READ when another member read past the message', async () => { + const { channel, result } = await setup(); + + act(() => setReceipts(channel, { delivered: cursorAt(1000), read: cursorAt(1000) })); + + expect(result.current.status).toBe(MessageDeliveryStatus.READ); + }); + + it('returns DELIVERED when the message was delivered past it but not read', async () => { + const { channel, result } = await setup(); + + act(() => setReceipts(channel, { delivered: cursorAt(1000), read: cursorAt(-1000) })); + + expect(result.current.status).toBe(MessageDeliveryStatus.DELIVERED); + }); + + it('returns SENT when every cursor is before the message', async () => { + const { channel, result } = await setup(); + + act(() => setReceipts(channel, { delivered: cursorAt(-1000), read: cursorAt(-1000) })); + + expect(result.current.status).toBe(MessageDeliveryStatus.SENT); + }); + + it('returns READ for a thread reply read past by another member', async () => { + const reply = generateMessage({ + parent_id: 'parent', + timestamp: new Date('2026-01-01T10:00:00Z'), + user: generateUser({ id: 'me' }), + }); + const { channel, result } = await setup({ lastMessage: reply }); + + act(() => setReceipts(channel, { delivered: cursorAt(1000), read: cursorAt(1000) })); + + expect(result.current.status).toBe(MessageDeliveryStatus.READ); + }); + // The receipts tracker rebuilds its reader arrays on every snapshot, presence and user updates // included, so a preview row must only re-render when the status itself changes. it('does not re-render when the receipts change without changing the status', async () => { @@ -95,24 +133,11 @@ describe('useMessageDeliveryStatus', () => { { wrapper }, ); - act(() => - setReceipts(channel, { - delivered: { [ownMessage.id]: [fromPartial({ id: 'other' })] }, - }), - ); + act(() => setReceipts(channel, { delivered: cursorAt(0, ownMessage.id) })); expect(result.current.status).toBe(MessageDeliveryStatus.DELIVERED); const rendersWhenDelivered = renders; - act(() => - setReceipts(channel, { - delivered: { - [ownMessage.id]: [ - fromPartial({ id: 'other' }), - fromPartial({ id: 'third' }), - ], - }, - }), - ); + act(() => setReceipts(channel, { delivered: cursorAt(1000) })); expect(result.current.status).toBe(MessageDeliveryStatus.DELIVERED); expect(renders).toBe(rendersWhenDelivered); }); diff --git a/package/src/hooks/messagePreview/useMessageDeliveryStatus.ts b/package/src/hooks/messagePreview/useMessageDeliveryStatus.ts index b890afc11a..99a3bf4438 100644 --- a/package/src/hooks/messagePreview/useMessageDeliveryStatus.ts +++ b/package/src/hooks/messagePreview/useMessageDeliveryStatus.ts @@ -5,7 +5,7 @@ import { LocalMessage, MessageReceiptsSnapshot, MessageResponse, - UserResponse, + MsgRef, } from 'stream-chat'; import { useChatContext } from '../../contexts/chatContext/ChatContext'; @@ -25,13 +25,19 @@ type MessageDeliveryStatusProps = { isReadEventsEnabled: boolean; }; -const hasOtherUser = (users: UserResponse[] | undefined, currentUserId: string | undefined) => - !!users && (users.length > 1 || (users.length === 1 && users[0].id !== currentUserId)); +/** Whether a cursor has reached a message created at `createdAt`, even from a later message. */ +const hasCursorReached = (createdAt: number | undefined, cursor: MsgRef | null) => + !!cursor && typeof createdAt === 'number' && Number.isFinite(createdAt) + ? createdAt <= cursor.timestamp + : false; /** * Delivery and read status of the last message when it is the current user's, derived from the * channel's `messageReceiptsTracker.snapshotStore`, the same store the in-list receipt hooks use. * + * It compares the message against `lastReadRefByOthers` and `lastDeliveredRefByOthers`, the furthest + * points anyone else has reached, so a message stays read once another member reads past it. + * * @param params.channel The channel the message belongs to. * @param params.lastMessage The channel's last message. * @param params.isReadEventsEnabled When false, the status is `NOT_SENT_BY_CURRENT_USER`. Defaults @@ -44,15 +50,15 @@ export const useMessageDeliveryStatus = ({ isReadEventsEnabled = true, }: MessageDeliveryStatusProps) => { const { client } = useChatContext(); - const messageId = lastMessage?.id ?? ''; const currentUserId = client.user?.id; + const createdAt = lastMessage?.created_at; const selector = useCallback( (snapshot: MessageReceiptsSnapshot) => ({ - isDelivered: hasOtherUser(snapshot.deliveredByMessageId[messageId], currentUserId), - isRead: hasOtherUser(snapshot.readersByMessageId[messageId], currentUserId), + isDelivered: hasCursorReached(createdAt, snapshot.lastDeliveredRefByOthers), + isRead: hasCursorReached(createdAt, snapshot.lastReadRefByOthers), }), - [currentUserId, messageId], + [createdAt], ); const { isDelivered, isRead } = useStateStore( diff --git a/package/src/hooks/useIsDirectChat.ts b/package/src/hooks/useIsDirectChat.ts index 973cbfb871..604d96f5c9 100644 --- a/package/src/hooks/useIsDirectChat.ts +++ b/package/src/hooks/useIsDirectChat.ts @@ -9,7 +9,7 @@ const pickId = (member: ChannelMemberResponse) => ({ id: member.user?.id }); export const useIsDirectChat = (channel?: Channel) => { const { client } = useChatContext(); - const ownUserId = client.userID; + const ownUserId = client.userId; const members = useChannelMembersState(channel, pickId); const otherMembers = useMemo( diff --git a/package/src/i18n/__tests__/catalog.fixture.json b/package/src/i18n/__tests__/catalog.fixture.json index 49ef471ce3..b693cfa477 100644 --- a/package/src/i18n/__tests__/catalog.fixture.json +++ b/package/src/i18n/__tests__/catalog.fixture.json @@ -158,6 +158,7 @@ "channelPreview.deliveryStatus.read.accessibilityLabel": "Read, sent by you", "channelPreview.deliveryStatus.sent.accessibilityLabel": "Sent by you", "channelPreview.displayName.others.label": "and {{ count }} others", + "channelPreview.doubleTapToOpen.accessibilityLabel": "Double tap to open", "channelPreview.lastMessage.accessibilityLabel": "Last message {{date}}", "channelPreview.messageFailed.error": "Message failed to send", "channelPreview.muted.accessibilityLabel": "Muted", @@ -208,6 +209,7 @@ "indicators.loading.error": "Error loading", "indicators.loading.messages.error": "Error loading messages for this channel...", "indicators.loading.messages.text": "Loading messages...", + "indicators.loading.replies.error": "Error loading replies...", "message.banUser.label": "Ban User", "message.blockUser.label": "Block User", "message.blockUserConfirm.label": "Block", diff --git a/package/src/i18n/keys.ts b/package/src/i18n/keys.ts index aea51e63cd..4c941c4fa7 100644 --- a/package/src/i18n/keys.ts +++ b/package/src/i18n/keys.ts @@ -169,6 +169,7 @@ export type TranslationCatalog = { 'channelPreview.deliveryStatus.read.accessibilityLabel': 'Read, sent by you'; 'channelPreview.deliveryStatus.sent.accessibilityLabel': 'Sent by you'; 'channelPreview.displayName.others.label': 'and {{ count }} others'; + 'channelPreview.doubleTapToOpen.accessibilityLabel': 'Double tap to open'; 'channelPreview.lastMessage.accessibilityLabel': 'Last message {{date}}'; 'channelPreview.messageFailed.error': 'Message failed to send'; 'channelPreview.muted.accessibilityLabel': 'Muted'; @@ -219,6 +220,7 @@ export type TranslationCatalog = { 'indicators.loading.error': 'Error loading'; 'indicators.loading.messages.error': 'Error loading messages for this channel...'; 'indicators.loading.messages.text': 'Loading messages...'; + 'indicators.loading.replies.error': 'Error loading replies...'; 'message.banUser.label': 'Ban User'; 'message.blockUser.label': 'Block User'; 'message.blockUserConfirm.label': 'Block'; @@ -468,6 +470,7 @@ export type BundledTranslationKey = | 'channelPreview.deliveryStatus.delivered.accessibilityLabel' | 'channelPreview.deliveryStatus.read.accessibilityLabel' | 'channelPreview.deliveryStatus.sent.accessibilityLabel' + | 'channelPreview.doubleTapToOpen.accessibilityLabel' | 'channelPreview.lastMessage.accessibilityLabel' | 'channelPreview.muted.accessibilityLabel' | 'channelPreview.pinned.accessibilityLabel' diff --git a/package/src/i18n/runtimeDefaults.ts b/package/src/i18n/runtimeDefaults.ts index 114b56d297..ebb0dc2f87 100644 --- a/package/src/i18n/runtimeDefaults.ts +++ b/package/src/i18n/runtimeDefaults.ts @@ -82,6 +82,7 @@ export const runtimeDefaults = { 'channelPreview.deliveryStatus.delivered.accessibilityLabel': 'Delivered, sent by you', 'channelPreview.deliveryStatus.read.accessibilityLabel': 'Read, sent by you', 'channelPreview.deliveryStatus.sent.accessibilityLabel': 'Sent by you', + 'channelPreview.doubleTapToOpen.accessibilityLabel': 'Double tap to open', 'channelPreview.lastMessage.accessibilityLabel': 'Last message {{date}}', 'channelPreview.muted.accessibilityLabel': 'Muted', 'channelPreview.pinned.accessibilityLabel': 'Pinned', diff --git a/package/src/utils/installNativeMultipartAdapter.ts b/package/src/utils/installNativeMultipartAdapter.ts index 8c7cd1f56f..6ecb0146b8 100644 --- a/package/src/utils/installNativeMultipartAdapter.ts +++ b/package/src/utils/installNativeMultipartAdapter.ts @@ -282,9 +282,11 @@ const createNativeMultipartAdapter = * Routes multipart file uploads through the native uploader by setting `adapter` on those requests * only. Does nothing when native multipart upload is unavailable or the client is already set up. * - * The client's default adapter is left alone: replacing it would require importing axios here to - * resolve the fallback, and a copy different from `stream-chat`'s would fail every request with an - * `AxiosError` that `stream-chat` does not recognize. + * Uploads go native even when the app installed an axios adapter of its own, as they did in v9; + * every other request still goes through the app's adapter. `defaults.adapter` itself is not + * replaced: that would require importing axios here to resolve the fallback, and a copy different + * from `stream-chat`'s would fail every request with an `AxiosError` that `stream-chat` does not + * recognize. * * @param client The client to install the adapter on. */