cometchat-native-placement
Purpose
Teaches Claude the five canonical placement patterns for putting chat inside a React Native app. Each pattern specifies:
- Which CometChat components to compose
- How to wire the placement into
@react-navigation/*(or Expo Router) - Platform gotchas (safe-area, keyboard avoiding, gesture handling)
- When to choose this placement over the alternatives
Ground truth: docs/ui-kit/react-native/react-native-conversation.mdx, react-native-one-to-one-chat.mdx, react-native-tab-based-chat.mdx, their expo-*.mdx equivalents, and the examples/SampleApp/ + examples/SampleAppExpo/ sample apps.
Read cometchat-native-core and cometchat-native-components before this skill — the provider wrapper chain and component catalog are prerequisites.
"What are you building?" — placement recommendation
Use this table to pick a placement. If the user says "add chat to my app" without specifying where, ask them what they're building.
| User intent | Recommended placement | Experience |
|---|---|---|
| Messaging app (WhatsApp / Telegram / Signal style) | Conversations stack — list → tap → full-page messages screen | Two-pane-equivalent on mobile |
| SaaS / marketplace / e-commerce with chat as a feature | Stack screen — dedicated /chat or /messages route |
Full-page chat inside the app |
| Support app or focused 1-to-1 | Stack screen (single thread) — no conversation list, go straight into one chat | Single thread |
| Full messaging hub with calls / users / groups | Bottom tabs — Chats / Users / Groups / Calls tabs + stack screen for message view | Tab-based messenger |
| Occasional chat overlay from a non-chat screen | Modal — present from anywhere, dismiss to return | Modal |
| Inline comments / contextual chat | BottomSheet — swipe up from a screen section | Sheet |
| Chat embedded inside an existing screen (e.g. a support tab next to product details) | Embedded — CometChat components inside a parent layout | Embedded |
Visual reference — five RN placement patterns
1. Stack screen (full page)
┌───────────────────────────────────┐
│ ← Hiking Group ⋮ │ ← CometChatMessageHeader
├───────────────────────────────────┤
│ │
│ ╭──────────╮ │
│ │ Message │ │
│ ╰──────────╯ │ ← CometChatMessageList
│ │
│ ╭──────────╮ │
│ │ Reply │ │
│ ╰──────────╯ │
│ │
├───────────────────────────────────┤
│ + Type a message... ▶ │ ← CometChatMessageComposer
└───────────────────────────────────┘
2. Bottom tab
┌───────────────────────────────────┐
│ ← Hiking Group ⋮ │ ← header
├───────────────────────────────────┤
│ │
│ (messages) │
│ │
├───────────────────────────────────┤
│ Chats Users Groups Calls │ ← bottom tab bar
└───────────────────────────────────┘
3. Modal (slide-up over current screen)
┌─────────────────┐
│ ═══ Chat ✕ │ ← drag handle + close
├─────────────────┤
│ │
│ (messages) │
│ │
├─────────────────┤
│ Type message ▶ │
└─────────────────┘
(parent screen dimmed behind)
4. BottomSheet (swipe-up partial)
parent screen visible at top ─────
┌─────────────────┐
│ ═══ (handle) │
│ Hiking Group │
├─────────────────┤
│ (messages) │
├─────────────────┤
│ Type message ▶ │
└─────────────────┘
5. Embedded (inside an existing screen)
┌───────────────────────────────────┐
│ Product details │
│ [product image + specs] │
├───────────────────────────────────┤
│ Contact seller │ ← section heading
│ ┌────────────────────────────┐ │
│ │ (CometChatMessageHeader) │ │
│ │ (CometChatMessageList) │ │ ← embedded chat
│ │ (CometChatMessageComposer) │ │
│ └────────────────────────────┘ │
└───────────────────────────────────┘
1. Stack screen
The most common pattern — chat lives in its own screen, pushed via @react-navigation/native-stack.
Pattern A — Conversations list → Messages
Two screens: list + messages.
// ConversationsScreen.tsx
import { CometChatConversations, CometChatUiKitConstants } from "@cometchat/chat-uikit-react-native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
export function ConversationsScreen({ navigation }: { navigation: NativeStackNavigationProp<any> }) {
return (
<CometChatConversations
onItemPress={(conversation) => {
const type = conversation.getConversationType();
if (type === CometChatUiKitConstants.ConversationTypeConstants.user) {
navigation.navigate("Messages", { user: conversation.getConversationWith() });
} else {
navigation.navigate("Messages", { group: conversation.getConversationWith() });
}
}}
/>
);
}
// MessagesScreen.tsx
import { View } from "react-native";
import {
CometChatMessageHeader,
CometChatMessageList,
CometChatMessageComposer,
} from "@cometchat/chat-uikit-react-native";
export function MessagesScreen({ route, navigation }: any) {
const { user, group } = route.params ?? {};
return (
<View style={{ flex: 1 }}>
<CometChatMessageHeader user={user} group={group} onBack={() => navigation.goBack()} showBackButton />
<CometChatMessageList user={user} group={group} hideReplyInThreadOption />
<CometChatMessageComposer user={user} group={group} />
</View>
);
}
// AppNavigator.tsx
import { createNativeStackNavigator } from "@react-navigation/native-stack";
const Stack = createNativeStackNavigator();
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Conversations" component={ConversationsScreen} />
<Stack.Screen name="Messages" component={MessagesScreen} />
</Stack.Navigator>
Pattern B — Single thread (no conversation list)
For support chat, marketplace "Contact seller", or any focused 1-to-1 where the target user/group is known in advance.
export function SupportChatScreen() {
const [agent, setAgent] = useState<CometChat.User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
CometChat.getUser("support-agent-uid")
.then((user) => {
setAgent(user);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
if (loading) return <ActivityIndicator style={{ flex: 1 }} />;
if (!agent) return <Text style={{ padding: 16 }}>Support unavailable. Try again shortly.</Text>;
return (
<View style={{ flex: 1 }}>
<CometChatMessageHeader user={agent} />
<CometChatMessageList user={agent} hideReplyInThreadOption />
<CometChatMessageComposer user={agent} />
</View>
);
}
Navigation wiring notes
- The screen is wrapped in a
<View style={{ flex: 1 }}>so the composer sits at the bottom and the list fills the middle. CometChatMessageHeader'sonBackshould callnavigation.goBack(). SetshowBackButtonexplicitly so the header knows to render it.- Keyboard avoiding: when the composer is visible, RN needs
KeyboardAvoidingViewon iOS orandroid:windowSoftInputMode="adjustResize"on Android. The framework patterns (cometchat-native-expo-patterns,cometchat-native-bare-patterns) cover the platform-specific wiring.
2. Bottom tab
For full-featured messengers with distinct entry points per content type.
// TabsNavigator.tsx
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
const Tab = createBottomTabNavigator();
const Stack = createNativeStackNavigator();
function MainTabs() {
return (
<Tab.Navigator screenOptions={{ headerShown: false }}>
<Tab.Screen name="Chats" component={ConversationsScreen} />
<Tab.Screen name="Users" component={UsersScreen} />
<Tab.Screen name="Groups" component={GroupsScreen} />
<Tab.Screen name="Calls" component={CallLogsScreen} />
</Tab.Navigator>
);
}
export function AppNavigator() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Main" component={MainTabs} />
<Stack.Screen name="Messages" component={MessagesScreen} />
</Stack.Navigator>
);
}
Each tab screen pushes to a shared Messages stack screen with the selected entity:
export function UsersScreen({ navigation }: any) {
return (
<CometChatUsers onItemPress={(user) => navigation.navigate("Messages", { user })} />
);
}
export function GroupsScreen({ navigation }: any) {
return (
<CometChatGroups onItemPress={(group) => navigation.navigate("Messages", { group })} />
);
}
export function CallLogsScreen() {
return <CometChatCallLogs />;
}
Wiring notes
- Tabs use
@react-navigation/bottom-tabs. TheMessagesscreen is OUTSIDE the tab navigator (at the stack level) so it presents full-screen without the tab bar. - For the Calls tab,
CometChatCallLogsonly works when@cometchat/calls-sdk-react-nativeis installed. Omit the Calls tab if the project doesn't use calling.
3. Modal
For occasional chat that doesn't belong in the primary navigation. Two approaches — native RN <Modal> or react-navigation's presentation: "modal".
Pattern A — React Navigation modal (recommended)
Cleaner — the modal is a regular stack screen with a modal presentation option.
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen
name="ChatModal"
component={ChatModalScreen}
options={{ presentation: "modal" }}
/>
</Stack.Navigator>
function ChatModalScreen({ navigation }: any) {
const [agent, setAgent] = useState<CometChat.User | null>(null);
useEffect(() => { CometChat.getUser("support-agent").then(setAgent); }, []);
if (!agent) return null;
return (
<View style={{ flex: 1 }}>
<CometChatMessageHeader user={agent} onBack={() => navigation.goBack()} showBackButton />
<CometChatMessageList user={agent} hideReplyInThreadOption />
<CometChatMessageComposer user={agent} />
</View>
);
}
// Trigger from anywhere:
<Button title="Contact support" onPress={() => navigation.navigate("ChatModal")} />
iOS gets the native modal slide-up. Android shows a fade-in full-screen by default — if you need a swipe-to-dismiss feel, use the BottomSheet pattern instead.
Pattern B — RN <Modal> component
For lightweight one-off modals that don't need a separate route.
import { Modal, Pressable, View } from "react-native";
const [visible, setVisible] = useState(false);
<Modal visible={visible} animationType="slide" onRequestClose={() => setVisible(false)}>
<SafeAreaView style={{ flex: 1 }}>
<View style={{ flex: 1 }}>
<CometChatMessageHeader user={agent} onBack={() => setVisible(false)} showBackButton />
<CometChatMessageList user={agent} hideReplyInThreadOption />
<CometChatMessageComposer user={agent} />
</View>
</SafeAreaView>
</Modal>
Works fine but bypasses navigation state — deep links and back-button handling need extra work.
4. BottomSheet
Native-feel swipe-up chat overlaid on a parent screen. Two library options; pick one based on the project's existing navigation:
| Library | When to use |
|---|---|
@gorhom/bottom-sheet |
Most flexible + most common. Good for partial-height sheets with snap points. |
@cometchat/chat-uikit-react-native's CometChatBottomSheet |
Lightweight. Good if the project doesn't already depend on @gorhom/bottom-sheet. |
Pattern A — @gorhom/bottom-sheet
import BottomSheet, { BottomSheetView } from "@gorhom/bottom-sheet";
import { useRef, useMemo } from "react";
function ProductScreen({ product }: any) {
const sheetRef = useRef<BottomSheet>(null);
const snapPoints = useMemo(() => ["25%", "90%"], []);
const [agent, setAgent] = useState<CometChat.User | null>(null);
useEffect(() => {
CometChat.getUser(product.sellerUid).then(setAgent);
}, [product.sellerUid]);
return (
<View style={{ flex: 1 }}>
<ProductDetails product={product} />
<Button title="Contact seller" onPress={() => sheetRef.current?.expand()} />
<BottomSheet ref={sheetRef} snapPoints={snapPoints} index={-1} enablePanDownToClose>
<BottomSheetView style={{ flex: 1 }}>
{agent && (
<>
<CometChatMessageHeader user={agent} />
<CometChatMessageList user={agent} hideReplyInThreadOption />
<CometChatMessageComposer user={agent} />
</>
)}
</BottomSheetView>
</BottomSheet>
</View>
);
}
Pattern B — CometChatBottomSheet
import { CometChatBottomSheet } from "@cometchat/chat-uikit-react-native";
const sheetRef = useRef<any>(null);
<CometChatBottomSheet ref={sheetRef}>
<View style={{ flex: 1, height: "100%" }}>
<CometChatMessageHeader user={agent} />
<CometChatMessageList user={agent} hideReplyInThreadOption />
<CometChatMessageComposer user={agent} />
</View>
</CometChatBottomSheet>
<Button title="Chat" onPress={() => sheetRef.current?.show()} />
BottomSheet gotchas
- Snap points must be memoized: Always wrap
snapPointsinuseMemo(() => [...], [])(see Pattern A above). An inline array creates a new reference on every parent render, which forces@gorhom/bottom-sheetto re-measure layout and tears the open/close gesture animation. The example above does this correctly — do NOT "simplify" by inlining the array. - Keyboard behavior:
@gorhom/bottom-sheethaskeyboardBehavior+keyboardBlurBehaviorprops. Without them the composer gets covered by the keyboard on iOS. UsekeyboardBehavior="interactive"+keyboardBlurBehavior="restore". - Gesture handler wrap: BottomSheet requires
<GestureHandlerRootView style={{ flex: 1 }}>at the root (already required by the UI Kit — seecometchat-native-core§ 3). - Height: Pass
flex: 1+height: "100%"on the inner View so the message list expands to fill the sheet.
5. Embedded
Chat inside an existing screen, not its own route.
export function ProductDetailScreen({ product }: any) {
const [agent, setAgent] = useState<CometChat.User | null>(null);
useEffect(() => { CometChat.getUser(product.sellerUid).then(setAgent); }, [product.sellerUid]);
return (
<ScrollView style={{ flex: 1 }} keyboardShouldPersistTaps="handled">
<ProductImages images={product.images} />
<ProductSpecs product={product} />
<View style={{ marginTop: 24 }}>
<Text style={{ fontSize: 18, fontWeight: "600", padding: 16 }}>Chat with seller</Text>
<View style={{ height: 480 }}>
{agent && (
<>
<CometChatMessageHeader user={agent} />
<CometChatMessageList user={agent} hideReplyInThreadOption />
<CometChatMessageComposer user={agent} />
</>
)}
</View>
</View>
</ScrollView>
);
}
Embedded gotchas
- Fixed height required. CometChat components fill 100% of their parent. If you put them inside a
ScrollViewwithout a bounded height, the list collapses to zero height. Wrap in a<View style={{ height: NNN }}>or flex container with an explicit height. - Scroll conflict. If the parent is a
ScrollView, the message list's internal scroll competes with the parent's scroll. Consider the single-thread-as-stack-screen pattern instead if the chat is a primary UX. - Composer focus. When the user taps the composer, the keyboard rises and can push the embedded chat off-screen on iOS.
keyboardShouldPersistTaps="handled"on the parent ScrollView +KeyboardAvoidingViewat the root help.
Usually the embedded pattern is the wrong default — prefer a Modal or BottomSheet trigger from a button on the screen, which gives users a dedicated surface for chatting.
Hard rules
These apply to ALL placement patterns. Violating any of them causes integration bugs or destroys the existing navigation.
-
NEVER modify the project's existing navigator without reading it first. Understand what's there before adding screens or tabs. Don't replace a user's navigation structure unless they explicitly chose "demo mode."
-
ALWAYS use a separate screen / stack entry for chat, not inline replacement of an existing screen. The one exception is embedded placement (§ 5) where chat is explicitly part of a bigger screen.
-
The four-wrapper chain is required at the app root, not per-screen (see
cometchat-native-core§ 3). Re-wrapping per screen causes duplicate init + login, dropped WebSockets, and a 2–3-second flicker on first mount. -
import "react-native-gesture-handler"must be at the very top ofindex.js(or Expo entry). Missing this import silently disables swipe gestures in the composer, bottom sheet, and attachment drawer. -
Every
<CometChatMessageList>MUST includehideReplyInThreadOptionunless the integration also wires a full thread panel (CometChatThreadHeader+ scoped list + scoped composer withparentMessageId). Drawer / modal / bottom sheet / embedded / stack-screen placements without a thread panel must include the flag — otherwise "Reply in Thread" shows in the message menu and silently does nothing. -
Resolve user / group before rendering. The component props
userandgroupexpectCometChat.UserandCometChat.Groupinstances — not bare UID strings. Fetch viaCometChat.getUser(uid)/CometChat.getGroup(guid)in auseEffectand gate the render on the resolved object. -
Pass either
userorgroup, never both. Passing both causes runtime errors. Branch in render based on which one is set. -
Every CometChat container must have explicit flex height. Components fill 100% of parent. If parent has no bounded height (
flex: 1,height: N, or inside a flex layout withflex: N), components collapse to zero height and render empty. This is THE most common "why is my chat blank" bug. -
For modals and bottom sheets, set
keyboardShouldPersistTaps="handled"on any ScrollView / FlatList parent and configure keyboard behavior explicitly. Otherwise the composer gets hidden by the keyboard on iOS. -
Never animate a CometChat-containing container with
transform(including Tailwind'stranslate-x-*/translate-y-*/scale-*/rotate-*utilities if using NativeWind).transformcreates a new containing block forposition: "absolute"descendants, which reparents CometChat's absolute-positioned overlays (emoji picker, action sheet, reactions popover) and makes them misalign. In RN this is less common than web (RN has noposition: fixed) but the same rule applies to anyposition: absolutepickers. Animateright/left/top/bottomoffsets instead.
Skill routing reference
| Skill | When to route |
|---|---|
cometchat-native-core |
Always first — init, login, provider wrapper chain |
cometchat-native-components |
For component prop details — always |
cometchat-native-placement |
This skill — picking + wiring a placement |
cometchat-native-expo-patterns |
Expo-specific integration (app.json, permissions, Expo Router) |
cometchat-native-bare-patterns |
Bare RN (pod install, native modules, privacy manifest) |
cometchat-native-theming |
Customize colors / typography / dark mode |
cometchat-native-features |
Calls, extensions, AI — the "add a feature" flow |
cometchat-native-customization |
Custom slot views, text formatters, events |
cometchat-native-production |
Server-side auth tokens |
cometchat-native-troubleshooting |
Blank chat / gestures not working / keyboard covering composer / pod install fails |