Skip to content
Open
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
12 changes: 11 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,17 @@ yarn workspace sampleapp android
└─ <Thread>
```

`<Chat>` is the entry point. It sets SDK metadata on the `stream-chat` client (identifier, device info), disables the JS client's `recoverStateOnReconnect` (the SDK handles recovery itself), registers subscriptions for threads/polls/reminders (cleaned up on unmount), initializes `OfflineDB` when `enableOfflineSupport` is set, and wraps children in `ChatProvider` → `TranslationProvider` → `ThemeProvider` → `ChannelsStateProvider`.
`<Chat>` is the entry point. It sets SDK metadata on the `stream-chat` client (identifier, device info), registers subscriptions for threads/polls/reminders (cleaned up on unmount), initializes `OfflineDB` when `enableOfflineSupport` is set, and wraps children in `ChatProvider` → `TranslationProvider` → `ThemeProvider`.

**Reconnect recovery is owned by the JS client, not this SDK.** `client.connectionRecovery` re-runs
each channel list's own first-page query and reloads whichever channels are `active` (`<Channel>`
marks its channel active on mount), then dispatches `connection.recovered`. So do not add a
`connection.changed` listener that re-queries a list or re-watches a channel — that duplicates it.
`recoverStateOnReconnect` is left at its default `true`; it used to be switched off here because the
client's old recovery was a single 30-channel query that ignored the lists' own filters. Two things
deliberately stay UI-side: mark-read after the reload (`<Channel>` listens for
`connection.recovered`), and the open thread's reply refresh (`resyncThread` in `Channel.tsx`, since
nothing in the client recovers an open thread's replies yet).

### Context three-layer pattern

Expand Down
273 changes: 269 additions & 4 deletions ai-docs/ai-migration-v9-to-v10.md

Large diffs are not rendered by default.

153 changes: 35 additions & 118 deletions package/src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ import React, { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useS
import { StyleSheet, Text, View } from 'react-native';

import {
Channel as ChannelType,
ChannelConfig,
EventHandler,
LocalMessage,
MessageComposerConfig,
MessageResponse,
SendMessageAPIResponse,
SendMessageOptions,
Event as StreamEvent,
Expand Down Expand Up @@ -84,12 +82,7 @@ import { primitives } from '../../theme';
import { FileTypes } from '../../types/types';
import { compressedImageURI } from '../../utils/compressImage';
import { patchMessageTextCommand } from '../../utils/patchMessageTextCommand';
import {
getFileNameFromPath,
isLocalUrl,
MessageStatusTypes,
ReactionData,
} from '../../utils/utils';
import { getFileNameFromPath, isLocalUrl, ReactionData } from '../../utils/utils';
import { NotificationAnnouncer } from '../Accessibility/NotificationAnnouncer';
import { AttachmentPicker } from '../AttachmentPicker/AttachmentPicker';
import type { KeyboardCompatibleViewProps } from '../KeyboardCompatibleView/KeyboardCompatibleView';
Expand Down Expand Up @@ -350,6 +343,10 @@ const availableCommandsSelector = (state: ChannelConfig) => ({
availableCommands: state.availableCommands,
});

const loadErrorSelector = (state: { lastLoadError?: Error }) => ({
lastLoadError: state.lastLoadError,
});

const messageFocusSignalSelector = (state: { signal: { messageId?: string } | null }) => ({
highlightedMessageId: state.signal?.messageId,
});
Expand Down Expand Up @@ -470,7 +467,6 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =

const styles = useStyles();
const [deleted, setDeleted] = useState<boolean>(false);
const [error, setError] = useState<Error | boolean>(false);
const lastReadRef = useRef<Date | undefined>(undefined);
// The active thread is fully prop-driven: derive it synchronously during render so the reply
// data is present on the first frame (no setState round-trip / one-frame gap). Opening a thread
Expand All @@ -494,13 +490,16 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
const [messageInputHeightStore] = useState(() => new MessageInputHeightStore());
const { bottomSheetRef, closePicker, openPicker } = useAttachmentPickerBottomSheet();

const syncingChannelRef = useRef(false);

const { highlightedMessageId } = useStateStore(
(threadInstance ?? channel).messagePaginator.messageFocusSignal,
messageFocusSignalSelector,
);

const { lastLoadError } = useStateStore(channel?.state, loadErrorSelector) ?? {};
const { lastLoadError: threadLoadError } =
useStateStore(threadInstance?.state, loadErrorSelector) ?? {};
const error = lastLoadError ?? threadLoadError;

/**
* This ref keeps track of message IDs which have already been optimistically updated.
* We need it to make sure we don't react on message.new/notification.message_new events
Expand Down Expand Up @@ -591,7 +590,6 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
await channel?.watch();
} catch (err) {
console.warn('Channel watch request failed with error:', err);
setError(true);
errored = true;
channel.offlineMode = true;
}
Expand Down Expand Up @@ -684,99 +682,28 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
// instance via useMarkRead(channel). Channel still needs it internally (mark-read-on-mount + resync).
const markRead = useMarkRead(channel);

const resyncChannel = useStableCallback(async () => {
if (!channel || syncingChannelRef.current || (!channel.initialized && !channel.offlineMode)) {
// Mark-read after the LLC's reconnect reload. `connection.recovered` is dispatched by
// `client.connectionRecovery` once that reload has landed, so `hasMoreHead` read here reflects the
// refreshed window — which is why this cannot hang off `connection.changed`. Only the reload moved
// into the LLC; whether a caught-up channel is marked read stays a UI decision (see `useMarkRead`).
useEffect(() => {
if (!shouldSyncChannel) {
return;
}
syncingChannelRef.current = true;
setError(false);

const parseMessage = (message: LocalMessage) =>
({
...message,
created_at: message.created_at.toString(),
pinned_at: message.pinned_at?.toString(),
updated_at: message.updated_at?.toString(),
}) as unknown as MessageResponse;

const getRecoverableFailedMessages = (messages: LocalMessage[] = []) =>
messages
.filter(
(message) =>
message.status === MessageStatusTypes.FAILED &&
!(message.parent_id
? threadInstance?.messagePaginator.getItem(message.id)
: channel.messagePaginator.getItem(message.id)),
)
.map(parseMessage);

try {
if (!thread) {
// The LLC owns the reconnect refresh now: channel.reload() re-watches, folds the newest page,
// and reconciles messages hard-deleted while offline — capturing the pre-fetch snapshot + the
// requested limit itself, so this no longer passes them (see Channel.reload /
// MessagePaginator.mergeNewestPage).
await channel.reload();
// Only mark read when the refreshed window is at the newest (hasMoreHead false); if the user
// has paginated up into older history, leave their read state untouched.
const atLatest = !channel.messagePaginator.hasMoreHead;
if (atLatest) {
await markRead();
}
} else if (threadInstance) {
await threadInstance.reload();

const currentThreadMessages =
threadInstance.messagePaginator.state.getLatestValue().items ?? [];
const failedThreadMessages = getRecoverableFailedMessages(currentThreadMessages);
if (failedThreadMessages.length) {
failedThreadMessages.forEach((m) =>
threadInstance.messagePaginator.ingestItem(channel.state.formatMessage(m)),
);
}
}
} catch (err) {
if (err instanceof Error) {
setError(err);
} else {
setError(true);
}
}

syncingChannelRef.current = false;
});

// resync channel is added to ref so that it can be used in useEffect without adding it as a dependency
const resyncChannelRef = useRef(resyncChannel);
resyncChannelRef.current = resyncChannel;

useEffect(() => {
const connectionChangedHandler = () => {
if (shouldSyncChannel) {
resyncChannelRef.current();
// Mark read has to wait for `connection.recovered`, as it is dispatched once the reloads have
// landed, so `hasMoreHead` read here reflects the refreshed window. Channel view only, and only
// when that window is at the newest, only if the user has paginated up into older history so leave
// their read state alone.
const { unsubscribe } = client.on('connection.recovered', () => {
if (thread || channel.messagePaginator.hasMoreHead) {
return;
}
};
let connectionChangedSubscription: ReturnType<ChannelType['on']>;
markRead();
});

if (enableOfflineSupport && client.offlineDb) {
connectionChangedSubscription = client.offlineDb.syncManager.onSyncStatusChange(
(statusChanged) => {
if (statusChanged) {
connectionChangedHandler();
}
},
);
} else {
connectionChangedSubscription = client.on('connection.changed', (event) => {
if (event.online) {
connectionChangedHandler();
}
});
}
return () => {
connectionChangedSubscription.unsubscribe();
};
}, [enableOfflineSupport, client, shouldSyncChannel]);
return unsubscribe;
}, [channel, client, markRead, shouldSyncChannel, thread]);

/**
* Channel configs for use in disabling local functionality.
Expand Down Expand Up @@ -807,21 +734,10 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
}
try {
if (thread) {
try {
// jumpToMessage loads the message range into thread.messagePaginator (which backs the
// reply list) and emits the focus signal driving the thread-aware highlight + scroll.
// The reply-list loading spinner is driven off the paginator's own isLoading flag.
await threadInstance?.messagePaginator?.jumpToMessage(messageIdToLoadAround, {
focusReason: 'jump-to-message',
focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
});
} catch (err) {
if (err instanceof Error) {
setError(err);
} else {
setError(true);
}
}
await threadInstance?.messagePaginator?.jumpToMessage(messageIdToLoadAround, {
focusReason: 'jump-to-message',
focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION,
});
} else {
await loadChannelAroundMessageFn({
messageId: messageIdToLoadAround,
Expand All @@ -839,10 +755,11 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
// (see the useChannelRequestHandlers call below) so it runs INSIDE the stream-chat send pipeline —
// after the LLC's optimistic ingest (message already shows pending), before the POST — awaiting
// `client.uploadManager` to finish the in-flight uploads and swapping local preview URLs for the
// returned CDN URLs. This deliberately stays RN-side: native image compression (`compressedImageURI`)
// returned CDN URLs. It lives here for now because native image compression (`compressedImageURI`)
// and a custom uploader registered through `client.config` must remain reachable, and the
// sendMessageRequest seam lets it run in the right place without a pre-ingest or any LLC change. It is NOT slated to move
// into the LLC — this handler is its intended home.
// sendMessageRequest seam lets it run in the right place without a pre-ingest or any LLC change.
// It IS slated to move into the LLC, just not yet — and that move is what lets the
// `doSendMessageRequest` prop and its wrapper in `useChannelRequestHandlers` go.
const uploadPendingAttachments = useStableCallback(async (message: LocalMessage) => {
if (!message.attachments?.length || !channel?.cid) {
return;
Expand Down
Loading
Loading