diff --git a/examples/SampleApp/src/hooks/useChatClient.ts b/examples/SampleApp/src/hooks/useChatClient.ts index 3abc2ad9da..80b4458cf8 100644 --- a/examples/SampleApp/src/hooks/useChatClient.ts +++ b/examples/SampleApp/src/hooks/useChatClient.ts @@ -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(); @@ -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) }); @@ -199,6 +202,7 @@ export const useChatClient = () => { }; const logout = async () => { + if (chatClient?.userID) await clearLastToken(chatClient.userID); await SqliteClient.resetDB(); setChatClient(null); chatClient?.disconnectUser(); diff --git a/examples/SampleApp/src/utils/tokenProvider.ts b/examples/SampleApp/src/utils/tokenProvider.ts index 329dbc93b7..133beddbb8 100644 --- a/examples/SampleApp/src/utils/tokenProvider.ts +++ b/examples/SampleApp/src/utils/tokenProvider.ts @@ -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'; @@ -20,10 +22,23 @@ 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(lastTokenKey(userId), null); + if (lastToken) return lastToken; + throw error; + } if (!response.ok) { throw new Error(`Token generator responded with ${response.status}`); } @@ -31,6 +46,7 @@ export const createTokenProvider = if (!token) { throw new Error('Token generator response did not contain a token'); } + await AsyncStore.setItem(lastTokenKey(userId), token); return token; }; diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index fb97c36da8..4ea010bb14 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -508,6 +508,11 @@ const ChannelWithContext = (props: PropsWithChildren) = 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; diff --git a/package/src/components/Channel/__tests__/Channel.test.tsx b/package/src/components/Channel/__tests__/Channel.test.tsx index 7161c03a9f..45700f8cb7 100644 --- a/package/src/components/Channel/__tests__/Channel.test.tsx +++ b/package/src/components/Channel/__tests__/Channel.test.tsx @@ -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 diff --git a/package/src/components/ChannelList/__tests__/ChannelList.test.tsx b/package/src/components/ChannelList/__tests__/ChannelList.test.tsx index f5b6940326..6cb5ed0a72 100644 --- a/package/src/components/ChannelList/__tests__/ChannelList.test.tsx +++ b/package/src/components/ChannelList/__tests__/ChannelList.test.tsx @@ -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'; @@ -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'; @@ -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( @@ -564,7 +563,7 @@ 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(() => { @@ -572,19 +571,18 @@ describe('ChannelList', () => { }); 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 () => { @@ -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) | undefined; + const LoadNextPageProbe = () => { + loadNextPage = useChannelsContext().loadNextPage; + return null; + }; + const executeQuery = jest.spyOn(ChannelPaginator.prototype, 'executeQuery'); + + render( + + + + + , + ); + // 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])]); diff --git a/package/src/components/ChannelList/hooks/__tests__/useChannelMembersState.test.tsx b/package/src/components/ChannelList/hooks/__tests__/useChannelMembersState.test.tsx new file mode 100644 index 0000000000..c57d49d3d2 --- /dev/null +++ b/package/src/components/ChannelList/hooks/__tests__/useChannelMembersState.test.tsx @@ -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 }>; + let channel: Channel; + + beforeEach(() => { + state = new StateStore<{ members: Record }>({ + 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']); + }); +}); diff --git a/package/src/components/ChannelList/hooks/useChannelMembersState.ts b/package/src/components/ChannelList/hooks/useChannelMembersState.ts index be2554d09b..7a54ea76ef 100644 --- a/package/src/components/ChannelList/hooks/useChannelMembersState.ts +++ b/package/src/components/ChannelList/hooks/useChannelMembersState.ts @@ -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 }) => ({ +type MembersState = { members: Record }; + +/** + * 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; + +const selector = (state: MembersState) => ({ members: state.members, }); +const isShallowEqual = (a: Record, b: Record) => { + 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[] } | 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; +export function useChannelMembersState( + channel: Channel, + pick?: ChannelMemberPick, +): Record; export function useChannelMembersState( channel?: Channel, + pick?: ChannelMemberPick, ): Record | 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; } diff --git a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts index 1bcdd58b99..8459972640 100644 --- a/package/src/components/ChannelList/hooks/usePaginatedChannels.ts +++ b/package/src/components/ChannelList/hooks/usePaginatedChannels.ts @@ -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) { @@ -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 diff --git a/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewDisplayName.test.tsx b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewDisplayName.test.tsx new file mode 100644 index 0000000000..dbe3a1cb2d --- /dev/null +++ b/package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewDisplayName.test.tsx @@ -0,0 +1,57 @@ +import { StateStore } from '@stream-io/state-store'; +import { act, renderHook } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import type { ChatContextValue } from '../../../../contexts/chatContext/ChatContext'; +import * as ChatContext from '../../../../contexts/chatContext/ChatContext'; +import * as useChannelNameModule from '../../../../hooks/useChannelName'; +import { useChannelPreviewDisplayName } from '../useChannelPreviewDisplayName'; + +const OWN_USER_ID = 'me'; + +const members = (online: boolean, name = 'Other User') => ({ + [OWN_USER_ID]: { user: { id: OWN_USER_ID, name: 'Me' } }, + other: { user: { id: 'other', name, online } }, +}); + +describe('useChannelPreviewDisplayName', () => { + let state: StateStore<{ members: ReturnType }>; + let channel: Channel; + + beforeEach(() => { + state = new StateStore({ members: members(false) }); + channel = { cid: 'messaging:test', state } as unknown as Channel; + jest + .spyOn(ChatContext, 'useChatContext') + .mockImplementation( + () => ({ client: { userID: OWN_USER_ID } }) as unknown as ChatContextValue, + ); + jest.spyOn(useChannelNameModule, 'useChannelName').mockReturnValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not re-render when only a presence changes', () => { + let renders = 0; + const { result } = renderHook(() => { + renders += 1; + return useChannelPreviewDisplayName(channel); + }); + const rendersBefore = renders; + + act(() => state.next({ members: members(true) })); + + expect(result.current).toBe('Other User'); + expect(renders).toBe(rendersBefore); + }); + + it('shows the new name when the other member is renamed', () => { + const { result } = renderHook(() => useChannelPreviewDisplayName(channel)); + + act(() => state.next({ members: members(false, 'Renamed User') })); + + expect(result.current).toBe('Renamed User'); + }); +}); diff --git a/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts b/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts index af23de9f86..9efd3ddc21 100644 --- a/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts +++ b/package/src/components/ChannelPreview/hooks/useChannelPreviewDisplayName.ts @@ -10,11 +10,16 @@ import { useChannelMembersState } from '../../ChannelList/hooks/useChannelMember const getMemberName = (member: ChannelMemberResponse) => member.user?.name || member.user?.id || 'Unknown User'; +const pickName = (member: ChannelMemberResponse) => ({ + id: member.user?.id, + name: member.user?.name, +}); + export const useChannelPreviewDisplayName = (channel?: Channel) => { const { client } = useChatContext(); const { t } = useTranslationContext(); const currentUserId = client?.userID; - const members = useChannelMembersState(channel); + const members = useChannelMembersState(channel, pickName); const channelName = useChannelName(channel); const displayName = useMemo(() => { diff --git a/package/src/components/ui/Avatar/ChannelAvatar.tsx b/package/src/components/ui/Avatar/ChannelAvatar.tsx index 0272a57af9..ef2579923a 100644 --- a/package/src/components/ui/Avatar/ChannelAvatar.tsx +++ b/package/src/components/ui/Avatar/ChannelAvatar.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; -import { Channel, UserResponse } from 'stream-chat'; +import { Channel, ChannelMemberResponse, UserResponse } from 'stream-chat'; import { Avatar } from './Avatar'; @@ -18,6 +18,12 @@ import { hashStringToNumber } from '../../../utils/utils'; import { CompositeAccessibilityProbe } from '../../Accessibility/CompositeAccessibilityProbe'; import { useChannelMembersState } from '../../ChannelList/hooks/useChannelMembersState'; +const pickAvatarFields = (member: ChannelMemberResponse) => ({ + id: member.user?.id, + image: member.user?.image, + name: member.user?.name, +}); + export type ChannelAvatarProps = { channel: Channel; /** @@ -59,7 +65,7 @@ export const ChannelAvatar = (props: ChannelAvatarProps) => { const channelImage = useChannelImage(channel); const imageToDisplay = isPreview ? previewUri : channelImage; - const members = useChannelMembersState(channel); + const members = useChannelMembersState(channel, pickAvatarFields); const usersForGroup = useMemo( () => Object.values(members ?? {}).map((member) => member.user as UserResponse), [members], diff --git a/package/src/components/ui/Avatar/__tests__/ChannelAvatar.test.tsx b/package/src/components/ui/Avatar/__tests__/ChannelAvatar.test.tsx index 98f70aeed7..5853188dc3 100644 --- a/package/src/components/ui/Avatar/__tests__/ChannelAvatar.test.tsx +++ b/package/src/components/ui/Avatar/__tests__/ChannelAvatar.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { render, screen } from '@testing-library/react-native'; +import { StateStore } from '@stream-io/state-store'; +import { act, render, screen } from '@testing-library/react-native'; import type { Channel } from 'stream-chat'; import { ChatProvider } from '../../../../contexts/chatContext/ChatContext'; @@ -8,11 +9,11 @@ import { ThemeProvider } from '../../../../contexts/themeContext/ThemeContext'; import { defaultTheme } from '../../../../contexts/themeContext/utils/theme'; import * as useChannelImageModule from '../../../../hooks/useChannelImage'; import * as useChannelNameModule from '../../../../hooks/useChannelName'; -import * as useChannelMembersStateModule from '../../../ChannelList/hooks/useChannelMembersState'; import * as useChannelPreviewDisplayPresenceModule from '../../../ChannelPreview/hooks/useChannelPreviewDisplayPresence'; import { ChannelAvatar } from '../ChannelAvatar'; const avatarCalls: Array<{ imageUrl?: string }> = []; +let avatarGroupRenders = 0; jest.mock('../Avatar', () => { const RN = jest.requireActual('react-native'); const ReactActual = jest.requireActual('react'); @@ -28,7 +29,10 @@ jest.mock('../AvatarGroup', () => { const RN = jest.requireActual('react-native'); const ReactActual = jest.requireActual('react'); return { - UserAvatarGroup: () => ReactActual.createElement(RN.View, { testID: 'user-avatar-group' }), + UserAvatarGroup: () => { + avatarGroupRenders += 1; + return ReactActual.createElement(RN.View, { testID: 'user-avatar-group' }); + }, }; }); @@ -42,21 +46,25 @@ jest.mock('../UserAvatar', () => { const OWN_USER_ID = 'me'; -const buildChannel = (): Channel => +const buildMembers = (...ids: string[]) => + Object.fromEntries(ids.map((id) => [id, { user: { id } }])); + +const buildChannel = (members: Record = {}): Channel => ({ cid: 'messaging:test', data: {}, on: () => ({ unsubscribe: () => undefined }), + state: new StateStore({ members }), }) as unknown as Channel; -const buildMembers = (...ids: string[]) => - Object.fromEntries(ids.map((id) => [id, { user: { id } }])); - -const renderAvatar = (props: Partial> = {}) => +const renderAvatar = ( + props: Partial> = {}, + channel: Channel = buildChannel(), +) => render( - + , ); @@ -64,12 +72,12 @@ const renderAvatar = (props: Partial> describe('ChannelAvatar', () => { beforeEach(() => { avatarCalls.length = 0; + avatarGroupRenders = 0; jest .spyOn(useChannelPreviewDisplayPresenceModule, 'useChannelPreviewDisplayPresence') .mockReturnValue(false); jest.spyOn(useChannelNameModule, 'useChannelName').mockReturnValue('Channel Name'); jest.spyOn(useChannelImageModule, 'useChannelImage').mockReturnValue(undefined); - jest.spyOn(useChannelMembersStateModule, 'useChannelMembersState').mockReturnValue({}); }); afterEach(() => { @@ -89,22 +97,14 @@ describe('ChannelAvatar', () => { }); it('falls back to the user-avatar group when there is no image and 2+ other members', () => { - jest - .spyOn(useChannelMembersStateModule, 'useChannelMembersState') - .mockReturnValue(buildMembers(OWN_USER_ID, 'a', 'b') as never); - - renderAvatar(); + renderAvatar({}, buildChannel(buildMembers(OWN_USER_ID, 'a', 'b'))); expect(screen.getByTestId('user-avatar-group')).toBeTruthy(); expect(screen.queryByTestId('avatar')).toBeNull(); }); it('falls back to a single user avatar when there is no image and at most one other member', () => { - jest - .spyOn(useChannelMembersStateModule, 'useChannelMembersState') - .mockReturnValue(buildMembers(OWN_USER_ID, 'a') as never); - - renderAvatar(); + renderAvatar({}, buildChannel(buildMembers(OWN_USER_ID, 'a'))); expect(screen.getByTestId('user-avatar')).toBeTruthy(); expect(screen.queryByTestId('avatar')).toBeNull(); @@ -137,14 +137,50 @@ describe('ChannelAvatar', () => { jest .spyOn(useChannelImageModule, 'useChannelImage') .mockReturnValue('https://example.com/live.png'); - jest - .spyOn(useChannelMembersStateModule, 'useChannelMembersState') - .mockReturnValue(buildMembers(OWN_USER_ID, 'a', 'b') as never); - renderAvatar({ isPreview: true, previewUri: null }); + renderAvatar( + { isPreview: true, previewUri: null }, + buildChannel(buildMembers(OWN_USER_ID, 'a', 'b')), + ); expect(screen.getByTestId('user-avatar-group')).toBeTruthy(); expect(screen.queryByTestId('avatar')).toBeNull(); }); }); + + describe('member updates', () => { + const groupMembers = (online: boolean, name = 'A') => ({ + [OWN_USER_ID]: { user: { id: OWN_USER_ID } }, + a: { user: { id: 'a', name, online } }, + b: { user: { id: 'b' } }, + }); + + it('does not re-render the group when only a presence changes', () => { + const channel = buildChannel(groupMembers(false)); + renderAvatar({}, channel); + const renders = avatarGroupRenders; + + act(() => { + (channel.state as unknown as StateStore>).next({ + members: groupMembers(true), + }); + }); + + expect(avatarGroupRenders).toBe(renders); + }); + + it('re-renders the group when a shown member is renamed', () => { + const channel = buildChannel(groupMembers(false)); + renderAvatar({}, channel); + const renders = avatarGroupRenders; + + act(() => { + (channel.state as unknown as StateStore>).next({ + members: groupMembers(false, 'Renamed'), + }); + }); + + expect(avatarGroupRenders).toBe(renders + 1); + }); + }); }); diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 37f70198f8..8afa1953f4 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -28,7 +28,6 @@ import { File } from '../../types/types'; import { compressedImageURI } from '../../utils/compressImage'; import { useAttachmentPickerContext } from '../attachmentPickerContext/AttachmentPickerContext'; import { useOwnCapabilitiesContext } from '../ownCapabilitiesContext/OwnCapabilitiesContext'; -import { useThreadContext } from '../threadContext/ThreadContext'; import { useTranslationContext } from '../translationContext/TranslationContext'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; @@ -193,7 +192,6 @@ export const MessageInputProvider = ({ const channelCapabilities = useOwnCapabilitiesContext(); const [audioRecorderManager] = useState(new AudioRecorderManager()); - const { threadInstance } = useThreadContext(); const { t } = useTranslationContext(); const inputBoxRef = useRef(null); @@ -445,7 +443,6 @@ export const MessageInputProvider = ({ pickFile, setInputBoxRef, takeAndUploadImage, - threadInstance, uploadNewFile, ...value, closePollCreationDialog, diff --git a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts index 828c43c360..0d14848804 100644 --- a/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts +++ b/package/src/contexts/messageInputContext/hooks/useCreateMessageInputContext.ts @@ -1,6 +1,5 @@ import { useMemo } from 'react'; -import type { ThreadContextValue } from '../../threadContext/ThreadContext'; import type { MessageInputContextValue } from '../MessageInputContext'; export const useCreateMessageInputContext = ({ @@ -40,10 +39,7 @@ export const useCreateMessageInputContext = ({ deleteVoiceRecording, uploadVoiceRecording, stopVoiceRecording, - threadInstance, -}: MessageInputContextValue & Pick) => { - const threadId = threadInstance?.id; - +}: MessageInputContextValue) => { const messageInputContext: MessageInputContextValue = useMemo( () => ({ additionalTextInputProps, @@ -84,7 +80,7 @@ export const useCreateMessageInputContext = ({ stopVoiceRecording, }), // eslint-disable-next-line react-hooks/exhaustive-deps - [hasCommands, threadId, showPollCreationDialog], + [hasCommands, showPollCreationDialog], ); return messageInputContext; diff --git a/package/src/hooks/__tests__/useIsDirectChat.test.tsx b/package/src/hooks/__tests__/useIsDirectChat.test.tsx new file mode 100644 index 0000000000..c8354428a3 --- /dev/null +++ b/package/src/hooks/__tests__/useIsDirectChat.test.tsx @@ -0,0 +1,55 @@ +import { StateStore } from '@stream-io/state-store'; +import { act, renderHook } from '@testing-library/react-native'; +import type { Channel } from 'stream-chat'; + +import type { ChatContextValue } from '../../contexts/chatContext/ChatContext'; +import * as ChatContext from '../../contexts/chatContext/ChatContext'; +import { useIsDirectChat } from '../useIsDirectChat'; + +const OWN_USER_ID = 'me'; + +const members = (online: boolean, ...otherIds: string[]) => ({ + [OWN_USER_ID]: { user: { id: OWN_USER_ID } }, + ...Object.fromEntries(otherIds.map((id) => [id, { user: { id, online } }])), +}); + +describe('useIsDirectChat', () => { + let state: StateStore<{ members: ReturnType }>; + let channel: Channel; + + beforeEach(() => { + state = new StateStore({ members: members(false, 'other') }); + channel = { cid: 'messaging:test', state } as unknown as Channel; + jest + .spyOn(ChatContext, 'useChatContext') + .mockImplementation( + () => ({ client: { userID: OWN_USER_ID } }) as unknown as ChatContextValue, + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not re-render when only a presence changes', () => { + let renders = 0; + const { result } = renderHook(() => { + renders += 1; + return useIsDirectChat(channel); + }); + const rendersBefore = renders; + + act(() => state.next({ members: members(true, 'other') })); + + expect(result.current).toBe(true); + expect(renders).toBe(rendersBefore); + }); + + it('turns false when a third member joins', () => { + const { result } = renderHook(() => useIsDirectChat(channel)); + + act(() => state.next({ members: members(false, 'other', 'third') })); + + expect(result.current).toBe(false); + }); +}); diff --git a/package/src/hooks/useIsDirectChat.ts b/package/src/hooks/useIsDirectChat.ts index e781cac073..973cbfb871 100644 --- a/package/src/hooks/useIsDirectChat.ts +++ b/package/src/hooks/useIsDirectChat.ts @@ -1,14 +1,16 @@ import { useMemo } from 'react'; -import type { Channel } from 'stream-chat'; +import type { Channel, ChannelMemberResponse } from 'stream-chat'; import { useChannelMembersState } from '../components/ChannelList/hooks/useChannelMembersState'; import { useChatContext } from '../contexts'; +const pickId = (member: ChannelMemberResponse) => ({ id: member.user?.id }); + export const useIsDirectChat = (channel?: Channel) => { const { client } = useChatContext(); const ownUserId = client.userID; - const members = useChannelMembersState(channel); + const members = useChannelMembersState(channel, pickId); const otherMembers = useMemo( () => Object.values(members ?? {}).filter((member) => member.user?.id !== ownUserId),