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
6 changes: 5 additions & 1 deletion examples/SampleApp/src/hooks/useChatClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { SqliteClient } from 'stream-chat-react-native';
import { USER_TOKENS, USERS } from '../ChatUsers';
import type { LoginConfig } from '../types';
import AsyncStore from '../utils/AsyncStore';
import { getTokenOrProvider, SAMPLE_APP_API_KEY } from '../utils/tokenProvider';
import { clearLastToken, getTokenOrProvider, SAMPLE_APP_API_KEY } from '../utils/tokenProvider';

const messaging = getMessaging();

Expand Down Expand Up @@ -75,6 +75,9 @@ export const useChatClient = () => {
unsubscribePushListenersRef.current?.();
const client = StreamChat.getInstance(config.apiKey, {
axiosRequestConfig: { timeout: 6000 },
// keeps the user when connecting fails offline, so the app opens on its offline data and
// reconnects once the network is back
persistUserOnConnectionFailure: true,
// logger: (type, msg) => console.log(type, msg)
});

Expand Down Expand Up @@ -199,6 +202,7 @@ export const useChatClient = () => {
};

const logout = async () => {
if (chatClient?.userID) await clearLastToken(chatClient.userID);
await SqliteClient.resetDB();
setChatClient(null);
chatClient?.disconnectUser();
Expand Down
18 changes: 17 additions & 1 deletion examples/SampleApp/src/utils/tokenProvider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { TokenOrProvider, TokenProvider } from 'stream-chat';

import AsyncStore from './AsyncStore';

import type { LoginConfig } from '../types';

export const SAMPLE_APP_API_KEY = 'yjrt5yxw77ev';
Expand All @@ -20,17 +22,31 @@ export const DEFAULT_TOKEN_URL =
const buildTokenRequestUrl = (tokenUrl: string, userId: string) =>
`${tokenUrl}${tokenUrl.includes('?') ? '&' : '?'}user_id=${encodeURIComponent(userId)}`;

const lastTokenKey = (userId: string) => `@stream-rn-sampleapp-last-token-${userId}`;

export const clearLastToken = (userId: string) => AsyncStore.removeItem(lastTokenKey(userId));

export const createTokenProvider =
(tokenUrl: string, userId: string): TokenProvider =>
async () => {
const response = await fetch(buildTokenRequestUrl(tokenUrl, userId));
let response: Response;
try {
response = await fetch(buildTokenRequestUrl(tokenUrl, userId));
} catch (error) {
// Offline the request fails before any response. The user's last token lets the client start
// from its offline data and reconnect when the network is back, which fetches a fresh one.
const lastToken = await AsyncStore.getItem<string | null>(lastTokenKey(userId), null);
if (lastToken) return lastToken;
throw error;
}
if (!response.ok) {
throw new Error(`Token generator responded with ${response.status}`);
}
const { token } = (await response.json()) as { token?: string };
if (!token) {
throw new Error('Token generator response did not contain a token');
}
await AsyncStore.setItem(lastTokenKey(userId), token);
return token;
};

Expand Down
5 changes: 5 additions & 0 deletions package/src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,11 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
return;
}

// a draft has no id and is marked initialized and querying it would create it on the server
if (channel.isProvisional && channel.initialized) {
return;
}

// Keep the message-list page light: the list's per-update commit cost scales with the number
// of loaded messages, and the paginator otherwise defaults to a 100-message page.
channel.messagePaginator.pageSize = DEFAULT_MESSAGE_LIST_PAGE_SIZE;
Expand Down
19 changes: 19 additions & 0 deletions package/src/components/Channel/__tests__/Channel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,25 @@ describe('Channel', () => {
await waitFor(() => expect(watchSpy).toHaveBeenCalledTimes(1));
});

it('does not watch or load a draft channel, which has no id and is marked initialized', async () => {
const draft = chatClient.channel('messaging', {
members: [{ user_id: user.id }, { user_id: 'other' }],
} as never);
draft.initialized = true;
const watchSpy = jest.spyOn(draft, 'watch');
const reloadSpy = jest.spyOn(draft.messagePaginator, 'reload');

renderComponent({ channel: draft });
// let the mount effect run to its end
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});

expect(draft.isProvisional).toBe(true);
expect(watchSpy).not.toHaveBeenCalled();
expect(reloadSpy).not.toHaveBeenCalled();
});

