diff --git a/examples/ExpoMessaging/package.json b/examples/ExpoMessaging/package.json index adf0fe99bf..e49f8a7bf8 100644 --- a/examples/ExpoMessaging/package.json +++ b/examples/ExpoMessaging/package.json @@ -51,7 +51,7 @@ "react-native-teleport": "^1.1.12", "react-native-web": "^0.21.2", "react-native-worklets": "0.11.1", - "stream-chat": "^10.0.0-rc.14", + "stream-chat": "^10.0.0-rc.16", "stream-chat-expo": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index e5f2075dd3..dfdc1494d8 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -65,7 +65,7 @@ "react-native-teleport": "^1.1.12", "react-native-video": "^6.19.2", "react-native-worklets": "^0.12.1", - "stream-chat": "^10.0.0-rc.14", + "stream-chat": "^10.0.0-rc.16", "stream-chat-react-native": "workspace:^", "stream-chat-react-native-core": "workspace:^" }, diff --git a/examples/SampleApp/src/i18n/de.ts b/examples/SampleApp/src/i18n/de.ts index 0cfde89dfb..35d0418e1c 100644 --- a/examples/SampleApp/src/i18n/de.ts +++ b/examples/SampleApp/src/i18n/de.ts @@ -486,7 +486,8 @@ export const de: TranslationDictionary = { // Thread list 'threadList.unreadBanner.loadFailed.error': 'Neue Threads konnten nicht geladen werden. Tippen, um es erneut zu versuchen', - 'threadList.unreadBanner.newThreads.label': '{{count}} neue Threads', + 'threadList.unreadBanner.newThreads.label_one': '{{count}} neuer Thread', + 'threadList.unreadBanner.newThreads.label_other': '{{count}} neue Threads', // Shared UI primitives 'uiComponents.bottomSheetModal.opened.accessibilityLabel': diff --git a/examples/SampleApp/src/i18n/it.ts b/examples/SampleApp/src/i18n/it.ts index 8e1fcb8699..329c9af405 100644 --- a/examples/SampleApp/src/i18n/it.ts +++ b/examples/SampleApp/src/i18n/it.ts @@ -486,7 +486,8 @@ export const it: TranslationDictionary = { // Thread list 'threadList.unreadBanner.loadFailed.error': 'Impossibile caricare nuovi thread. Tocca per riprovare', - 'threadList.unreadBanner.newThreads.label': '{{count}} nuovi thread', + 'threadList.unreadBanner.newThreads.label_one': '{{count}} nuovo thread', + 'threadList.unreadBanner.newThreads.label_other': '{{count}} nuovi thread', // Shared UI primitives 'uiComponents.bottomSheetModal.opened.accessibilityLabel': diff --git a/package/package.json b/package/package.json index c7888ff116..67ff054f40 100644 --- a/package/package.json +++ b/package/package.json @@ -80,7 +80,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^10.0.0-rc.14", + "stream-chat": "^10.0.0-rc.16", "use-sync-external-store": "^1.7.0" }, "peerDependencies": { diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 1acb49eada..3f835d1f8f 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -446,12 +446,7 @@ const ChannelWithContext = (props: PropsWithChildren) = if (!threadProps?.id || !channel) { return null; } - return ( - client.threads.threadsById[threadProps.id] ?? - new Thread({ channel, client, parentMessage: threadProps }) - ); - // Keyed on threadProps.id (stable) rather than the threadProps object so an unmanaged thread's - // constructed instance isn't recreated (losing paginator state) on unrelated re-renders. + return client.threads.ensure({ channel, parentMessage: threadProps }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [threadInstanceFromProps, threadProps?.id, channel, client]); const [messageInputHeightStore] = useState(() => new MessageInputHeightStore()); diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index 698cd3f740..74ab490679 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -3,7 +3,7 @@ import { 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 { StreamChat, Thread } from 'stream-chat'; +import { asTimestampNS, StreamChat, Thread } from 'stream-chat'; import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; import { ChannelContext, ChannelProvider } from '../../../contexts/channelContext/ChannelContext'; @@ -763,13 +763,31 @@ describe('Channel initial load useEffect', () => { }); }); + it('registers the thread it builds for a thread prop before anything activates it', async () => { + const mockedChannel = generateChannelResponse({ messages: [generateMessage({})] }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const testChannel = chatClient.channel('messaging', mockedChannel.channel.id); + await testChannel.watch(); + const parentMessage = testChannel.state.formatMessage(generateMessage({ user })); + + // No , so nothing calls activate(): a list query landing now must find this instance. + render( + + + , + ); + + await waitFor(() => expect(chatClient.threads.get(parentMessage.id)).toBeDefined()); + expect(chatClient.threads.get(parentMessage.id)?.state.getLatestValue().active).toBe(false); + }); + // Regression guard for the reconnect refresh of an OPEN THREAD's replies, which now runs entirely in // `client.connectionRecovery` — this component's only part is marking the thread active. // - // Asserted end to end on purpose: the LLC can only reach the thread through `client.activeThreads`, - // and a thread resolved as `threadsById[id] ?? new Thread(...)` (the common path — see the - // `threadInstance` memo) is in no other registry. Drop the `threadInstance.activate()` effect and - // recovery silently skips the thread with nothing else failing, so it is pinned here. + // Asserted end to end on purpose: a `threadInstance` passed in as a prop (not built by the + // `threadInstance` memo's `client.threads.ensure`) reaches the LLC's thread store only through + // `threadInstance.activate()`. Drop that effect and recovery silently skips the thread with nothing + // else failing, so it is pinned here. it('reloads an open thread on reconnect', async () => { const mockedChannel = generateChannelResponse({ messages: [generateMessage({})] }); useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); @@ -783,11 +801,8 @@ describe('Channel initial load useEffect', () => { parentMessage: testChannel.state.formatMessage(parentMessage), }); const reload = jest.spyOn(threadInstance, 'reload').mockResolvedValue(undefined); - // Recovery finds threads through `client.threads.threadsById`, and only adopts an - // unmanaged instance into the manager once its reply paginator has loaded (Thread.tsx:126, gated - // on `items !== undefined`). Seed loaded-but-empty replies so that adoption actually happens — - // without it this test exercises the documented gap (active but unadopted → skipped) rather than - // the path it means to cover. + // Seed loaded-but-empty replies so 's mount-time metadata reload and first-page fetch + // are skipped, keeping the spy clean for the reconnect-driven call. act(() => threadInstance.messagePaginator.state.partialNext({ items: [], isLoading: false })); render( @@ -800,20 +815,20 @@ describe('Channel initial load useEffect', () => { thread={{ thread: testChannel.state.formatMessage(parentMessage), threadInstance }} > {/* The real is what calls `threadInstance.activate()`, which is the ONLY thing - that puts the instance in `client.activeThreads` for recovery to find. Rendering it is - the point of the test — a bare would not activate anything. */} + that registers the instance with `client.threads` for recovery to find. Rendering it + is the point of the test — a bare would not activate anything. */} , ); - // Wait for to activate AND adopt the instance — both are preconditions for recovery to - // see it at all. (With replies seeded above, Thread.tsx's mount metadata-reload is skipped, so - // the spy is clean; cleared anyway so this can only pass on a reconnect-driven call.) + // Activation registers the instance without putting it in the thread list. (Cleared anyway so + // this can only pass on a reconnect-driven call.) await waitFor(() => { - expect(chatClient.threads.threadsById[threadInstance.id]).toBeDefined(); + expect(chatClient.threads.get(threadInstance.id)).toBe(threadInstance); expect(threadInstance.state.getLatestValue().active).toBe(true); }); + expect(chatClient.threads.paginator.getItem(threadInstance.id)).toBeUndefined(); reload.mockClear(); act(() => dispatchConnectionChanged(chatClient, false)); @@ -822,63 +837,103 @@ describe('Channel initial load useEffect', () => { await waitFor(() => expect(reload).toHaveBeenCalled()); }); - it('does not mark a reply-less thread read on open, but does once it has replies', async () => { - // A parent with no replies has no server-side thread, so the mark-read 404s on every open. There - // is also nothing that could be unread, so the call is skipped rather than made and swallowed. - const mockedChannel = generateChannelResponse({ messages: [generateMessage({})] }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const testChannel = chatClient.channel('messaging', mockedChannel.channel.id); - await testChannel.watch(); - const markRead = jest - .spyOn(testChannel, 'markRead') - .mockResolvedValue({} as Awaited>); + // Reads are the LLC's job: the thread's active auto-read marks it once its read state says it has + // unread replies. `` makes no mark-read call of its own. + describe('read on open', () => { + const openThread = (threadInstance: Thread, testChannel: ChannelType) => + render( + + + + + , + ); - const parentMessage = generateMessage({ user }); - const makeThread = (replyCount: number) => { - const instance = new Thread({ + const setup = async () => { + const mockedChannel = generateChannelResponse({ messages: [generateMessage({})] }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const testChannel = chatClient.channel('messaging', mockedChannel.channel.id); + await testChannel.watch(); + const markRead = jest + .spyOn(testChannel, 'markRead') + .mockResolvedValue({} as Awaited>); + const parentMessage = testChannel.state.formatMessage( + generateMessage({ reply_count: 3, user }), + ); + const readState = (unreadMessageCount: number) => ({ + [chatClient.userID as string]: { + lastReadAt: 1, + unreadMessageCount, + user: { id: chatClient.userID as string }, + }, + }); + return { markRead, parentMessage, readState, testChannel }; + }; + + it('marks a thread with unread replies read exactly once, and a read one not at all', async () => { + const { markRead, parentMessage, readState, testChannel } = await setup(); + const read = new Thread({ channel: testChannel, client: chatClient, parentMessage }); + read.state.partialNext({ read: readState(0) } as never); + jest.spyOn(read, 'reload').mockResolvedValue(undefined); + const { unmount } = await openThread(read, testChannel); + await waitFor(() => expect(read.state.getLatestValue().active).toBe(true)); + expect(markRead).not.toHaveBeenCalled(); + unmount(); + + const unread = new Thread({ channel: testChannel, client: chatClient, - parentMessage: testChannel.state.formatMessage({ - ...parentMessage, - reply_count: replyCount, - }), + parentMessage: { ...parentMessage, id: `${parentMessage.id}-2` }, }); - jest.spyOn(instance, 'reload').mockResolvedValue(undefined); - return instance; - }; + unread.state.partialNext({ read: readState(2) } as never); + jest.spyOn(unread, 'reload').mockResolvedValue(undefined); + await openThread(unread, testChannel); - const empty = makeThread(0); - const { unmount } = render( - - - - - , - ); - await waitFor(() => expect(empty.state.getLatestValue().active).toBe(true)); - expect(markRead).not.toHaveBeenCalled(); - unmount(); + await waitFor(() => expect(markRead).toHaveBeenCalledWith({ thread_id: unread.id })); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(markRead).toHaveBeenCalledTimes(1); + }); - // Same component, a thread that does have replies: the call is made as before. - const withReplies = makeThread(3); - render( - - - - - , - ); - await waitFor(() => expect(markRead).toHaveBeenCalledWith({ thread_id: withReplies.id })); + it('marks a thread ensure() built read once its reload brings the read state', async () => { + const { markRead, parentMessage, readState, testChannel } = await setup(); + const threadInstance = chatClient.threads.ensure({ channel: testChannel, parentMessage }); + jest.spyOn(threadInstance, 'reload').mockImplementation(() => { + threadInstance.state.partialNext({ isStateStale: false, read: readState(2) } as never); + return Promise.resolve(); + }); + + await openThread(threadInstance, testChannel); + + await waitFor(() => expect(markRead).toHaveBeenCalledWith({ thread_id: threadInstance.id })); + expect(markRead).toHaveBeenCalledTimes(1); + }); + + it('still marks a channel with unread messages read when it is opened', async () => { + const otherUser = generateUser(); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user }), generateMember({ user: otherUser })], + messages: [generateMessage({ user: otherUser }), generateMessage({ user: otherUser })], + read: [{ last_read: asTimestampNS(1), unread_messages: 2, user }], + }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const testChannel = chatClient.channel('messaging', mockedChannel.channel.id); + await testChannel.watch(); + const markRead = jest + .spyOn(testChannel, 'markRead') + .mockResolvedValue({} as Awaited>); + + render( + + + , + ); + + await waitFor(() => expect(markRead).toHaveBeenCalled()); + expect(markRead.mock.calls.every(([options]) => !options?.thread_id)).toBe(true); + }); }); }); diff --git a/package/src/components/Chat/hooks/useIsOnline.ts b/package/src/components/Chat/hooks/useIsOnline.ts index 291578ae05..c87e6b12c6 100644 --- a/package/src/components/Chat/hooks/useIsOnline.ts +++ b/package/src/components/Chat/hooks/useIsOnline.ts @@ -1,5 +1,7 @@ import { useCallback, useEffect } from 'react'; +import { AppState } from 'react-native'; + import NetInfo from '@react-native-community/netinfo'; import type { NetworkStatusReporter, StreamChat } from 'stream-chat'; @@ -40,6 +42,15 @@ export const useIsOnline = (client: StreamChat, closeConnectionOnBackground = tr useAppStateListener(onForeground, onBackground); + // A mounted while the app is already active (Android recreating the activity in the same JS + // runtime) never sees the background→active change that reopens a socket its predecessor closed. + useEffect(() => { + if (!closeConnectionOnBackground || !clientExists || !client.userID) return; + if (AppState.currentState === 'active' && client.wsConnection.connection?.isDisconnected) { + client.openConnection(); + } + }, [client, clientExists, closeConnectionOnBackground]); + useEffect(() => { if (!clientExists) { return; diff --git a/package/src/components/Message/MessageItemView/MessageWrapper.tsx b/package/src/components/Message/MessageItemView/MessageWrapper.tsx index ddf88bf337..0b43099bfe 100644 --- a/package/src/components/Message/MessageItemView/MessageWrapper.tsx +++ b/package/src/components/Message/MessageItemView/MessageWrapper.tsx @@ -54,6 +54,7 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW // Wire timestamps throughout, directly comparable. `new Date(ns)` yielded NaN, so the unread // separator never rendered. const createdAtTimestamp = message.created_at; + const messageId = message.id; const nextMessageId = nextMessage?.id; const nextMessageIsOwn = nextMessage?.user?.id === client.userID; const nextMessageCreatedAt = nextMessage?.created_at ?? undefined; @@ -63,12 +64,6 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW // `useStateStore`'s per-key comparison keeps the flag referentially stable (`false === false`) for // every non-boundary row: a mark-read changes the channel-wide unread fields but only re-renders // the one or two boundary rows whose flag actually flips, not the whole list. - // - // We deliberately do NOT anchor on `lastReadMessageId`. It tracks the last read message from - // ANOTHER user and is not advanced by our own sends, so anchoring on it drops the separator in - // front of our own just-sent messages (read → us → new-unread would wrongly separate before "us"). - // Skipping our own messages (they are always read) places it correctly above the first incoming - // unread instead. const showUnreadSeparatorSelector = useCallback( (snapshot: UnreadSnapshotState) => { let showUnreadSeparator: boolean; @@ -91,7 +86,15 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW nextMessageCreatedAt > lastReadAt; const thisIsRead = typeof createdAtTimestamp === 'number' && createdAtTimestamp <= lastReadAt; - showUnreadSeparator = nextIsUnreadFromOther && thisIsRead; + // Own messages sent elsewhere after the boundary leave no read row above the first unread, + // so the rule above never fires. Anchor on the last read message in those cases. + const isLastReadBeforeOwnUnread = + thisIsRead && + messageId === snapshot.lastReadMessageId && + nextMessageIsOwn && + nextMessageCreatedAt !== undefined && + nextMessageCreatedAt > lastReadAt; + showUnreadSeparator = (nextIsUnreadFromOther && thisIsRead) || isLastReadBeforeOwnUnread; } else { showUnreadSeparator = false; } @@ -104,7 +107,7 @@ export const MessageWrapper = React.memo(function MessageWrapper(props: MessageW unreadCount: showUnreadSeparator ? snapshot.unreadCount : undefined, }; }, - [createdAtTimestamp, nextMessageCreatedAt, nextMessageId, nextMessageIsOwn], + [createdAtTimestamp, messageId, nextMessageCreatedAt, nextMessageId, nextMessageIsOwn], ); const { showUnreadSeparator, unreadCount } = useStateStore( channel.messagePaginator.unreadStateSnapshot, diff --git a/package/src/components/Message/hooks/__tests__/useMessageOperations.test.tsx b/package/src/components/Message/hooks/__tests__/useMessageOperations.test.tsx new file mode 100644 index 0000000000..4d6eed82c5 --- /dev/null +++ b/package/src/components/Message/hooks/__tests__/useMessageOperations.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; + +import { act, renderHook } from '@testing-library/react-native'; +import type { Channel, LocalMessage, StreamChat } from 'stream-chat'; + +import { ChannelContext } from '../../../../contexts/channelContext/ChannelContext'; +import type { ChannelContextValue } from '../../../../contexts/channelContext/ChannelContext'; +import { ChatContext } from '../../../../contexts/chatContext/ChatContext'; +import type { ChatContextValue } from '../../../../contexts/chatContext/ChatContext'; +import { generateMessage } from '../../../../mock-builders/generator/message'; +import { generateUser } from '../../../../mock-builders/generator/user'; +import { getTestClientWithUser } from '../../../../mock-builders/mock'; +import { MessageStatusTypes } from '../../../../utils/utils'; +import { useMessageOperations } from '../useMessageOperations'; + +describe('useMessageOperations.deleteMessage', () => { + let client: StreamChat; + let channel: Channel; + + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'me' }); + channel = client.channel('messaging', 'delete-test'); + }); + + const render = () => + renderHook(() => useMessageOperations(), { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + + const failed = (overrides: Partial = {}) => + ({ + ...generateMessage({ cid: channel.cid, user: generateUser({ id: 'me' }) }), + // As the composer builds it: an unsent message has never had a server-confirmed text update. + message_text_updated_at: undefined, + status: MessageStatusTypes.FAILED, + ...overrides, + }) as unknown as LocalMessage; + + it('removes a failed send locally, without a server delete', async () => { + const serverDelete = jest.spyOn(channel, 'deleteMessageWithLocalUpdate').mockResolvedValue(); + const removeItem = jest.spyOn(channel.messagePaginator, 'removeItem'); + const { result } = render(); + + await act(() => result.current.deleteMessage(failed())); + + expect(serverDelete).not.toHaveBeenCalled(); + expect(removeItem).toHaveBeenCalled(); + }); + + it('deletes a message whose edit failed on the server, since the server has it', async () => { + const serverDelete = jest.spyOn(channel, 'deleteMessageWithLocalUpdate').mockResolvedValue(); + const { result } = render(); + + await act(() => + result.current.deleteMessage( + failed({ + message_text_updated_at: 1790000000000000000 as LocalMessage['message_text_updated_at'], + }), + ), + ); + + expect(serverDelete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/package/src/components/Message/hooks/useMessageOperations.ts b/package/src/components/Message/hooks/useMessageOperations.ts index fe930e5371..11a83febfc 100644 --- a/package/src/components/Message/hooks/useMessageOperations.ts +++ b/package/src/components/Message/hooks/useMessageOperations.ts @@ -111,8 +111,9 @@ export const useMessageOperations = (): MessageOperations => { throw new Error('Channel has not been initialized yet'); } - // A failed (never-sent) message exists only locally — remove it without a server delete. - if (message.status === MessageStatusTypes.FAILED) { + // A failed send exists only locally, so it is removed without a server delete. A failed edit has + // the same status but the server has the message; it is the one carrying `message_text_updated_at`. + if (message.status === MessageStatusTypes.FAILED && !message.message_text_updated_at) { await removeMessage(message); return; } diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index dee752bf6c..d09acdd5d1 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -737,9 +737,10 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => ); // Channels with disabled `read-events` (i.e livestreams) still surface the unread - // notification when the client opted into a local unread count, so the gate accepts + // notification when the channel opted into a local unread count, so the gate accepts // either source. - const unreadNotificationSupported = readEvents || client.options.isLocalUnreadCountEnabled; + const unreadNotificationSupported = + readEvents || channel.config.readEvents.localUnreadCountEnabled; if ( !viewableItems.length || diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index 8249830f2b..8c7a9b2d19 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -541,9 +541,10 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { ); // Channels with disabled `read-events` (i.e livestreams) still surface the unread - // notification when the client opted into a local unread count, so the gate accepts + // notification when the channel opted into a local unread count, so the gate accepts // either source. - const unreadNotificationSupported = readEvents || client.options.isLocalUnreadCountEnabled; + const unreadNotificationSupported = + readEvents || channel.config.readEvents.localUnreadCountEnabled; if ( !viewableItems.length || diff --git a/package/src/components/MessageList/__tests__/MessageList.test.tsx b/package/src/components/MessageList/__tests__/MessageList.test.tsx index 0c0aa85d50..5395e9967e 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/package/src/components/MessageList/__tests__/MessageList.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { FlatList } from 'react-native'; -import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/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'; @@ -486,6 +486,78 @@ describe('MessageList', () => { }); }); + // Messages 0-5 are read (boundary at '5'); `authors` sets who wrote 6, 7 and 8. + const renderWithUnreadAfterOwn = async (authors: Array<'own' | 'other'>) => { + const user1 = generateUser(); + const user2 = generateUser(); + const base = new Date('2020-01-01T00:00:00.000Z').getTime(); + const messages = Array.from({ length: 6 + authors.length }, (_, i) => + generateMessage({ + id: `${i}`, + text: `message-${i}`, + timestamp: new Date(base + i * 1000), + user: i > 5 && authors[i - 6] === 'own' ? user1 : user2, + }), + ); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user: user1 }), generateMember({ user: user2 })], + messages, + read: [ + { + user: user1, + last_read: msToNs(base + 5000), + last_read_message_id: '5', + unread_messages: authors.filter((a) => a === 'other').length, + }, + ] as unknown as NonNullable[0]>['read'], + }); + + const chatClient = await getTestClientWithUser({ id: user1.id } as UserResponse); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.channel.id); + await channel.watch(); + + return render( + + + + + + + , + ); + }; + + it('should render the InlineUnreadIndicator after the last read message when own messages sent elsewhere follow it', async () => { + const { getAllByLabelText, getByTestId } = await renderWithUnreadAfterOwn([ + 'own', + 'other', + 'other', + ]); + + await waitFor(() => { + expect(getAllByLabelText('Inline unread indicator')).toHaveLength(1); + expect( + within(getByTestId('message-list-item-5')).queryByLabelText('Inline unread indicator'), + ).toBeTruthy(); + }); + }); + + it('should render a single InlineUnreadIndicator when own messages sit between unread ones', async () => { + const { getAllByLabelText, getByTestId } = await renderWithUnreadAfterOwn([ + 'other', + 'own', + 'other', + ]); + + await waitFor(() => { + expect(getAllByLabelText('Inline unread indicator')).toHaveLength(1); + expect( + within(getByTestId('message-list-item-5')).queryByLabelText('Inline unread indicator'), + ).toBeTruthy(); + }); + }); + it("should not render the UnreadMessagesIndicator when there's no unread messages", async () => { const user1 = generateUser(); const user2 = generateUser(); diff --git a/package/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx b/package/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx new file mode 100644 index 0000000000..6a53400775 --- /dev/null +++ b/package/src/components/MessageList/hooks/__tests__/useMarkRead.test.tsx @@ -0,0 +1,108 @@ +import React from 'react'; + +import { act, renderHook } from '@testing-library/react-native'; +import type { Channel, LocalMessage, StreamChat } from 'stream-chat'; +import { msToNs } from 'stream-chat'; + +import { ChatContext } from '../../../../contexts/chatContext/ChatContext'; +import type { ChatContextValue } from '../../../../contexts/chatContext/ChatContext'; +import { getOrCreateChannelApi } from '../../../../mock-builders/api/getOrCreateChannel'; +import { useMockedApis } from '../../../../mock-builders/api/useMockedApis'; +import { generateChannelResponse } from '../../../../mock-builders/generator/channel'; +import { generateMessage } from '../../../../mock-builders/generator/message'; +import { generateUser } from '../../../../mock-builders/generator/user'; +import { getTestClientWithUser } from '../../../../mock-builders/mock'; +import { useMarkRead } from '../useMarkRead'; + +describe('useMarkRead', () => { + let client: StreamChat; + let channel: Channel; + + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'me' }); + const response = generateChannelResponse({ messages: [] }); + useMockedApis(client, [getOrCreateChannelApi(response)]); + channel = client.channel('messaging', response.channel.id); + await channel.watch(); + jest.spyOn(client.messageDeliveryReporter, 'throttledMarkRead').mockImplementation(() => {}); + }); + + const render = () => + renderHook(() => useMarkRead(channel), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + // Messages stamped by a server clock running `aheadMs` ahead of the device. + const ingest = (texts: string[], aheadMs: number) => + texts.map((text, i) => { + const createdAt = msToNs(Date.now() + aheadMs + i); + const message = channel.state.formatMessage( + generateMessage({ + cid: channel.cid, + created_at: createdAt, + text, + user: generateUser({ id: 'other' }), + }), + ) as LocalMessage; + channel.messagePaginator.ingestItem(message); + return message; + }); + + it('sets the read boundary on the server clock, so a device behind it still covers what was read', () => { + const read = ingest(['one', 'two', 'three'], 2000); + const { result } = render(); + + act(() => result.current()); + + const snapshot = channel.messagePaginator.unreadStateSnapshot.getLatestValue(); + expect(snapshot.unreadCount).toBe(0); + expect(snapshot.lastReadMessageId).toBe(read[2].id); + // The separator treats `created_at > lastReadAt` as unread: none of these may qualify. + read.forEach((message) => + expect(message.created_at).toBeLessThanOrEqual(snapshot.lastReadAt as number), + ); + + const [next] = ingest(['four'], 3000); + expect(next.created_at).toBeGreaterThan(snapshot.lastReadAt as number); + }); + + describe('with read events off', () => { + const markRead = () => { + const local = jest.spyOn(channel, 'markReadLocally').mockImplementation(() => undefined); + const { result } = render(); + act(() => result.current()); + return local; + }; + + it('resets the unread count locally when the channel opted into counting it', () => { + client.config.set({ + channel: { readEvents: { enabled: false, localUnreadCountEnabled: true } }, + }); + + expect(markRead()).toHaveBeenCalledTimes(1); + expect(client.messageDeliveryReporter.throttledMarkRead).not.toHaveBeenCalled(); + }); + + it('does nothing when the channel did not opt in', () => { + client.config.set({ channel: { readEvents: { enabled: false } } }); + + expect(markRead()).not.toHaveBeenCalled(); + expect(client.messageDeliveryReporter.throttledMarkRead).not.toHaveBeenCalled(); + }); + }); + + it('keeps the snapshot as it is when asked not to update the unread state', () => { + ingest(['one'], 2000); + const before = channel.messagePaginator.unreadStateSnapshot.getLatestValue(); + const { result } = render(); + + act(() => result.current({ updateChannelUnreadState: false })); + + expect(channel.messagePaginator.unreadStateSnapshot.getLatestValue()).toBe(before); + expect(client.messageDeliveryReporter.throttledMarkRead).toHaveBeenCalledWith(channel); + }); +}); diff --git a/package/src/components/MessageList/hooks/useMarkRead.ts b/package/src/components/MessageList/hooks/useMarkRead.ts index 9f4c8b8455..a73cb358df 100644 --- a/package/src/components/MessageList/hooks/useMarkRead.ts +++ b/package/src/components/MessageList/hooks/useMarkRead.ts @@ -41,11 +41,11 @@ export const useMarkRead = (channel: Channel) => { return; } - // Read events disabled (e.g. livestreams): if the client opted into a local unread count, reset + // Read events disabled (e.g. livestreams): if the channel opted into a local unread count, reset // it locally (dispatches message.read_locally) — no backend round trip. The paginator's unread // snapshot updates from that. if (!getReadEventsEnabledSafely()) { - if (client.options.isLocalUnreadCountEnabled) { + if (channel.config.readEvents.localUnreadCountEnabled) { channel.markReadLocally(); } return; @@ -60,19 +60,19 @@ export const useMarkRead = (channel: Channel) => { // clear once the channel is caught up. The LLC bumps `unreadCount` on every incoming // `message.new` but never clears it on `message.read`, so without this the banner latches on and // can't be dismissed. `throttledMarkRead` is fire-and-forget (no response to read), so advance - // the boundary to the latest loaded message. + // the boundary to the latest message. // // Gated on `updateChannelUnreadState` (default true): the mark-read-on-mount call passes `false` // so opening a channel with unreads keeps its original unread UI (separator frozen at the // boundary) until the user actually catches up. const { updateChannelUnreadState = true } = options ?? {}; if (updateChannelUnreadState) { - const loadedItems = channel.messagePaginator.state.getLatestValue().items ?? []; - const previous = channel.messagePaginator.unreadStateSnapshot.getLatestValue(); - channel.messagePaginator.unreadStateSnapshot.next({ + const { lastMessage, lastMessageAt, unreadStateSnapshot } = channel.messagePaginator; + const previous = unreadStateSnapshot.getLatestValue(); + unreadStateSnapshot.next({ firstUnreadMessageId: null, - lastReadAt: nowNs(), - lastReadMessageId: loadedItems[loadedItems.length - 1]?.id ?? previous.lastReadMessageId, + lastReadAt: lastMessageAt ?? nowNs(), + lastReadMessageId: lastMessage?.id ?? previous.lastReadMessageId, unreadCount: 0, }); } diff --git a/package/src/components/Thread/Thread.tsx b/package/src/components/Thread/Thread.tsx index bc918feab6..ef32a5165a 100644 --- a/package/src/components/Thread/Thread.tsx +++ b/package/src/components/Thread/Thread.tsx @@ -1,6 +1,6 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; -import type { LocalMessage, Thread as StreamThread } from 'stream-chat'; +import type { LocalMessage } from 'stream-chat'; import { ThreadFooterComponent } from './components/ThreadFooterComponent'; @@ -72,17 +72,13 @@ const paginatorSelector = (state: ThreadReplyPaginatorState) => ({ lastQueryError: state.lastQueryError, }); -const threadStaleSelector = (state: { isStateStale: boolean }) => ({ +const threadStaleSelector = (state: { isLoading: boolean; isStateStale: boolean }) => ({ isStateStale: state.isStateStale, -}); - -const threadManagerSelector = (state: { threads: StreamThread[] }) => ({ - threads: state.threads, + isThreadReloading: state.isLoading, }); const ThreadWithContext = (props: ThreadPropsWithContext) => { const { - client, additionalMessageComposerProps, additionalMessageListProps, additionalMessageFlashListProps, @@ -102,65 +98,23 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { const { hasItems, isLoading, lastQueryError } = useStateStore(threadInstance?.messagePaginator?.state, paginatorSelector) ?? {}; - const { isStateStale } = useStateStore(threadInstance?.state, threadStaleSelector) ?? {}; - const { threads } = useStateStore(client.threads.state, threadManagerSelector) ?? { - threads: client.threads.state.getLatestValue().threads, - }; - const isThreadManaged = threadInstance?.id - ? threads.some((managedThread) => managedThread.id === threadInstance.id) - : false; - - // Mirror stream-chat-react: an unmanaged thread whose reply paginator hasn't loaded yet gets a - // metadata reload (parent message, read state, participants) — not a paginator reload. - useEffect(() => { - if (!threadInstance || isThreadManaged) return; - if (hasItems || isLoading) return; - void threadInstance.reload().catch((err) => console.warn('Thread reload failed', err)); - }, [isThreadManaged, threadInstance, isLoading, hasItems]); + const { isStateStale, isThreadReloading } = + useStateStore(threadInstance?.state, threadStaleSelector) ?? {}; + const threadId = threadInstance?.id; - // Reload when the thread's state goes stale (e.g. user stopped then resumed watching the channel). useEffect(() => { if (threadInstance && isStateStale) { void threadInstance.reload().catch((err) => console.warn('Thread reload failed', err)); } }, [isStateStale, threadInstance]); - // Once the reply paginator has loaded, adopt the instance into the ThreadManager. The manager - // registers the thread's subscriptions on adoption, which keeps the reply list live (incoming - // replies, read state, thread.updated) — mirrors stream-chat-react. + // Activating registers the thread with `client.threads` for the session, which keeps it subscribed + // (incoming replies, read state, thread.updated) whether or not the thread list holds it. Keyed on + // the instance, which can arrive after mount; the cleanup deactivates the previous one. useEffect(() => { - if (!threadInstance || isThreadManaged) return; - if (isLoading || lastQueryError || !hasItems) return; - client.threads.state.next((current) => - current.threads.some((managedThread) => managedThread.id === threadInstance.id) - ? current - : { ...current, threads: [threadInstance, ...current.threads] }, - ); - }, [client.threads.state, isThreadManaged, threadInstance, isLoading, hasItems, lastQueryError]); - - // Activate the thread instance once it is available. `threadInstance` can resolve asynchronously - // (Channel adopts it from the ThreadManager after this component mounts), so keying on it — rather - // than running on mount alone — ensures activation isn't missed when the instance arrives late. - useEffect(() => { - threadInstance?.activate?.(); - }, [threadInstance]); - - // Mark the thread read on open. Mirrors the pre-refactor openThread behavior: channel.markRead with - // a thread_id marks reliably even when the thread instance's own unread count is 0 (so it can't - // rely on the LLC's active-thread auto-read); a reply-less parent has no server-side thread yet, so - // the call is skipped rather than left to 404 on every open. - useEffect(() => { - const channel = threadInstance?.channel; - if (!threadInstance?.id || !channel?.initialized) { - return; - } - // No replies means nothing that could be unread — true whether or not the thread exists yet. - if (threadInstance.state.getLatestValue().replyCount === 0) { - return; - } - channel - .markRead({ thread_id: threadInstance.id }) - .catch((err) => console.warn('Marking thread as read on open failed with error:', err)); + if (!threadInstance) return; + threadInstance.activate?.(); + return () => threadInstance.deactivate?.(); }, [threadInstance]); // Load the first reply page, but only when the paginator hasn't already been seeded from the @@ -168,25 +122,25 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { // seeded paginator already holds its first page, so we skip the fetch and let scroll-up load older // replies — mirroring stream-chat-react, whose thread list has no mount-time fetch. Reactive on // `threadInstance`/`hasItems` because the instance can arrive after mount; the `hasItems` - // guard makes this fire at most once (an unseeded thread fetches; the fetch defines `items`, which - // also lets the adopt effect register it with the manager). + // guard makes this fire at most once (an unseeded thread fetches, and the fetch defines `items`). useEffect(() => { if (!threadInstance || isLoading || hasItems || lastQueryError) { return; } + // A reload in flight (a stale thread's, started by the effect above in this same commit) seeds the + // replies itself; read live, as this commit's render predates it. If it fails, this runs again. + if (isThreadReloading || threadInstance.state.getLatestValue().isLoading) { + return; + } void threadInstance.messagePaginator.toTail(); // `lastQueryError` is load-bearing here, not decorative: a failed query flips `isLoading` back to // false with `hasItems` still false, which would re-run this effect and refetch forever. The // retry is the user's to make, through the error indicator below. - }, [threadInstance, hasItems, isLoading, lastQueryError]); + }, [threadInstance, hasItems, isLoading, lastQueryError, isThreadReloading]); - // Tear down on unmount. Use a ref so we deactivate whichever instance is current at unmount, not - // the (possibly null) one captured when this effect first ran. - const threadInstanceRef = useRef(threadInstance); - threadInstanceRef.current = threadInstance; + // Deactivation is the activation effect's cleanup; this only notifies the integrator. useEffect( () => () => { - threadInstanceRef.current?.deactivate?.(); if (onThreadDismount) { onThreadDismount(); } @@ -208,7 +162,6 @@ const ThreadWithContext = (props: ThreadPropsWithContext) => { [disabled, autoFocus], ); - const threadId = threadInstance?.id; if (!threadId) { return null; } diff --git a/package/src/components/Thread/__tests__/Thread.test.tsx b/package/src/components/Thread/__tests__/Thread.test.tsx index b4fc4f7e11..04024bb719 100644 --- a/package/src/components/Thread/__tests__/Thread.test.tsx +++ b/package/src/components/Thread/__tests__/Thread.test.tsx @@ -205,6 +205,72 @@ describe('Thread', () => { }); }); + // Metadata (parent, read state, participants) comes with a queried page. A thread `ensure` builds has only + // its parent message, so it starts stale and reloads once on open; one that already has its data doesn't. + describe('metadata reload on open', () => { + const openThread = ( + threadInstance: ThreadClass, + parentMessage: ReturnType, + ) => { + const reload = jest.spyOn(threadInstance, 'reload').mockResolvedValue(undefined); + const toTail = jest + .spyOn(threadInstance.messagePaginator, 'toTail') + .mockResolvedValue(undefined); + renderComponent({ channel, chatClient, thread: { thread: parentMessage, threadInstance } }); + return { reload, toTail }; + }; + + it('reloads a thread ensure built from its parent message', async () => { + const parentMessage = generateMessage({ cid: 'messaging:test-channel', text: 'Parent' }); + const threadInstance = chatClient.threads.ensure({ channel, parentMessage }); + const { reload } = openThread(threadInstance, parentMessage); + + await waitFor(() => expect(reload).toHaveBeenCalled()); + }); + + it('does not fetch the first reply page while that reload is in flight, only if it fails', async () => { + const parentMessage = generateMessage({ cid: 'messaging:test-channel', text: 'Parent' }); + const threadInstance = chatClient.threads.ensure({ channel, parentMessage }); + let failReload: () => void = () => undefined; + jest.spyOn(threadInstance, 'reload').mockImplementation(async () => { + threadInstance.state.partialNext({ isLoading: true }); + await new Promise((resolve) => (failReload = resolve)); + threadInstance.state.partialNext({ isLoading: false }); + }); + const toTail = jest + .spyOn(threadInstance.messagePaginator, 'toTail') + .mockResolvedValue(undefined); + renderComponent({ channel, chatClient, thread: { thread: parentMessage, threadInstance } }); + + await waitFor(() => expect(threadInstance.reload).toHaveBeenCalled()); + expect(toTail).not.toHaveBeenCalled(); + + // The reload ended without seeding replies (a failure): the first page is fetched instead. + await act(async () => { + failReload(); + await Promise.resolve(); + }); + await waitFor(() => expect(toTail).toHaveBeenCalled()); + }); + + it('does not reload a thread that already has its data', async () => { + const parentMessage = generateMessage({ cid: 'messaging:test-channel', text: 'Parent' }); + const threadInstance = new ThreadClass({ channel, client: chatClient, parentMessage }); + act(() => { + chatClient.threads.paginator.setItems({ + isFirstPage: true, + isLastPage: true, + valueOrFactory: [threadInstance], + }); + }); + const { reload, toTail } = openThread(threadInstance, parentMessage); + + // The first-page fetch runs from the same mount, so its call marks the effects as settled. + await waitFor(() => expect(toTail).toHaveBeenCalled()); + expect(reload).not.toHaveBeenCalled(); + }); + }); + describe('reply query errors', () => { const makeThread = () => { const cid = 'messaging:test-channel'; diff --git a/package/src/components/ThreadList/ThreadList.tsx b/package/src/components/ThreadList/ThreadList.tsx index 643ce48e36..0c56af7315 100644 --- a/package/src/components/ThreadList/ThreadList.tsx +++ b/package/src/components/ThreadList/ThreadList.tsx @@ -1,7 +1,7 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { FlatList, StyleSheet, View } from 'react-native'; -import { Thread, ThreadManagerState } from 'stream-chat'; +import type { PaginatorState, Thread } from 'stream-chat'; import { ThreadListItem } from './ThreadListItem'; import { ThreadListItemSkeleton } from './ThreadListItemSkeleton'; @@ -21,12 +21,10 @@ import { EmptyStateIndicator } from '../Indicators/EmptyStateIndicator'; import { LoadingIndicator } from '../Indicators/LoadingIndicator'; import { NotificationTargetProvider } from '../Notifications/NotificationTargetContext'; -const selector = (nextValue: ThreadManagerState) => - ({ - isLoading: nextValue.pagination.isLoading, - isLoadingNext: nextValue.pagination.isLoadingNext, - threads: nextValue.threads, - }) as const; +const NO_THREADS: Thread[] = []; + +const paginatorSelector = ({ isLoading, items }: PaginatorState) => + ({ isLoading, threads: items ?? NO_THREADS }) as const; export type ThreadListProps = Pick< ThreadsContextValue, @@ -102,28 +100,22 @@ export const ThreadList = (props: ThreadListProps) => { if (!client) { return; } - - // Only the socket recovers — a device regaining its network has no reconnected socket yet, and - // the event is dispatched once the client's own post-reconnect reloads have landed. - const listener = client.on('connection.recovered', () => { - client.threads.reload({ force: true }); - }); - - return () => { - client.threads.deactivate(); - listener.unsubscribe(); - }; + return () => client.threads.deactivate(); }, [client]); - const { isLoading, isLoadingNext, threads } = useStateStore(client.threads.state, selector); + const { isLoading, threads } = useStateStore(client.threads.paginator.state, paginatorSelector); + // A no-op until the first page has landed, at the end of the list, and while a page is loading. + const loadMore = useCallback(async () => { + await client.threads.paginator.toTail(); + }, [client]); return ( 0, + loadMore, threads, ...props, }} diff --git a/package/src/components/ThreadList/ThreadListUnreadBanner.tsx b/package/src/components/ThreadList/ThreadListUnreadBanner.tsx index 29181d5c33..fcd720ccbe 100644 --- a/package/src/components/ThreadList/ThreadListUnreadBanner.tsx +++ b/package/src/components/ThreadList/ThreadListUnreadBanner.tsx @@ -71,8 +71,10 @@ export const ThreadListUnreadBanner = () => { > - {t('threadList.unreadBanner.newThreads.label', '{{count}} new threads', { + {t('threadList.unreadBanner.newThreads.label', { count: unseenThreadIds.length, + defaultValue_one: '{{count}} new thread', + defaultValue_other: '{{count}} new threads', })} diff --git a/package/src/components/ThreadList/__tests__/ThreadList.test.tsx b/package/src/components/ThreadList/__tests__/ThreadList.test.tsx new file mode 100644 index 0000000000..96b03f4897 --- /dev/null +++ b/package/src/components/ThreadList/__tests__/ThreadList.test.tsx @@ -0,0 +1,130 @@ +import React from 'react'; + +import { act, cleanup, render, screen, waitFor } from '@testing-library/react-native'; +import type { Channel as ChannelType, StreamChat } from 'stream-chat'; +import { Thread } from 'stream-chat'; + +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { Chat } from '../../Chat/Chat'; +import { ThreadList } from '../ThreadList'; + +// Like stream-chat-react: the skeleton is the first load only, a reload keeps showing the list, and +// the footer is a next page. +describe('ThreadList loading states', () => { + let chatClient: StreamChat; + let channel: ChannelType; + + const makeThread = () => + new Thread({ + channel, + client: chatClient, + parentMessage: generateMessage({ cid: channel.cid, text: 'Parent' }), + }); + + /** A `queryThreadsAndHydrate` response that lands only when the test says so. */ + const respondLater = () => { + let settle: (threads: Thread[], next?: string) => void = () => undefined; + jest.spyOn(chatClient, 'queryThreadsAndHydrate').mockImplementationOnce( + () => + new Promise((resolve) => { + settle = (threads, next) => resolve({ next, threads }); + }), + ); + return (threads: Thread[], next?: string) => act(() => settle(threads, next)); + }; + + const skeletons = () => screen.queryAllByTestId('channel-preview-skeleton'); + const rows = () => screen.queryAllByTestId('thread-list-item'); + // The footer spinner has no text or accessible role to query, so read what the mapping sets. + const footer = () => screen.getByTestId('thread-flatlist').props.ListFooterComponent; + + beforeEach(async () => { + const { client, channels } = await initiateClientWithChannels(); + chatClient = client; + channel = channels[0]; + }); + + afterEach(() => { + jest.restoreAllMocks(); + cleanup(); + }); + + it('shows the skeleton on the first load, then the list', async () => { + const settle = respondLater(); + + render( + + + , + ); + + await waitFor(() => expect(skeletons().length).toBeGreaterThan(0)); + await settle([makeThread()]); + await waitFor(() => expect(rows()).toHaveLength(1)); + expect(skeletons()).toHaveLength(0); + }); + + it('keeps showing the list, without the skeleton, while a loaded list reloads', async () => { + jest + .spyOn(chatClient, 'queryThreadsAndHydrate') + .mockResolvedValueOnce({ next: undefined, threads: [makeThread()] }); + render( + + + , + ); + await waitFor(() => expect(rows()).toHaveLength(1)); + const settle = respondLater(); + + act(() => { + void chatClient.threads.reload({ force: true }); + }); + + expect(rows()).toHaveLength(1); + expect(skeletons()).toHaveLength(0); + await settle([makeThread(), makeThread()]); + await waitFor(() => expect(rows()).toHaveLength(2)); + }); + + it('shows the footer, not the skeleton, while a next page loads', async () => { + jest + .spyOn(chatClient, 'queryThreadsAndHydrate') + .mockResolvedValueOnce({ next: 'cursor', threads: [makeThread()] }); + // Queued up front: a short list fires `onEndReached` by itself once the first page lands. + const settle = respondLater(); + render( + + + , + ); + await waitFor(() => expect(rows()).toHaveLength(1)); + + act(() => { + void chatClient.threads.paginator.toTail(); + }); + + await waitFor(() => expect(footer()).toBeDefined()); + expect(skeletons()).toHaveLength(0); + expect(rows()).toHaveLength(1); + await settle([makeThread()]); + await waitFor(() => expect(rows()).toHaveLength(2)); + expect(footer()).toBeUndefined(); + }); + it('pluralizes the unseen-threads banner', async () => { + jest + .spyOn(chatClient, 'queryThreadsAndHydrate') + .mockResolvedValueOnce({ next: undefined, threads: [makeThread()] }); + render( + + + , + ); + await waitFor(() => expect(rows()).toHaveLength(1)); + + act(() => chatClient.threads.state.partialNext({ unseenThreadIds: ['a'] })); + await waitFor(() => expect(screen.getByText('1 new thread')).toBeTruthy()); + act(() => chatClient.threads.state.partialNext({ unseenThreadIds: ['a', 'b'] })); + await waitFor(() => expect(screen.getByText('2 new threads')).toBeTruthy()); + }); +}); diff --git a/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx b/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx index 1dfe1bde3a..7544595992 100644 --- a/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx +++ b/package/src/contexts/liveLocationManagerContext/LiveLocationManagerContext.tsx @@ -46,7 +46,9 @@ export const LiveLocationManagerProvider = ( return; } // Initialize the live location manager - liveLocationManager.init(); + liveLocationManager + .init() + .catch((error) => console.warn('Initializing the live location manager failed:', error)); return () => { liveLocationManager.unregisterSubscriptions(); diff --git a/package/src/i18n/__tests__/catalog.fixture.json b/package/src/i18n/__tests__/catalog.fixture.json index 8a680899bc..acd5a11a5b 100644 --- a/package/src/i18n/__tests__/catalog.fixture.json +++ b/package/src/i18n/__tests__/catalog.fixture.json @@ -397,7 +397,8 @@ "reply.replying.withUser.accessibilityLabel": "Replying to {{user}}", "reply.replying.withUserAndText.accessibilityLabel": "Replying to {{user}}: {{text}}", "threadList.unreadBanner.loadFailed.error": "Couldn't load new threads. Tap to retry", - "threadList.unreadBanner.newThreads.label": "{{count}} new threads", + "threadList.unreadBanner.newThreads.label_one": "{{count}} new thread", + "threadList.unreadBanner.newThreads.label_other": "{{count}} new threads", "timestamp.ChannelPreviewStatus": "{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {\"lastDay\":\"[Yesterday]\", \"lastWeek\":\"dddd\", \"nextDay\":\"[Tomorrow]\", \"nextWeek\":\"dddd [at] LT\", \"sameDay\":\"LT\", \"sameElse\":\"L\"}) }}", "timestamp.FileAttachmentListSection": "{{ timestamp | timestampFormatter(format: MMMM YYYY) }}", "timestamp.ImageGalleryHeader": "{{ timestamp | timestampFormatter(calendar: true) }}", diff --git a/package/src/i18n/keys.ts b/package/src/i18n/keys.ts index 3206287be2..5cc59b9295 100644 --- a/package/src/i18n/keys.ts +++ b/package/src/i18n/keys.ts @@ -408,7 +408,8 @@ export type TranslationCatalog = { 'reply.replying.withUser.accessibilityLabel': 'Replying to {{user}}'; 'reply.replying.withUserAndText.accessibilityLabel': 'Replying to {{user}}: {{text}}'; 'threadList.unreadBanner.loadFailed.error': "Couldn't load new threads. Tap to retry"; - 'threadList.unreadBanner.newThreads.label': '{{count}} new threads'; + 'threadList.unreadBanner.newThreads.label_one': '{{count}} new thread'; + 'threadList.unreadBanner.newThreads.label_other': '{{count}} new threads'; 'timestamp.ChannelPreviewStatus': '{{ timestamp | timestampFormatter(calendar: true; calendarFormats: {"lastDay":"[Yesterday]", "lastWeek":"dddd", "nextDay":"[Tomorrow]", "nextWeek":"dddd [at] LT", "sameDay":"LT", "sameElse":"L"}) }}'; 'timestamp.FileAttachmentListSection': '{{ timestamp | timestampFormatter(format: MMMM YYYY) }}'; 'timestamp.ImageGalleryHeader': '{{ timestamp | timestampFormatter(calendar: true) }}'; diff --git a/yarn.lock b/yarn.lock index 45f6e2bdd8..ec4ca8557e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6610,7 +6610,7 @@ __metadata: react-native-teleport: "npm:^1.1.12" react-native-web: "npm:^0.21.2" react-native-worklets: "npm:0.11.1" - stream-chat: "npm:^10.0.0-rc.14" + stream-chat: "npm:^10.0.0-rc.16" stream-chat-expo: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -17608,7 +17608,7 @@ __metadata: react-native-teleport: "npm:^1.1.12" react-native-video: "npm:^6.19.2" react-native-worklets: "npm:^0.12.1" - stream-chat: "npm:^10.0.0-rc.14" + stream-chat: "npm:^10.0.0-rc.16" stream-chat-react-native: "workspace:^" stream-chat-react-native-core: "workspace:^" typescript: "npm:6.0.3" @@ -18352,7 +18352,7 @@ __metadata: react-native-worklets: "npm:^0.12.1" react-test-renderer: "npm:19.2.3" rimraf: "npm:^6.0.1" - stream-chat: "npm:^10.0.0-rc.14" + stream-chat: "npm:^10.0.0-rc.16" typescript: "npm:6.0.3" use-sync-external-store: "npm:^1.7.0" uuid: "npm:^11.1.0" @@ -18426,9 +18426,9 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:^10.0.0-rc.14": - version: 10.0.0-rc.14 - resolution: "stream-chat@npm:10.0.0-rc.14" +"stream-chat@npm:^10.0.0-rc.16": + version: 10.0.0-rc.16 + resolution: "stream-chat@npm:10.0.0-rc.16" dependencies: "@stream-io/logger": "npm:^2.0.0" "@stream-io/state-store": "npm:^1.1.6" @@ -18439,7 +18439,7 @@ __metadata: built: true husky: built: true - checksum: 10c0/2db57b3297da1d35ef208189b7be12940f418854fd765e300f83895038ff092fa157e37f64c2ce2e82ff1dcc6bf1f7b995b5d0e59092ac3da4a44fe248ad96d5 + checksum: 10c0/accb88f0763ff513f9a3899c4a96c32968067056898232ee5ad58540482ee0794fc06d13862904770d2d9fb65907b54b767a57f3f26ce613ef71e22228e7a3a2 languageName: node linkType: hard