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.19",
"stream-chat": "^10.0.0-rc.20",
"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.19",
"stream-chat": "^10.0.0-rc.20",
"stream-chat-react-native": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
Expand Down
9 changes: 4 additions & 5 deletions examples/SampleApp/src/components/UnreadCountBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const ChannelsUnreadCountBadge: React.FC = () => {
* Listen to changes in unread counts and update the badge count.
*
* We deliberately derive the total from each channel's local `countUnread()` (a sum over the
* loaded/active channels) rather than the server-sent `event.total_unread_count`. The local count
* loaded channels) rather than the server-sent `event.total_unread_count`. The local count
* respects the "viewing live" gate — the channel the user is currently reading at the bottom stays
* at 0 — so the badge does NOT flicker when a message arrives in the active channel. The server
* total, by contrast, briefly counts that message until the mark-read round-trips.
Expand All @@ -36,10 +36,9 @@ export const ChannelsUnreadCountBadge: React.FC = () => {
return;
}
const computeUnreadCount = () =>
Object.values(chatClient.activeChannels).reduce(
(count, channel) => count + (channel?.countUnread() ?? 0),
0,
);
chatClient.channelManager
.values()
.reduce((count, channel) => count + channel.countUnread(), 0);

setUnreadCount(computeUnreadCount());
const listener = chatClient.on(() => {
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.19",
"stream-chat": "^10.0.0-rc.20",
"use-sync-external-store": "^1.7.0"
},
"peerDependencies": {
Expand Down
52 changes: 39 additions & 13 deletions package/src/__tests__/offline-support/offline-feature.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ const getSyncManager = (client: StreamChat): TestSyncManager =>
(client.offlineDb as unknown as { syncManager: TestSyncManager }).syncManager;
const asHydrateChannelsMock = (
client: StreamChat,
): StreamChat['hydrateActiveChannels'] & { mock: { calls: unknown[][] } } =>
client.hydrateActiveChannels as StreamChat['hydrateActiveChannels'] & {
): StreamChat['hydrateChannels'] & { mock: { calls: unknown[][] } } =>
client.hydrateChannels as StreamChat['hydrateChannels'] & {
mock: { calls: unknown[][] };
};

Expand Down Expand Up @@ -220,7 +220,11 @@ export const Generic = () => {
// `cid` is not part of `GeneratedChannelResponseCustomValues`, but tests rely on reading it
// back as a top-level field on the generated channel response — keep the runtime shape and
// widen the input type.
// The list below filters on the custom field `foo`, so every channel the server returns for it
// carries `foo: 'bar'`. Client-side filter matching (channel.updated / channel.truncated) relies on it,
// reading custom fields from `data.custom`.
return generateChannelResponse({
channel: { custom: { foo: 'bar' } },
cid,
id,
members,
Expand Down Expand Up @@ -409,10 +413,31 @@ export const Generic = () => {
);
});

// `disconnectUser()` is not always a logout (e.g. `useCreateChatClient` unmounting), so it must not
// touch what the offline DB cached for the user, or the next offline cold start shows nothing.
it('keeps the cached channel list when the user disconnects', async () => {
useMockedApis(chatClient, [queryChannelsApi(channels)]);
await renderComponent();
act(() => dispatchConnectionChangedEvent(chatClient));
await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));

const cachedCids = async () => {
const rows = await BetterSqlite.selectFromTable('channelQueries');
return JSON.parse(rows[0].cids as string) as string[];
};
await waitFor(async () => expect(await cachedCids()).toHaveLength(channels.length));

await act(async () => {
await chatClient.disconnectUser();
});

expect(await cachedCids()).toHaveLength(channels.length);
});

it('should fetch channels from the db correctly even if they are empty', async () => {
const emptyChannel = createChannel([]);
useMockedApis(chatClient, [queryChannelsApi([emptyChannel])]);
jest.spyOn(chatClient, 'hydrateActiveChannels');
jest.spyOn(chatClient, 'hydrateChannels');

await renderComponent();

Expand All @@ -421,7 +446,7 @@ export const Generic = () => {
await act(async () => await getSyncManager(chatClient).invokeSyncStatusListeners(true));
expect(screen.getByTestId('channel-list-view')).toBeTruthy();
expect(screen.getByTestId(emptyChannel.cid)).toBeTruthy();
expect(chatClient.hydrateActiveChannels).toHaveBeenCalled();
expect(chatClient.hydrateChannels).toHaveBeenCalled();
expect(asHydrateChannelsMock(chatClient).mock.calls[0][0]).toStrictEqual([emptyChannel]);
});
});
Expand Down Expand Up @@ -767,7 +792,7 @@ export const Generic = () => {
expect(matchingRows.length).toBe(1);
expect(matchingRows[0].hidden).toBeTruthy();
expect(matchingMessagesRows.length).toBe(
chatClient.activeChannels[hiddenChannel.cid].messagePaginator.headItems.length,
chatClient.channelManager.get(hiddenChannel.cid)?.messagePaginator.headItems.length,
);
});
});
Expand Down Expand Up @@ -802,11 +827,12 @@ export const Generic = () => {
expect(matchingRows.length).toBe(1);
expect(matchingRows[0].hidden).toBeTruthy();
expect(matchingMessagesRows.length).toBe(
chatClient.activeChannels[hiddenChannel.cid].messagePaginator.headItems.length,
chatClient.channelManager.get(hiddenChannel.cid)?.messagePaginator.headItems.length,
);
});