it('should set an error if channel watch fails and render a LoadingErrorIndicator', async () => {
const watchError = new Error('channel watch fail');
// Rejected persistently, at the API seam: an offline open fails the mount `watch()` AND the
Expand Down
57 changes: 41 additions & 16 deletions package/src/components/ChannelList/__tests__/ChannelList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
waitFor,
within,
} from '@testing-library/react-native';
import { ChannelPaginator } from 'stream-chat';
import type { Channel as ChannelType, StreamChat, UserResponse } from 'stream-chat';

import { useChannelsContext } from '../../../contexts/channelsContext/ChannelsContext';
Expand All @@ -25,7 +26,6 @@ import { useMockedApis } from '../../../mock-builders/api/useMockedApis';
import dispatchChannelDeletedEvent from '../../../mock-builders/event/channelDeleted';
import dispatchChannelHiddenEvent from '../../../mock-builders/event/channelHidden';
import dispatchChannelUpdatedEvent from '../../../mock-builders/event/channelUpdated';
import dispatchChannelVisibleEvent from '../../../mock-builders/event/channelVisible';
import dispatchConnectionChangedEvent from '../../../mock-builders/event/connectionChanged';
import dispatchConnectionRecoveredEvent from '../../../mock-builders/event/connectionRecovered';
import dispatchMessageNewEvent from '../../../mock-builders/event/messageNew';
Expand Down Expand Up @@ -551,10 +551,9 @@ describe('ChannelList', () => {
]);
});

