diff --git a/ai-docs/ai-migration.md b/ai-docs/ai-migration.md new file mode 100644 index 0000000000..fe270625da --- /dev/null +++ b/ai-docs/ai-migration.md @@ -0,0 +1,708 @@ +# stream-chat-react-native v8 → v9 — Agent Migration Guide + +> Machine-oriented migration reference for AI coding agents. The prose-heavy +> human version lives at +> https://getstream.io/chat/docs/sdk/react-native/basics/upgrading-from-v8/ +> — do not load it for agent-driven migrations; this file supersedes it. + +## 0. For the agent (read first) + +1. **Your training data predates v9.** Do not rely on memory for v9 symbols, prop + shapes, or export paths. Always verify against the installed SDK source. +2. **Package name in `node_modules/` is `stream-chat-react-native-core`.** The + published packages are: + + - `stream-chat-react-native-core` — the actual SDK; ships `src/` + `lib/`. + - `stream-chat-react-native` — bare-RN wrapper; peer-deps on core. + - `stream-chat-expo` — Expo wrapper; peer-deps on core. + + Every source path below is rooted at + `node_modules/stream-chat-react-native-core/src/`. Do **not** look under + `node_modules/stream-chat-react-native/src/` for SDK source — that's the + wrapper, not the SDK. + +3. **Order of work.** Do §2 (prereqs) → §3 (big-3 structural moves) → §4/§5 + (leaf renames + replacements) → §6 (behavior changes) → §9 (verify). + Many leaf renames disappear automatically once §3.1 (`WithComponents`) + lands, so doing §3 first saves churn. +4. **Detect before editing.** Run §1 first. Skip any section whose patterns + don't match the customer codebase. +5. **When uncertain, read source.** §8 lists the files to consult. If a symbol + is not in this guide, read the public barrel + (`node_modules/stream-chat-react-native-core/src/index.ts`) — do not guess. + +## 1. Detection (run first) + +Run each ripgrep against the customer's app source directory (replace `src/` +with their source root). Zero hits = skip the corresponding section. + +```bash +# §3.1 — WithComponents migration needed? +rg '<(Channel|ChannelList|Chat|Thread)\s[^>]*\b([A-Z]\w+)=\{' src/ + +# §3.2 — five breaking component renames +rg '\b(MessageSimple|MessageAvatar|ChannelListMessenger|ChannelPreviewMessenger)\b' src/ +# MessageInput is special: the component was renamed, but MessageInputContext, +# useMessageInputContext, and the MessageInput*View helpers were NOT. Filter +# the noise so only real component usages remain: +rg '\bMessageInput\b' src/ | rg -v 'MessageInputContext|MessageInputHeaderView|MessageInputFooterView|MessageInputLeadingView|MessageInputTrailingView|useMessageInputContext|MessageInputContextValue' + +# §3.3 — inverted audio semantics +rg '\basyncMessagesMultiSendEnabled\b' src/ + +# §4.2 — hook renames +rg '\buseAudioController\b' src/ +# useMutedUsers is scope-split: only rename Chat-level uses (ones that take a +# StreamChat client arg). Review each match against §4.2 before editing. +rg '\buseMutedUsers\b' src/ + +# §5 — removed components with replacements +rg '\b(AttachmentActions|AttachmentUploadProgressIndicator|Card|CardCover|CardFooter|CardHeader|ImageReloadIndicator|MessagePreview|imageGalleryCustomComponents)\b' src/ +rg '\b(CameraSelectorIcon|FileSelectorIcon|ImageSelectorIcon|VideoRecorderSelectorIcon)\b' src/ + +# §6 — behavior / contract changes +rg '\b(latestMessagePreview|deletedMessagesVisibilityType|messageContentWidth|setMessageContentWidth|legacyImageViewerSwipeBehaviour)\b' src/ +# Custom MessageMenu override is silently dead in v9 — flag any. +rg '\boverrides=\{[^}]*\bMessageMenu\b' src/ +rg '\bMessageMenu=\{' src/ + +# Theme — v8 namespace names that moved +rg "\b(messageInput|messageSimple|channelListMessenger)\s*:" src/ + +# Semantic token renames +rg '\b(backgroundCoreSurface|badgeTextInverse|textInverse|backgroundCoreElevation4)\b' src/ + +# Icon removals / renames +rg '\b(CircleStop|Refresh|Close|User|MessageIcon|ArrowRight|ArrowLeft|Attach|ChatIcon|CheckSend|Folder|MenuPointVertical|Notification|PinHeader|LOLReaction|LoveReaction|ThumbsUpReaction|ThumbsDownReaction|WutReaction)\b' src/ +``` + +## 2. Prerequisites (hard blockers — fail fast) + +- **React Native New Architecture is required.** RN 0.76+ or an Expo SDK that + defaults to the new arch. If the app is on the old architecture, stop and + tell the user to migrate the arch first. +- **New required peer dependency.** Run `yarn add react-native-teleport` (or + the npm equivalent). Minimum version `0.5.4`. +- **Keyboard handling cleanup.** Remove every one of these v8 workarounds + before running the app: + - Negative `keyboardVerticalOffset` values (e.g. `-300`) on any screen that + renders `MessageComposer`. Set it to the navigation header height instead + (same value on iOS and Android). + - `SafeAreaView` wrappers placed around `MessageComposer` purely to fix + bottom spacing. `MessageComposer` handles its own safe-area in v9. + - Manual Android IME padding hacks that pushed the composer above the + keyboard. + +## 3. The big 3 structural migrations (do these first, in order) + +### 3.1 Component override props → `` + +Every `ComponentType` prop on `Channel`, `ChannelList`, `Chat`, and `Thread` +was removed. They are now provided via a single `` wrapper and +read with `useComponentsContext()`. + +**Before (v8):** + +```tsx + + + + + + + + +``` + +**After (v9):** + +```tsx + + + + + + + + + + +``` + +Key rules: + +- `WithComponents` nests. Inner overrides merge over outer ones (closest wins). +- Two prop keys were renamed when moving into `overrides`: + - `Chat` prop `LoadingIndicator` -> overrides key `ChatLoadingIndicator` + - `Thread` prop `MessageComposer` -> overrides key `ThreadMessageComposer` +- If a custom component used to read other components from a specific context, + switch to `useComponentsContext()`: + + ```tsx + // v8 + const { MessageItemView } = useMessagesContext(); + const { SendButton } = useMessageInputContext(); + const { Preview } = useChannelsContext(); + + // v9 + const { MessageItemView, SendButton, Preview } = useComponentsContext(); + ``` + +- For the full list of overridable keys, read + `node_modules/stream-chat-react-native-core/src/contexts/componentsContext/defaultComponents.ts`. + The `DEFAULT_COMPONENTS` map is the source of truth; `ComponentOverrides` is + derived from it. **Do not use a hardcoded list from your training data.** + +### 3.2 Five breaking component renames + +Pure symbol renames. Apply with find-and-replace (whole-word, case-sensitive). +Each rename also carries a prop-type rename and, in some cases, a theme +namespace rename. + +Components + props: + +- `MessageSimple` -> `MessageItemView` + - `MessageSimpleProps` -> `MessageItemViewProps` + - `MessageSimplePropsWithContext` -> `MessageItemViewPropsWithContext` + - Theme namespace: `messageSimple` -> `messageItemView` + - Test ID: `message-simple-wrapper` -> `message-item-view-wrapper` +- `MessageAvatar` -> `MessageAuthor` + - `MessageAvatarProps` -> `MessageAuthorProps` + - `MessageAvatarPropsWithContext` -> `MessageAuthorPropsWithContext` + - Theme sub-key: `messageItemView.avatarWrapper` -> `messageItemView.authorWrapper` + - Test ID: `message-avatar` -> `message-author` +- `MessageInput` -> `MessageComposer` + - `MessageInputProps` -> `MessageComposerProps` + - Theme namespace: `messageInput` -> `messageComposer` + - `additionalMessageInputProps` -> `additionalMessageComposerProps` + - `Thread` prop `MessageInput` -> overrides key `ThreadMessageComposer` (see §3.1) +- `ChannelListMessenger` -> `ChannelListView` + - `ChannelListMessengerProps` -> `ChannelListViewProps` + - Theme namespace: `channelListMessenger` -> `channelListView` + - Test ID: `channel-list-messenger` -> `channel-list-view` +- `ChannelPreviewMessenger` -> `ChannelPreviewView` + - `ChannelPreviewMessengerProps` -> `ChannelPreviewViewProps` + - Theme namespace for `channelPreview` is **unchanged** (stays `channelPreview`). + - Test ID `channel-preview-button` is **unchanged**. + - `` becomes the `ChannelPreview` + overrides key on ``. + +**Do NOT rename these (common over-migration traps):** + +- `MessageInputContext`, `useMessageInputContext`, `MessageInputContextValue` — + the **component** was renamed, the context was not. +- The `MessageInput/` source folder name. Stays as `MessageInput/`. +- `MessageInputHeaderView`, `MessageInputFooterView`, `MessageInputLeadingView`, + `MessageInputTrailingView` — these helpers keep their names. +- `channelPreview` theme namespace and `channel-preview-button` test ID. +- `MessagePinnedHeader` — still exists; just now rendered inside the + consolidated `MessageHeader` flow. + +### 3.3 Audio recording: semantics inverted + +`asyncMessagesMultiSendEnabled` was removed and replaced with +`audioRecordingSendOnComplete`. This is **not a rename** — the boolean meaning +is inverted **and** the default changed. + +Rewrite rule: + +- v8 `asyncMessagesMultiSendEnabled={true}` ≡ v9 `audioRecordingSendOnComplete={false}` +- v8 `asyncMessagesMultiSendEnabled={false}` ≡ v9 `audioRecordingSendOnComplete={true}` +- **Default behavior flipped.** Both defaults are the literal boolean `true`, + but the semantics are inverted — so the runtime behavior changed: + - v8 default `asyncMessagesMultiSendEnabled=true` → recordings **stayed** in + the composer. + - v9 default `audioRecordingSendOnComplete=true` → recordings **send + immediately**. + - If v8 code omitted the prop (relying on the default), pass + `audioRecordingSendOnComplete={false}` in v9 to preserve the old UX. + +Semantics in plain terms: + +- v9 `audioRecordingSendOnComplete={true}` — upload completes → send immediately. +- v9 `audioRecordingSendOnComplete={false}` — upload completes → stays in composer + so the user can add text or more attachments before sending. + +Apply on `Channel`, `MessageComposer`, and any direct +`useMessageInputContext()` consumer. Also update direct +`uploadVoiceRecording(sendOnComplete)` callsites: the boolean now carries +`sendOnComplete` semantics, not the old `multiSendEnabled` semantics. + +## 4. Rename lists (bullets, not tables) + +### 4.1 Components (rename or removed-with-1:1-replacement) + +- `MessageSimple` -> `MessageItemView` +- `MessageAvatar` -> `MessageAuthor` +- `MessageInput` -> `MessageComposer` +- `ChannelListMessenger` -> `ChannelListView` +- `ChannelPreviewMessenger` -> `ChannelPreviewView` +- `AudioAttachment` — **not renamed.** Source folder moved from + `Attachment/AudioAttachment/` to `Attachment/Audio/`, but the public export + name and the `ComponentOverrides` key are both still `AudioAttachment`. + Do not rename import sites or override keys. +- `Card` -> `UrlPreview` (new props type `URLPreviewProps`; see §5) +- `Avatar` (numeric `size`) -> `Avatar` (string enum `'xs'..'2xl'`); prop `image` -> `imageUrl`; `name` -> `placeholder`; `online`/`presenceIndicator` -> separate `OnlineIndicator` component +- `GroupAvatar` -> `AvatarGroup` / `AvatarStack` +- `CameraSelectorIcon` -> `AttachmentTypePickerButton` +- `FileSelectorIcon` -> `AttachmentTypePickerButton` +- `ImageSelectorIcon` -> `AttachmentTypePickerButton` +- `VideoRecorderSelectorIcon` -> `AttachmentTypePickerButton` +- `CreatePollIcon` -> (no replacement; poll creation is internal now) +- `MessageMenu` -> split into `MessageReactionPicker` + `MessageActionList` + + `MessageActionListItem` + `MessageUserReactions` (see §6) +- `MessageMenuProps` -> no direct replacement (customize the four components + above individually) +- `ChannelAvatar` (legacy) -> `ChannelAvatar` from `ui/Avatar/` (different API; `size='xl'` default) +- `PreviewAvatar` -> `ChannelAvatar` with `size='xl'` + +### 4.2 Hooks + +- `useAudioController` -> `useAudioRecorder` +- `useMutedUsers` (Chat-level, called with a `StreamChat` client) -> + `useClientMutedUsers`. **Scope-split rename.** A separate `useMutedUsers` + from `ChannelList/hooks/` still exists in v9 and was **not** renamed — if + the call site uses it inside a channel-list context (no `client` arg), + leave it alone. To decide: read the import path or the argument. If it + takes a `StreamChat` client, rename. Otherwise keep. +- `useAudioPlayerControl` -> `useAudioPlayer` (also rename type + `UseAudioPlayerControlProps` -> `UseAudioPlayerProps`). The v9 barrel + exports `useAudioPlayer`; `useAudioPlayerControl` is removed. +- New hooks that replace removed components/values: + - `useMessageDeliveryStatus`, `useGroupedAttachments`, `useMessagePreviewIcon`, + `useMessagePreviewText` (replace the removed `MessagePreview` component) + - `useMessageComposer` (direct access to composer state — replaces reading + from `useMessageInputContext()` for most composer needs) + - `useAttachmentPickerState` (replaces context-based `selectedPicker` / + `toggleAttachmentPicker`) + - `useImageGalleryVideoPlayer`, `useHasOwnReaction`, + `useChannelPreviewDraftMessage`, `useChannelPreviewPollLabel`, + `useChannelTypingState` + +### 4.3 Props (breaking only) + +On `Channel` / `MessageComposer` / `MessageInputContext`: + +- `asyncMessagesMultiSendEnabled` -> `audioRecordingSendOnComplete` (SEMANTICS INVERTED — see §3.3) +- `toggleAttachmentPicker` -> `openAttachmentPicker` / `closeAttachmentPicker` +- `selectedPicker` -> `useAttachmentPickerState()` hook +- `cooldownEndsAt` -> (removed; cooldown managed internally by `OutputButtons`) +- `getMessagesGroupStyles` -> (removed; grouping handled internally) +- `legacyImageViewerSwipeBehaviour` -> (removed; legacy viewer gone) +- `deletedMessagesVisibilityType` -> (removed; deleted messages always shown — see §6) +- `isAttachmentEqual` -> (removed) +- `additionalMessageInputProps` -> `additionalMessageComposerProps` + +On `ChannelPreviewContext` (and `ChannelList`): + +- `latestMessagePreview` (pre-formatted) -> `lastMessage` (raw). Text access + goes from `latestMessagePreview.messageObject.text` to `lastMessage.text`. +- `` -> `` + (the prop is on `ChannelList`, but migrates to the `ChannelPreview` key.) + +On `MessageList`: + +- `isListActive` -> (removed) +- `setMessages` -> (removed; use centralized state store) +- `channelUnreadState` -> `channelUnreadStateStore` +- `additionalFlatListProps` type: `FlatListProps` -> `FlatListProps` +- `setFlatListRef` type: `FlatListType` -> `FlatListType` + +On `OverlayProvider`: + +- `imageGalleryCustomComponents` (nested) -> flat keys on `` (see §5) +- `MessageOverlayBackground`, `ImageGalleryHeader`, `ImageGalleryFooter`, `ImageGalleryGrid`, `ImageGalleryVideoControls` -> moved from `OverlayProvider` props to `` overrides +- `imageGalleryGridHandleHeight` -> (removed) +- `imageGalleryGridSnapPoints` -> (removed) + +On `AudioAttachment`: + +- `onLoad` / `onPlayPause` / `onProgress` -> `useAudioPlayer` hook +- `titleMaxLength` -> `showTitle` boolean + +### 4.4 Theme namespaces + +- `messageInput` -> `messageComposer` +- `messageSimple` -> `messageItemView` +- `messageItemView.avatarWrapper` -> `messageItemView.authorWrapper` +- `channelListMessenger` -> `channelListView` +- `messageItemView.card` — still exists but restructured. Inner keys + `authorName`, `authorNameContainer`, `authorNameFooter`, `noURI`, + `playButtonStyle`, `playIcon` were removed; `linkPreview` + `linkPreviewText` + were added. A sibling key `messageItemView.compactUrlPreview` was added for + the new compact URL preview. Diff the full shape against `theme.ts`. +- `channelPreview` namespace: **unchanged** (internal structure changed heavily; if + the app had custom `channelPreview` overrides, diff against + `node_modules/stream-chat-react-native-core/src/contexts/themeContext/utils/theme.ts`) + +For every other theme change: read the current `Theme` type at +`node_modules/stream-chat-react-native-core/src/contexts/themeContext/utils/theme.ts` +and let TypeScript report the mismatches. Do not try to remember the full +sub-key restructuring. + +### 4.5 Icons (breaking only) + +Renamed (public): + +- `CircleStop` -> `Stop` +- `Refresh` -> `Reload` + +Removed with no SDK replacement (supply your own): + +- Navigation / UI: `ArrowRight`, `ArrowLeft`, `Close`, `User`, `MessageIcon`, + `Attach`, `Back`, `AtMentions`, `ChatIcon`, `CheckSend`, `CircleClose`, + `CirclePlus`, `CircleRight`, `DownloadArrow`, `DownloadCloud`, `DragHandle`, + `Error`, `Eye`, `Folder`, `GenericFile`, `GiphyLightning`, `Grid`, `Group`, + `Logo`, `MailOpen`, `MenuPointVertical`, `MessageBubble`, `Notification`, + `PinHeader`, `SendCheck`, `SendPoll`, `SendUp`, `ShareRightArrow`, + `UserAdmin`, `UserMinus` +- Reactions: `LOLReaction`, `LoveReaction`, `ThumbsDownReaction`, + `ThumbsUpReaction`, `WutReaction` +- File-type icons: `CSV`, `DOCX`, `HTML`, `MD`, `ODT`, `PPT`, `PPTX`, `RAR`, + `RTF`, `SEVEN_Z`, `TAR`, `TXT`, `XLS`, `XLSX` + +Visually updated but keep the same import name (no code change needed; flag if +the app has pixel-precise snapshots): `Archive`, `ArrowUp`, `Audio`, `Camera`, +`Check`, `CheckAll`, `Copy`, `Delete`, `Down`, `Edit`, `Flag`, `GiphyIcon`, +`Imgur`, `Lightning`, `Link`, `Loading`, `Lock`, `MenuPointHorizontal`, `Mic`, +`Mute`, `PDF`, `Pause`, `Picture`, `Pin`, `Play`, `Recorder`, `Reload`, +`Resend`, `Search`, `SendRight`, `Share`, `Smile`, `Sound`, `ThreadReply`, +`Time`, `Unpin`, `UserAdd`, `UserDelete`, `Video`, `Warning`, `ZIP`. + +## 5. Removed-with-structural-replacement + +Not simple renames — require a code shape change. + +### 5.1 `AttachmentUploadProgressIndicator` → six granular indicators + +One component split into six. Pick the right one per upload type × state: + +- `FileUploadInProgressIndicator` +- `FileUploadRetryIndicator` +- `FileUploadNotSupportedIndicator` +- `ImageUploadInProgressIndicator` +- `ImageUploadRetryIndicator` +- `ImageUploadNotSupportedIndicator` + +Provide any custom ones as `` overrides using the matching key. + +### 5.2 `Card` / `CardCover` / `CardFooter` / `CardHeader` → `UrlPreview` + `URLPreviewCompact` + +- Old type `CardProps` -> new type `URLPreviewProps`. +- Choose rendering style via the new `urlPreviewType` prop on `Channel`: + `'full'` (default) or `'compact'`. +- Customize via ``. + +### 5.3 `AttachmentActions` → inline + +Removed with no standalone replacement — actions now render inline on +attachments. Delete any custom `AttachmentActions` override. + +### 5.4 `MessagePreview` → four hooks + +Component removed. Replace with composable hooks: + +- `useMessageDeliveryStatus` +- `useGroupedAttachments` +- `useMessagePreviewIcon` +- `useMessagePreviewText` + +### 5.5 `imageGalleryCustomComponents` → flat `WithComponents` keys + +- v8: nested object on `OverlayProvider` + (`imageGalleryCustomComponents={{ header: { Component: ... }, footer: {...}, grid: {...}, gridHandle: {...} }}`). +- v9: flat ``. + Note the v8 `gridHandle` has no replacement (use `ImageGalleryGrid` instead). + +### 5.6 Individual attachment-picker selector icons → `AttachmentTypePickerButton` + +`CameraSelectorIcon`, `FileSelectorIcon`, `ImageSelectorIcon`, and +`VideoRecorderSelectorIcon` are all replaced by the unified +`AttachmentTypePickerButton`. `AttachmentPickerBottomSheetHandle`, +`AttachmentPickerError`, and `AttachmentPickerErrorImage` are removed with no +replacement. + +## 6. Behavior changes (runtime semantics, not grep renames) + +- **`messageContentOrder` default swapped `'text'`/`'attachments'`.** If the v8 + app depended on text rendering before attachments, pass the old order + explicitly: + `messageContentOrder={['quoted_reply','gallery','files','poll','ai_text','text','attachments','location']}`. +- **`deletedMessagesVisibilityType` removed.** Deleted messages are always + shown now. The `'sender' | 'receiver' | 'never'` modes are gone; delete the + prop. +- **Swipe-to-reply boundary moved.** Was `MessageBubble`, now the full + `MessageItemView`. Gesture behavior (`messageSwipeToReplyHitSlop`, thresholds, + haptics, `MessageSwipeContent`, spring-back) is unchanged. +- **`messageContentWidth` / `setMessageContentWidth` removed.** Strip any + custom `MessageContent`, `MessageBubble`, or `ReactionListTop` that read or + called them (and the matching test mocks). +- **`MessagesContext` no longer carries any UI component keys.** The v9 + design is that all component overrides live in `ComponentsContext` (provided + via ``; see §3.1). If an app wraps its own `MessagesContext` + provider directly, stop passing `Message`, `MessageItemView`, `MessageHeader`, + `Attachment`, etc. into the value — those fields are gone. Move them to + ``. + - **Known intentional exception:** `FlatList` (typed as + `typeof NativeHandlers.FlatList | undefined`) is still a key on + `MessagesContextValue`. It's not a user-overridable `ComponentType`; + it's injected by `registerNativeHandlers()` at module load from the + native-package or expo-package. Don't flag it as a leak; don't try to + route it through ``. +- **Audio recording defaults changed.** See §3.3. +- **Semantic token renames:** + - `backgroundCoreSurface` -> `backgroundCoreSurfaceDefault` + - `badgeTextInverse` -> `badgeTextOnInverse` + - `textInverse` -> `textOnInverse` + - `backgroundCoreElevation4` -> **removed, no replacement.** Remove any + dependency on it. +- **Reaction-list default changed.** `reactionListType` default is now + `'clustered'`. If the app relied on the v8 segmented style, set + `reactionListType='default'` explicitly on `Channel`. +- **`MessageMenu` component removed.** The v9 overlay path does not render + `MessageMenu`; the component and its `MessageMenuProps` type are removed + from the public barrel. Any v8 custom `MessageMenu` override becomes a + type error on v9 — migrate that logic to the four components that actually + render in v9: `MessageReactionPicker`, `MessageActionList`, + `MessageActionListItem`, and `MessageUserReactions`. Provide them via + ``. +- **`MessageActionType` shape changed.** Two breaking changes for apps with + custom message actions: + 1. New required field `type: 'standard' | 'destructive'` on every + `MessageActionType` object. Destructive entries render in a visually + separated group in the action list. Add `type` to every entry in any + custom `messageActions` array. + 2. The `ActionType` union gained `'blockUser'`. If the app has an + exhaustive switch over `ActionType`, add a case for `'blockUser'` or + the TypeScript compiler will flag the switch as non-exhaustive. +- **Hook exports flattened.** Subpath imports like + `'stream-chat-react-native/ChannelPreview/hooks/useChannelPreviewDisplayName'` + no longer resolve. Import from the package root: + `import { useChannelPreviewDisplayName } from 'stream-chat-react-native'`. +- **Attachment-picker defaults.** `attachmentPickerBottomSheetHeight` is now + fixed pixels (`disableAttachmentPicker ? 72 : 333`), not viewport-relative. + `attachmentSelectionBarHeight` default is `72` (was `52`). + `numberOfAttachmentImagesToLoadPerCall` default is `25` (was `60`). + +## 7. Machine-readable rename block + +Parseable JSON for deterministic find-and-replace. Keys are v8 symbols; +values are v9 replacements. `null` = removed with no in-SDK replacement. + +```json +{ + "components": { + "MessageSimple": "MessageItemView", + "MessageAvatar": "MessageAuthor", + "MessageInput": "MessageComposer", + "ChannelListMessenger": "ChannelListView", + "ChannelPreviewMessenger": "ChannelPreviewView", + "Card": "UrlPreview", + "CardCover": "UrlPreview", + "CardFooter": "UrlPreview", + "CardHeader": "UrlPreview", + "GroupAvatar": "AvatarGroup", + "CameraSelectorIcon": "AttachmentTypePickerButton", + "FileSelectorIcon": "AttachmentTypePickerButton", + "ImageSelectorIcon": "AttachmentTypePickerButton", + "VideoRecorderSelectorIcon": "AttachmentTypePickerButton", + "PreviewAvatar": "ChannelAvatar", + "AttachmentActions": null, + "ImageReloadIndicator": null, + "AttachmentPickerBottomSheetHandle": null, + "AttachmentPickerError": null, + "AttachmentPickerErrorImage": null, + "MessagePreview": null, + "MessageEditedTimestamp": null, + "MessageMenu": "MessageReactionPicker + MessageActionList + MessageActionListItem + MessageUserReactions", + "CreatePollIcon": null, + "CommandsButton": null, + "MoreOptionsButton": null, + "InputEditingStateHeader": "MessageInputHeaderView", + "InputReplyStateHeader": "MessageInputHeaderView", + "CommandInput": null + }, + "componentProps": { + "MessageSimpleProps": "MessageItemViewProps", + "MessageAvatarProps": "MessageAuthorProps", + "MessageInputProps": "MessageComposerProps", + "ChannelListMessengerProps": "ChannelListViewProps", + "ChannelPreviewMessengerProps": "ChannelPreviewViewProps", + "CardProps": "URLPreviewProps", + "MessageMenuProps": null + }, + "hooks": { + "useAudioController": "useAudioRecorder", + "useAudioPlayerControl": "useAudioPlayer", + "useMutedUsers (Chat-level)": "useClientMutedUsers" + }, + "props": { + "asyncMessagesMultiSendEnabled": "audioRecordingSendOnComplete", + "toggleAttachmentPicker": "openAttachmentPicker|closeAttachmentPicker", + "additionalMessageInputProps": "additionalMessageComposerProps", + "latestMessagePreview": "lastMessage", + "imageGalleryCustomComponents": "WithComponents overrides", + "deletedMessagesVisibilityType": null, + "legacyImageViewerSwipeBehaviour": null, + "isAttachmentEqual": null, + "cooldownEndsAt": null, + "getMessagesGroupStyles": null, + "messageContentWidth": null, + "setMessageContentWidth": null, + "imageGalleryGridSnapPoints": null, + "imageGalleryGridHandleHeight": null, + "titleMaxLength": "showTitle", + "onLoad": "useAudioPlayer", + "onPlayPause": "useAudioPlayer", + "onProgress": "useAudioPlayer" + }, + "overridesKeyRenames": { + "LoadingIndicator (on Chat)": "ChatLoadingIndicator", + "MessageComposer (on Thread)": "ThreadMessageComposer" + }, + "themeNamespaces": { + "messageInput": "messageComposer", + "messageSimple": "messageItemView", + "channelListMessenger": "channelListView" + }, + "themeSubKeys": { + "messageItemView.avatarWrapper": "messageItemView.authorWrapper" + }, + "semanticTokens": { + "backgroundCoreSurface": "backgroundCoreSurfaceDefault", + "badgeTextInverse": "badgeTextOnInverse", + "textInverse": "textOnInverse", + "backgroundCoreElevation4": null + }, + "icons": { + "CircleStop": "Stop", + "Refresh": "Reload", + "ArrowRight": null, + "ArrowLeft": null, + "Close": null, + "User": null, + "MessageIcon": null, + "Attach": null, + "Back": null, + "ChatIcon": null, + "LOLReaction": null, + "LoveReaction": null, + "ThumbsUpReaction": null, + "ThumbsDownReaction": null, + "WutReaction": null, + "CSV": null, + "DOCX": null, + "HTML": null, + "MD": null, + "ODT": null, + "PPT": null, + "PPTX": null, + "RAR": null, + "RTF": null, + "SEVEN_Z": null, + "TAR": null, + "TXT": null, + "XLS": null, + "XLSX": null + } +} +``` + +## 8. When to read source (not training data) + +All paths are under `node_modules/stream-chat-react-native-core/src/`. Read the +file directly — do not attempt to reconstruct its contents from memory. + +- Public exports barrel: `index.ts` +- Components barrel: `components/index.ts` +- Hooks barrel: `hooks/index.ts` +- Contexts barrel: `contexts/index.ts` +- `WithComponents` + `useComponentsContext` + `ComponentOverrides` type: + `contexts/componentsContext/ComponentsContext.tsx` +- Full overridable component list (source of truth): + `contexts/componentsContext/defaultComponents.ts` — the `DEFAULT_COMPONENTS` + map. +- Theme type (source of truth for every theme key): + `contexts/themeContext/utils/theme.ts` +- `useAudioRecorder`: `components/MessageInput/hooks/useAudioRecorder.tsx` +- `useAudioPlayer`: `hooks/useAudioPlayer.ts` +- `useMessageComposer`: `contexts/messageInputContext/hooks/useMessageComposer.ts` +- `useAttachmentPickerState`: `hooks/useAttachmentPickerState.ts` +- Message item stack: `components/Message/MessageItemView/` + (`MessageItemView.tsx`, `MessageAuthor.tsx`, `MessageHeader.tsx`, etc.) +- Composer: `components/MessageInput/MessageComposer.tsx` (folder stays named + `MessageInput/`) +- Channel preview: `components/ChannelPreview/ChannelPreviewView.tsx` +- Channel list view: `components/ChannelList/ChannelListView.tsx` +- Attachment-picker redesign: `components/AttachmentPicker/components/AttachmentPickerContent.tsx` + +Rule of thumb for anything not in this guide: open `index.ts`, grep for the +symbol you think you need, and follow the re-export path to the definition. +If it's not in `index.ts`, it's not a public API — do not rely on it. + +## 9. Verification workflow + +An agent MUST run all of these before declaring the migration complete. Every +bullet is a hard gate; none are optional. + +```bash +# 1. No v8 symbols remain in source. +rg '\b(MessageSimple|MessageAvatar|ChannelPreviewMessenger|ChannelListMessenger)\b' src/ +rg '\buseAudioController\b' src/ +# useMutedUsers is scope-split (§4.2). Any remaining matches must be verified by +# hand — Chat-level usage is a bug, ChannelList-level usage is fine. +rg '\buseMutedUsers\b' src/ +rg '\basyncMessagesMultiSendEnabled\b' src/ +rg '\b(latestMessagePreview|deletedMessagesVisibilityType|messageContentWidth|setMessageContentWidth|legacyImageViewerSwipeBehaviour)\b' src/ +rg '\b(AttachmentActions|AttachmentUploadProgressIndicator|Card|CardCover|CardFooter|CardHeader|ImageReloadIndicator|MessagePreview)\b' src/ +rg '\b(CameraSelectorIcon|FileSelectorIcon|ImageSelectorIcon|VideoRecorderSelectorIcon)\b' src/ +rg '\b(backgroundCoreSurface|badgeTextInverse|textInverse|backgroundCoreElevation4)\b' src/ + +# 2. Component override props no longer appear on Channel/ChannelList/Chat/Thread. +# This regex matches JSX like `]*\b([A-Z]\w+)=\{' src/ + +# 3. `MessageInput` as a component reference is gone (component renamed to MessageComposer). +# The context name, hook name, and helper-view names are allowed — filter them out: +rg '\bMessageInput\b' src/ | rg -v 'MessageInputContext|MessageInputHeaderView|MessageInputFooterView|MessageInputLeadingView|MessageInputTrailingView|useMessageInputContext|MessageInputContextValue' + +# 4. New required peer dep present in package.json. +rg '"react-native-teleport"' package.json + +# 5. TypeScript passes. +yarn tsc --noEmit # or: npx tsc --noEmit + +# 6. Lint + tests if the project has them. +yarn lint +yarn test +``` + +If any `rg` above returns hits (except #4 which should return a hit), either +finish migrating the matches or verify each match against §3–§6 and explain in +the agent's final report why the match is legitimate (e.g. a test string or a +comment referencing the v8 name). + +Smoke-test the running app at minimum: + +- Render `` → `` → `` + ``. +- Send a text message; long-press it → confirm reactions and action list + appear via the new overlay. +- Swipe a message right → confirm reply preview appears (whole + `MessageItemView` row is the hit area now, not just the bubble). +- Attach an image; confirm upload indicators render. +- Start an audio recording; confirm send behavior matches §3.3 intent. +- Open the image gallery from a message with media. + +If anything renders but looks wrong, the theme likely has stale keys — diff +against `contexts/themeContext/utils/theme.ts` (§8) and let TypeScript +highlight every mismatch. diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index beaeacb61d..9deda9f191 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -3327,7 +3327,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Teleport (0.5.4): + - Teleport (1.1.2): - boost - DoubleConversion - fast_float @@ -3354,9 +3354,9 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - SocketRocket - - Teleport/common (= 0.5.4) + - Teleport/common (= 1.1.2) - Yoga - - Teleport/common (0.5.4): + - Teleport/common (1.1.2): - boost - DoubleConversion - fast_float @@ -3827,7 +3827,7 @@ SPEC CHECKSUMS: RNGestureHandler: 6bc8f2f56c8a68f3380cd159f3a1ae06defcfabb RNNotifee: 5e3b271e8ea7456a36eec994085543c9adca9168 RNReactNativeHapticFeedback: 5f1542065f0b24c9252bd8cf3e83bc9c548182e4 - RNReanimated: 0e779d4d219b01331bf5ad620d30c5b993d18856 + RNReanimated: a1e0ce339c1d8f9164b7499920d8787d6a7f7a23 RNScreens: e902eba58a27d3ad399a495d578e8aba3ea0f490 RNShare: c0f25f3d0ec275239c35cadbc98c94053118bee7 RNSVG: b1cb00d54dbc3066a3e98732e5418c8361335124 @@ -3836,7 +3836,7 @@ SPEC CHECKSUMS: SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 stream-chat-react-native: d15df89b47c1a08bc7db90c316d34b8ac4e13900 - Teleport: c5c5d9ac843d3024fd5776a7fcba22d37080f86b + Teleport: ed828b19e62ca8b9ec101d991bf0594b1c1c8812 Yoga: ff16d80456ce825ffc9400eeccc645a0dfcccdf5 PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d diff --git a/examples/SampleApp/package.json b/examples/SampleApp/package.json index 5cacc802f0..228b0e2334 100644 --- a/examples/SampleApp/package.json +++ b/examples/SampleApp/package.json @@ -66,7 +66,7 @@ "react-native-screens": "^4.24.0", "react-native-share": "^12.0.11", "react-native-svg": "^15.15.4", - "react-native-teleport": "^0.5.4", + "react-native-teleport": "^1.1.2", "react-native-video": "^6.16.1", "react-native-worklets": "^0.8.1", "stream-chat-react-native": "link:../../package/native-package", diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 70af5c4a61..8dac41d7b4 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -7674,10 +7674,10 @@ react-native-svg@^15.15.4: css-tree "^1.1.3" warn-once "0.1.1" -react-native-teleport@^0.5.4: - version "0.5.4" - resolved "https://registry.yarnpkg.com/react-native-teleport/-/react-native-teleport-0.5.4.tgz#6af16e3984ee0fc002e5d6c6dae0ad40f22699c2" - integrity sha512-kiNbB27lJHAO7DdG7LlPi6DaFnYusVQV9rqp0q5WoRpnqKNckIvzXULox6QpZBstaTF/jkO+xmlkU+pnQqKSAQ== +react-native-teleport@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/react-native-teleport/-/react-native-teleport-1.1.2.tgz#23deea2a34f6b1bb378e0305d44deeb93d51d490" + integrity sha512-64dcEkxlVKzxIts2FAVhzI2tDExcD23T13c2yDC/E+1dA1vP9UlDwPYUEkHvnoTOFtMDGrKLH03RJahIWfQC1g== react-native-url-polyfill@^2.0.0: version "2.0.0" @@ -8270,10 +8270,10 @@ stream-chat-react-native-core@8.1.0: version "0.0.0" uid "" -stream-chat@^9.40.0: - version "9.40.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.40.0.tgz#32ee29ae3442744fb7068b0ecaa3cc1a3b456b97" - integrity sha512-IH3MdxS1zGwOob1dBqRTIqS7wB2Y6Spu4ufo4/yVKW/IFEYRs38BSLHcMsJISvUbPpBleXKIrUOQZu6VsgJpdw== +stream-chat@^9.41.1: + version "9.41.1" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.41.1.tgz#a877c8aa800d78b497eec2fad636345d4422309c" + integrity sha512-W8zjfINYol2UtdRMz2t/NN2GyjDrvb4pJgKmhtuRYzCY1u0Cjezcsu5OCNgyAM0QsenlY6tRqnvAU8Qam5R49Q== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" diff --git a/package/package.json b/package/package.json index 52f941ae89..3cf1d43ef8 100644 --- a/package/package.json +++ b/package/package.json @@ -82,7 +82,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.40.0", + "stream-chat": "^9.41.1", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 2a2893b67a..d66cdf3d5a 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -261,6 +261,7 @@ export type ChannelPropsWithContext = Pick & | 'markdownRules' | 'messageActions' | 'messageContentOrder' + | 'messageOverlayTargetId' | 'messageTextNumberOfLines' | 'messageSwipeToReplyHitSlop' | 'myMessageTheme' @@ -464,6 +465,7 @@ const ChannelWithContext = (props: PropsWithChildren) = 'text', 'location', ], + messageOverlayTargetId, messageInputFloating = false, messageId, messageSwipeToReplyHitSlop, @@ -1665,6 +1667,7 @@ const ChannelWithContext = (props: PropsWithChildren) = markdownRules, messageActions, messageContentOrder, + messageOverlayTargetId, messageSwipeToReplyHitSlop, messageTextNumberOfLines, myMessageTheme, diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index e90b13ef3b..a237709a95 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -37,6 +37,7 @@ export const useCreateMessagesContext = ({ markdownRules, messageActions, messageContentOrder, + messageOverlayTargetId, messageSwipeToReplyHitSlop, messageTextNumberOfLines, myMessageTheme, @@ -100,6 +101,7 @@ export const useCreateMessagesContext = ({ markdownRules, messageActions, messageContentOrder, + messageOverlayTargetId, messageSwipeToReplyHitSlop, messageTextNumberOfLines, myMessageTheme, @@ -127,6 +129,7 @@ export const useCreateMessagesContext = ({ initialScrollToFirstUnreadMessage, markdownRulesLength, messageContentOrderValue, + messageOverlayTargetId, supportedReactionsLength, myMessageTheme, targetedMessage, diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 327fff2648..316979be3f 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -19,6 +19,8 @@ import { useMessageActions } from './hooks/useMessageActions'; import { useMessageDeliveredData } from './hooks/useMessageDeliveryData'; import { useMessageReadData } from './hooks/useMessageReadData'; import { useProcessReactions } from './hooks/useProcessReactions'; +import { DEFAULT_MESSAGE_OVERLAY_TARGET_ID } from './messageOverlayConstants'; +import { MessageOverlayWrapper } from './MessageOverlayWrapper'; import { measureInWindow } from './utils/measureInWindow'; import { messageActions as defaultMessageActions } from './utils/messageActions'; @@ -36,7 +38,11 @@ import { MessageComposerAPIContextValue, useMessageComposerAPIContext, } from '../../contexts/messageComposerContext/MessageComposerAPIContext'; -import { MessageContextValue, MessageProvider } from '../../contexts/messageContext/MessageContext'; +import { + MessageContextValue, + MessageOverlayRuntimeProvider, + MessageProvider, +} from '../../contexts/messageContext/MessageContext'; import { useMessageListItemContext } from '../../contexts/messageListItemContext/MessageListItemContext'; import { MessagesContextValue, @@ -207,6 +213,7 @@ export type MessagePropsWithContext = Pick< | 'handleBlockUser' | 'isAttachmentEqual' | 'messageActions' + | 'messageOverlayTargetId' | 'messageContentOrder' | 'onLongPressMessage' | 'onPressInMessage' @@ -278,6 +285,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { members, message, messageActions: messageActionsProp = defaultMessageActions, + messageOverlayTargetId = DEFAULT_MESSAGE_OVERLAY_TARGET_ID, messageContentOrder: messageContentOrderProp, messagesContext, onLongPressMessage: onLongPressMessageProp, @@ -323,11 +331,28 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const rectRef = useRef(undefined); const bubbleRect = useRef(undefined); const contextMenuAnchorRef = useRef(null); + const messageOverlayTargetsRef = useRef>({}); + const registerMessageOverlayTarget = useStableCallback( + ({ id, view }: { id: string; view: View | null }) => { + messageOverlayTargetsRef.current[id] = view; + }, + ); + const unregisterMessageOverlayTarget = useStableCallback((id: string) => { + delete messageOverlayTargetsRef.current[id]; + }); const showMessageOverlay = useStableCallback(async () => { dismissKeyboard(); try { - const layout = await measureInWindow(messageWrapperRef, insets); + const activeTargetView = messageOverlayTargetsRef.current[messageOverlayTargetId]; + + if (!activeTargetView) { + throw new Error( + `No message overlay target is registered for target id "${messageOverlayTargetId}".`, + ); + } + + const layout = await measureInWindow({ current: activeTargetView }, insets); const bubbleLayout = await measureInWindow(contextMenuAnchorRef, insets).catch(() => layout); rectRef.current = layout; @@ -655,8 +680,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { unpinMessage: handleTogglePinMessage, }; - const messageWrapperRef = useRef(null); - const onLongPress = () => { setNativeScrollability(false); if (hasAttachmentActions || isBlockedMessage(message) || !enableLongPress) { @@ -771,6 +794,8 @@ const MessageWithContext = (props: MessagePropsWithContext) => { onThreadSelect, otherAttachments: attachments.other, preventPress: overlayActive ? true : preventPress, + registerMessageOverlayTarget, + unregisterMessageOverlayTarget, reactions, readBy, setQuotedMessage, @@ -781,6 +806,14 @@ const MessageWithContext = (props: MessagePropsWithContext) => { threadList, videos: attachments.videos, }); + const messageOverlayRuntimeContext = useMemo( + () => ({ + overlayTargetRectRef: rectRef, + messageOverlayTargetId, + overlayActive, + }), + [messageOverlayTargetId, overlayActive], + ); const prevActive = useRef(overlayActive); @@ -817,82 +850,74 @@ const MessageWithContext = (props: MessagePropsWithContext) => { return ( - - {overlayActive && rect ? ( - - ) : null} - {/*TODO: V9: Find a way to separate these in a dedicated file*/} - - {overlayActive && rect && overlayItemsAnchorRect ? ( - { - const { width: w, height: h } = e.nativeEvent.layout; - - setOverlayTopH({ - h, - w, - x: - overlayItemAlignment === 'right' - ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w - : overlayItemsAnchorRect.x, - y: rect.y - h, - }); - }} - > - - - ) : null} - - - + + + {/*TODO: V9: Find a way to separate these in a dedicated file*/} + + {overlayActive && rect && overlayItemsAnchorRect ? ( + { + const { width: w, height: h } = e.nativeEvent.layout; + + setOverlayTopH({ + h, + w, + x: + overlayItemAlignment === 'right' + ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w + : overlayItemsAnchorRect.x, + y: rect.y - h, + }); + }} + > + + + ) : null} + + - - - {showMessageReactions ? ( - setShowMessageReactions(false)} - visible={showMessageReactions} - height={424} - > - - - ) : null} - - {overlayActive && rect && overlayItemsAnchorRect ? ( - { - const { width: w, height: h } = e.nativeEvent.layout; - setOverlayBottomH({ - h, - w, - x: - overlayItemAlignment === 'right' - ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w - : overlayItemsAnchorRect.x, - y: rect.y + rect.h, - }); - }} + + {showMessageReactions ? ( + setShowMessageReactions(false)} + visible={showMessageReactions} + height={424} > - - + + ) : null} - - {isBounceDialogOpen ? ( - - ) : null} - + + {overlayActive && rect && overlayItemsAnchorRect ? ( + { + const { width: w, height: h } = e.nativeEvent.layout; + setOverlayBottomH({ + h, + w, + x: + overlayItemAlignment === 'right' + ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w + : overlayItemsAnchorRect.x, + y: rect.y + rect.h, + }); + }} + > + + + ) : null} + + {isBounceDialogOpen ? ( + + ) : null} + + ); }; @@ -905,6 +930,7 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit groupStyles: prevGroupStyles, isAttachmentEqual, isTargetedMessage: prevIsTargetedMessage, + messageOverlayTargetId: prevMessageOverlayTargetId, members: prevMembers, message: prevMessage, messagesContext: prevMessagesContext, @@ -918,6 +944,7 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit goToMessage: nextGoToMessage, groupStyles: nextGroupStyles, isTargetedMessage: nextIsTargetedMessage, + messageOverlayTargetId: nextMessageOverlayTargetId, members: nextMembers, message: nextMessage, messagesContext: nextMessagesContext, @@ -958,6 +985,11 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit return false; } + const messageOverlayTargetIdEqual = prevMessageOverlayTargetId === nextMessageOverlayTargetId; + if (!messageOverlayTargetIdEqual) { + return false; + } + const messageEqual = checkMessageEquality(prevMessage, nextMessage); if (!messageEqual) { diff --git a/package/src/components/Message/MessageItemView/__tests__/Message.test.js b/package/src/components/Message/MessageItemView/__tests__/Message.test.js index ebafc968c9..9a87d7f7b8 100644 --- a/package/src/components/Message/MessageItemView/__tests__/Message.test.js +++ b/package/src/components/Message/MessageItemView/__tests__/Message.test.js @@ -1,9 +1,13 @@ import React from 'react'; +import { Pressable, Text, View } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { cleanup, fireEvent, render, waitFor } from '@testing-library/react-native'; +import { WithComponents } from '../../../../contexts/componentsContext/ComponentsContext'; +import { useMessageContext } from '../../../../contexts/messageContext/MessageContext'; +import { MessageListItemProvider } from '../../../../contexts/messageListItemContext/MessageListItemContext'; import { OverlayProvider } from '../../../../contexts/overlayContext/OverlayProvider'; import { getOrCreateChannelApi } from '../../../../mock-builders/api/getOrCreateChannel'; @@ -16,7 +20,38 @@ import { getTestClientWithUser } from '../../../../mock-builders/mock'; import { Channel } from '../../../Channel/Channel'; import { Chat } from '../../../Chat/Chat'; import { MessageComposer } from '../../../MessageInput/MessageComposer'; +import { useShouldUseOverlayStyles } from '../../hooks/useShouldUseOverlayStyles'; import { Message } from '../../Message'; +import { MessageOverlayWrapper } from '../../MessageOverlayWrapper'; + +const OverlayStateText = ({ label }) => { + const shouldUseOverlayStyles = useShouldUseOverlayStyles(); + + return {`${label}:${shouldUseOverlayStyles ? 'overlay' : 'normal'}`}; +}; + +const OverlayTrigger = () => { + const { onLongPress } = useMessageContext(); + + return ( + onLongPress({ emitter: 'message' })} + testID='custom-overlay-trigger' + > + Open overlay + + ); +}; + +const CustomMessageItemView = () => ( + + + + + + + +); describe('Message', () => { let channel; @@ -37,16 +72,34 @@ describe('Message', () => { useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); channel = chatClient.channel('messaging', mockedChannel.id); - renderMessage = (options) => + renderMessage = (options, channelProps, componentOverrides) => render( - - - - - - + + + {componentOverrides ? ( + + + + + + + ) : ( + + + + + )} + + , ); @@ -88,4 +141,31 @@ describe('Message', () => { expect(onLongPressMessage).toHaveBeenCalledTimes(1); }); }); + + it('teleports a custom overlay target without applying overlay styles to siblings', async () => { + const message = generateMessage({ user }); + const { getByTestId, getByText } = renderMessage( + { message }, + { + messageOverlayTargetId: 'custom-overlay-target', + }, + { + MessageItemView: CustomMessageItemView, + }, + ); + + await waitFor(() => { + expect(getByTestId('custom-message-item-view')).toBeTruthy(); + expect(getByText('outside:normal')).toBeTruthy(); + expect(getByText('inside:normal')).toBeTruthy(); + }); + + fireEvent(getByTestId('custom-overlay-trigger'), 'longPress'); + + await waitFor(() => { + expect(getByText('outside:normal')).toBeTruthy(); + expect(getByText('inside:overlay')).toBeTruthy(); + expect(getByTestId('custom-overlay-target-placeholder')).toBeTruthy(); + }); + }); }); diff --git a/package/src/components/Message/MessageOverlayWrapper.tsx b/package/src/components/Message/MessageOverlayWrapper.tsx new file mode 100644 index 0000000000..a27f2eb7af --- /dev/null +++ b/package/src/components/Message/MessageOverlayWrapper.tsx @@ -0,0 +1,81 @@ +import React, { PropsWithChildren, useCallback, useEffect } from 'react'; +import { View } from 'react-native'; + +import { Portal } from 'react-native-teleport'; + +import { + MessageOverlayTargetProvider, + useMessageContext, + useMessageOverlayRuntimeContext, +} from '../../contexts/messageContext/MessageContext'; + +export type MessageOverlayWrapperProps = PropsWithChildren<{ + /** + * Stable identifier for this overlay target. Must match `messageOverlayTargetId` + * when this subtree should be teleported into the overlay. + */ + targetId: string; + /** + * Optional test id attached to the wrapped target container. + */ + testID?: string; +}>; + +/** + * Wraps the primary message overlay target so the active message can be teleported + * into the overlay host while a placeholder preserves its original layout space. + */ +export const MessageOverlayWrapper = ({ + children, + targetId, + testID, +}: MessageOverlayWrapperProps) => { + const { registerMessageOverlayTarget, unregisterMessageOverlayTarget } = useMessageContext(); + const { messageOverlayTargetId, overlayActive, overlayTargetRectRef } = + useMessageOverlayRuntimeContext(); + const isActiveTarget = messageOverlayTargetId === targetId; + const placeholderLayout = overlayTargetRectRef.current; + + const handleTargetRef = useCallback( + (view: View | null) => { + registerMessageOverlayTarget({ + id: targetId, + view, + }); + }, + [registerMessageOverlayTarget, targetId], + ); + + useEffect( + () => () => { + unregisterMessageOverlayTarget(targetId); + }, + [targetId, unregisterMessageOverlayTarget], + ); + + if (!isActiveTarget) { + return children; + } + + return ( + <> + + + + {children} + + + + {overlayActive ? ( + 0 ? placeholderLayout.w : '100%', + }} + testID={testID ? `${testID}-placeholder` : 'message-overlay-wrapper-placeholder'} + /> + ) : null} + + ); +}; diff --git a/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx b/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx index 7ae7133225..a8173e45f9 100644 --- a/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx +++ b/package/src/components/Message/hooks/__tests__/useShouldUseOverlayStyles.test.tsx @@ -4,10 +4,12 @@ import { act, cleanup, renderHook } from '@testing-library/react-native'; import { MessageContextValue, + MessageOverlayRuntimeProvider, MessageProvider, } from '../../../../contexts/messageContext/MessageContext'; import { generateMessage } from '../../../../mock-builders/generator/message'; import { finalizeCloseOverlay, openOverlay, overlayStore } from '../../../../state-store'; +import { DEFAULT_MESSAGE_OVERLAY_TARGET_ID } from '../../messageOverlayConstants'; import { useShouldUseOverlayStyles } from '../useShouldUseOverlayStyles'; @@ -22,6 +24,7 @@ const createMessageContextValue = (overrides: Partial): Mes groupStyles: [], handleAction: jest.fn(), handleToggleReaction: jest.fn(), + hasAttachmentActions: false, hasReactions: false, images: [], isMessageAIGenerated: jest.fn(), @@ -29,6 +32,7 @@ const createMessageContextValue = (overrides: Partial): Mes lastGroupMessage: false, members: {}, message: generateMessage({ id: 'shared-message-id' }), + contextMenuAnchorRef: { current: null }, messageContentOrder: [], messageHasOnlySingleAttachment: false, messageOverlayId: 'message-overlay-default', @@ -38,6 +42,7 @@ const createMessageContextValue = (overrides: Partial): Mes onPress: jest.fn(), onPressIn: null, otherAttachments: [], + registerMessageOverlayTarget: jest.fn(), reactions: [], readBy: false, setQuotedMessage: jest.fn(), @@ -46,13 +51,23 @@ const createMessageContextValue = (overrides: Partial): Mes showReactionsOverlay: jest.fn(), showMessageStatus: false, threadList: false, + unregisterMessageOverlayTarget: jest.fn(), videos: [], ...overrides, }) as MessageContextValue; -const createWrapper = (value: MessageContextValue) => { +const createWrapper = ( + value: MessageContextValue, + runtimeValue = { + overlayTargetRectRef: { current: undefined }, + messageOverlayTargetId: DEFAULT_MESSAGE_OVERLAY_TARGET_ID, + overlayActive: false, + }, +) => { const Wrapper = ({ children }: PropsWithChildren) => ( - {children} + + {children} + ); return Wrapper; diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts index 55e176e95d..3c2141699e 100644 --- a/package/src/components/Message/hooks/useCreateMessageContext.ts +++ b/package/src/components/Message/hooks/useCreateMessageContext.ts @@ -47,6 +47,8 @@ export const useCreateMessageContext = ({ onThreadSelect, otherAttachments, preventPress, + registerMessageOverlayTarget, + unregisterMessageOverlayTarget, reactions, readBy, showAvatar, @@ -102,6 +104,8 @@ export const useCreateMessageContext = ({ onThreadSelect, otherAttachments, preventPress, + registerMessageOverlayTarget, + unregisterMessageOverlayTarget, reactions, readBy, setQuotedMessage, @@ -134,6 +138,7 @@ export const useCreateMessageContext = ({ showMessageStatus, threadList, preventPress, + unregisterMessageOverlayTarget, ], ); diff --git a/package/src/components/Message/hooks/useShouldUseOverlayStyles.ts b/package/src/components/Message/hooks/useShouldUseOverlayStyles.ts index 9baa0f2046..e030113325 100644 --- a/package/src/components/Message/hooks/useShouldUseOverlayStyles.ts +++ b/package/src/components/Message/hooks/useShouldUseOverlayStyles.ts @@ -1,9 +1,22 @@ -import { useMessageContext } from '../../../contexts'; +import { + useMessageContext, + useMessageOverlayRuntimeContext, + useMessageOverlayTargetContext, +} from '../../../contexts'; import { useIsOverlayActive } from '../../../state-store'; +import { DEFAULT_MESSAGE_OVERLAY_TARGET_ID } from '../messageOverlayConstants'; export const useShouldUseOverlayStyles = () => { const { messageOverlayId } = useMessageContext(); + const { messageOverlayTargetId } = useMessageOverlayRuntimeContext(); + const isWithinMessageOverlayTarget = useMessageOverlayTargetContext(); const { active, closing } = useIsOverlayActive(messageOverlayId); - return active && !closing; + if (!active || closing) { + return false; + } + + return messageOverlayTargetId === DEFAULT_MESSAGE_OVERLAY_TARGET_ID + ? true + : isWithinMessageOverlayTarget; }; diff --git a/package/src/components/Message/messageOverlayConstants.ts b/package/src/components/Message/messageOverlayConstants.ts new file mode 100644 index 0000000000..3080d4cd12 --- /dev/null +++ b/package/src/components/Message/messageOverlayConstants.ts @@ -0,0 +1 @@ +export const DEFAULT_MESSAGE_OVERLAY_TARGET_ID = '@stream-io/message-root'; diff --git a/package/src/components/MessageMenu/MessageMenu.tsx b/package/src/components/MessageMenu/MessageMenu.tsx deleted file mode 100644 index bf0564e020..0000000000 --- a/package/src/components/MessageMenu/MessageMenu.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React, { PropsWithChildren } from 'react'; - -import { useWindowDimensions } from 'react-native'; - -import { MessageActionType } from './MessageActionListItem'; - -import { MessageContextValue } from '../../contexts/messageContext/MessageContext'; -import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { BottomSheetModal } from '../UIComponents/BottomSheetModal'; - -export type MessageMenuProps = PropsWithChildren< - Partial> & { - /** - * Function to close the message actions bottom sheet - * @returns void - */ - dismissOverlay: () => void; - /** - * An array of message actions to render - */ - messageActions: MessageActionType[]; - /** - * Boolean to determine if there are message actions - */ - showMessageReactions: boolean; - /** - * Boolean to determine if the overlay is visible. - */ - visible: boolean; - /** - * Function to handle reaction on press - * @param reactionType - * @returns - */ - handleReaction?: (reactionType: string) => Promise; - /** - * The selected reaction - */ - selectedReaction?: string; - - layout: { - x: number; - y: number; - w: number; - h: number; - }; - } ->; - -// TODO: V9: Either remove this or refactor it so that it's useful again, as its logic -// is offloaded to other components now. -export const MessageMenu = (props: MessageMenuProps) => { - const { - dismissOverlay, - // handleReaction, - // message: propMessage, - // MessageActionList: propMessageActionList, - // MessageActionListItem: propMessageActionListItem, - // messageActions, - // MessageReactionPicker: propMessageReactionPicker, - // MessageUserReactions: propMessageUserReactions, - // MessageUserReactionsAvatar: propMessageUserReactionsAvatar, - // MessageUserReactionsItem: propMessageUserReactionsItem, - // selectedReaction, - showMessageReactions, - visible, - // layout, - children, - } = props; - const { height } = useWindowDimensions(); - // const { - // MessageActionList: contextMessageActionList, - // MessageActionListItem: contextMessageActionListItem, - // MessageReactionPicker: contextMessageReactionPicker, - // MessageUserReactions: contextMessageUserReactions, - // MessageUserReactionsAvatar: contextMessageUserReactionsAvatar, - // MessageUserReactionsItem: contextMessageUserReactionsItem, - // } = useMessagesContext(); - // const { message: contextMessage } = useMessageContext(); - // const MessageActionList = propMessageActionList ?? contextMessageActionList; - // const MessageActionListItem = propMessageActionListItem ?? contextMessageActionListItem; - // const MessageReactionPicker = propMessageReactionPicker ?? contextMessageReactionPicker; - // const MessageUserReactions = propMessageUserReactions ?? contextMessageUserReactions; - // const MessageUserReactionsAvatar = - // propMessageUserReactionsAvatar ?? contextMessageUserReactionsAvatar; - // const MessageUserReactionsItem = propMessageUserReactionsItem ?? contextMessageUserReactionsItem; - // const message = propMessage ?? contextMessage; - const { - theme: { - messageMenu: { - bottomSheet: { height: bottomSheetHeight }, - }, - }, - } = useTheme(); - - return ( - - {children} - - ); -}; diff --git a/package/src/components/Reply/Reply.tsx b/package/src/components/Reply/Reply.tsx index 8a1ed92d26..7408e85608 100644 --- a/package/src/components/Reply/Reply.tsx +++ b/package/src/components/Reply/Reply.tsx @@ -3,6 +3,7 @@ import { I18nManager, Image, ImageProps, + Platform, StyleSheet, Text, TextStyle, @@ -303,19 +304,26 @@ const useStyles = () => { paddingHorizontal: primitives.spacingXs, gap: primitives.spacingXxxs, alignItems: 'flex-start', - ...(isRTL + ...(Platform.OS === 'android' ? { - borderRightColor: isMyMessage - ? semantics.chatReplyIndicatorOutgoing - : semantics.chatReplyIndicatorIncoming, - borderRightWidth: 2, - } - : { borderLeftColor: isMyMessage ? semantics.chatReplyIndicatorOutgoing : semantics.chatReplyIndicatorIncoming, borderLeftWidth: 2, - }), + } + : isRTL + ? { + borderRightColor: isMyMessage + ? semantics.chatReplyIndicatorOutgoing + : semantics.chatReplyIndicatorIncoming, + borderRightWidth: 2, + } + : { + borderLeftColor: isMyMessage + ? semantics.chatReplyIndicatorOutgoing + : semantics.chatReplyIndicatorIncoming, + borderLeftWidth: 2, + }), }, rightContainer: {}, title: { diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index 0987d6c577..049ff2af71 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -368,7 +368,9 @@ exports[`Thread should match thread snapshot 1`] = ` > - + - + - + - + void) | null; /** The images attached to a message */ otherAttachments: Attachment[]; + /** + * Registers the subtree that should be measured and portaled into the message overlay. + * Custom message renderers typically interact with this via `MessageOverlayWrapper`. + */ + registerMessageOverlayTarget: (params: { id: string; view: View | null }) => void; + unregisterMessageOverlayTarget: (id: string) => void; reactions: ReactionSummary[]; /** Read count of the message */ readBy: number | boolean; @@ -164,3 +172,39 @@ export const useMessageContext = () => { return contextValue; }; + +type MessageOverlayRuntimeContextValue = { + overlayTargetRectRef: { current: Rect }; + messageOverlayTargetId: string; + overlayActive: boolean; +}; + +const MessageOverlayRuntimeContext = React.createContext({ + overlayTargetRectRef: { current: undefined }, + messageOverlayTargetId: DEFAULT_MESSAGE_OVERLAY_TARGET_ID, + overlayActive: false, +}); + +export const MessageOverlayRuntimeProvider = ({ + children, + value, +}: PropsWithChildren<{ value: MessageOverlayRuntimeContextValue }>) => ( + + {children} + +); + +export const useMessageOverlayRuntimeContext = () => useContext(MessageOverlayRuntimeContext); + +const MessageOverlayTargetContext = React.createContext(false); + +export const MessageOverlayTargetProvider = ({ + children, + value, +}: PropsWithChildren<{ value: boolean }>) => ( + + {children} + +); + +export const useMessageOverlayTargetContext = () => useContext(MessageOverlayTargetContext); diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index badfc58d94..b4846b074b 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -75,6 +75,11 @@ export type MessagesContextValue = Pick Promise; /** * Override the api request for retry message functionality. diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts index 094549af5b..8e368a9532 100644 --- a/package/src/hooks/index.ts +++ b/package/src/hooks/index.ts @@ -12,7 +12,7 @@ export * from './useClientNotifications'; export * from './useInAppNotificationsState'; export * from './usePortalSettledCallback'; export * from './useRAFCoalescedValue'; -export * from './useAudioPlayerControl'; +export * from './useAudioPlayer'; export * from './useAttachmentPickerState'; export * from './messagePreview/useMessageDeliveryStatus'; export * from './messagePreview/useGroupedAttachments'; diff --git a/package/src/hooks/useAudioPlayerControl.ts b/package/src/hooks/useAudioPlayerControl.ts deleted file mode 100644 index cbe30f1704..0000000000 --- a/package/src/hooks/useAudioPlayerControl.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useMemo } from 'react'; - -import { useAudioPlayerContext } from '../contexts/audioPlayerContext/AudioPlayerContext'; -import { AudioPlayerOptions } from '../state-store/audio-player'; - -export type UseAudioPlayerControlProps = { - /** - * Identifier of the entity that requested the audio playback, e.g. message ID. - * Asset to specific audio player is a many-to-many relationship - * - one URL can be associated with multiple UI elements, - * - one UI element can display multiple audio sources. - * Therefore, the AudioPlayer ID is a combination of request:src. - * - * The requester string can take into consideration whether there are multiple instances of - * the same URL requested by the same requester (message has multiple attachments with the same asset URL). - * In reality the fact that one message has multiple attachments with the same asset URL - * could be considered a bad practice or a bug. - */ - requester?: string; -} & Partial; - -const makeAudioPlayerId = ({ - requester, - src, - id, -}: { - src: string; - requester?: string; - id?: string; -}) => `${requester ?? 'requester-unknown'}:${src}:${id ?? ''}`; - -export const useAudioPlayerControl = ({ - duration, - mimeType, - playbackRates, - previewVoiceRecording, - requester = '', - type, - uri, - id: fileId, -}: UseAudioPlayerControlProps) => { - const { audioPlayerPool } = useAudioPlayerContext(); - const id = makeAudioPlayerId({ id: fileId, requester, src: uri ?? '' }); - const audioPlayer = useMemo( - () => - audioPlayerPool?.getOrAddPlayer({ - duration: duration ?? 0, - id, - mimeType: mimeType ?? '', - playbackRates, - previewVoiceRecording, - type: type ?? 'audio', - uri: uri ?? '', - }), - [audioPlayerPool, duration, id, mimeType, playbackRates, previewVoiceRecording, type, uri], - ); - - return audioPlayer; -}; diff --git a/package/yarn.lock b/package/yarn.lock index 3bda374e32..6a6ea61ebc 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -8352,10 +8352,10 @@ stdin-discarder@^0.2.2: resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== -stream-chat@^9.40.0: - version "9.40.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.40.0.tgz#32ee29ae3442744fb7068b0ecaa3cc1a3b456b97" - integrity sha512-IH3MdxS1zGwOob1dBqRTIqS7wB2Y6Spu4ufo4/yVKW/IFEYRs38BSLHcMsJISvUbPpBleXKIrUOQZu6VsgJpdw== +stream-chat@^9.41.1: + version "9.41.1" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.41.1.tgz#a877c8aa800d78b497eec2fad636345d4422309c" + integrity sha512-W8zjfINYol2UtdRMz2t/NN2GyjDrvb4pJgKmhtuRYzCY1u0Cjezcsu5OCNgyAM0QsenlY6tRqnvAU8Qam5R49Q== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14"