Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/skills/perf-benchmarking/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
272 changes: 272 additions & 0 deletions .claude/skills/perf-gauntlet/SKILL.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ yarn.lock
package/src/components/docs/
package/src/theme/generated/

# Perf gauntlet output and generated tokens (gitignored)
perf/gauntlet/out/
perf/gauntlet/runtime/generated/

# Workflow / tooling configs not owned by this project's Prettier
.github/
.husky/
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@ This repo ships three project skills — load the relevant one **before** touchi

- `.claude/skills/accessibility` — VoiceOver/TalkBack work: interactive components, gestures, modals, lists, media controls, focus behavior, live announcements
- `.claude/skills/rtl` — anything with a horizontal or directional axis: styles, positioning, flex, swipe gestures, animated transforms, icons, text alignment
- `.claude/skills/perf-gauntlet` — the full multi-hour base-vs-head performance gauntlet (release builds with React's profiling renderer, every major flow on FlatList and FlashList, per-component mount/re-render numbers, session-level statistics). Harness in `perf/gauntlet/`.
- `.claude/skills/perf-benchmarking` — on-device measurement: Hermes CPU profiles, render profiling, deterministic call counting, memory/jank capture. Drives `examples/SampleApp` on a connected Android device via the `perf/` toolkit (`scenario-lib.sh`, `capture-hermes-profile.js`, `analyze-react-profile.js`, `analyze-cpuprofile.js`, `android-heap-dump.sh`; see `perf/README.md`).

Accessibility is **opt-in** — see `ai-docs/accessibility.md` for the full contract. New interactive UI should reuse the primitives in `package/src/a11y/`.
Expand Down Expand Up @@ -456,7 +457,7 @@ Never commit API keys or real user data. Example code must use obvious placehold
## References

- **Agent deep dives:** `ai-docs/ai-migration.md` (v8 → v9) and `ai-docs/ai-migration-v9-to-v10.md` (v9 → v10) — load these instead of the prose upgrade guides for agent-driven migrations; `ai-docs/accessibility.md` (opt-in a11y layer); `ai-docs/i18n-v10-migration.md` + `ai-docs/i18n-v10-key-map.json` (English-only i18n, and the reviewed old-key → dotted-key map)
- **Repo skills:** `.claude/skills/{accessibility,rtl,perf-benchmarking}`, `perf/README.md`
- **Repo skills:** `.claude/skills/{accessibility,rtl,perf-benchmarking,perf-gauntlet}`, `perf/README.md`, `perf/gauntlet/README.md`
- **Contributing / process:** `CONTRIBUTING.md`, `RELEASE_PROCESS.md`, `PULL_REQUEST_TEMPLATE.md`, `SECURITY.md`
- **Component docs:** https://getstream.io/chat/docs/sdk/reactnative/
- **Stream Chat API:** https://getstream.io/chat/docs/javascript/
Expand Down
3 changes: 2 additions & 1 deletion ai-docs/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ Integrators override per key:
const i18n = new Streami18n({ language: 'nl' });
i18n.registerTranslation('nl', {
'avatar.accessibilityLabel': 'Avatar van {{name}}',
'messageList.scrollToBottom.withCount.accessibilityLabel': '{{count}} nieuwe berichten',
'messageList.scrollToBottom.withCount.accessibilityLabel_one': '{{count}} nieuw bericht',
'messageList.scrollToBottom.withCount.accessibilityLabel_other': '{{count}} nieuwe berichten',
});
<OverlayProvider accessibility={{ enabled: true }} i18nInstance={i18n}>
<Chat client={client} i18nInstance={i18n}>
Expand Down
78 changes: 78 additions & 0 deletions ai-docs/ai-migration-v9-to-v10.md
Original file line number Diff line number Diff line change
Expand Up @@ -2519,6 +2519,84 @@ which is an older reply once the thread has been scrolled back.

---

# Part Q — Bug fixes after the state cleanup

## Q.1 Count strings are plural keys (breaking for translations)

Five strings that take a `{{count}}` read "1 new messages" for a count of 1. They are plural keys now,
like every other count string in the SDK. A translation dictionary has to provide the `_one` /
`_other` forms (plus `_few`, `_many` and so on where the language needs them). i18next looks up the
bundled English plural before an override of the bare key, so an override of the bare key no longer
applies.

| Key | Where |
| --- | --- |
| `messageList.newMessages.text` | the unread separator in a message list |
| `messageList.scrollToBottom.withCount.accessibilityLabel` | the scroll-to-bottom button |
| `channelPreview.unreadCount.accessibilityLabel` | a channel row's unread badge |
| `message.reactionList.more.accessibilityLabel` | the clustered reaction list |
| `avatar.channel.group.accessibilityLabel` | a group channel's avatar |

```ts
// Before
'messageList.newMessages.text': '{{count}} neue Nachrichten',
// After
'messageList.newMessages.text_one': '{{count}} neue Nachricht',
'messageList.newMessages.text_other': '{{count}} neue Nachrichten',
```

`TranslationDictionary` rejects the bare key, so a typed dictionary fails to compile until it is
updated.

## Q.2 Behaviour fixes

- **Reactions sheet** (`MessageUserReactions` / `useFetchReactions`):
- reactions added to other messages no longer appear in an open sheet;
- the sheet no longer refetches, and briefly empties, on every reaction to its message;
- offline, the sheet shows the reactions stored in the offline database again, through
`client.queryReactionsAndHydrate` (as in v9);
- the counts come from `reaction_groups`, so a reaction added offline or not yet confirmed is
counted.
- **Reminder header:** setting or clearing the time of an existing reminder switches the message
header between "Saved for later" and the reminder right away.
- **`useHandleLiveLocationEvents`:** another message's ended share no longer marks the watched
location as stopped.
- **`usePollAnswersPagination` / `usePollOptionVotesPagination`:** votes and answers that arrive
together are all kept. Only the last one used to survive.
- **Thread list:** a failed refresh from the "new threads" banner shows "Couldn't load new threads. Tap
to retry", and tapping it retries. The tile could not appear before, and was not pressable.
- **Channel list swipe sheet:** a direct chat shows "Online" when the other member is online. It used to
read the watcher count, which includes the current user, so the label was inverted.
- **Sticky date header:** it shows again after scrolling back down from the oldest message.
- **`MessageFlashList`:** a channel whose messages fit on screen no longer shows the date twice (the
sticky header over the inline separator).

## Q.3 `isMessageAIGenerated` lives on `<Chat>` only (breaking)

`<Chat isMessageAIGenerated>` is the only place to set it, and it defaults to a function that returns
`false`. `<Channel>` no longer accepts the prop: `<Chat>`'s value always overrode it, in v9 as well, so
it never had an effect. The message components read it from `useChatContext()`, and it is no longer on
the message contexts.

| Removed | Use instead |
| --- | --- |
| `<Channel isMessageAIGenerated={…}>` | `<Chat isMessageAIGenerated={…}>` |
| `useMessagesContext().isMessageAIGenerated` | `useChatContext().isMessageAIGenerated` |
| `useMessageContext().isMessageAIGenerated` | `useChatContext().isMessageAIGenerated` |
| `MessageContent`'s `isMessageAIGenerated` prop | `<Chat isMessageAIGenerated={…}>` |

```diff
- <Channel channel={channel} isMessageAIGenerated={isAIMessage}>
+ <Chat client={client} isMessageAIGenerated={isAIMessage}>
```

```diff
- const { isMessageAIGenerated } = useMessageContext();
+ const { isMessageAIGenerated } = useChatContext();
```

---

# Part I — i18n

## 19. English-only bundle, dotted keys, shared runtime
Expand Down
10 changes: 5 additions & 5 deletions ai-docs/i18n-v10-key-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -1320,7 +1320,7 @@
"a11y/Channel with {{count}} members": {
"key": "avatar.channel.group.accessibilityLabel",
"prose": true,
"plural": false
"plural": true
},
"a11y/Clear search": {
"key": "uiComponents.searchInput.clear.accessibilityLabel",
Expand Down Expand Up @@ -1630,7 +1630,7 @@
"a11y/Scroll to bottom, {{count}} new messages": {
"key": "messageList.scrollToBottom.withCount.accessibilityLabel",
"prose": true,
"plural": false
"plural": true
},
"a11y/Search members": {
"key": "channelDetails.memberList.search.accessibilityLabel",
Expand Down Expand Up @@ -1715,7 +1715,7 @@
"a11y/and {{count}} more reactions": {
"key": "message.reactionList.more.accessibilityLabel",
"prose": true,
"plural": false
"plural": true
},
"a11y/you reacted": {
"key": "message.reactionList.youReacted.accessibilityLabel",
Expand All @@ -1730,7 +1730,7 @@
"a11y/{{count}} unread messages": {
"key": "channelPreview.unreadCount.accessibilityLabel",
"prose": true,
"plural": false
"plural": true
},
"a11y/{{name}} is already a member": {
"key": "channelDetails.addMembers.alreadyMember.accessibilityLabel",
Expand Down Expand Up @@ -1925,7 +1925,7 @@
"{{count}} new messages": {
"key": "messageList.newMessages.text",
"prose": true,
"plural": false
"plural": true
},
"{{count}} new threads": {
"key": "threadList.unreadBanner.newThreads.label",
Expand Down
4 changes: 4 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export default tsEslint.config(
'package/native-package/',
'package/expo-package/',

// Perf gauntlet: Node CommonJS scripts, an ES5 probe loaded ahead of the app, and
// exported baseline trees (with their own sources) under out/
'perf/gauntlet/',

// Repo metadata
'.github/',
'.husky/',
Expand Down
16 changes: 11 additions & 5 deletions examples/SampleApp/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ export const de: TranslationDictionary = {
// Avatars
'avatar.accessibilityLabel': 'Avatar von {{name}}',
'avatar.channel.direct.accessibilityLabel': 'Direktchat mit {{name}}',
'avatar.channel.group.accessibilityLabel': 'Kanal mit {{count}} Mitgliedern',
'avatar.channel.group.accessibilityLabel_one': 'Kanal mit {{count}} Mitglied',
'avatar.channel.group.accessibilityLabel_other': 'Kanal mit {{count}} Mitgliedern',

// Channel screen, system messages and errors
'channel.addMembersFailed.error': 'Mitglieder konnten nicht hinzugefügt werden',
Expand Down Expand Up @@ -216,7 +217,8 @@ export const de: TranslationDictionary = {
'channelPreview.typing.withTwoUsers.label': '{{ firstUser }} und {{ secondUser }} tippen',
'channelPreview.typing.withUser.label': '{{ user }} tippt',
'channelPreview.typing.withUserCount.label': '{{ numberOfUsers }} Personen tippen',
'channelPreview.unreadCount.accessibilityLabel': '{{count}} ungelesene Nachrichten',
'channelPreview.unreadCount.accessibilityLabel_one': '{{count}} ungelesene Nachricht',
'channelPreview.unreadCount.accessibilityLabel_other': '{{count}} ungelesene Nachrichten',

// Shared labels reused across screens
'common.anonymousUser.label': 'Anonym',
Expand Down Expand Up @@ -305,7 +307,8 @@ export const de: TranslationDictionary = {
'message.pinMessage.label': 'An Unterhaltung anheften',
'message.pinned.text': 'Nachricht angeheftet',
'message.pinnedBy.text': 'Angeheftet von',
'message.reactionList.more.accessibilityLabel': 'und {{count}} weitere Reaktionen',
'message.reactionList.more.accessibilityLabel_one': 'und {{count}} weitere Reaktion',
'message.reactionList.more.accessibilityLabel_other': 'und {{count}} weitere Reaktionen',
'message.reactionList.viewHint.accessibilityLabel':
'Doppelt tippen, um die Reaktionen anzuzeigen',
'message.reactionList.youReacted.accessibilityLabel': 'du hast reagiert',
Expand Down Expand Up @@ -359,9 +362,12 @@ export const de: TranslationDictionary = {

// Message list
'messageList.dismissUnread.accessibilityLabel': 'Ungelesene Nachrichten ausblenden',
'messageList.newMessages.text': '{{count}} neue Nachrichten',
'messageList.newMessages.text_one': '{{count}} neue Nachricht',
'messageList.newMessages.text_other': '{{count}} neue Nachrichten',
'messageList.scrollToBottom.accessibilityLabel': 'Nach unten scrollen',
'messageList.scrollToBottom.withCount.accessibilityLabel':
'messageList.scrollToBottom.withCount.accessibilityLabel_one':
'Nach unten scrollen, {{count}} neue Nachricht',
'messageList.scrollToBottom.withCount.accessibilityLabel_other':
'Nach unten scrollen, {{count}} neue Nachrichten',
'messageList.typing.withMoreUsers.label':
'{{ firstUser }} und {{ nonSelfUserLength }} weitere tippen',
Expand Down
16 changes: 11 additions & 5 deletions examples/SampleApp/src/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ export const it: TranslationDictionary = {
// Avatars
'avatar.accessibilityLabel': 'Avatar di {{name}}',
'avatar.channel.direct.accessibilityLabel': 'Chat diretta con {{name}}',
'avatar.channel.group.accessibilityLabel': 'Canale con {{count}} membri',
'avatar.channel.group.accessibilityLabel_one': 'Canale con {{count}} membro',
'avatar.channel.group.accessibilityLabel_other': 'Canale con {{count}} membri',

// Channel screen, system messages and errors
'channel.addMembersFailed.error': 'Impossibile aggiungere membri',
Expand Down Expand Up @@ -210,7 +211,8 @@ export const it: TranslationDictionary = {
'channelPreview.typing.withTwoUsers.label': '{{ firstUser }} e {{ secondUser }} stanno scrivendo',
'channelPreview.typing.withUser.label': '{{ user }} sta scrivendo',
'channelPreview.typing.withUserCount.label': '{{ numberOfUsers }} persone stanno scrivendo',
'channelPreview.unreadCount.accessibilityLabel': '{{count}} messaggi non letti',
'channelPreview.unreadCount.accessibilityLabel_one': '{{count}} messaggio non letto',
'channelPreview.unreadCount.accessibilityLabel_other': '{{count}} messaggi non letti',

// Shared labels reused across screens
'common.anonymousUser.label': 'Anonimo',
Expand Down Expand Up @@ -301,7 +303,8 @@ export const it: TranslationDictionary = {
'message.pinMessage.label': 'Metti in evidenza',
'message.pinned.text': 'Messaggio fissato',
'message.pinnedBy.text': 'Fissato da',
'message.reactionList.more.accessibilityLabel': 'e altre {{count}} reazioni',
'message.reactionList.more.accessibilityLabel_one': "e un'altra reazione",
'message.reactionList.more.accessibilityLabel_other': 'e altre {{count}} reazioni',
'message.reactionList.viewHint.accessibilityLabel': 'Tocca due volte per vedere le reazioni',
'message.reactionList.youReacted.accessibilityLabel': 'hai reagito',
'message.reminderOverdue.text': 'Promemoria scaduto',
Expand Down Expand Up @@ -355,9 +358,12 @@ export const it: TranslationDictionary = {

// Message list
'messageList.dismissUnread.accessibilityLabel': 'Ignora i messaggi non letti',
'messageList.newMessages.text': '{{count}} nuovi messaggi',
'messageList.newMessages.text_one': '{{count}} nuovo messaggio',
'messageList.newMessages.text_other': '{{count}} nuovi messaggi',
'messageList.scrollToBottom.accessibilityLabel': 'Vai in fondo',
'messageList.scrollToBottom.withCount.accessibilityLabel':
'messageList.scrollToBottom.withCount.accessibilityLabel_one':
'Vai in fondo, {{count}} nuovo messaggio',
'messageList.scrollToBottom.withCount.accessibilityLabel_other':
'Vai in fondo, {{count}} nuovi messaggi',
'messageList.typing.withMoreUsers.label':
'{{ firstUser }} e altri {{ nonSelfUserLength }} stanno scrivendo',
Expand Down
11 changes: 2 additions & 9 deletions package/src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -231,7 +230,6 @@ export type ChannelPropsWithContext = Pick<ChannelContextValue, 'channel'> &
| 'hasCreatePoll'
>
> &
Partial<Pick<MessageContextValue, 'isMessageAIGenerated'>> &
Partial<
Pick<ThreadContextValue, 'allowThreadMessagesInChannel' | 'onAlsoSentToChannelHeaderPress'>
> & {
Expand Down Expand Up @@ -375,7 +373,6 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
hideStickyDateHeader = false,
initialScrollToFirstUnreadMessage = false,
isAttachmentEqual,
isMessageAIGenerated = () => false,
keyboardBehavior,
keyboardVerticalOffset,
markdownRules,
Expand Down Expand Up @@ -737,7 +734,6 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
hasCreatePoll:
hasCreatePoll === undefined ? pollCreationEnabled : hasCreatePoll && pollCreationEnabled,
isAttachmentEqual,
isMessageAIGenerated,
markdownRules,
messageActions,
messageContentOrder,
Expand Down Expand Up @@ -837,7 +833,7 @@ export type ChannelProps = Partial<Omit<ChannelPropsWithContext, 'channel' | 'th
* @example ./Channel.md
*/
export const Channel = (props: PropsWithChildren<ChannelProps>) => {
const { client, isMessageAIGenerated } = useChatContext();
const { client } = useChatContext();
const { t } = useTranslationContext();
const channel = useSupersededChannelSwap(props.channel);
const notificationHostId =
Expand Down Expand Up @@ -866,10 +862,7 @@ export const Channel = (props: PropsWithChildren<ChannelProps>) => {
{...props}
channel={channel}
shouldSyncChannel={shouldSyncChannel}
{...{
isMessageAIGenerated,
thread,
}}
thread={thread}
/>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export const useCreateMessagesContext = ({
handleBlockUser,
hasCreatePoll,
isAttachmentEqual,
isMessageAIGenerated,
markdownRules,
messageActions,
messageContentOrder,
Expand Down Expand Up @@ -86,7 +85,6 @@ export const useCreateMessagesContext = ({
handleBlockUser,
hasCreatePoll,
isAttachmentEqual,
isMessageAIGenerated,
markdownRules,
messageActions,
messageContentOrder,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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}`;
Expand All @@ -64,7 +74,7 @@ export const ChannelDetailsHeader = ({ channel }: ChannelDetailsHeaderProps) =>
</View>
<Text style={styles.headerMeta}>
{isDirectChat
? onlineCount === 1
? isOtherMemberOnline
? t('common.presence.online.label', 'Online')
: t('common.presence.offline.label', 'Offline')
: membersAndOnlineLabel}
Expand Down
Loading
Loading