Skip to content

Commit 9042015

Browse files
authored
fix: improve the entire channel list re-render for useUserPresence hook (#3011)
* fix: improve the entire channel list re-render for useUserPresence hook * tests: add a few tests
1 parent c423453 commit 9042015

File tree

8 files changed

+208
-81
lines changed

8 files changed

+208
-81
lines changed

package/src/components/ChannelList/ChannelList.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { ChannelListHeaderNetworkDownIndicator } from './ChannelListHeaderNetwor
1010
import { ChannelListLoadingIndicator } from './ChannelListLoadingIndicator';
1111
import { ChannelListMessenger, ChannelListMessengerProps } from './ChannelListMessenger';
1212
import { useChannelUpdated } from './hooks/listeners/useChannelUpdated';
13-
import { useUserPresence } from './hooks/listeners/useUserPresence';
1413
import { useCreateChannelsContext } from './hooks/useCreateChannelsContext';
1514
import { usePaginatedChannels } from './hooks/usePaginatedChannels';
1615
import { Skeleton as SkeletonDefault } from './Skeleton';
@@ -367,11 +366,6 @@ export const ChannelList = <
367366
setChannels: channelManager.setChannels,
368367
});
369368

370-
useUserPresence({
371-
setChannels: channelManager.setChannels,
372-
setForceUpdate,
373-
});
374-
375369
const channelIdsStr = channels?.reduce((acc, channel) => `${acc}${channel.cid}`, '');
376370

377371
useEffect(() => {

package/src/components/ChannelList/__tests__/ChannelList.test.js

Lines changed: 0 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,6 @@ import dispatchMessageNewEvent from '../../../mock-builders/event/messageNew';
2525
import dispatchNotificationAddedToChannelEvent from '../../../mock-builders/event/notificationAddedToChannel';
2626
import dispatchNotificationMessageNewEvent from '../../../mock-builders/event/notificationMessageNew';
2727
import dispatchNotificationRemovedFromChannel from '../../../mock-builders/event/notificationRemovedFromChannel';
28-
import dispatchUserPresenceEvent from '../../../mock-builders/event/userPresence';
29-
import dispatchUserUpdatedEvent from '../../../mock-builders/event/userUpdated';
3028
import { generateChannel, generateChannelResponse } from '../../../mock-builders/generator/channel';
3129
import { generateMessage } from '../../../mock-builders/generator/message';
3230
import { generateUser } from '../../../mock-builders/generator/user';
@@ -691,65 +689,5 @@ describe('ChannelList', () => {
691689
});
692690
});
693691
});
694-
695-
describe('user.updated', () => {
696-
it('should call handleEvent in the custom hook if the user updates', async () => {
697-
useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
698-
const updateSpy = jest.spyOn(chatClient, 'on');
699-
const offlineUser = generateUser();
700-
701-
render(
702-
<Chat client={chatClient}>
703-
<ChannelList {...props} />
704-
</Chat>,
705-
);
706-
707-
await waitFor(() => {
708-
expect(screen.getByTestId('channel-list')).toBeTruthy();
709-
});
710-
711-
act(() =>
712-
dispatchUserUpdatedEvent(
713-
chatClient,
714-
{ ...offlineUser, name: 'dan' },
715-
testChannel1.channel,
716-
),
717-
);
718-
719-
await waitFor(() => {
720-
expect(updateSpy).toHaveBeenCalledWith('user.updated', expect.any(Function));
721-
});
722-
});
723-
});
724-
725-
describe('user.presence.changed', () => {
726-
it('should call handleEvent in the custom hook if user presence changes', async () => {
727-
useMockedApis(chatClient, [queryChannelsApi([testChannel1])]);
728-
const updateSpy = jest.spyOn(chatClient, 'on');
729-
const offlineUser = generateUser();
730-
731-
render(
732-
<Chat client={chatClient}>
733-
<ChannelList {...props} />
734-
</Chat>,
735-
);
736-
737-
await waitFor(() => {
738-
expect(screen.getByTestId('channel-list')).toBeTruthy();
739-
});
740-
741-
act(() =>
742-
dispatchUserPresenceEvent(
743-
chatClient,
744-
{ ...offlineUser, online: true },
745-
testChannel1.channel,
746-
),
747-
);
748-
749-
await waitFor(() => {
750-
expect(updateSpy).toHaveBeenCalledWith('user.presence.changed', expect.any(Function));
751-
});
752-
});
753-
});
754692
});
755693
});

package/src/components/ChannelList/hooks/listeners/useUserPresence.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ type Parameters<StreamChatGenerics extends DefaultStreamChatGenerics = DefaultSt
1212
setForceUpdate: React.Dispatch<React.SetStateAction<number>>;
1313
};
1414

