Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/ExpoMessaging/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^"
},
Expand Down
2 changes: 1 addition & 1 deletion examples/SampleApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^"
},
Expand Down
3 changes: 2 additions & 1 deletion examples/SampleApp/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
3 changes: 2 additions & 1 deletion examples/SampleApp/src/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
2 changes: 1 addition & 1 deletion package/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
7 changes: 1 addition & 6 deletions package/src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -446,12 +446,7 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
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());
Expand Down
191 changes: 123 additions & 68 deletions package/src/components/Channel/__tests__/Channel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <Thread>, so nothing calls activate(): a list query landing now must find this instance.
render(
<Chat client={chatClient}>
<Channel channel={testChannel} threadList thread={parentMessage} />
</Chat>,
);

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)]);
Expand All @@ -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 <Thread> 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 <Thread>'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(
Expand All @@ -800,20 +815,20 @@ describe('Channel initial load useEffect', () => {
thread={{ thread: testChannel.state.formatMessage(parentMessage), threadInstance }}
>
{/* The real <Thread> 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 <Channel> 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 <Channel> would not activate anything. */}
<ThreadComponent />
</Channel>
</Chat>,
);

// Wait for <Thread> 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));
Expand All @@ -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<ReturnType<typeof testChannel.markRead>>);
// Reads are the LLC's job: the thread's active auto-read marks it once its read state says it has
// unread replies. `<Thread>` makes no mark-read call of its own.
describe('read on open', () => {
const openThread = (threadInstance: Thread, testChannel: ChannelType) =>
render(
<Chat client={chatClient}>
<Channel
channel={testChannel}
threadList
thread={{ thread: threadInstance.state.getLatestValue().parentMessage, threadInstance }}
>
<ThreadComponent />
</Channel>
</Chat>,
);

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<ReturnType<typeof testChannel.markRead>>);
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(
<Chat client={chatClient}>
<Channel
channel={testChannel}
threadList
thread={{ thread: testChannel.state.formatMessage(parentMessage), threadInstance: empty }}
>
<ThreadComponent />
</Channel>
</Chat>,
);
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(
<Chat client={chatClient}>
<Channel
channel={testChannel}
threadList
thread={{
thread: testChannel.state.formatMessage(parentMessage),
threadInstance: withReplies,
}}
>
<ThreadComponent />
</Channel>
</Chat>,
);
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<ReturnType<typeof testChannel.markRead>>);

render(
<Chat client={chatClient}>
<Channel channel={testChannel} />
</Chat>,
);

await waitFor(() => expect(markRead).toHaveBeenCalled());
expect(markRead.mock.calls.every(([options]) => !options?.thread_id)).toBe(true);
});
});
});
11 changes: 11 additions & 0 deletions package/src/components/Chat/hooks/useIsOnline.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -40,6 +42,15 @@ export const useIsOnline = (client: StreamChat, closeConnectionOnBackground = tr

useAppStateListener(onForeground, onBackground);

// A <Chat> 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;
Expand Down
19 changes: 11 additions & 8 deletions package/src/components/Message/MessageItemView/MessageWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
}
Expand All @@ -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,
Expand Down
Loading
Loading