// then, we make it visible after waiting for everything to finish
// then, we make it visible after waiting for everything to finish. It still matches the list's
// filter, so the list takes it back (v9 promoted it on `channel.visible` by default too).
act(() => dispatchChannelVisibleEvent(chatClient, hiddenChannel));
await waitFor(async () => {
const channelIdsOnUI = screen
Expand All @@ -816,7 +842,7 @@ export const Generic = () => {
(node as unknown as { _fiber: { pendingProps: { testID: string } } })._fiber
.pendingProps.testID,
);
expect(channelIdsOnUI.includes(hiddenChannel.cid)).toBeFalsy();
expect(channelIdsOnUI.includes(hiddenChannel.cid)).toBeTruthy();
await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText);

const channelsRows = await BetterSqlite.selectFromTable('channels');
Expand All @@ -828,7 +854,7 @@ export const Generic = () => {
expect(matchingRows.length).toBe(1);
expect(matchingRows[0].hidden).toBeFalsy();
expect(matchingMessagesRows.length).toBe(
chatClient.activeChannels[hiddenChannel.cid].messagePaginator.headItems.length,
chatClient.channelManager.get(hiddenChannel.cid)?.messagePaginator.headItems.length,
);
});
});
Expand Down Expand Up @@ -908,7 +934,6 @@ export const Generic = () => {
.pendingProps.testID,
);
expect(channelIdsOnUI.includes(channelToTruncate.cid)).toBeTruthy();
expectCIDsOnUIToBeInDB(screen.queryAllByLabelText);

const messagesRows = await BetterSqlite.selectFromTable('messages');
const matchingMessagesRows = messagesRows.filter((m) => m.cid === channelToTruncate.cid);
Expand All @@ -922,6 +947,7 @@ export const Generic = () => {
expect(matchingReadRows.length).toBe(1);
expect(matchingReadRows[0].unreadMessages).toBe(0);
});
await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText);
});

it('should truncate the correct messages if channel.truncated arrives with truncated_at', async () => {
Expand Down Expand Up @@ -959,7 +985,6 @@ export const Generic = () => {
.pendingProps.testID,
);
expect(channelIdsOnUI.includes(channelToTruncate.cid)).toBeTruthy();
expectCIDsOnUIToBeInDB(screen.queryAllByLabelText);

const messagesRows = await BetterSqlite.selectFromTable('messages');
const matchingMessagesRows = messagesRows.filter((m) => m.cid === channelToTruncate.cid);
Expand All @@ -975,6 +1000,7 @@ export const Generic = () => {
expect(matchingReadRows.length).toBe(1);
expect(matchingReadRows[0].unreadMessages).toBe(messagesLeft);
});
await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText);
});

it('should gracefully handle a truncated_at date before each message', async () => {
Expand Down Expand Up @@ -1004,13 +1030,13 @@ export const Generic = () => {
.pendingProps.testID,
);
expect(channelIdsOnUI.includes(channelToTruncate.cid)).toBeTruthy();
expectCIDsOnUIToBeInDB(screen.queryAllByLabelText);

const messagesRows = await BetterSqlite.selectFromTable('messages');
const matchingMessagesRows = messagesRows.filter((m) => m.cid === channelToTruncate.cid);

expect(matchingMessagesRows.length).toBe(channelResponse.messages.length);
});
await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText);
});