15+
/**
16+
* Hook to update the channel members when the user presence changes
17+
* @deprecated this hook will be removed in favour of the useChannelPreviewDisplayPresence to improve performance
18+
*/
1519
export const useUserPresence = <
1620
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
1721
>({

package/src/components/ChannelList/hooks/useChannelMembershipState.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Channel, ChannelMemberResponse, EventTypes } from 'stream-chat';
22

3-
import { useSelectedChannelState } from './useSelectedChannelState';
3+
import { useSelectedChannelState } from '../../../hooks/useSelectedChannelState';
44

55
import { DefaultStreamChatGenerics } from '../../../types/types';
66

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { renderHook } from '@testing-library/react-native';
2+
import type { Channel, StreamChat, UserResponse } from 'stream-chat';
3+
4+
import type { ChatContextValue } from '../../../../contexts/chatContext/ChatContext';
5+
import * as ChatContext from '../../../../contexts/chatContext/ChatContext';
6+
import { getTestClientWithUser } from '../../../../mock-builders/mock';
7+
import { DefaultStreamChatGenerics } from '../../../../types/types';
8+
import { useChannelPreviewDisplayPresence } from '../useChannelPreviewDisplayPresence';
9+
10+
describe('useChannelPreviewDisplayPresence', () => {
11+
// Mock user data
12+
const currentUserId = 'current-user';
13+
const otherUserId = 'other-user';
14+
let chatClient: StreamChat<DefaultStreamChatGenerics>;
15+
16+
let mockChannel: Channel<DefaultStreamChatGenerics>;
17+
18+
beforeEach(async () => {
19+
jest.clearAllMocks();
20+
chatClient = await getTestClientWithUser({
21+
id: currentUserId,
22+
userID: currentUserId,
23+
});
24+
25+
// Create mock channel
26+
mockChannel = {
27+
state: {
28+
members: {
29+
[currentUserId]: {
30+
user: { id: currentUserId, online: true } as UserResponse<DefaultStreamChatGenerics>,
31+
},
32+
[otherUserId]: {
33+
user: { id: otherUserId, online: false } as UserResponse<DefaultStreamChatGenerics>,
34+
},
35+
},
36+
},
37+
} as unknown as Channel<DefaultStreamChatGenerics>;
38+
39+
// Mock the useChatContext hook
40+
jest
41+
.spyOn(ChatContext, 'useChatContext')
42+
.mockImplementation(() => ({ client: chatClient }) as unknown as ChatContextValue);
43+
});
44+
45+
afterEach(() => {
46+
jest.clearAllMocks();
47+
});
48+
49+
it('should return false for channels with more than 2 members', () => {
50+
// Create a channel with 3 members
51+
const thirdUserId = 'third-user';
52+
const channelWithThreeMembers = {
53+
state: {
54+
members: {
55+
[currentUserId]: {
56+
user: { id: currentUserId } as UserResponse<DefaultStreamChatGenerics>,
57+
},
58+
[otherUserId]: {
59+
user: { id: otherUserId } as UserResponse<DefaultStreamChatGenerics>,
60+
},
61+
[thirdUserId]: {
62+
user: { id: thirdUserId } as UserResponse<DefaultStreamChatGenerics>,
63+
},
64+
},
65+
},
66+
} as unknown as Channel<DefaultStreamChatGenerics>;
67+
68+
const { result } = renderHook(() => useChannelPreviewDisplayPresence(channelWithThreeMembers));
69+
expect(result.current).toBe(false);
70+
});
71+
72+
it('should return false when the other user is offline', () => {
73+
const { result } = renderHook(() => useChannelPreviewDisplayPresence(mockChannel));
74+
expect(result.current).toBe(false);
75+
});
76+
77+
it('should return true when the other user is online', () => {
78+
// Update the other user to be online
79+
const onlineUser = {
80+
...mockChannel.state.members[otherUserId].user,
81+
online: true,
82+
} as UserResponse<DefaultStreamChatGenerics>;
83+
84+
mockChannel.state.members[otherUserId].user = onlineUser;
85+
86+
const { result } = renderHook(() => useChannelPreviewDisplayPresence(mockChannel));
87+
expect(result.current).toBe(true);
88+
});
89+
90+
it('should handle null user gracefully', () => {
91+
// Create a channel with a member that has no user
92+
const channelWithNullUser = {
93+
state: {
94+
members: {
95+
[currentUserId]: {
96+
user: { id: currentUserId } as UserResponse<DefaultStreamChatGenerics>,
97+
},
98+
'null-user': {
99+
user: null,
100+
},
101+
},
102+
},
103+
} as unknown as Channel<DefaultStreamChatGenerics>;
104+
105+
const { result } = renderHook(() => useChannelPreviewDisplayPresence(channelWithNullUser));
106+
expect(result.current).toBe(false);
107+
});
108+
});
Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,41 @@
1-
import type { Channel } from 'stream-chat';
1+
import type { Channel, EventTypes, StreamChat } from 'stream-chat';
22

33
import { useChatContext } from '../../../contexts/chatContext/ChatContext';
44

5+
import { useSyncClientEventsToChannel } from '../../../hooks/useSyncClientEvents';
56
import type { DefaultStreamChatGenerics } from '../../../types/types';
67

78
/**
8-
* Hook to set the display avatar presence for channel preview
9-
* @param {*} channel
9+
* Selector to get the display avatar presence for channel preview
10+
* @param channel
11+
* @param client
12+
* @returns boolean
1013
*
11-
* @returns {boolean} e.g., true
14+
* NOTE: If you want to listen to the value changes where you call the hook, the selector should return primitive values instead of object.
1215
*/
13-
export const useChannelPreviewDisplayPresence = <
14-
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
15-
>(
16+
const selector = <StreamChatGenerics extends DefaultStreamChatGenerics>(
1617
channel: Channel<StreamChatGenerics>,
18+
client: StreamChat<StreamChatGenerics>,
1719
) => {
18-
const { client } = useChatContext<StreamChatGenerics>();
1920
const members = channel.state.members;
2021
const membersCount = Object.keys(members).length;
21-
22-
if (membersCount !== 2) return false;
23-
2422
const otherMember = Object.values(members).find((member) => member.user?.id !== client.userID);
2523

24+
if (membersCount !== 2) return false;
2625
return otherMember?.user?.online ?? false;
2726
};
27+
28+
const keys: EventTypes[] = ['user.presence.changed', 'user.updated'];
29+
30+
/**
31+
* Hook to set the display avatar presence for channel preview
32+
* @param {*} channel
33+
*
34+
* @returns {boolean} e.g., true
35+
*/
36+
export function useChannelPreviewDisplayPresence<
37+
StreamChatGenerics extends DefaultStreamChatGenerics,
38+
>(channel: Channel<StreamChatGenerics>) {
39+
const { client } = useChatContext<StreamChatGenerics>();
40+
return useSyncClientEventsToChannel({ channel, client, selector, stateChangeEventKeys: keys });
41+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useCallback } from 'react';
33
import type { Channel, EventTypes } from 'stream-chat';
44
import { useSyncExternalStore } from 'use-sync-external-store/shim';
55

6-
import { DefaultStreamChatGenerics } from '../../../types/types';
6+
import { DefaultStreamChatGenerics } from '../types/types';
77

88
const noop = () => {};
99

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { useCallback } from 'react';
2+
3+
import type { Channel, EventTypes, StreamChat } from 'stream-chat';
4+
import { useSyncExternalStore } from 'use-sync-external-store/shim';
5+
6+
import { DefaultStreamChatGenerics } from '../types/types';
7+
8+
const noop = () => {};
9+
10+
export function useSyncClientEventsToChannel<
11+
StreamChatGenerics extends DefaultStreamChatGenerics,
12+
O,
13+
>(_: {
14+
channel: Channel<StreamChatGenerics>;
15+
client: StreamChat<StreamChatGenerics>;
16+
selector: (channel: Channel<StreamChatGenerics>, client: StreamChat<StreamChatGenerics>) => O;
17+
stateChangeEventKeys?: EventTypes[];
18+
}): O;
19+
export function useSyncClientEventsToChannel<
20+
StreamChatGenerics extends DefaultStreamChatGenerics,
21+
O,
22+
>(_: {
23+
selector: (channel: Channel<StreamChatGenerics>, client: StreamChat<StreamChatGenerics>) => O;
24+
channel?: Channel<StreamChatGenerics> | undefined;
25+
client?: StreamChat<StreamChatGenerics> | undefined;
26+
stateChangeEventKeys?: EventTypes[];
27+
}): O | undefined;
28+
export function useSyncClientEventsToChannel<
29+
StreamChatGenerics extends DefaultStreamChatGenerics,
30+
O,
31+
>({
32+
channel,
33+
client,
34+
selector,
35+
stateChangeEventKeys = ['all'],
36+
}: {
37+
selector: (channel: Channel<StreamChatGenerics>, client: StreamChat<StreamChatGenerics>) => O;
38+
channel?: Channel<StreamChatGenerics> | undefined;
39+
client?: StreamChat<StreamChatGenerics>;
40+
stateChangeEventKeys?: EventTypes[];
41+
}): O | undefined {
42+
const subscribe = useCallback(
43+
(onStoreChange: (value: O) => void) => {
44+
if (!client || !channel) {
45+
return noop;
46+
}
47+
48+
const subscriptions = stateChangeEventKeys.map((et) =>
49+
client.on(et, () => {
50+
onStoreChange(selector(channel, client));
51+
}),
52+
);
53+
54+
return () => subscriptions.forEach((subscription) => subscription.unsubscribe());
55+
},
56+
[channel, client, selector, stateChangeEventKeys],
57+
);
58+
59+
const getSnapshot = useCallback(() => {
60+
if (!client || !channel) {
61+
return undefined;
62+
}
63+
64+
const originalSnapshot = selector(channel, client);
65+
return originalSnapshot;
66+
}, [channel, client, selector]);
67+
68+
return useSyncExternalStore(subscribe, getSnapshot);
69+
}

0 commit comments

Comments
 (0)