From 5622434310097cf7d131867d0ef18a7332d658be Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 12 Oct 2026 12:11:02 +0200 Subject: [PATCH 1/2] fix: miscellaneous ui sdk bug cleanup --- package/src/components/Channel/Channel.tsx | 11 +- .../Channel/hooks/useCreateMessagesContext.ts | 2 - .../ChannelDetailsBottomSheet.tsx | 12 +- .../ChannelDetailsBottomSheet.test.tsx | 39 +++++- package/src/components/Chat/Chat.tsx | 4 +- package/src/components/Message/Message.tsx | 10 +- .../MessageItemView/MessageContent.tsx | 6 +- .../Message/MessageItemView/MessageFooter.tsx | 18 +-- .../Message/MessageItemView/MessageHeader.tsx | 65 ++++----- .../__tests__/MessageHeader.test.tsx | 78 +++++++++++ .../useShouldUseOverlayStyles.test.tsx | 1 - .../Message/hooks/useCreateMessageContext.ts | 2 - .../MessageList/InlineUnreadIndicator.tsx | 6 +- .../MessageList/MessageFlashList.tsx | 8 +- .../__tests__/InlineUnreadIndicator.test.tsx | 26 ++++ .../__tests__/MessageFlashList.test.tsx | 130 ++++++++++++++++++ .../__tests__/ScrollToBottomButton.test.tsx | 30 ++++ .../__tests__/useStickyHeaderDate.test.ts | 31 +++++ .../MessageList/hooks/useStickyHeaderDate.ts | 1 + .../MessageMenu/MessageUserReactions.tsx | 10 +- .../__tests__/MessageUserReactions.test.tsx | 17 +++ .../hooks/__tests__/useFetchReactions.test.ts | 108 ++++++++++++++- .../MessageMenu/hooks/useFetchReactions.ts | 48 ++++--- .../__tests__/usePollPagination.test.tsx | 71 ++++++++++ .../Poll/hooks/usePollAnswersPagination.ts | 6 +- .../hooks/usePollOptionVotesPagination.ts | 15 +- .../ThreadList/ThreadListUnreadBanner.tsx | 46 ++++--- .../ThreadList/__tests__/ThreadList.test.tsx | 24 +++- .../src/contexts/chatContext/ChatContext.tsx | 7 +- .../useHandleLiveLocationEvents.test.tsx | 74 ++++++++++ .../hooks/useHandleLiveLocationEvents.ts | 8 +- .../messageContext/MessageContext.tsx | 4 - .../messagesContext/MessagesContext.tsx | 4 +- package/src/i18n/__tests__/callSites.test.ts | 7 +- .../src/i18n/__tests__/catalog.fixture.json | 15 +- package/src/i18n/keys.ts | 19 +-- package/src/i18n/runtimeDefaults.ts | 13 +- .../src/utils/__tests__/Streami18n.test.ts | 11 ++ 38 files changed, 812 insertions(+), 175 deletions(-) create mode 100644 package/src/components/Message/MessageItemView/__tests__/MessageHeader.test.tsx create mode 100644 package/src/components/MessageList/__tests__/InlineUnreadIndicator.test.tsx create mode 100644 package/src/components/MessageList/__tests__/MessageFlashList.test.tsx create mode 100644 package/src/components/MessageList/hooks/__tests__/useStickyHeaderDate.test.ts create mode 100644 package/src/components/Poll/hooks/__tests__/usePollPagination.test.tsx create mode 100644 package/src/contexts/liveLocationManagerContext/hooks/__tests__/useHandleLiveLocationEvents.test.tsx diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 261ae9c42c..fb97c36da8 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -36,7 +36,6 @@ import { ChannelContextValue, ChannelProvider } from '../../contexts/channelCont import { ChatContextValue, useChatContext } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { MessageComposerProvider } from '../../contexts/messageComposerContext/MessageComposerContext'; -import { MessageContextValue } from '../../contexts/messageContext/MessageContext'; import { InputMessageInputContextValue, MessageInputProvider, @@ -231,7 +230,6 @@ export type ChannelPropsWithContext = Pick & | 'hasCreatePoll' > > & - Partial> & Partial< Pick > & { @@ -375,7 +373,6 @@ const ChannelWithContext = (props: PropsWithChildren) = hideStickyDateHeader = false, initialScrollToFirstUnreadMessage = false, isAttachmentEqual, - isMessageAIGenerated = () => false, keyboardBehavior, keyboardVerticalOffset, markdownRules, @@ -737,7 +734,6 @@ const ChannelWithContext = (props: PropsWithChildren) = hasCreatePoll: hasCreatePoll === undefined ? pollCreationEnabled : hasCreatePoll && pollCreationEnabled, isAttachmentEqual, - isMessageAIGenerated, markdownRules, messageActions, messageContentOrder, @@ -837,7 +833,7 @@ export type ChannelProps = Partial) => { - const { client, isMessageAIGenerated } = useChatContext(); + const { client } = useChatContext(); const { t } = useTranslationContext(); const channel = useSupersededChannelSwap(props.channel); const notificationHostId = @@ -866,10 +862,7 @@ export const Channel = (props: PropsWithChildren) => { {...props} channel={channel} shouldSyncChannel={shouldSyncChannel} - {...{ - isMessageAIGenerated, - thread, - }} + thread={thread} /> ); diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index e9f10f61d9..dab7ad3ec6 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -30,7 +30,6 @@ export const useCreateMessagesContext = ({ handleBlockUser, hasCreatePoll, isAttachmentEqual, - isMessageAIGenerated, markdownRules, messageActions, messageContentOrder, @@ -86,7 +85,6 @@ export const useCreateMessagesContext = ({ handleBlockUser, hasCreatePoll, isAttachmentEqual, - isMessageAIGenerated, markdownRules, messageActions, messageContentOrder, diff --git a/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx b/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx index 26e7348a03..dfcabd70c3 100644 --- a/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx +++ b/package/src/components/ChannelPreview/ChannelDetailsBottomSheet.tsx @@ -10,6 +10,7 @@ import { ChannelPreviewMutedStatus } from './ChannelPreviewMutedStatus'; import { ChannelPreviewTitle } from './ChannelPreviewTitle'; import { useBottomSheetContext } from '../../contexts/bottomSheetContext/BottomSheetContext'; +import { useChatContext } from '../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; import { useSwipeRegistryContext } from '../../contexts/swipeableContext/SwipeRegistryContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; @@ -34,11 +35,20 @@ export type ChannelDetailsBottomSheetProps = { export const ChannelDetailsHeader = ({ channel }: ChannelDetailsHeaderProps) => { const styles = useStyles(); + const { client } = useChatContext(); const { t } = useTranslationContext(); const members = useChannelMembersState(channel); const memberCount = useMemo(() => Object.keys(members).length, [members]); const onlineCount = useChannelOnlineMemberCount(channel); const isDirectChat = useIsDirectChat(channel); + // The watcher count includes the current user, so a DM reads the other member's presence. + const isOtherMemberOnline = useMemo( + () => + Object.values(members).some( + (member) => member.user?.id !== client.userID && member.user?.online, + ), + [client, members], + ); const muted = useChannelMuteActive(channel); const displayedMemberCount = memberCount > 9 ? '9+' : `${memberCount}`; const displayedOnlineCount = onlineCount > 9 ? '9+' : `${onlineCount}`; @@ -64,7 +74,7 @@ export const ChannelDetailsHeader = ({ channel }: ChannelDetailsHeaderProps) => {isDirectChat - ? onlineCount === 1 + ? isOtherMemberOnline ? t('common.presence.online.label', 'Online') : t('common.presence.offline.label', 'Offline') : membersAndOnlineLabel} diff --git a/package/src/components/ChannelPreview/__tests__/ChannelDetailsBottomSheet.test.tsx b/package/src/components/ChannelPreview/__tests__/ChannelDetailsBottomSheet.test.tsx index 337bb57300..e00e47deff 100644 --- a/package/src/components/ChannelPreview/__tests__/ChannelDetailsBottomSheet.test.tsx +++ b/package/src/components/ChannelPreview/__tests__/ChannelDetailsBottomSheet.test.tsx @@ -1,15 +1,19 @@ import React, { type ComponentProps } from 'react'; import { Text } from 'react-native'; -import { render } from '@testing-library/react-native'; +import { render, screen } from '@testing-library/react-native'; import type { Channel } from 'stream-chat'; import { ThemeProvider, defaultTheme } from '../../../contexts'; import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; import type { ChannelActionItem } from '../../../hooks/actions/useChannelActionItems'; +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; +import { generateMember } from '../../../mock-builders/generator/member'; +import { generateUser } from '../../../mock-builders/generator/user'; +import { Chat } from '../../Chat/Chat'; import { StreamBottomSheetModalFlatList } from '../../UIComponents/StreamBottomSheetModalFlatList'; import type { ChannelDetailsHeaderProps } from '../ChannelDetailsBottomSheet'; -import { ChannelDetailsBottomSheet } from '../ChannelDetailsBottomSheet'; +import { ChannelDetailsBottomSheet, ChannelDetailsHeader } from '../ChannelDetailsBottomSheet'; type StreamBottomSheetModalFlatListProps = ComponentProps; @@ -91,3 +95,34 @@ describe('ChannelDetailsBottomSheet', () => { ); }); }); + +describe('ChannelDetailsHeader', () => { + const me = generateUser({ id: 'me' }); + + const renderDirectChat = async (otherOnline: boolean, watcherCount: number) => { + const other = generateUser({ id: 'other', online: otherOnline }); + const { + channels: [channel], + client, + } = await initiateClientWithChannels({ + channelsData: [{ members: [generateMember({ user: me }), generateMember({ user: other })] }], + customUser: me, + }); + channel.state.watcher_count = watcherCount; + render( + + + , + ); + }; + + it('shows a direct chat as offline while only the current user watches it', async () => { + await renderDirectChat(false, 1); + expect(await screen.findByText('Offline')).toBeTruthy(); + }); + + it('shows a direct chat as online when the other member is online', async () => { + await renderDirectChat(true, 2); + expect(await screen.findByText('Online')).toBeTruthy(); + }); +}); diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index 72472dd8d8..01d6b73f87 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -170,6 +170,8 @@ export type ChatProps = Pick & style?: ThemeStyle; }; +const isNeverAIGenerated = () => false; + const selector = (nextValue: OfflineDBState) => ({ initialized: nextValue.initialized, @@ -184,7 +186,7 @@ const ChatWithContext = (props: PropsWithChildren) => { enableOfflineSupport = false, getOfflineDbEncryptionKey, i18nInstance, - isMessageAIGenerated, + isMessageAIGenerated = isNeverAIGenerated, maxSyncEventsLimit = DEFAULT_MAX_SYNC_EVENTS_LIMIT, style, useNativeMultipartUpload = false, diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index d61e3a69ab..90cf83d8d4 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -211,10 +211,8 @@ export type MessagePropsWithContext = Pick< 'channel' | 'enforceUniqueReaction' > & Pick & - Partial< - Omit - > & - Pick & + Partial> & + Pick & Pick< MessageOperations, | 'sendReaction' @@ -279,7 +277,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const [showMessageReactions, setShowMessageReactions] = useState(false); const [selectedReaction, setSelectedReaction] = useState(undefined); const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false); - const { client } = useChatContext(); + const { client, isMessageAIGenerated } = useChatContext(); const { channel, @@ -340,7 +338,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { } = useComponentsContext(); // TODO: V9: Reconsider using safe area insets in every message. const insets = useSafeAreaInsets(); - const isMessageAIGenerated = messagesContext.isMessageAIGenerated; const isAIGenerated = useMemo( () => isMessageAIGenerated(message), [message, isMessageAIGenerated], @@ -731,7 +728,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { handleToggleReaction, hasReactions, images: attachments.images, - isMessageAIGenerated, isMyMessage, lastGroupMessage: groupStyles?.[0] === 'single' || groupStyles?.[0] === 'bottom', message: overlayActive ? frozenMessage.current : message, diff --git a/package/src/components/Message/MessageItemView/MessageContent.tsx b/package/src/components/Message/MessageItemView/MessageContent.tsx index ee4bb1a1d0..773177b978 100644 --- a/package/src/components/Message/MessageItemView/MessageContent.tsx +++ b/package/src/components/Message/MessageItemView/MessageContent.tsx @@ -65,7 +65,6 @@ export type MessageContentPropsWithContext = Pick< | 'otherAttachments' | 'preventPress' | 'threadList' - | 'isMessageAIGenerated' > & Pick< MessagesContextValue, @@ -113,7 +112,6 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { groupStyles, goToMessage, hasInteractiveAccessibilityContent, - isMessageAIGenerated, isMyMessage, isVeryLastMessage, message, @@ -129,7 +127,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { hidePaddingHorizontal, hidePaddingBottom, } = props; - const { client } = useChatContext(); + const { client, isMessageAIGenerated } = useChatContext(); const accessibilityHint = useA11yLabel('message.content.contextMenuHint.accessibilityLabel'); const a11ySenderLabel = useA11yLabel( isMyMessage @@ -615,7 +613,6 @@ export const MessageContent = (props: MessageContentProps) => { groupStyles, hasInteractiveAccessibilityContent, images, - isMessageAIGenerated, isMyMessage, message, messageContentOrder, @@ -681,7 +678,6 @@ export const MessageContent = (props: MessageContentProps) => { groupStyles, hasInteractiveAccessibilityContent, isAttachmentEqual, - isMessageAIGenerated, isMyMessage, message, messageContentOrder, diff --git a/package/src/components/Message/MessageItemView/MessageFooter.tsx b/package/src/components/Message/MessageItemView/MessageFooter.tsx index fae1adf2bd..772d1b6adc 100644 --- a/package/src/components/Message/MessageItemView/MessageFooter.tsx +++ b/package/src/components/Message/MessageItemView/MessageFooter.tsx @@ -4,6 +4,7 @@ import { StyleSheet, Text, View } from 'react-native'; import type { Attachment, LocalMessage } from 'stream-chat'; import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext'; import { Alignment, @@ -34,21 +35,14 @@ const isDirectChannelSelector = (state: { memberCount: number }) => ({ type MessageFooterPropsWithContext = Pick< MessageContextValue, - 'alignment' | 'message' | 'showMessageStatus' | 'lastGroupMessage' | 'isMessageAIGenerated' + 'alignment' | 'message' | 'showMessageStatus' | 'lastGroupMessage' > & MessageFooterComponentProps; const MessageFooterWithContext = (props: MessageFooterPropsWithContext) => { - const { - alignment, - date, - formattedDate, - isMessageAIGenerated, - lastGroupMessage, - message, - showMessageStatus, - } = props; + const { alignment, date, formattedDate, lastGroupMessage, message, showMessageStatus } = props; const { channel } = useChannelContext(); + const { isMessageAIGenerated } = useChatContext(); const { isDirectChannel } = useStateStore(channel.state, isDirectChannelSelector) ?? { isDirectChannel: false, }; @@ -173,14 +167,12 @@ export type MessageFooterProps = MessageFooterComponentProps & { }; export const MessageFooter = (props: MessageFooterProps) => { - const { alignment, isMessageAIGenerated, lastGroupMessage, message, showMessageStatus } = - useMessageContext(); + const { alignment, lastGroupMessage, message, showMessageStatus } = useMessageContext(); return ( & { - shouldShowSavedForLaterHeader?: boolean; + reminder?: Reminder; shouldShowPinnedHeader: boolean; - shouldShowReminderHeader: boolean; shouldShowSentToChannelHeader: boolean; }; +// Setting or clearing the time of an existing reminder publishes only on the reminder's own state. +const hasRemindAtSelector = (state: ReminderState) => ({ hasRemindAt: state.remind_at != null }); + +// Mounted only for a message with a reminder, so other messages never subscribe to one. +const MessageReminderHeaderSwitch = ({ reminder }: { reminder: Reminder }) => { + const { MessageReminderHeader, MessageSavedForLaterHeader } = useComponentsContext(); + const { hasRemindAt } = useStateStore(reminder.state, hasRemindAtSelector); + return hasRemindAt ? : ; +}; + const MessageHeaderWithContext = (props: MessageHeaderPropsWithContext) => { - const { - alignment, - message, - shouldShowSavedForLaterHeader, - shouldShowPinnedHeader, - shouldShowReminderHeader, - shouldShowSentToChannelHeader, - } = props; - const { - MessagePinnedHeader, - MessageReminderHeader, - MessageSavedForLaterHeader, - SentToChannelHeader, - } = useComponentsContext(); + const { alignment, message, reminder, shouldShowPinnedHeader, shouldShowSentToChannelHeader } = + props; + const { MessagePinnedHeader, SentToChannelHeader } = useComponentsContext(); const containerStyle: ViewStyle = useMemo(() => { return { @@ -40,8 +41,7 @@ const MessageHeaderWithContext = (props: MessageHeaderPropsWithContext) => { return ( - {shouldShowReminderHeader && } - {shouldShowSavedForLaterHeader && } + {reminder ? : null} {shouldShowPinnedHeader && } {shouldShowSentToChannelHeader && } @@ -54,16 +54,14 @@ const areEqual = ( ) => { const { alignment: prevAlignment, - shouldShowSavedForLaterHeader: prevShouldShowSavedForLaterHeader, + reminder: prevReminder, shouldShowPinnedHeader: prevShouldShowPinnedHeader, - shouldShowReminderHeader: prevShouldShowReminderHeader, shouldShowSentToChannelHeader: prevShouldShowSentToChannelHeader, } = prevProps; const { alignment: nextAlignment, - shouldShowSavedForLaterHeader: nextShouldShowSavedForLaterHeader, + reminder: nextReminder, shouldShowPinnedHeader: nextShouldShowPinnedHeader, - shouldShowReminderHeader: nextShouldShowReminderHeader, shouldShowSentToChannelHeader: nextShouldShowSentToChannelHeader, } = nextProps; @@ -72,9 +70,8 @@ const areEqual = ( return false; } - const shouldShowSavedForLaterHeaderEqual = - prevShouldShowSavedForLaterHeader === nextShouldShowSavedForLaterHeader; - if (!shouldShowSavedForLaterHeaderEqual) { + const reminderEqual = prevReminder === nextReminder; + if (!reminderEqual) { return false; } @@ -83,12 +80,6 @@ const areEqual = ( return false; } - const shouldShowReminderHeaderEqual = - prevShouldShowReminderHeader === nextShouldShowReminderHeader; - if (!shouldShowReminderHeaderEqual) { - return false; - } - const shouldShowSentToChannelHeaderEqual = prevShouldShowSentToChannelHeader === nextShouldShowSentToChannelHeader; if (!shouldShowSentToChannelHeaderEqual) { @@ -109,17 +100,10 @@ export const MessageHeader = (props: MessageHeaderProps) => { const { alignment, message } = useMessageContext(); const reminder = useMessageReminder(message.id); - const shouldShowSavedForLaterHeader = reminder != null && reminder.remindAt == null; - const shouldShowReminderHeader = reminder?.remindAt != null; const shouldShowPinnedHeader = !!message?.pinned; const shouldShowSentToChannelHeader = !!message?.show_in_channel; - if ( - !shouldShowPinnedHeader && - !shouldShowSavedForLaterHeader && - !shouldShowReminderHeader && - !shouldShowSentToChannelHeader - ) { + if (!shouldShowPinnedHeader && !reminder && !shouldShowSentToChannelHeader) { return null; } @@ -127,9 +111,8 @@ export const MessageHeader = (props: MessageHeaderProps) => { diff --git a/package/src/components/Message/MessageItemView/__tests__/MessageHeader.test.tsx b/package/src/components/Message/MessageItemView/__tests__/MessageHeader.test.tsx new file mode 100644 index 0000000000..7fd7f22c9c --- /dev/null +++ b/package/src/components/Message/MessageItemView/__tests__/MessageHeader.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import { act, render, screen } from '@testing-library/react-native'; + +import { nowNs } from 'stream-chat'; +import type { StreamChat } from 'stream-chat'; + +import { ChatProvider } from '../../../../contexts/chatContext/ChatContext'; +import { WithComponents } from '../../../../contexts/componentsContext/ComponentsContext'; +import { MessageProvider } from '../../../../contexts/messageContext/MessageContext'; +import { generateMessage } from '../../../../mock-builders/generator/message'; +import { getTestClientWithUser } from '../../../../mock-builders/mock'; +import { MessageHeader } from '../MessageHeader'; + +describe('MessageHeader', () => { + let client: StreamChat; + const message = generateMessage({ id: 'message-1' }); + const reminderData = (remindAt: number | null) => + ({ + channel_cid: 'messaging:general', + created_at: nowNs(), + message_id: 'message-1', + remind_at: remindAt, + updated_at: nowNs(), + user_id: 'me', + }) as never; + + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'me' }); + }); + + afterEach(() => { + client.reminders.clearTimers(); + }); + + const renderHeader = (headerMessage = message) => + render( + + pinned, + MessageReminderHeader: () => reminder, + MessageSavedForLaterHeader: () => saved for later, + }} + > + + + + + , + ); + + it('renders no reminder header for a message without a reminder', () => { + renderHeader({ ...message, pinned: true }); + expect(screen.getByText('pinned')).toBeTruthy(); + expect(screen.queryByText('reminder')).toBeNull(); + expect(screen.queryByText('saved for later')).toBeNull(); + }); + + it('switches between saved for later and reminder when the reminder time changes', () => { + client.reminders.upsertToState({ data: reminderData(null) }); + renderHeader(); + expect(screen.getByText('saved for later')).toBeTruthy(); + + act(() => { + client.reminders.upsertToState({ data: reminderData(nowNs() + 60_000_000_000) }); + }); + expect(screen.getByText('reminder')).toBeTruthy(); + expect(screen.queryByText('saved for later')).toBeNull(); + + act(() => { + client.reminders.upsertToState({ data: reminderData(null) }); + }); + expect(screen.getByText('saved for later')).toBeTruthy(); + expect(screen.queryByText('reminder')).toBeNull(); + }); +}); diff --git a/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx b/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx index 7ade6d0428..4e8cc6d260 100644 --- a/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx +++ b/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx @@ -26,7 +26,6 @@ const createMessageContextValue = (overrides: Partial): Mes hasAttachmentActions: false, hasReactions: false, images: [], - isMessageAIGenerated: jest.fn(), isMyMessage: false, lastGroupMessage: false, members: {}, diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts index dfbeb5d92e..74107c7a26 100644 --- a/package/src/components/Message/hooks/useCreateMessageContext.ts +++ b/package/src/components/Message/hooks/useCreateMessageContext.ts @@ -31,7 +31,6 @@ export const useCreateMessageContext = ({ hasReactions, messageHasOnlySingleAttachment, images, - isMessageAIGenerated, isMyMessage, lastGroupMessage, message, @@ -93,7 +92,6 @@ export const useCreateMessageContext = ({ hasReactions, messageHasOnlySingleAttachment, images, - isMessageAIGenerated, isMyMessage, lastGroupMessage, message, diff --git a/package/src/components/MessageList/InlineUnreadIndicator.tsx b/package/src/components/MessageList/InlineUnreadIndicator.tsx index a3e93916e9..a5d79c0768 100644 --- a/package/src/components/MessageList/InlineUnreadIndicator.tsx +++ b/package/src/components/MessageList/InlineUnreadIndicator.tsx @@ -18,7 +18,11 @@ export const InlineUnreadIndicator = (props: InlineUnreadIndicatorProps) => { {unreadCount - ? t('messageList.newMessages.text', '{{count}} new messages', { count: unreadCount }) + ? t('messageList.newMessages.text', { + count: unreadCount, + defaultValue_one: '{{count}} new message', + defaultValue_other: '{{count}} new messages', + }) : t('messageList.unreadMessages.label', 'Unread Messages')} diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index 23ee827e6e..6f17796212 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -517,10 +517,16 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const viewableMessages = viewableItems.map((viewable) => viewable.item); // Not inverted: the first viewable item is the topmost one on screen. const topVisibleMessage = viewableMessages[0]; + // Content shorter than the window cannot scroll, so FlashList keeps the offset of its initial + // scroll and misses the oldest message on screen. Short content shows every message. + const list = flashListRef.current; + const contentFitsWindow = list + ? list.getChildContainerDimensions().height <= list.getWindowSize().height + : false; const isAtOldestMessage = topVisibleMessage !== undefined && !paginator?.hasMoreTail && - processedMessageList[0]?.id === topVisibleMessage.id; + (contentFitsWindow || processedMessageList[0]?.id === topVisibleMessage.id); if (!hideStickyDateHeader && topVisibleMessage) { updateStickyHeaderDate({ isAtOldestMessage, topVisibleMessage }); diff --git a/package/src/components/MessageList/__tests__/InlineUnreadIndicator.test.tsx b/package/src/components/MessageList/__tests__/InlineUnreadIndicator.test.tsx new file mode 100644 index 0000000000..b57956957e --- /dev/null +++ b/package/src/components/MessageList/__tests__/InlineUnreadIndicator.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react-native'; + +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { TranslationProvider } from '../../../contexts/translationContext/TranslationContext'; +import { Streami18n } from '../../../utils/i18n/Streami18n'; +import { InlineUnreadIndicator } from '../InlineUnreadIndicator'; + +describe('InlineUnreadIndicator', () => { + it.each([ + [1, '1 new message'], + [3, '3 new messages'], + ])('labels %i unread messages as "%s"', async (unreadCount, label) => { + const translators = await new Streami18n().init(); + render( + + + + + , + ); + + expect(screen.getByText(label)).toBeTruthy(); + }); +}); diff --git a/package/src/components/MessageList/__tests__/MessageFlashList.test.tsx b/package/src/components/MessageList/__tests__/MessageFlashList.test.tsx new file mode 100644 index 0000000000..45f9f2e4a5 --- /dev/null +++ b/package/src/components/MessageList/__tests__/MessageFlashList.test.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { Text, View } from 'react-native'; + +import { act, render, screen, waitFor } from '@testing-library/react-native'; +import type { Channel as ChannelType, LocalMessage, StreamChat } from 'stream-chat'; + +import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; +import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; +import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels'; +import { generateMessage } from '../../../mock-builders/generator/message'; +import { Channel } from '../../Channel/Channel'; +import { Chat } from '../../Chat/Chat'; +import { MessageFlashList } from '../MessageFlashList'; + +type ViewableItemsChanged = (info: { + viewableItems: { index: number; isViewable: boolean; item: LocalMessage; key: string }[]; +}) => void; + +const mockFlashList: { + containerHeight: number; + onViewableItemsChanged?: ViewableItemsChanged; + windowHeight: number; +} = { containerHeight: 0, windowHeight: 700 }; + +// Renders nothing but captures the viewability callback; the two sizes drive the "content fits" check +// and every other ref method is a no-op. +jest.mock('@shopify/flash-list', () => { + const ReactModule = jest.requireActual('react'); + const { View: MockView } = jest.requireActual('react-native'); + return { + FlashList: ReactModule.forwardRef( + ( + props: { onViewableItemsChanged: ViewableItemsChanged; testID?: string }, + ref: React.Ref, + ) => { + ReactModule.useImperativeHandle( + ref, + () => + new Proxy( + { + getChildContainerDimensions: () => ({ + height: mockFlashList.containerHeight, + width: 400, + }), + getWindowSize: () => ({ height: mockFlashList.windowHeight, width: 400 }), + }, + { + get: (target, key) => + key in target ? target[key as keyof typeof target] : () => undefined, + }, + ), + ); + mockFlashList.onViewableItemsChanged = props.onViewableItemsChanged; + return ; + }, + ), + }; +}); + +describe('MessageFlashList sticky date header', () => { + let chatClient: StreamChat; + let channel: ChannelType; + let messages: LocalMessage[]; + + beforeEach(async () => { + ({ + channels: [channel], + client: chatClient, + } = await initiateClientWithChannels()); + messages = [generateMessage({ id: 'oldest' }), generateMessage({ id: 'newest' })].map( + (message) => channel.state.formatMessage(message as never), + ); + channel.messagePaginator.setItems({ + isFirstPage: true, + isLastPage: true, + valueOrFactory: messages, + }); + }); + + const renderList = () => + render( + + + ( + + {date ? 'shown' : 'hidden'} + + ), + }} + > + + + + + + , + ); + + // FlashList reports only the newest message on short content: it keeps the offset of its initial + // scroll, which puts the oldest one above the window. + const reportOnlyNewestViewable = () => + act(() => { + mockFlashList.onViewableItemsChanged?.({ + viewableItems: [{ index: 1, isViewable: true, item: messages[1], key: 'newest' }], + }); + }); + + it('stays hidden when every message fits on screen', async () => { + mockFlashList.containerHeight = 200; + renderList(); + await waitFor(() => expect(mockFlashList.onViewableItemsChanged).toBeDefined()); + expect(channel.messagePaginator.hasMoreTail).toBe(false); + + reportOnlyNewestViewable(); + + expect(screen.getByText('hidden', { includeHiddenElements: true })).toBeTruthy(); + }); + + it('shows the date of the topmost message when the content scrolls', async () => { + mockFlashList.containerHeight = 2000; + renderList(); + await waitFor(() => expect(mockFlashList.onViewableItemsChanged).toBeDefined()); + + reportOnlyNewestViewable(); + + expect(screen.getByText('shown', { includeHiddenElements: true })).toBeTruthy(); + }); +}); diff --git a/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx b/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx index dab1e4b551..83e6a421f7 100644 --- a/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx +++ b/package/src/components/MessageList/__tests__/ScrollToBottomButton.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { cleanup, fireEvent, render, waitFor } from '@testing-library/react-native'; import { nowNs } from 'stream-chat'; +import { AccessibilityProvider } from '../../../contexts/accessibilityContext/AccessibilityContext'; import type { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; import { ChannelProvider } from '../../../contexts/channelContext/ChannelContext'; import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext'; @@ -125,4 +126,33 @@ describe('ScrollToBottomButton', () => { expect(toJSON()).toMatchSnapshot(); }); }); + + it.each([ + [1, 'Scroll to bottom, 1 new message'], + [3, 'Scroll to bottom, 3 new messages'], + ])('labels the button for %i unread messages as "%s"', async (unreadMessages, label) => { + const translators = await new Streami18n().init(); + const channel = { + state: generateChannelState({ + read: { me: { last_read: nowNs(), unread_messages: unreadMessages, user: { id: 'me' } } }, + }), + }; + const { getByTestId } = render( + + + + + + null} showNotification={true} /> + + + + + , + ); + + expect(getByTestId('scroll-to-bottom-button').props.accessibilityLabel).toBe(label); + }); }); diff --git a/package/src/components/MessageList/hooks/__tests__/useStickyHeaderDate.test.ts b/package/src/components/MessageList/hooks/__tests__/useStickyHeaderDate.test.ts new file mode 100644 index 0000000000..1f4568a0e7 --- /dev/null +++ b/package/src/components/MessageList/hooks/__tests__/useStickyHeaderDate.test.ts @@ -0,0 +1,31 @@ +import { act, renderHook } from '@testing-library/react-native'; + +import type { LocalMessage } from 'stream-chat'; + +import { generateMessage } from '../../../../mock-builders/generator/message'; +import { convertDateToTimestamp } from '../../../../mock-builders/generator/time'; +import { useStickyHeaderDate } from '../useStickyHeaderDate'; + +describe('useStickyHeaderDate', () => { + it('shows the date again after scrolling back down from the oldest message', () => { + const topVisibleMessage = generateMessage({ + created_at: convertDateToTimestamp('2025-07-09T10:00:00.000Z'), + }) as unknown as LocalMessage; + const { result } = renderHook(() => useStickyHeaderDate()); + + act(() => + result.current.updateStickyHeaderDate({ isAtOldestMessage: false, topVisibleMessage }), + ); + expect(result.current.stickyHeaderDate?.toDateString()).toBe('Wed Jul 09 2025'); + + act(() => + result.current.updateStickyHeaderDate({ isAtOldestMessage: true, topVisibleMessage }), + ); + expect(result.current.stickyHeaderDate).toBeUndefined(); + + act(() => + result.current.updateStickyHeaderDate({ isAtOldestMessage: false, topVisibleMessage }), + ); + expect(result.current.stickyHeaderDate?.toDateString()).toBe('Wed Jul 09 2025'); + }); +}); diff --git a/package/src/components/MessageList/hooks/useStickyHeaderDate.ts b/package/src/components/MessageList/hooks/useStickyHeaderDate.ts index ba491476b5..204c0df967 100644 --- a/package/src/components/MessageList/hooks/useStickyHeaderDate.ts +++ b/package/src/components/MessageList/hooks/useStickyHeaderDate.ts @@ -22,6 +22,7 @@ export const useStickyHeaderDate = () => { topVisibleMessage: LocalMessage; }) => { if (isAtOldestMessage) { + stickyHeaderDateRef.current = undefined; setStickyHeaderDate(undefined); return; } diff --git a/package/src/components/MessageMenu/MessageUserReactions.tsx b/package/src/components/MessageMenu/MessageUserReactions.tsx index 34e5290bcb..d1751395b6 100644 --- a/package/src/components/MessageMenu/MessageUserReactions.tsx +++ b/package/src/components/MessageMenu/MessageUserReactions.tsx @@ -137,13 +137,13 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { ...reactionData, onSelectReaction, selectedReaction, - count: (message?.reaction_counts?.[reactionData.type] ?? 0).toString(), + count: (message?.reaction_groups?.[reactionData.type]?.count ?? 0).toString(), }); } return acc; }, []), [ - message?.reaction_counts, + message?.reaction_groups, onSelectReaction, reactionTypes, selectedReaction, @@ -194,11 +194,11 @@ export const MessageUserReactions = (props: MessageUserReactionsProps) => { const totalReactionCount = useMemo( () => - Object.values(message?.reaction_counts ?? {}).reduce( - (acc, reactionCount) => acc + reactionCount, + Object.values(message?.reaction_groups ?? {}).reduce( + (acc, reactionGroup) => acc + reactionGroup.count, 0, ), - [message?.reaction_counts], + [message?.reaction_groups], ); const reactions = useMemo( diff --git a/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx b/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx index a9b5f08198..5e98d29159 100644 --- a/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx +++ b/package/src/components/MessageMenu/__tests__/MessageUserReactions.test.tsx @@ -148,6 +148,23 @@ describe('MessageUserReactions when the supportedReactions are defined', () => { expect(getByText('2 love')).toBeDefined(); }); + it('counts reactions from reaction_groups, which optimistic reactions update', () => { + const { getByText } = renderComponent({ + message: { + ...generateMessage(), + reaction_counts: { like: 1, love: 1 }, + reaction_groups: { like: { count: 3, sum_scores: 3 }, love: { count: 2, sum_scores: 2 } }, + }, + }); + + expect(getByText('3')).toBeTruthy(); + expect(getByText('2')).toBeTruthy(); + expect(mockTranslations.t).toHaveBeenLastCalledWith( + 'messageMenu.userReactions.title', + expect.objectContaining({ count: 5 }), + ); + }); + it('uses provided reactions when passed as a prop', () => { const propReactions = [{ id: '3', image: 'user3.jpg', name: 'User 3', type: 'wow' }]; const { getByText } = renderComponent({ reactions: propReactions }); diff --git a/package/src/components/MessageMenu/hooks/__tests__/useFetchReactions.test.ts b/package/src/components/MessageMenu/hooks/__tests__/useFetchReactions.test.ts index b3718bd5a4..ae39941883 100644 --- a/package/src/components/MessageMenu/hooks/__tests__/useFetchReactions.test.ts +++ b/package/src/components/MessageMenu/hooks/__tests__/useFetchReactions.test.ts @@ -1,9 +1,18 @@ -import { ReactionResponse } from 'stream-chat'; +import React, { PropsWithChildren } from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react-native'; + +import { LocalMessage, ReactionResponse, StreamChat } from 'stream-chat'; + +import { ChatProvider } from '../../../../contexts/chatContext/ChatContext'; +import { generateMessage } from '../../../../mock-builders/generator/message'; +import { generateReaction } from '../../../../mock-builders/generator/reaction'; +import { getTestClientWithUser } from '../../../../mock-builders/mock'; import { reconcileUpdatedReactionInList, removeReactionFromList, upsertReactionInList, + useFetchReactions, } from '../useFetchReactions'; const makeReaction = ( @@ -54,3 +63,100 @@ describe('useFetchReactions helpers', () => { expect(result).toEqual([]); }); }); + +describe('useFetchReactions', () => { + let client: StreamChat; + let queryReactions: jest.SpyInstance; + const message = generateMessage({ id: 'message-1' }) as unknown as LocalMessage; + const fetched = generateReaction({ message_id: 'message-1', type: 'like' }); + + const wrapper = ({ children }: PropsWithChildren) => + React.createElement(ChatProvider, { value: { client } as never }, children); + + const renderUseFetchReactions = () => + renderHook((props: { message: LocalMessage }) => useFetchReactions(props), { + initialProps: { message }, + wrapper, + }); + + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'me' }); + queryReactions = jest + .spyOn(client, 'queryReactionsAndHydrate') + .mockResolvedValue({ duration: '1ms', reactions: [fetched] } as never); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('queries through the offline-hydrating client method', async () => { + const { result } = renderUseFetchReactions(); + + await waitFor(() => expect(result.current.reactions).toEqual([fetched])); + expect(queryReactions).toHaveBeenCalledWith({ id: 'message-1' }, expect.anything()); + }); + + it('applies reaction events for its own message only', async () => { + const { result } = renderUseFetchReactions(); + await waitFor(() => expect(result.current.reactions).toEqual([fetched])); + + const otherMessageReaction = generateReaction({ message_id: 'message-2', type: 'love' }); + const ownMessageReaction = generateReaction({ message_id: 'message-1', type: 'love' }); + + act(() => { + client.dispatchEvent({ reaction: otherMessageReaction, type: 'reaction.new' } as never); + }); + expect(result.current.reactions).toEqual([fetched]); + + act(() => { + client.dispatchEvent({ reaction: ownMessageReaction, type: 'reaction.new' } as never); + }); + expect(result.current.reactions).toEqual([ownMessageReaction, fetched]); + + act(() => { + client.dispatchEvent({ + reaction: { ...fetched, message_id: 'message-2' }, + type: 'reaction.deleted', + } as never); + }); + expect(result.current.reactions).toEqual([ownMessageReaction, fetched]); + }); + + it('applies offline reactions queried for its own message only', async () => { + const { result } = renderUseFetchReactions(); + await waitFor(() => expect(result.current.reactions).toEqual([fetched])); + + const otherMessageReaction = generateReaction({ message_id: 'message-2', type: 'love' }); + const ownMessageReaction = generateReaction({ message_id: 'message-1', type: 'love' }); + + act(() => { + client.dispatchEvent({ + offlineReactions: [otherMessageReaction], + type: 'offline_reactions.queried', + }); + }); + expect(result.current.reactions).toEqual([fetched]); + + act(() => { + client.dispatchEvent({ + offlineReactions: [ownMessageReaction], + type: 'offline_reactions.queried', + }); + }); + expect(result.current.reactions).toEqual([ownMessageReaction]); + }); + + it('does not refetch when the message object changes', async () => { + const { rerender, result } = renderUseFetchReactions(); + await waitFor(() => expect(result.current.reactions).toEqual([fetched])); + + rerender({ message: { ...message, reaction_groups: { like: { count: 2 } } } as never }); + await act(async () => { + await Promise.resolve(); + }); + + expect(queryReactions).toHaveBeenCalledTimes(1); + expect(result.current.reactions).toEqual([fetched]); + }); +}); diff --git a/package/src/components/MessageMenu/hooks/useFetchReactions.ts b/package/src/components/MessageMenu/hooks/useFetchReactions.ts index b60fe60035..168e53ff63 100644 --- a/package/src/components/MessageMenu/hooks/useFetchReactions.ts +++ b/package/src/components/MessageMenu/hooks/useFetchReactions.ts @@ -4,6 +4,7 @@ import { LocalMessage, ReactionResponse, SortParamRequest } from 'stream-chat'; import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { useStableCallback } from '../../../hooks/useStableCallback'; import { useNotificationApi } from '../../Notifications'; export type UseFetchReactionParams = { @@ -105,13 +106,28 @@ export const useFetchReactions = ({ const sortString = useMemo(() => JSON.stringify(sort), [sort]); + const notifyFetchFailed = useStableCallback((error: unknown) => { + addNotification({ + message: t('messageMenu.userReactions.fetchFailed.error', 'Error fetching reactions'), + options: { + ...(error instanceof Error ? { originalError: error } : {}), + severity: 'error', + type: 'api:message:reactions:fetch:failed', + }, + origin: { + ...(message ? { context: { message } } : {}), + emitter: 'Reactions', + }, + }); + }); + const fetchReactions = useCallback( async (next: string | undefined) => { if (!messageId) { return; } try { - const response = await client.queryReactions( + const response = await client.queryReactionsAndHydrate( { id: messageId }, { filter: reactionType ? { type: reactionType } : undefined, limit, next, sort }, ); @@ -124,21 +140,10 @@ export const useFetchReactions = ({ setLoading(false); } } catch (error) { - addNotification({ - message: t('messageMenu.userReactions.fetchFailed.error', 'Error fetching reactions'), - options: { - ...(error instanceof Error ? { originalError: error } : {}), - severity: 'error', - type: 'api:message:reactions:fetch:failed', - }, - origin: { - ...(message ? { context: { message } } : {}), - emitter: 'Reactions', - }, - }); + notifyFetchFailed(error); } }, - [addNotification, client, limit, message, messageId, reactionType, sort, t], + [client, limit, messageId, notifyFetchFailed, reactionType, sort], ); const loadNextPage = useCallback(async () => { @@ -158,7 +163,12 @@ export const useFetchReactions = ({ listeners.push( client.on('offline_reactions.queried', (event) => { const { offlineReactions } = event; - if (offlineReactions) { + // The event does not say which message was queried. + const isForThisQuery = offlineReactions?.every( + (reaction) => + reaction.message_id === messageId && (!reactionType || reaction.type === reactionType), + ); + if (offlineReactions && isForThisQuery) { setReactions(offlineReactions); setLoading(false); setNext(undefined); @@ -170,7 +180,7 @@ export const useFetchReactions = ({ client.on('reaction.new', (event) => { const { reaction } = event; - if (reaction) { + if (reaction && reaction.message_id === messageId) { setReactions((prevReactions) => upsertReactionInList({ prevReactions, @@ -186,7 +196,7 @@ export const useFetchReactions = ({ client.on('reaction.updated', (event) => { const { reaction } = event; - if (reaction) { + if (reaction && reaction.message_id === messageId) { setReactions((prevReactions) => reconcileUpdatedReactionInList({ prevReactions, @@ -202,7 +212,7 @@ export const useFetchReactions = ({ client.on('reaction.deleted', (event) => { const { reaction } = event; - if (reaction) { + if (reaction && reaction.message_id === messageId) { setReactions((prevReactions) => removeReactionFromList({ prevReactions, @@ -219,7 +229,7 @@ export const useFetchReactions = ({ listener?.unsubscribe(); }); }; - }, [client, reactionType]); + }, [client, messageId, reactionType]); return { loading, loadNextPage, reactions }; }; diff --git a/package/src/components/Poll/hooks/__tests__/usePollPagination.test.tsx b/package/src/components/Poll/hooks/__tests__/usePollPagination.test.tsx new file mode 100644 index 0000000000..92ea4d9c75 --- /dev/null +++ b/package/src/components/Poll/hooks/__tests__/usePollPagination.test.tsx @@ -0,0 +1,71 @@ +import React, { PropsWithChildren } from 'react'; + +import { act, renderHook, waitFor } from '@testing-library/react-native'; + +import type { PollVoteResponseData, StreamChat } from 'stream-chat'; + +import { ChatProvider } from '../../../../contexts/chatContext/ChatContext'; +import { PollContextProvider } from '../../../../contexts/pollContext/pollContext'; +import { generateMessage } from '../../../../mock-builders/generator/message'; +import { getTestClientWithUser } from '../../../../mock-builders/mock'; +import { usePollAnswersPagination } from '../usePollAnswersPagination'; +import { usePollOptionVotesPagination } from '../usePollOptionVotesPagination'; + +const answer = (id: string) => + ({ answer_text: id, id, is_answer: true, poll_id: 'poll-1' }) as unknown as PollVoteResponseData; +const vote = (id: string) => + ({ id, option_id: 'option-1', poll_id: 'poll-1' }) as unknown as PollVoteResponseData; + +describe('poll pagination hooks', () => { + let client: StreamChat; + const emptyPage = { next: undefined, votes: [] }; + const poll = { + id: 'poll-1', + queryAnswers: jest.fn(() => Promise.resolve(emptyPage)), + queryOptionVotes: jest.fn(() => Promise.resolve(emptyPage)), + }; + + const wrapper = ({ children }: PropsWithChildren) => ( + + + {children} + + + ); + + const castInOneBatch = (votes: PollVoteResponseData[]) => + act(() => { + votes.forEach((pollVote) => + client.dispatchEvent({ + poll: { id: 'poll-1' }, + poll_vote: pollVote, + type: 'poll.vote_casted', + } as never), + ); + }); + + beforeEach(async () => { + client = await getTestClientWithUser({ id: 'me' }); + }); + + it('keeps every answer cast in the same batch', async () => { + const { result } = renderHook(() => usePollAnswersPagination(), { wrapper }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + castInOneBatch([answer('a1'), answer('a2')]); + + expect(result.current.pollAnswers.map(({ id }) => id)).toEqual(['a2', 'a1']); + }); + + it('keeps every option vote cast in the same batch', async () => { + const { result } = renderHook( + () => usePollOptionVotesPagination({ option: { id: 'option-1', text: 'One' } as never }), + { wrapper }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + castInOneBatch([vote('v1'), vote('v2')]); + + expect(result.current.votes.map(({ id }) => id)).toEqual(['v2', 'v1']); + }); +}); diff --git a/package/src/components/Poll/hooks/usePollAnswersPagination.ts b/package/src/components/Poll/hooks/usePollAnswersPagination.ts index 31b3229440..a4663ec7d7 100644 --- a/package/src/components/Poll/hooks/usePollAnswersPagination.ts +++ b/package/src/components/Poll/hooks/usePollAnswersPagination.ts @@ -85,7 +85,7 @@ export const usePollAnswersPagination = ({ } const vote = event.poll_vote; if (vote && isVoteAnswer(vote)) { - setPollAnswers([vote, ...pollAnswers.filter((answer) => answer.id !== vote.id)]); + setPollAnswers((prev) => [vote, ...prev.filter((answer) => answer.id !== vote.id)]); } }), ); @@ -96,7 +96,7 @@ export const usePollAnswersPagination = ({ } const vote = event.poll_vote; if (vote && isVoteAnswer(vote)) { - setPollAnswers(pollAnswers.filter((item) => item.id !== vote.id)); + setPollAnswers((prev) => prev.filter((item) => item.id !== vote.id)); } }); @@ -104,7 +104,7 @@ export const usePollAnswersPagination = ({ castedListeners.forEach((listener) => listener.unsubscribe()); removedListener.unsubscribe(); }; - }, [client, poll, pollAnswers]); + }, [client, poll]); return { error, diff --git a/package/src/components/Poll/hooks/usePollOptionVotesPagination.ts b/package/src/components/Poll/hooks/usePollOptionVotesPagination.ts index 3e18399b98..0d99ea398b 100644 --- a/package/src/components/Poll/hooks/usePollOptionVotesPagination.ts +++ b/package/src/components/Poll/hooks/usePollOptionVotesPagination.ts @@ -55,7 +55,8 @@ export const usePollOptionVotesPagination = ({ const [error, setError] = useState(); const cursorRef = useRef(undefined); const queryInProgress = useRef(false); - const optionFilter = useMemo(() => ({ option_id: option.id }), [option.id]); + const optionId = option.id; + const optionFilter = useMemo(() => ({ option_id: optionId }), [optionId]); const loadMore = useCallback(async () => { if (cursorRef.current === null || queryInProgress.current) { @@ -96,10 +97,10 @@ export const usePollOptionVotesPagination = ({ } const vote = event.poll_vote; if (vote && !isVoteAnswer(vote)) { - if (vote.option_id === option.id) { - setVotes([vote, ...votes.filter((v) => v.id !== vote.id)]); + if (vote.option_id === optionId) { + setVotes((prev) => [vote, ...prev.filter((v) => v.id !== vote.id)]); } else if (eventName === 'poll.vote_changed') { - setVotes(votes.filter((v) => v.id !== vote.id)); + setVotes((prev) => prev.filter((v) => v.id !== vote.id)); } } }), @@ -110,8 +111,8 @@ export const usePollOptionVotesPagination = ({ return; } const vote = event.poll_vote; - if (vote && !isVoteAnswer(vote) && vote.option_id === option.id) { - setVotes(votes.filter((v) => v.id !== vote.id)); + if (vote && !isVoteAnswer(vote) && vote.option_id === optionId) { + setVotes((prev) => prev.filter((v) => v.id !== vote.id)); } }); @@ -119,7 +120,7 @@ export const usePollOptionVotesPagination = ({ castedListeners.forEach((listener) => listener.unsubscribe()); removedListener.unsubscribe(); }; - }, [client, option, poll, votes]); + }, [client, optionId, poll]); return { error, diff --git a/package/src/components/ThreadList/ThreadListUnreadBanner.tsx b/package/src/components/ThreadList/ThreadListUnreadBanner.tsx index fcd720ccbe..c1963ac5fd 100644 --- a/package/src/components/ThreadList/ThreadListUnreadBanner.tsx +++ b/package/src/components/ThreadList/ThreadListUnreadBanner.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { ThreadManagerState } from 'stream-chat'; @@ -11,9 +11,13 @@ import { primitives } from '../../theme'; const selector = (nextValue: ThreadManagerState) => ({ unseenThreadIds: nextValue.unseenThreadIds }) as const; +// The paginator records a failed reload and clears it on the next successful query. +const lastQueryErrorSelector = (nextValue: { lastQueryError?: Error }) => ({ + lastQueryError: nextValue.lastQueryError, +}); + export const ThreadListUnreadBanner = () => { const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); const { client } = useChatContext(); const { icons } = useComponentsContext(); const { t } = useTranslationContext(); @@ -22,16 +26,30 @@ export const ThreadListUnreadBanner = () => { } = useTheme(); const styles = useStyles(); const { unseenThreadIds } = useStateStore(client.threads.state, selector); + const { lastQueryError } = useStateStore(client.threads.paginator.state, lastQueryErrorSelector); + + const pressableStyle = useCallback( + ({ pressed }: { pressed: boolean }) => [ + styles.container, + { + backgroundColor: pressed + ? semantics.backgroundUtilityPressed + : semantics.backgroundCoreSurfaceDefault, + }, + ], + [semantics, styles], + ); + if (!unseenThreadIds.length) { return null; } const handlePress = async () => { + setLoading(true); try { - setLoading(true); await client.threads.reload(); - } catch (err) { - setError(err as Error); + } catch { + // Only with the paginator's `throwErrors`; the error is already in its state. } finally { setLoading(false); } @@ -46,29 +64,19 @@ export const ThreadListUnreadBanner = () => { ); } - if (error) { + if (lastQueryError) { return ( - + {t('threadList.unreadBanner.loadFailed.error', "Couldn't load new threads. Tap to retry")} - + ); } return ( - [ - styles.container, - { - backgroundColor: pressed - ? semantics.backgroundUtilityPressed - : semantics.backgroundCoreSurfaceDefault, - }, - ]} - > + {t('threadList.unreadBanner.newThreads.label', { diff --git a/package/src/components/ThreadList/__tests__/ThreadList.test.tsx b/package/src/components/ThreadList/__tests__/ThreadList.test.tsx index 2c5adaebe6..6f9c3e1dd8 100644 --- a/package/src/components/ThreadList/__tests__/ThreadList.test.tsx +++ b/package/src/components/ThreadList/__tests__/ThreadList.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { act, cleanup, render, screen, waitFor } from '@testing-library/react-native'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import type { Channel as ChannelType, StreamChat } from 'stream-chat'; import { Thread } from 'stream-chat'; @@ -157,4 +157,26 @@ describe('ThreadList loading states', () => { act(() => chatClient.threads.state.partialNext({ unseenThreadIds: ['a', 'b'] })); await waitFor(() => expect(screen.getByText('2 new threads')).toBeTruthy()); }); + + it('offers a retry when loading the new threads fails', async () => { + const query = jest + .spyOn(chatClient, 'queryThreadsAndHydrate') + .mockResolvedValueOnce({ next: undefined, threads: [makeThread()] }); + render( + + + , + ); + await waitFor(() => expect(rows()).toHaveLength(1)); + act(() => chatClient.threads.state.partialNext({ unseenThreadIds: ['a'] })); + + query.mockRejectedValueOnce(new Error('offline')); + fireEvent.press(await screen.findByText('1 new thread')); + const retry = await screen.findByText("Couldn't load new threads. Tap to retry"); + + query.mockResolvedValueOnce({ next: undefined, threads: [makeThread(), makeThread()] }); + fireEvent.press(retry); + await waitFor(() => expect(rows()).toHaveLength(2)); + expect(screen.queryByText("Couldn't load new threads. Tap to retry")).toBeNull(); + }); }); diff --git a/package/src/contexts/chatContext/ChatContext.tsx b/package/src/contexts/chatContext/ChatContext.tsx index dde7834a47..1b5c97c777 100644 --- a/package/src/contexts/chatContext/ChatContext.tsx +++ b/package/src/contexts/chatContext/ChatContext.tsx @@ -1,8 +1,7 @@ import React, { PropsWithChildren, useContext } from 'react'; -import type { GetApplicationResponse, StreamChat } from 'stream-chat'; +import type { GetApplicationResponse, LocalMessage, StreamChat } from 'stream-chat'; -import { MessageContextValue } from '../messageContext/MessageContext'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; import { isTestEnvironment } from '../utils/isTestEnvironment'; @@ -31,7 +30,9 @@ export type ChatContextValue = { * @overrideType StreamChat * */ client: StreamChat; -} & Partial>; + /** Decides whether a message renders as AI generated. Defaults to never. */ + isMessageAIGenerated: (message: LocalMessage) => boolean; +}; export const ChatContext = React.createContext(DEFAULT_BASE_CONTEXT_VALUE as ChatContextValue); diff --git a/package/src/contexts/liveLocationManagerContext/hooks/__tests__/useHandleLiveLocationEvents.test.tsx b/package/src/contexts/liveLocationManagerContext/hooks/__tests__/useHandleLiveLocationEvents.test.tsx new file mode 100644 index 0000000000..4da86df051 --- /dev/null +++ b/package/src/contexts/liveLocationManagerContext/hooks/__tests__/useHandleLiveLocationEvents.test.tsx @@ -0,0 +1,74 @@ +import React, { PropsWithChildren } from 'react'; + +import { act, renderHook } from '@testing-library/react-native'; + +import { msToNs } from 'stream-chat'; +import type { Channel, LocalMessage, StreamChat } from 'stream-chat'; + +import { initiateClientWithChannels } from '../../../../mock-builders/api/initiateClientWithChannels'; +import { generateMessage } from '../../../../mock-builders/generator/message'; +import { ChatProvider } from '../../../chatContext/ChatContext'; +import { useHandleLiveLocationEvents } from '../useHandleLiveLocationEvents'; + +const sharedLocation = (messageId: string, endAtMs: number) => ({ + channel_cid: 'messaging:location', + created_by_device_id: 'device', + end_at: msToNs(endAtMs), + latitude: 1, + longitude: 2, + message_id: messageId, +}); + +describe('useHandleLiveLocationEvents', () => { + let client: StreamChat; + let channel: Channel; + + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + + const updateMessage = (id: string, endAtMs: number) => + act(() => { + client.dispatchEvent({ + cid: channel.cid, + message: generateMessage({ + id, + shared_location: sharedLocation(id, endAtMs) as never, + }) as unknown as LocalMessage, + type: 'message.updated', + } as never); + }); + + beforeEach(async () => { + ({ + channels: [channel], + client, + } = await initiateClientWithChannels()); + }); + + it('is not stopped by another message whose share ended', () => { + const { result } = renderHook( + () => useHandleLiveLocationEvents({ channel, messageId: 'watched' }), + { wrapper }, + ); + + updateMessage('other', Date.now() - 60_000); + + expect(result.current.isLiveLocationStopped).toBeNull(); + expect(result.current.locationResponse).toBeUndefined(); + }); + + it('stops when its own share ends', () => { + const { result } = renderHook( + () => useHandleLiveLocationEvents({ channel, messageId: 'watched' }), + { wrapper }, + ); + + updateMessage('watched', Date.now() + 60_000); + expect(result.current.isLiveLocationStopped).toBeNull(); + expect(result.current.locationResponse?.message_id).toBe('watched'); + + updateMessage('watched', Date.now() - 60_000); + expect(result.current.isLiveLocationStopped).toBe(true); + }); +}); diff --git a/package/src/contexts/liveLocationManagerContext/hooks/useHandleLiveLocationEvents.ts b/package/src/contexts/liveLocationManagerContext/hooks/useHandleLiveLocationEvents.ts index 8c82734884..39bc98aa3c 100644 --- a/package/src/contexts/liveLocationManagerContext/hooks/useHandleLiveLocationEvents.ts +++ b/package/src/contexts/liveLocationManagerContext/hooks/useHandleLiveLocationEvents.ts @@ -39,14 +39,12 @@ export const useHandleLiveLocationEvents = ({ useEffect(() => { const handleMessageUpdate = (event: EventPayload<'message.updated'>) => { const { message } = event; - if (!message || !message.shared_location) { + if (!message || !message.shared_location || message.id !== messageId) { return; } const { shared_location } = message; - if (message.id === messageId) { - setLocationResponse(message.shared_location as SharedLocationResponse); - onLocationUpdate?.(message.shared_location as SharedLocationResponse); - } + setLocationResponse(shared_location as SharedLocationResponse); + onLocationUpdate?.(shared_location as SharedLocationResponse); if (shared_location.end_at != null && shared_location.end_at <= nowNs()) { setIsLiveLocationStopped(true); } diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx index dec4f3cead..21135c6e1a 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -59,10 +59,6 @@ export type MessageContextValue = { messageHasOnlySingleAttachment: boolean; /** The images attached to a message */ images: Attachment[]; - /** - * A factory function that determines whether a message is AI generated or not. - */ - isMessageAIGenerated: (message: LocalMessage) => boolean; /** Whether or not this is the active user's message */ isMyMessage: boolean; /** Whether or not this is the last message in a group of messages */ diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 46435b79bd..e45bfb3401 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -15,7 +15,7 @@ import type { MessageActionType } from '../../components/MessageMenu/MessageActi import { NativeHandlers } from '../../native'; import type { ReactionData } from '../../utils/utils'; -import type { Alignment, MessageContextValue } from '../messageContext/MessageContext'; +import type { Alignment } from '../messageContext/MessageContext'; import type { DeepPartial } from '../themeContext/ThemeContext'; import type { Theme } from '../themeContext/utils/theme'; import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; @@ -36,7 +36,7 @@ export type MessageLocationProps = { message: LocalMessage; }; -export type MessagesContextValue = Pick & { +export type MessagesContextValue = { /** Should keyboard be dismissed when messaged is touched */ dismissKeyboardOnMessageTouch: boolean; diff --git a/package/src/i18n/__tests__/callSites.test.ts b/package/src/i18n/__tests__/callSites.test.ts index fccc569b35..2a7c0201a0 100644 --- a/package/src/i18n/__tests__/callSites.test.ts +++ b/package/src/i18n/__tests__/callSites.test.ts @@ -26,12 +26,15 @@ import { runtimeDefaults } from '../runtimeDefaults'; const SRC = path.resolve(__dirname, '../..'); const catalog = catalogJson as Record; -const bundledKeys = new Set(Object.keys(runtimeDefaults)); +const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/; +// A bundled plural is stored as `_one` / `_other` and called as the bare ``. +const bundledKeys = new Set( + Object.keys(runtimeDefaults).map((key) => key.replace(PLURAL_SUFFIX, '')), +); /** Callables that translate their first argument. None of them takes an inline default. */ const TRANSLATING = new Set(['t', 'translate', 'useA11yLabel']); -const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/; const catalogKeys = new Set(Object.keys(catalog)); const pluralBases = new Set( Object.keys(catalog) diff --git a/package/src/i18n/__tests__/catalog.fixture.json b/package/src/i18n/__tests__/catalog.fixture.json index f916f6c4b6..cdadf0884a 100644 --- a/package/src/i18n/__tests__/catalog.fixture.json +++ b/package/src/i18n/__tests__/catalog.fixture.json @@ -49,7 +49,8 @@ "autoCompleteInput.suggestions.mentionsAvailable.accessibilityLabel": "Mention suggestions available", "avatar.accessibilityLabel": "Avatar of {{name}}", "avatar.channel.direct.accessibilityLabel": "Direct chat with {{name}}", - "avatar.channel.group.accessibilityLabel": "Channel with {{count}} members", + "avatar.channel.group.accessibilityLabel_one": "Channel with {{count}} member", + "avatar.channel.group.accessibilityLabel_other": "Channel with {{count}} members", "channel.addMembersFailed.error": "Failed to add members", "channel.archiveUpdateFailed.error": "Failed to update channel archive status", "channel.archived.text": "Channel archived", @@ -166,7 +167,8 @@ "channelPreview.typing.withTwoUsers.label": "{{ firstUser }} and {{ secondUser }} are typing", "channelPreview.typing.withUser.label": "{{ user }} is typing", "channelPreview.typing.withUserCount.label": "{{ numberOfUsers }} people are typing", - "channelPreview.unreadCount.accessibilityLabel": "{{count}} unread messages", + "channelPreview.unreadCount.accessibilityLabel_one": "{{count}} unread message", + "channelPreview.unreadCount.accessibilityLabel_other": "{{count}} unread messages", "common.anonymousUser.label": "Anonymous", "common.cameraPermission.text": "Device camera is used to take photos or videos.", "common.cameraPermission.title": "Allow camera access in device settings", @@ -240,7 +242,8 @@ "message.pinMessage.label": "Pin to Conversation", "message.pinned.text": "Message pinned", "message.pinnedBy.text": "Pinned by", - "message.reactionList.more.accessibilityLabel": "and {{count}} more reactions", + "message.reactionList.more.accessibilityLabel_one": "and {{count}} more reaction", + "message.reactionList.more.accessibilityLabel_other": "and {{count}} more reactions", "message.reactionList.viewHint.accessibilityLabel": "Double tap to view reactions", "message.reactionList.youReacted.accessibilityLabel": "you reacted", "message.reminderOverdue.text": "Reminder overdue", @@ -288,9 +291,11 @@ "messageInput.sendMessage.accessibilityLabel": "Send message", "messageInput.sendMessageDisallowed.text": "You can't send messages in this channel", "messageList.dismissUnread.accessibilityLabel": "Dismiss unread messages", - "messageList.newMessages.text": "{{count}} new messages", + "messageList.newMessages.text_one": "{{count}} new message", + "messageList.newMessages.text_other": "{{count}} new messages", "messageList.scrollToBottom.accessibilityLabel": "Scroll to bottom", - "messageList.scrollToBottom.withCount.accessibilityLabel": "Scroll to bottom, {{count}} new messages", + "messageList.scrollToBottom.withCount.accessibilityLabel_one": "Scroll to bottom, {{count}} new message", + "messageList.scrollToBottom.withCount.accessibilityLabel_other": "Scroll to bottom, {{count}} new messages", "messageList.typing.withMoreUsers.label": "{{ firstUser }} and {{ nonSelfUserLength }} more are typing", "messageList.unreadMessages.label": "Unread Messages", "messageList.unreadMessages.withCount.label": "{{count}} unread", diff --git a/package/src/i18n/keys.ts b/package/src/i18n/keys.ts index 2178970fbd..976bfed276 100644 --- a/package/src/i18n/keys.ts +++ b/package/src/i18n/keys.ts @@ -60,7 +60,8 @@ export type TranslationCatalog = { 'autoCompleteInput.suggestions.mentionsAvailable.accessibilityLabel': 'Mention suggestions available'; 'avatar.accessibilityLabel': 'Avatar of {{name}}'; 'avatar.channel.direct.accessibilityLabel': 'Direct chat with {{name}}'; - 'avatar.channel.group.accessibilityLabel': 'Channel with {{count}} members'; + 'avatar.channel.group.accessibilityLabel_one': 'Channel with {{count}} member'; + 'avatar.channel.group.accessibilityLabel_other': 'Channel with {{count}} members'; 'channel.addMembersFailed.error': 'Failed to add members'; 'channel.archiveUpdateFailed.error': 'Failed to update channel archive status'; 'channel.archived.text': 'Channel archived'; @@ -177,7 +178,8 @@ export type TranslationCatalog = { 'channelPreview.typing.withTwoUsers.label': '{{ firstUser }} and {{ secondUser }} are typing'; 'channelPreview.typing.withUser.label': '{{ user }} is typing'; 'channelPreview.typing.withUserCount.label': '{{ numberOfUsers }} people are typing'; - 'channelPreview.unreadCount.accessibilityLabel': '{{count}} unread messages'; + 'channelPreview.unreadCount.accessibilityLabel_one': '{{count}} unread message'; + 'channelPreview.unreadCount.accessibilityLabel_other': '{{count}} unread messages'; 'common.anonymousUser.label': 'Anonymous'; 'common.cameraPermission.text': 'Device camera is used to take photos or videos.'; 'common.cameraPermission.title': 'Allow camera access in device settings'; @@ -251,7 +253,8 @@ export type TranslationCatalog = { 'message.pinMessage.label': 'Pin to Conversation'; 'message.pinned.text': 'Message pinned'; 'message.pinnedBy.text': 'Pinned by'; - 'message.reactionList.more.accessibilityLabel': 'and {{count}} more reactions'; + 'message.reactionList.more.accessibilityLabel_one': 'and {{count}} more reaction'; + 'message.reactionList.more.accessibilityLabel_other': 'and {{count}} more reactions'; 'message.reactionList.viewHint.accessibilityLabel': 'Double tap to view reactions'; 'message.reactionList.youReacted.accessibilityLabel': 'you reacted'; 'message.reminderOverdue.text': 'Reminder overdue'; @@ -299,9 +302,11 @@ export type TranslationCatalog = { 'messageInput.sendMessage.accessibilityLabel': 'Send message'; 'messageInput.sendMessageDisallowed.text': "You can't send messages in this channel"; 'messageList.dismissUnread.accessibilityLabel': 'Dismiss unread messages'; - 'messageList.newMessages.text': '{{count}} new messages'; + 'messageList.newMessages.text_one': '{{count}} new message'; + 'messageList.newMessages.text_other': '{{count}} new messages'; 'messageList.scrollToBottom.accessibilityLabel': 'Scroll to bottom'; - 'messageList.scrollToBottom.withCount.accessibilityLabel': 'Scroll to bottom, {{count}} new messages'; + 'messageList.scrollToBottom.withCount.accessibilityLabel_one': 'Scroll to bottom, {{count}} new message'; + 'messageList.scrollToBottom.withCount.accessibilityLabel_other': 'Scroll to bottom, {{count}} new messages'; 'messageList.typing.withMoreUsers.label': '{{ firstUser }} and {{ nonSelfUserLength }} more are typing'; 'messageList.unreadMessages.label': 'Unread Messages'; 'messageList.unreadMessages.withCount.label': '{{count}} unread'; @@ -450,7 +455,6 @@ export type BundledTranslationKey = | 'autoCompleteInput.suggestions.mentionsAvailable.accessibilityLabel' | 'avatar.accessibilityLabel' | 'avatar.channel.direct.accessibilityLabel' - | 'avatar.channel.group.accessibilityLabel' | 'channelDetails.addMembers.accessibilityLabel' | 'channelDetails.editChannel.accessibilityLabel' | 'channelDetails.editChannel.upload.accessibilityLabel' @@ -465,7 +469,6 @@ export type BundledTranslationKey = | 'channelPreview.lastMessage.accessibilityLabel' | 'channelPreview.muted.accessibilityLabel' | 'channelPreview.pinned.accessibilityLabel' - | 'channelPreview.unreadCount.accessibilityLabel' | 'common.close.accessibilityLabel' | 'common.messageOverlay.swipeHint.accessibilityLabel' | 'duration.messageReminder' @@ -477,7 +480,6 @@ export type BundledTranslationKey = | 'message.content.contextMenuHint.accessibilityLabel' | 'message.content.fromSender.accessibilityLabel' | 'message.content.fromYou.accessibilityLabel' - | 'message.reactionList.more.accessibilityLabel' | 'message.reactionList.viewHint.accessibilityLabel' | 'message.reactionList.youReacted.accessibilityLabel' | 'message.status.delivered.accessibilityLabel' @@ -495,7 +497,6 @@ export type BundledTranslationKey = | 'messageInput.sendMessage.accessibilityLabel' | 'messageList.dismissUnread.accessibilityLabel' | 'messageList.scrollToBottom.accessibilityLabel' - | 'messageList.scrollToBottom.withCount.accessibilityLabel' | 'messageMenu.actionList.accessibilityLabel' | 'messageMenu.reactionPicker.moreReactions.accessibilityLabel' | 'notifications.attachmentUploadFailed.error' diff --git a/package/src/i18n/runtimeDefaults.ts b/package/src/i18n/runtimeDefaults.ts index 0e886b3ae9..b96c0a47f4 100644 --- a/package/src/i18n/runtimeDefaults.ts +++ b/package/src/i18n/runtimeDefaults.ts @@ -74,7 +74,8 @@ export const runtimeDefaults = { 'Mention suggestions available', 'avatar.accessibilityLabel': 'Avatar of {{name}}', 'avatar.channel.direct.accessibilityLabel': 'Direct chat with {{name}}', - 'avatar.channel.group.accessibilityLabel': 'Channel with {{count}} members', + 'avatar.channel.group.accessibilityLabel_one': 'Channel with {{count}} member', + 'avatar.channel.group.accessibilityLabel_other': 'Channel with {{count}} members', 'channelDetails.addMembers.accessibilityLabel': 'Add members', 'channelDetails.editChannel.accessibilityLabel': 'Edit channel', 'channelDetails.editChannel.upload.accessibilityLabel': 'Upload channel image', @@ -89,7 +90,8 @@ export const runtimeDefaults = { 'channelPreview.lastMessage.accessibilityLabel': 'Last message {{date}}', 'channelPreview.muted.accessibilityLabel': 'Muted', 'channelPreview.pinned.accessibilityLabel': 'Pinned', - 'channelPreview.unreadCount.accessibilityLabel': '{{count}} unread messages', + 'channelPreview.unreadCount.accessibilityLabel_one': '{{count}} unread message', + 'channelPreview.unreadCount.accessibilityLabel_other': '{{count}} unread messages', 'common.close.accessibilityLabel': 'Close', 'common.messageOverlay.swipeHint.accessibilityLabel': 'Swipe right to go through different actions', @@ -102,7 +104,8 @@ export const runtimeDefaults = { 'Double tap and hold to activate contextual menu', 'message.content.fromSender.accessibilityLabel': 'Message from {{sender}}', 'message.content.fromYou.accessibilityLabel': 'Message from you', - 'message.reactionList.more.accessibilityLabel': 'and {{count}} more reactions', + 'message.reactionList.more.accessibilityLabel_one': 'and {{count}} more reaction', + 'message.reactionList.more.accessibilityLabel_other': 'and {{count}} more reactions', 'message.reactionList.viewHint.accessibilityLabel': 'Double tap to view reactions', 'message.reactionList.youReacted.accessibilityLabel': 'you reacted', 'message.status.delivered.accessibilityLabel': 'Delivered', @@ -120,7 +123,9 @@ export const runtimeDefaults = { 'messageInput.sendMessage.accessibilityLabel': 'Send message', 'messageList.dismissUnread.accessibilityLabel': 'Dismiss unread messages', 'messageList.scrollToBottom.accessibilityLabel': 'Scroll to bottom', - 'messageList.scrollToBottom.withCount.accessibilityLabel': + 'messageList.scrollToBottom.withCount.accessibilityLabel_one': + 'Scroll to bottom, {{count}} new message', + 'messageList.scrollToBottom.withCount.accessibilityLabel_other': 'Scroll to bottom, {{count}} new messages', 'messageMenu.actionList.accessibilityLabel': 'Message actions', 'messageMenu.reactionPicker.moreReactions.accessibilityLabel': 'Open more reactions', diff --git a/package/src/utils/__tests__/Streami18n.test.ts b/package/src/utils/__tests__/Streami18n.test.ts index d2f0b578b3..e1a9258d44 100644 --- a/package/src/utils/__tests__/Streami18n.test.ts +++ b/package/src/utils/__tests__/Streami18n.test.ts @@ -58,6 +58,17 @@ describe('runtimeDefaults', () => { 'timestamp.UserActivityStatus', ]); }); + + it.each([ + ['avatar.channel.group.accessibilityLabel', 'Channel with 1 member'], + ['channelPreview.unreadCount.accessibilityLabel', '1 unread message'], + ['message.reactionList.more.accessibilityLabel', 'and 1 more reaction'], + ['messageList.scrollToBottom.withCount.accessibilityLabel', 'Scroll to bottom, 1 new message'], + ])('uses the singular of %s for a count of 1', async (key, singular) => { + const { t } = await new Streami18n().init(); + + expect(t(key as never, { count: 1 })).toBe(singular); + }); }); describe('English calendar wording', () => { From 3995b650fd37f3169f00005f346d07a697b127e6 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 12 Oct 2026 12:13:29 +0200 Subject: [PATCH 2/2] feat: introduce perf gauntlet skill --- .claude/skills/perf-benchmarking/SKILL.md | 2 + .claude/skills/perf-gauntlet/SKILL.md | 272 ++++++++++++++++ .prettierignore | 4 + AGENTS.md | 3 +- ai-docs/accessibility.md | 3 +- ai-docs/ai-migration-v9-to-v10.md | 78 +++++ ai-docs/i18n-v10-key-map.json | 10 +- eslint.config.mjs | 4 + examples/SampleApp/src/i18n/de.ts | 16 +- examples/SampleApp/src/i18n/it.ts | 16 +- perf/README.md | 5 + perf/gauntlet/.gitignore | 3 + perf/gauntlet/README.md | 46 +++ perf/gauntlet/actor.cjs | 116 +++++++ perf/gauntlet/analyze.py | 178 ++++++++++ perf/gauntlet/build.sh | 30 ++ perf/gauntlet/check-native-parity.cjs | 35 ++ perf/gauntlet/devmark.sh | 22 ++ perf/gauntlet/diag.py | 90 ++++++ perf/gauntlet/export-ref.sh | 15 + perf/gauntlet/find.py | 15 + perf/gauntlet/gauntlet-transformer.js | 59 ++++ perf/gauntlet/gauntlet.init.gradle | 15 + perf/gauntlet/lib.cjs | 23 ++ perf/gauntlet/metro.gauntlet.config.js | 74 +++++ perf/gauntlet/preflight.sh | 45 +++ perf/gauntlet/report.py | 254 +++++++++++++++ perf/gauntlet/run.sh | 25 ++ perf/gauntlet/runtime/entry-shim.js | 4 + perf/gauntlet/runtime/probe-llc.js | 25 ++ perf/gauntlet/runtime/probe.js | 378 ++++++++++++++++++++++ perf/gauntlet/seed.cjs | 255 +++++++++++++++ perf/gauntlet/session.sh | 251 ++++++++++++++ perf/gauntlet/tokens.cjs | 39 +++ perf/gauntlet/ui.py | 179 ++++++++++ 35 files changed, 2572 insertions(+), 17 deletions(-) create mode 100644 .claude/skills/perf-gauntlet/SKILL.md create mode 100644 perf/gauntlet/.gitignore create mode 100644 perf/gauntlet/README.md create mode 100644 perf/gauntlet/actor.cjs create mode 100644 perf/gauntlet/analyze.py create mode 100755 perf/gauntlet/build.sh create mode 100644 perf/gauntlet/check-native-parity.cjs create mode 100644 perf/gauntlet/devmark.sh create mode 100644 perf/gauntlet/diag.py create mode 100755 perf/gauntlet/export-ref.sh create mode 100644 perf/gauntlet/find.py create mode 100644 perf/gauntlet/gauntlet-transformer.js create mode 100644 perf/gauntlet/gauntlet.init.gradle create mode 100644 perf/gauntlet/lib.cjs create mode 100644 perf/gauntlet/metro.gauntlet.config.js create mode 100755 perf/gauntlet/preflight.sh create mode 100644 perf/gauntlet/report.py create mode 100755 perf/gauntlet/run.sh create mode 100644 perf/gauntlet/runtime/entry-shim.js create mode 100644 perf/gauntlet/runtime/probe-llc.js create mode 100644 perf/gauntlet/runtime/probe.js create mode 100644 perf/gauntlet/seed.cjs create mode 100755 perf/gauntlet/session.sh create mode 100644 perf/gauntlet/tokens.cjs create mode 100644 perf/gauntlet/ui.py diff --git a/.claude/skills/perf-benchmarking/SKILL.md b/.claude/skills/perf-benchmarking/SKILL.md index 5c74b3fe4f..728e28492f 100644 --- a/.claude/skills/perf-benchmarking/SKILL.md +++ b/.claude/skills/perf-benchmarking/SKILL.md @@ -5,6 +5,8 @@ description: Measure React Native performance on-device for stream-chat-react-na # Perf benchmarking (on-device, stream-chat-react-native) +> For a full multi-hour base-vs-head comparison of every major flow (release builds, FlatList and FlashList, statistics), use the `perf-gauntlet` skill instead. + How to measure SDK performance for real, against the **SampleApp running on a physical Android device**, using the committed `perf/` toolkit. This skill exists because the methodology is full of traps that produce confidently-wrong numbers; follow the rules and recipes here instead of improvising. Prerequisites for almost everything below: diff --git a/.claude/skills/perf-gauntlet/SKILL.md b/.claude/skills/perf-gauntlet/SKILL.md new file mode 100644 index 0000000000..c1e7519ce0 --- /dev/null +++ b/.claude/skills/perf-gauntlet/SKILL.md @@ -0,0 +1,272 @@ +--- +name: perf-gauntlet +description: Full on-device performance gauntlet for stream-chat-react-native - a multi-hour, base-vs-head comparison of every major SDK flow (channel list, channel open with 100 messages, live events, composer, scroll, threads, context menu, cold start) on FlatList AND FlashList, in RELEASE builds with React's profiling renderer, with per-component render/re-render numbers, CPU per thread, frames, and statistics that survive emulator noise. Use when asked for "a full gauntlet", "perf smoke tests", "does this regress performance", "compare develop and this branch", "performance before release", or "run the perf thing again". Not for quick single-change checks (use perf-benchmarking for those). +--- + +# Perf gauntlet (base vs head, release builds, every major flow) + +This is the procedure behind the October 2026 develop-vs-V10 comparison. It produces a table of "JS work" per +scenario for two builds (base = usually `origin/develop`, head = usually the working tree), with mount and +re-render numbers per major component, FlatList vs FlashList, CPU per thread, frame stats, and confidence +intervals. The harness lives in `perf/gauntlet/` (see its README for the file map). Read this whole file before +starting: almost every rule below exists because breaking it produced confidently wrong numbers once. + +## 0. Before touching anything: tell the user what this costs, and ask two things + +Say this up front (adjust numbers if the plan differs): + +| Step | Time | +|---|---| +| Export base tree + install deps | 2-5 min | +| First release build of the native project | 10-15 min (later builds: 1-2 min each, JS only) | +| Seed one fresh user per session (~700 API calls each) | ~5 min per user, 4-8 users | +| Dry runs (1 run per scenario, both sides) | ~30 min | +| Main gauntlet: 4 sessions, ABBA, 5 runs per scenario | ~3.5 h (~50 min per session) | +| Extra samples for small row/scroll deltas (12 short sessions) | ~1.6 h | +| Diagnostic builds + root-causing | 0.5-2 h | +| **Total** | **~5-7 h**, the emulator is busy the whole time | + +Ask (or default and say so): +1. **What is base and what is head?** Default: base = `origin/develop`, head = the current working tree. +2. **Which device?** Default: the Pixel 7 AVD (1080x2400; the driver's taps assume this size). + +Nothing in the repo is edited: every source change the measurement needs is applied at bundle time by the Metro +config/transformer, and all output goes to `perf/gauntlet/out/` (gitignored). Never commit, never stash, never +check out other refs in the user's tree (base is exported with `git archive`). + +## 1. The non-negotiable rules (each one cost hours once) + +1. **Release builds with React's profiling renderer, never debug.** Debug React is 3-4x slower and does dev-only + work that differs between versions; an attached CDP profiler doubles dev work again. The Metro config swaps + `ReactFabric-prod` for `ReactFabric-profiling`, which keeps `fiber.actualDuration` and Profiler commit hooks + in an otherwise normal release bundle. +2. **The emulator's speed depends on the HOST, and it can silently drop 5-7x for React work.** Confirmed + mechanism: host memory pressure (gradle builds, yarn install, Metro/hermesc, a big IDE) makes macOS compress the + emulator's RAM (3.3 GB compressed right after two builds); memory-heavy work (React rendering, Fabric) then runs + 5-7x slower until the emulator is cold-booted, while tight CPU loops look almost normal. It can also flip + mid-session. Therefore: + - do ALL builds first, `./gradlew --stop`, then COLD-BOOT the emulator, then measure; never build during a run; + - start the emulator from the user's Terminal panel (`run_in_terminal`), never from a background Bash task + (that gives it nice 5); `ps -o nice -p $(pgrep -f qemu-system | head -1)` must be 0; + - `preflight.sh` (run by `run.sh` before every session) waits for host load < 6 and refuses to measure when the + host has < 20% free memory, > 512 MB of the emulator is compressed, or an on-device CPU loop is > 1.4x its + calibration. When it stops a run: free memory, cold-boot the emulator, resume with the remaining users; + - inside the data, the per-window speed indices (`arith`, `mem` in `report.py main`) FLAG a mode change (they + rise ~60-70% when React work is 7x slower) but do not scale with it: never "normalize" across modes. If a + scenario's windows split into two clusters (e.g. cold start 230 ms vs 1550 ms with the same mounts), compare + only within one mode, and say so. +3. **One native binary for both sides.** Build both APKs from THIS checkout's Android project; only the JS bundle + differs (`build.sh` points the RN gradle plugin at each side's SampleApp). `check-native-parity.cjs` must pass; + if native modules differ, each side needs its own native build and you must say the comparison includes native + changes. +4. **A fresh, identically seeded user per session; ABBA side order; nothing else running.** Sessions mutate the + user's channels (new messages, reorders, unread counts), so a reused user is a different dataset. `run.sh` + alternates base/head as ABBA so slow drift cancels. Never seed, build or run Metro/gradle while a session runs. +5. **Nothing that touches the UI hierarchy inside a measurement window.** `uiautomator dump` is slow and can hang + for minutes; all dumps happen before a window opens. Waits inside windows are fixed `settle` sleeps. +6. **Never edit a script bash is executing.** Bash reads scripts incrementally; an edit mid-run corrupts the run. + `run.sh` freezes `session.sh` into `out//frozen/` first. To change scripts mid-gauntlet, write a new + file and `mv` it over (the running bash keeps the old inode). +7. **Grant notifications after `pm clear`** (`session.sh` does). The Android 13+ permission dialog pauses the + activity AND the JS timers; a cold start behind it measures a paused app. +8. **Cold start is `am force-stop; am start` back to back, with the window opened BEFORE the stop.** A plain + force-stop + wait lets Firebase's datatransport job restart the process ~0.4 s later, so the launch lands in a + half-initialized background process and cold starts become bimodal (1x vs 7x CPU). +9. **Counts first, time second.** Render counts, mounts, hook counts and context changes are deterministic and + immune to emulator speed. Time on an emulator varies +/-10-15% between sessions; that is bigger than most real + effects. A timing claim of a few percent needs: (a) the session-level CI (`report.py ci`), (b) the result + normalized by the speed index, (c) JS-thread CPU agreeing. If they disagree, say the effect is below what the + emulator can resolve. +10. **Before naming a cause, check the code is actually different.** If the "slower" components are unchanged + code with identical hook counts, the difference is the runtime, not the code. Use diagnostic builds (section + 6) to prove a cause; never report a correlation as a cause. +11. **FlatList windowing pollutes event windows.** About 3 in 10 FlatList event windows coincide with VirtualizedList + mounting a batch of ~30 cells (~1 s). `report.py` splits those out as `~batch`; report them + separately, never in the event's median. +12. **Don't flip-flop in front of the user.** Report interim numbers as interim ("not significant yet"), and only + give a verdict once the counts, CPU and the session-level CI agree. Say how big an effect the data can and + cannot rule out. + +## 2. How the harness measures (so you can interpret and extend it) + +`perf/gauntlet/runtime/probe.js` loads before anything else in the bundle (the Metro config replaces index.js's +first import with `runtime/entry-shim.js`). It installs a fake `__REACT_DEVTOOLS_GLOBAL_HOOK__` before the renderer +loads, so React reports every commit: + +| Metric | Source | Meaning | +|---|---|---| +| `render_ms` | HostRoot `actualDuration` | render phase incl. host-node creation | +| `wall_ms` | hook time - HostRoot `actualStartTime` | render + Fabric commit (Yoga layout on the JS thread) + layout effects | +| `layout_fx_ms` / `passive_fx_ms` | root `` (AppRegistry patch) | effect time | +| **`js_react_ms`** | `wall + passive` | **"JS work", the headline number** | +| per component | fiber walk over reconciled subtrees | self ms, inclusive ms (outermost instance), renders (PerformedWork), mounts | +| `dispatch_ms` | `client.dispatchEvent` wrapper (`probe-llc.js`, ES import so it hits the app's stream-chat build) | LLC event handling, per event type | +| `cpu_js/main/rt/proc_ms` | `/proc//task/*/stat` via `devmark.sh` at window edges | thread CPU (10 ms ticks), immune to preemption | +| `speed_ms`, `mem_speed_ms` | a fixed arithmetic loop and a fixed allocate-and-walk loop, timed once a second | runtime state during the window; a jump flags a mode change (rule 2); lower = faster | +| `frames` | `dumpsys gfxinfo` reset/dump per window | UI-thread frames, jank %, p50/p90/p99 (rough on emulators) | +| `gc_n` | `HermesInternal.getInstrumentedStats()` once a second | GC count (coarse) | +| counts | probe `M` lines | loaded messages per Bench channel: proves "100 loaded" on both sides | + +Everything is buffered and flushed to logcat once a second (`BENCH `, < 3.5k chars per line). Windows +are opened/closed by `devmark.sh` (`PERFMARK S|E