it('should gracefully handle a truncated_at date after each message', async () => {
Expand Down Expand Up @@ -1045,13 +1071,13 @@ export const Generic = () => {
.pendingProps.testID,
);
expect(channelIdsOnUI.includes(channelToTruncate.cid)).toBeTruthy();
expectCIDsOnUIToBeInDB(screen.queryAllByLabelText);

const messagesRows = await BetterSqlite.selectFromTable('messages');
const matchingMessagesRows = messagesRows.filter((m) => m.cid === channelToTruncate.cid);

expect(matchingMessagesRows.length).toBe(0);
});
await expectCIDsOnUIToBeInDB(screen.queryAllByLabelText);
});

it('should add a reaction to DB when a new reaction is added', async () => {
Expand Down
23 changes: 11 additions & 12 deletions package/src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { useCreateThreadContext } from './hooks/useCreateThreadContext';

import { DEFAULT_HIGHLIGHT_DURATION } from './hooks/useMessageListPagination';

import { useSupersededChannelSwap } from './hooks/useSupersededChannelSwap';

import {
AttachmentPickerContextValue,
AttachmentPickerProvider,
Expand Down Expand Up @@ -541,14 +543,14 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
}

// Seed the paginator for a cold open (deep link / push). Channels reached via the channel
// list are already seeded by client.hydrateActiveChannels, so guard on an empty paginator
// list are already seeded by client.hydrateChannels, so guard on an empty paginator
// to avoid a redundant fetch.
if (!channel.messagePaginator.state.getLatestValue().items?.length) {
await channel.messagePaginator.reload();
}

// Re-seed the unread snapshot from the CURRENT read state on every open. The paginator is
// usually reused from cache (the reload above is skipped), and hydrateActiveChannels merges
// usually reused from cache (the reload above is skipped), and hydrateChannels merges
// rather than re-seeds, so without this the snapshot's boundary/count/first-unread stay frozen
// at the very first open — making the separator, the "N new" banner and the jump-to-first-unread
// target all go stale on reopen. Mirrors stream-chat-react, which re-seeds by re-querying on open.
Expand Down Expand Up @@ -581,15 +583,10 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
// Mark the channel active while this <Channel> is mounted. The LLC refcounts `active`, so a
// Channel instance shared with the channel-list preview or a thread stays active until the last
// mount unmounts. Being active enables auto-mark-read-on-focus and suppresses destructive
// channel-list re-seeding of the open channel's message list on reconnect. Keyed on `channel` and
// balanced (the cleanup deactivates the exact instance the effect activated), so swapping the
// `channel` prop deactivates the previous instance before activating the new one.
useEffect(() => {
channel?.activate?.();
return () => {
channel?.deactivate?.();
};
}, [channel]);
// channel-list re-seeding of the open channel's message list on reconnect. `activate()` returns
// the release for this exact activation, so swapping the `channel` prop releases the previous
// instance before activating the new one.
useEffect(() => channel?.activate(), [channel]);

// subscribe to channel.deleted event
useEffect(() => {
Expand Down Expand Up @@ -945,9 +942,10 @@ export const Channel = (props: PropsWithChildren<ChannelProps>) => {
const { client, isMessageAIGenerated } = useChatContext();
const isOnline = useSettledWSConnectionHealth();
const { t } = useTranslationContext();
const channel = useSupersededChannelSwap(props.channel);
const notificationHostId =
props.notificationHostId ??
(props.channel?.cid ? getChannelNotificationHostId(props.channel.cid) : undefined);
(channel?.cid ? getChannelNotificationHostId(channel.cid) : undefined);

const threadFromProps = props?.thread;
const threadInstance = (threadFromProps as ThreadType)?.threadInstance as Thread;
Expand All @@ -969,6 +967,7 @@ export const Channel = (props: PropsWithChildren<ChannelProps>) => {
t,
}}
{...props}
channel={channel}
shouldSyncChannel={shouldSyncChannel}
{...{
isMessageAIGenerated,
Expand Down
83 changes: 83 additions & 0 deletions package/src/components/Channel/__tests__/Channel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,89 @@ describe('Channel', () => {
});
});

// `activate()` returns the release for that one activation. A mount that never runs it leaves the
// channel active for the rest of the session, which nothing else would surface.
it('releases its activation when the channel prop changes and on unmount', async () => {
const otherChannel = chatClient.channel('messaging', 'other-channel');
const renderChannel = (target: ChannelType) => (
<Chat client={chatClient}>
<Channel channel={target} initializeOnMount={false} />
</Chat>
);

const { rerender, unmount } = render(renderChannel(channel));
await waitFor(() => expect(channel.active).toBe(true));

rerender(renderChannel(otherChannel));
await waitFor(() => expect(otherChannel.active).toBe(true));
expect(channel.active).toBe(false);

unmount();
expect(otherChannel.active).toBe(false);
});

// A channel created from members whose first query finds its real cid already stored (e.g. by
// `notification.added_to_channel`) gets no events from then on, so <Channel> moves to the stored one.
describe('when the channel is superseded by the stored instance', () => {
const setup = () => {
const otherUser = generateUser({ id: 'other' });
const draft = chatClient.channel('messaging', {
members: [{ user_id: user.id }, { user_id: otherUser.id }],
});
const stored = chatClient.channel('messaging', '!members-xyz');
// the mount's first-page load would query (and so supersede) the draft before the test does
jest.spyOn(draft.messagePaginator, 'reload').mockResolvedValue(undefined);
useMockedApis(chatClient, [
getOrCreateChannelApi(
generateChannelResponse({
id: '!members-xyz',
members: [generateMember({ user }), generateMember({ user: otherUser })],
type: channelType,
}),
),
]);
let contextChannel: ChannelType | undefined;
renderComponent({ channel: draft, initializeOnMount: false }, (ctx) => {
contextChannel = (ctx as ChannelContextValue).channel;
});
return { draft, getContextChannel: () => contextChannel, stored };
};

it('renders the stored instance, with what was typed', async () => {
const { draft, getContextChannel, stored } = setup();
await waitFor(() => expect(draft.active).toBe(true));
draft.messageComposer.textComposer.setText('hello');

await act(async () => {
await draft.watch();
});

await waitFor(() => expect(getContextChannel()).toBe(stored));
expect(draft.supersededBy).toBe(stored);
expect(stored.active).toBe(true);
expect(draft.active).toBe(false);
expect(stored.messageComposer.textComposer.text).toBe('hello');
});

it('waits while the stored instance is open elsewhere and the composer holds a draft', async () => {
const { draft, getContextChannel, stored } = setup();
const releaseElsewhere = stored.activate();
await waitFor(() => expect(draft.active).toBe(true));
draft.messageComposer.textComposer.setText('hello');

await act(async () => {
await draft.watch();
});

expect(draft.supersededBy).toBe(stored);
expect(getContextChannel()).toBe(draft);

act(() => draft.messageComposer.clear());
await waitFor(() => expect(getContextChannel()).toBe(stored));
releaseElsewhere();
});
});

it('does not re-render while the message list queries', async () => {
// `Channel` subscribes to the paginator for `hasMessages` (what to show when a query errors).
// That selector must not carry `isLoading` with it: nothing here reads it, but `useStateStore`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import type { Channel } from 'stream-chat';
import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext';
import { useCreateChannelContext } from '../hooks/useCreateChannelContext';

// one instance, as the channel store keeps one per cid and channel traffic never replaces it
const channel = { id: 'channel-1' } as unknown as Channel;

const baseInput = (overrides: Partial<ChannelContextValue> = {}) =>
({
channel: { id: 'channel-1' } as unknown as Channel,
channel,
disabled: false,
enableMessageGroupingByUser: true,
enforceUniqueReaction: false,
Expand Down Expand Up @@ -39,6 +42,8 @@ describe('useCreateChannelContext', () => {

it.each([
['channel id', { channel: { id: 'channel-2' } as unknown as Channel }],
// a superseded instance shares its id with the instance that replaced it
['channel instance', { channel: { id: 'channel-1' } as unknown as Channel }],
['disabled', { disabled: true }],
['isChannelActive', { isChannelActive: false }],
['threadList', { threadList: true }],
Expand Down
Loading
Loading