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: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ Native code shared by both wrappers lives in `package/shared-native/{ios,android
### DO NOT

1. **Edit generated or synced files** (see below) — regenerate them instead.
2. **Add `channel` or `channel.state` to dependency arrays** — use `channel.cid`, which is stable.
2. **Key on `channel.cid` where you mean the channel instance** — depend on `channel` itself. A disposed channel comes back as a new instance under the same cid, so anything bound to the instance (a context value holding it, a `useStateStore` subscription, a listener, `watch()`) must rebuild when `channel` changes. Use `cid` only when you mean the conversation.
3. **Mutate `channel.state.messages` directly** — go through the `stream-chat` client's state API.
4. **Inline a `useStateStore` selector** — define it at module scope.
5. **Use unguarded web-only APIs in shared code** — it runs on Hermes, not a browser.
Expand Down
380 changes: 348 additions & 32 deletions ai-docs/ai-migration-v9-to-v10.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions examples/SampleApp/src/hooks/useLeaveGoneChannel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useEffect } from 'react';

import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { Channel, ChannelLifecycleState } from 'stream-chat';
import { useStateStore } from 'stream-chat-react-native';

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

const selector = (state: ChannelLifecycleState) => ({ pendingDisposal: state.pendingDisposal });

/**
* Returns to the channel list once the channel is gone: deleted, or the user removed from it.
* `<Channel>` keeps rendering a gone channel, so what to show instead is up to the app.
*/
export const useLeaveGoneChannel = (channel: Channel | undefined) => {
const navigation = useNavigation<NativeStackNavigationProp<StackNavigatorParamList>>();
const { pendingDisposal } = useStateStore(channel?.state, selector) ?? {};

useEffect(() => {
if (pendingDisposal) {
navigation.popTo('MessagingScreen');
}
}, [navigation, pendingDisposal]);
};
2 changes: 2 additions & 0 deletions examples/SampleApp/src/screens/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import { useAppContext } from '../context/AppContext';
import { useStreamChatContext } from '../context/StreamChatContext.tsx';
import { useChannelMembersStatus } from '../hooks/useChannelMembersStatus';
import { useLeaveGoneChannel } from '../hooks/useLeaveGoneChannel';
import type { StreamThemeWithColors } from '../theme/AppTheme';

import type { StackNavigatorParamList } from '../types';
Expand Down Expand Up @@ -192,6 +193,7 @@ export const ChannelScreen: React.FC<ChannelScreenProps> = ({ navigation, route
const [selectedMessage, setSelectedMessage] = useState<LocalMessage | undefined>(undefined);

const [channel, setChannel] = useState<StreamChatChannel | undefined>(channelFromProp);
useLeaveGoneChannel(channel);

useLayoutEffect(() => {
if (!channel || !messageId) {
Expand Down
2 changes: 2 additions & 0 deletions examples/SampleApp/src/screens/ThreadScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { ScreenHeader } from '../components/ScreenHeader';

import { useAppContext } from '../context/AppContext.ts';
import { useStreamChatContext } from '../context/StreamChatContext.tsx';
import { useLeaveGoneChannel } from '../hooks/useLeaveGoneChannel';
import { useLegacyColors } from '../theme/useLegacyColors';
import type { StackNavigatorParamList } from '../types';
import { channelMessageActions } from '../utils/messageActions.tsx';
Expand Down Expand Up @@ -110,6 +111,7 @@ export const ThreadScreen: React.FC<ThreadScreenProps> = ({ navigation, route })
const { messageInputFloating, messageListImplementation } = useAppContext();

const { setInputRef } = useScreenReaderComposerFocusEffect();
useLeaveGoneChannel(channel);

const onPressMessage: NonNullable<React.ComponentProps<typeof Channel>['onPressMessage']> = (
payload,
Expand Down
26 changes: 13 additions & 13 deletions package/src/__tests__/offline-support/optimistic-update.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -629,9 +629,9 @@ export const OptimisticUpdates = () => {
<Chat client={chatClient} enableOfflineSupport>
<Channel channel={channel}>
<CallbackEffectWithContext
callback={async ({ editMessage }) => {
callback={async () => {
await flushMountEffects();
await editMessage({
await channel.messageOperations.update({
localMessage: {
...message,
cid: channel.cid,
Expand Down Expand Up @@ -696,10 +696,10 @@ export const OptimisticUpdates = () => {
// one.
>
<CallbackEffectWithContext
callback={async ({ editMessage }) => {
callback={async () => {
await flushMountEffects();
try {
await editMessage({
await channel.messageOperations.update({
localMessage: {
...message,
cid: channel.cid,
Expand Down Expand Up @@ -766,9 +766,9 @@ export const OptimisticUpdates = () => {
<Chat client={chatClient} enableOfflineSupport>
<Channel channel={channel}>
<CallbackEffectWithContext
callback={async ({ editMessage }) => {
callback={async () => {
await flushMountEffects();
await editMessage({
await channel.messageOperations.update({
localMessage: {
...message,
cid: channel.cid,
Expand Down Expand Up @@ -845,10 +845,10 @@ export const OptimisticUpdates = () => {
// Persist the optimistic attachment edit locally, then reject the request (offline).
>
<CallbackEffectWithContext
callback={async ({ editMessage }) => {
callback={async () => {
await flushMountEffects();
try {
await editMessage({
await channel.messageOperations.update({
localMessage: {
...message,
attachments: editedAttachments,
Expand Down Expand Up @@ -906,18 +906,18 @@ export const OptimisticUpdates = () => {
<Chat client={chatClient} enableOfflineSupport>
<Channel channel={channel}>
<CallbackEffectWithContext
callback={async ({ editMessage }) => {
callback={async () => {
// Same barrier every other "edit message" test uses. Without it the edit fires from
// this child mount effect BEFORE `Channel`'s own effect runs `channel.watch()`, whose
// seed then re-ingests the pre-edit copy from the mocked query response and overwrites
// the optimistic edit. Measured: the optimistic copy is correct at `editMessage`
// the optimistic edit. Measured: the optimistic copy is correct at `messageOperations.update`
// resolution and still correct a macrotask later, then the in-flight watch lands on
// top of it. That window is unreachable in production (see flushMountEffects).
await flushMountEffects();
// Go offline BEFORE editing so the default (no-handler) offline path runs.
markConnectionUnhealthy(chatClient);
try {
await editMessage({
await channel.messageOperations.update({
localMessage: { ...message, cid: channel.cid, text: editedText },
options: {},
});
Expand Down Expand Up @@ -1139,10 +1139,10 @@ export const OptimisticUpdates = () => {
<Chat client={chatClient}>
<Channel channel={channel}>
<CallbackEffectWithContext
callback={async ({ editMessage }) => {
callback={async () => {
await flushMountEffects();
try {
await editMessage({
await channel.messageOperations.update({
localMessage: { ...message, cid: channel.cid, text: editedText },
options: {},
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import React from 'react';
import React, { useCallback } from 'react';

import { Alert, Image, StyleSheet, Text, View } from 'react-native';

import { FileReference, isLocalImageAttachment, isLocalVideoAttachment } from 'stream-chat';
import {
AttachmentManagerState,
FileReference,
isLocalImageAttachment,
isLocalVideoAttachment,
LocalAttachment,
} from 'stream-chat';

import { isIosLimited, type PhotoContentItemType } from './shared';

import { useA11yLabel } from '../../../../a11y/hooks/useA11yLabel';
import { useAttachmentPickerContext } from '../../../../contexts';
import { useComponentsContext } from '../../../../contexts/componentsContext/ComponentsContext';
import { useAttachmentManagerState } from '../../../../contexts/messageInputContext/hooks/useAttachmentManagerState';
import { useMessageComposer } from '../../../../contexts/messageInputContext/hooks/useMessageComposer';
import { useMessageInputContext } from '../../../../contexts/messageInputContext/MessageInputContext';
import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
import { useTranslationContext } from '../../../../contexts/translationContext/TranslationContext';
import { useStateStore } from '../../../../hooks/useStateStore';
import { useWindowContentWidth } from '../../../../hooks/useWindowContentWidth';
import { NativeHandlers } from '../../../../native';
import { primitives } from '../../../../theme';
Expand All @@ -25,22 +31,35 @@ type AttachmentPickerItemType = {
asset: File;
};

/**
* Where this cell's asset sits among the composer's attachments, or -1. Selected per cell, so an
* upload progressing re-renders no cell and a selection change only the cells whose index moved.
*/
const useSelectedIndex = (isAsset: (attachment: LocalAttachment) => boolean) => {
const { attachmentManager } = useMessageComposer();
const selector = useCallback(
(state: AttachmentManagerState) => ({ selectedIndex: state.attachments.findIndex(isAsset) }),
[isAsset],
);
return useStateStore(attachmentManager.state, selector).selectedIndex;
};

const AttachmentVideo = (props: AttachmentPickerItemType) => {
const { asset } = props;
const { numberOfAttachmentPickerImageColumns } = useAttachmentPickerContext();
const { ImageOverlaySelectedComponent } = useComponentsContext();
const contentWidth = useWindowContentWidth();
const { t } = useTranslationContext();
const messageComposer = useMessageComposer();
const { attachmentManager } = useMessageComposer();
const { uploadNewFile } = useMessageInputContext();
const { attachmentManager } = messageComposer;
const { attachments, availableUploadSlots } = useAttachmentManagerState();

const selectedIndex = attachments.findIndex((attachment) =>
isLocalVideoAttachment(attachment)
? (attachment.localMetadata.file as FileReference).uri === asset.uri
: false,
const isAsset = useCallback(
(attachment: LocalAttachment) =>
isLocalVideoAttachment(attachment)
? (attachment.localMetadata.file as FileReference).uri === asset.uri
: false,
[asset.uri],
);
const selectedIndex = useSelectedIndex(isAsset);

const {
theme: {
Expand All @@ -61,12 +80,12 @@ const AttachmentVideo = (props: AttachmentPickerItemType) => {

const onPressVideo = async () => {
if (selected) {
const attachment = attachments[selectedIndex];
const attachment = attachmentManager.attachments.find(isAsset);
if (attachment) {
attachmentManager.removeAttachments([attachment.localMetadata.id]);
}
} else {
if (!availableUploadSlots) {
if (!attachmentManager.availableUploadSlots) {
Alert.alert(t('attachmentPicker.maxFiles.error', 'Maximum number of files reached'));
return;
}
Expand Down Expand Up @@ -112,12 +131,15 @@ const AttachmentImage = (props: AttachmentPickerItemType) => {
const contentWidth = useWindowContentWidth();
const { t } = useTranslationContext();
const { uploadNewFile } = useMessageInputContext();
const messageComposer = useMessageComposer();
const { attachmentManager } = messageComposer;
const { attachments, availableUploadSlots } = useAttachmentManagerState();
const selectedIndex = attachments.findIndex((attachment) =>
isLocalImageAttachment(attachment) ? attachment.localMetadata.previewUri === asset.uri : false,
const { attachmentManager } = useMessageComposer();
const isAsset = useCallback(
(attachment: LocalAttachment) =>
isLocalImageAttachment(attachment)
? attachment.localMetadata.previewUri === asset.uri
: false,
[asset.uri],
);
const selectedIndex = useSelectedIndex(isAsset);

const size = contentWidth / (numberOfAttachmentPickerImageColumns || 3) - 2;
const selected = selectedIndex !== -1;
Expand All @@ -131,12 +153,12 @@ const AttachmentImage = (props: AttachmentPickerItemType) => {

const onPressImage = async () => {
if (selected) {
const attachment = attachments[selectedIndex];
const attachment = attachmentManager.attachments.find(isAsset);
if (attachment) {
await attachmentManager.removeAttachments([attachment.localMetadata.id]);
}
} else {
if (!availableUploadSlots) {
if (!attachmentManager.availableUploadSlots) {
Alert.alert(t('attachmentPicker.maxFiles.error', 'Maximum number of files reached'));
return;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React, { Profiler } from 'react';

import { act, cleanup, render } from '@testing-library/react-native';
import type { Channel, LocalImageAttachment, StreamChat } from 'stream-chat';

import { MessageComposerProvider } from '../../../../../contexts/messageComposerContext/MessageComposerContext';
import { initiateClientWithChannels } from '../../../../../mock-builders/api/initiateClientWithChannels';
import { Chat } from '../../../../Chat/Chat';
import { renderAttachmentPickerItem } from '../AttachmentPickerItem';

const uploadingImage = (uploadProgress: number) =>
({
localMetadata: {
file: { name: 'photo.jpg', type: 'image/jpeg', uri: 'file://photo.jpg' },
id: 'photo',
previewUri: 'file://photo.jpg',
uploadProgress,
uploadState: 'uploading',
},
type: 'image',
}) as unknown as LocalImageAttachment;

describe('AttachmentPickerItem', () => {
let channel: Channel;
let chatClient: StreamChat;

beforeEach(async () => {
const { client, channels } = await initiateClientWithChannels();
channel = channels[0];
chatClient = client;
});

afterEach(cleanup);

it('does not re-render while its selected image uploads', () => {
const { attachmentManager } = channel.messageComposer;
act(() => attachmentManager.upsertAttachments([uploadingImage(10)]));
let renders = 0;

render(
<Chat client={chatClient}>
<MessageComposerProvider value={{ channel, threadInstance: undefined }}>
<Profiler id='cell' onRender={() => (renders += 1)}>
{renderAttachmentPickerItem({
item: { name: 'photo.jpg', size: 1024, type: 'image/jpeg', uri: 'file://photo.jpg' },
})}
</Profiler>
</MessageComposerProvider>
</Chat>,
);
const rendersBefore = renders;

// What each upload progress tick publishes: a new attachments array, the cell's index unchanged.
const progress = (uploadProgress: number) =>
attachmentManager.state.partialNext({ attachments: [uploadingImage(uploadProgress)] });
act(() => progress(50));
act(() => progress(90));

// Guard against a vacuous pass: the progress really was written.
expect(attachmentManager.attachments[0].localMetadata.uploadProgress).toBe(90);
expect(renders).toBe(rendersBefore);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { useComponentsContext } from '../../../contexts/componentsContext/ComponentsContext';
import { useTheme } from '../../../contexts/themeContext/ThemeContext';
import { useAttachmentPickerState, useStableCallback } from '../../../hooks';
import { useStateStore } from '../../../hooks/useStateStore';
import { primitives } from '../../../theme';
import { CommandSuggestionItem } from '../../AutoCompleteInput/AutoCompleteSuggestionItem';

Expand Down Expand Up @@ -124,13 +125,13 @@ const useCommandPickerStyle = () => {

export const AttachmentCommandPicker = () => {
const { t } = useTranslationContext();
const messageComposer = useMessageComposer();
const [commands] = useState(() => {
const commandsSearchSource = new CommandSearchSource(messageComposer.channel);
const result = commandsSearchSource.query('');

return result.items;
});
const { channel } = useMessageComposer();
const commandSearchSource = useMemo(() => new CommandSearchSource(channel), [channel]);
const commandsSelector = useCallback(
() => ({ commands: commandSearchSource.query('').items }),
[commandSearchSource],
);
const { commands } = useStateStore(channel.configState, commandsSelector);
const styles = useCommandPickerStyle();

return (
Expand Down
Loading
Loading