// v10: a `message.new` alone no longer un-hides a channel client-side (only `channel.visible`
// clears `data.hidden`), and there is no float-to-top, so a background message does not resurface
// a channel the user hid. The channel returns to the list when it becomes visible again.
it('should not resurface a hidden channel on a new message (channel.visible does)', async () => {
// The server unhides a channel on any new message without sending `channel.visible`, so a hidden
// channel comes back to the list with the new message, leading it as the most recent one.
it('should bring a hidden channel back to the list on a new message', async () => {
render(
<Chat client={chatClient}>
<WithComponents overrides={{ ChannelPreview: ChannelPreviewComponent }}>
Expand All @@ -564,27 +563,26 @@ describe('ChannelList', () => {
);

await waitFor(() => expect(screen.getByTestId('channel-list-view')).toBeTruthy());
await waitForRenderedChannels(3);
const orderBefore = await waitForRenderedChannels(3);

act(() => dispatchChannelHiddenEvent(chatClient, testChannel3.channel));
await waitFor(() => {
expect(getRenderedOrder()).toHaveLength(2);
});

const newMessage = sendNewMessageOnChannel3();
// Give the async event pipeline a chance to run; the hidden channel must stay out of the list.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(getRenderedOrder()).toHaveLength(2);
expect(screen.queryByTestId(testChannel3.channel.id)).toBeNull();
expect(screen.queryByText(newMessage.text as string)).toBeNull();

act(() => dispatchChannelVisibleEvent(chatClient, testChannel3.channel));
await waitFor(() => {
expect(getRenderedOrder()).toHaveLength(3);
expect(
within(screen.getByTestId(testChannel3.channel.id)).getByText(
newMessage.text as string,
),
).toBeTruthy();
});
expect(screen.getByTestId(testChannel3.channel.id)).toBeTruthy();
expect(getRenderedOrder()).toEqual([
testChannel3.channel.id,
...orderBefore.filter((id) => id !== testChannel3.channel.id),
]);
});

it('should not alter order if `lockChannelOrder` prop is true', async () => {
Expand Down Expand Up @@ -1031,6 +1029,33 @@ describe('ChannelList', () => {
expect(secondOnSelect).toHaveBeenCalledTimes(1);
});

it('does not load a next page before the first page', async () => {
jest.spyOn(chatClient, 'queryChannels').mockReturnValue(new Promise(() => {}));
let loadNextPage: (() => Promise<void>) | undefined;
const LoadNextPageProbe = () => {
loadNextPage = useChannelsContext().loadNextPage;
return null;
};
const executeQuery = jest.spyOn(ChannelPaginator.prototype, 'executeQuery');

render(
<Chat client={chatClient}>
<WithComponents overrides={{ ChannelListLoadingIndicator: LoadNextPageProbe }}>
<ChannelList {...props} />
</WithComponents>
</Chat>,
);
// the mount query, which never resolves
await waitFor(() => expect(executeQuery).toHaveBeenCalledTimes(1));

await act(async () => {
await loadNextPage?.();
});

expect(executeQuery).toHaveBeenCalledTimes(1);
executeQuery.mockRestore();
});

it('loads the list again once a user connects after a disconnect', async () => {
useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { StateStore } from '@stream-io/state-store';
import { act, renderHook } from '@testing-library/react-native';
import type { Channel, ChannelMemberResponse } from 'stream-chat';

import { useChannelMembersState } from '../useChannelMembersState';

const member = (id: string, name: string, online = false) =>
({ user: { id, name, online } }) as unknown as ChannelMemberResponse;

const pickName = (m: ChannelMemberResponse) => ({ name: m.user?.name });

describe('useChannelMembersState', () => {
let state: StateStore<{ members: Record<string, ChannelMemberResponse> }>;
let channel: Channel;

beforeEach(() => {
state = new StateStore<{ members: Record<string, ChannelMemberResponse> }>({
members: { a: member('a', 'A'), b: member('b', 'B') },
});
channel = { cid: 'messaging:test', state } as unknown as Channel;
});

const renderCounted = (pick?: typeof pickName) => {
let renders = 0;
const hook = renderHook(() => {
renders += 1;
return useChannelMembersState(channel, pick);
});
const rendersBefore = renders;
return { ...hook, rendersSince: () => renders - rendersBefore };
};

it('re-renders on every members change without a pick', () => {
const { rendersSince } = renderCounted();

act(() => state.next({ members: { a: member('a', 'A', true), b: member('b', 'B') } }));

expect(rendersSince()).toBe(1);
});

it('keeps the members when no picked field changed', () => {
const { result, rendersSince } = renderCounted(pickName);
const members = result.current;

act(() => state.next({ members: { a: member('a', 'A', true), b: member('b', 'B') } }));

expect(rendersSince()).toBe(0);
expect(result.current).toBe(members);
});

it('updates when a picked field changes', () => {
const { result } = renderCounted(pickName);

act(() => state.next({ members: { a: member('a', 'Renamed'), b: member('b', 'B') } }));

expect(result.current.a.user?.name).toBe('Renamed');
});

it('updates when a member leaves', () => {
const { result } = renderCounted(pickName);

act(() => state.next({ members: { a: member('a', 'A') } }));

expect(Object.keys(result.current)).toEqual(['a']);
});
});
52 changes: 48 additions & 4 deletions package/src/components/ChannelList/hooks/useChannelMembersState.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,62 @@
import { useMemo } from 'react';

import type { Channel, ChannelMemberResponse } from 'stream-chat';

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

const selector = (state: { members: Record<string, ChannelMemberResponse> }) => ({
type MembersState = { members: Record<string, ChannelMemberResponse> };

/**
* The fields of a member a caller reads. Define it at module scope, so it stays the same function.
*/
export type ChannelMemberPick = (member: ChannelMemberResponse) => Record<string, unknown>;

const selector = (state: MembersState) => ({
members: state.members,
});

const isShallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>) => {
const keys = Object.keys(a);
return keys.length === Object.keys(b).length && keys.every((key) => Object.is(a[key], b[key]));
};

// keeps the previous members while every member's picked fields are unchanged
const createPickedMembersSelector = (pick: ChannelMemberPick) => {
let previous: { members: MembersState['members']; picked: Record<string, unknown>[] } | undefined;
return (state: MembersState) => {
const picked = Object.values(state.members).map(pick);
const last = previous;
if (
last &&
picked.length === last.picked.length &&
picked.every((fields, i) => isShallowEqual(fields, last.picked[i]))
) {
return { members: last.members };
}
previous = { members: state.members, picked };
return { members: state.members };
};
};

/**
* Returns the channel's members, sourced reactively from `channel.state`.
*
* With `pick`, the members update only when one of the picked fields changes for some member, or
* when members join or leave. A presence change replaces every member object of that user, so a caller
* that does not read `online` should pick what it reads to skip those re-renders.
*/
export function useChannelMembersState(channel: Channel): Record<string, ChannelMemberResponse>;
export function useChannelMembersState(
channel: Channel,
pick?: ChannelMemberPick,
): Record<string, ChannelMemberResponse>;
export function useChannelMembersState(
channel?: Channel,
pick?: ChannelMemberPick,
): Record<string, ChannelMemberResponse> | undefined;
export function useChannelMembersState(channel?: Channel) {
return useStateStore(channel?.state, selector)?.members;
export function useChannelMembersState(channel?: Channel, pick?: ChannelMemberPick) {
const membersSelector = useMemo(
() => (pick ? createPickedMembersSelector(pick) : selector),
[pick],
);
return useStateStore(channel?.state, membersSelector)?.members;
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,6 @@ export const usePaginatedChannels = ({
// Initial load / filters-sort-options change — fresh first page (blanks to the skeleton).
await paginator.reload();
} else {
// Pull-to-refresh / reconnect — first-page reset that REPLACES the list, but keeps the current
// channels visible during the fetch (no skeleton flash). `reset: 'yes'` re-establishes the
// window from page 1; `keepPreviousItems` keeps the list visible until the fresh page swaps in.
await paginator.toTail({ keepPreviousItems: true, reset: 'yes' });
}
} catch (err: unknown) {
Expand Down Expand Up @@ -174,7 +171,10 @@ export const usePaginatedChannels = ({

const refreshListWithDefaults = useStableCallback(() => refreshList());

const loadNextPage = useStableCallback(() => queryChannels('loadChannels'));
const loadNextPage = useStableCallback(async () => {
if (!paginator.isInitialized) return;
await queryChannels('loadChannels');
});

/**
* Equality check using stringified filters/options/sort ensures we don't run unnecessary queries
Expand Down
Loading
Loading