Cariosan

React Native SDK

Drop-in mobile chat UI for iOS and Android, built on the same headless core as the web kit.

5 min readUpdated Jun 1, 2026

@cariosan/react-native is the mobile UI kit — native components (View/FlatList/TextInput) rendering the same chat experience as the web kit. It reuses the framework-agnostic core: @cariosan/client (transport) and the DOM-free hooks from @cariosan/react run unchanged in React Native, so engagement features (quoted reply, @-mentions, read receipts, search) behave identically to web.

What the kit gives you — and what you build

The kit ships building blocks, not whole screens. That's deliberate: chat UX is app-specific, and we don't want to drag a navigation library, a media picker, or Firebase into your bundle. The kit stays dependency-light; you bring your own platform pieces.

In the kit (@cariosan/react-native + @cariosan/client) — the engine + reusable UI:

  • Components: MessageList, MessageInput, ChannelList, TypingIndicator, PresenceBadge.
  • Hooks: useMessages (with reconnect resync), useChannels, usePresence, useReadReceipts, useMessageActions, useMessageSelection, useReplyDraft, …
  • All the logic on client / channel: send / edit / delete, reactions, receipts, search, @-mentions, attachment upload, group avatars + roles (updateChannel / addMembers / setMemberRole / removeMember), presence (with role), lastMessage preview.

You build (the app layer) — what's UX- or platform-specific:

  • Screens + navigation — chat list, chat room, group info, etc. Use any router (Expo Router, React Navigation, or a simple state switch); the kit doesn't impose one.
  • Auth — get a user JWT from your backend. (The example's "paste a JWT" login is a test shim, not production auth.)
  • Attachment picker UI — the kit gives you the hooks (MessageInput's onAttachPress + attachmentPreview + onSendAttachment, plus channel.uploadAttachment); you supply the picker with the library you prefer (expo-image-picker, react-native-image-picker, …).
  • Push — register the token via client.registerDevice(...); the Firebase / native wiring is yours (BYO).

The example is a reference, not a dependency

examples/mobile-react-native wires all of the above into a full app (chat list, group info, member actions, attachment panel). Copy its screens as a starting point — but they live in the example, not in the published @cariosan/* packages. A real app composes the kit the same way, with its own design.

Install

pnpm add @cariosan/client @cariosan/react @cariosan/react-native
# peers: react + react-native (provided by your app, e.g. Expo)

Provider

Wrap your app once. You own the SDK lifecycle (construct, connect, pause on background) — the provider just shares the client:

App.tsx
import { Cariosan } from "@cariosan/client";
import { ChatProvider, ReplyDraftProvider, MessageSelectionProvider } from "@cariosan/react-native";
 
const client = new Cariosan({
  apiUrl: "https://api.example.com",
  // Called for the first token and for every renewal; see "Tokens" below.
  token: async () => {
    const res = await fetch("https://your-backend.example.com/chat-token", {
      method: "POST",
      headers: { Authorization: `Bearer ${await getAppSession()}` }, // your app's own auth
    });
    if (!res.ok) throw new Error(`chat token: ${res.status}`);
    return (await res.json()).token as string;
  },
});
await client.connect();
 
export default function App() {
  return (
    <ChatProvider client={client}>
      <ReplyDraftProvider>
        {/* optional: enables hold-to-select (highlight + quick reactions) */}
        <MessageSelectionProvider>
          <ChatScreen />
        </MessageSelectionProvider>
      </ReplyDraftProvider>
    </ChatProvider>
  );
}

Tokens. Pass a token provider (as above) instead of a fixed string, and create the client once. Do not rebuild it on renewal.

  • Renewal before expiry. The SDK fetches a new token shortly before the current one expires and moves the open socket to it without reconnecting.
  • Expired while backgrounded. JavaScript timers are suspended in the background, so the token may expire while the app sleeps. When the app wakes, the server refuses the old token; the SDK then asks the provider again and reconnects.
  • Do not store the chat token. There is no need: AsyncStorage is readable by anything with access to the app's storage, and the provider can always fetch a new token.

See Tokens for the full behaviour.

Components

ChatScreen.tsx
import {
  ChannelList,
  MessageList,
  MessageInput,
  TypingIndicator,
  PresenceBadge,
} from "@cariosan/react-native";
 
<MessageList channelId="order_42" currentUserId={myUserId} />
<TypingIndicator channelId="order_42" />
<MessageInput channelId="order_42" />
  • MessageList — a FlatList of messages in a familiar chat layout: day separators (Today / Yesterday / date), left/right bubbles (received in slate, your own right-aligned in emerald), bottom-right time + ✓/✓✓/✓✓-blue read-receipt ticks on your own messages, plus @-mention highlighting, quoted-reply previews (tap to scroll to the original), reaction pills, and edited/tombstone states. Inside a MessageSelectionProvider, long-press a message to highlight it and float a quick-reactions row (❤️ 👍 😂 …) above it. Props: currentUserId (internal id or external_id) decides which bubbles are "yours"; showSenderNames={false} hides the sender name on received bubbles for 1:1 chats (it shows by default, which suits group channels). Your own messages show a 🕒 while they are being sent and Not sent with Retry / Delete if the send fails; pass renderOutgoing to draw those rows yourself. While a channel is on screen the list marks it delivered and read so the sender's ticks advance (markReadOnView, default true; pass false if your app marks read elsewhere). The delivery ack travels over the WebSocket and is dropped while it is closed, so the list repeats it once the socket connects.
  • MessageInput — text composer with an @-mention autocomplete (tap to insert; pass mentionMembers or it lazily loads the roster), a 🙂 emoji inserter, a quote-reply chip, and a send button. Pass onAttachPress to show a 📎 button — your handler picks a file and stages it via attachmentPreview (a preview node) + onSendAttachment(caption), which fires when send is tapped (upload + send happen then, not on pick), e.g. channel.uploadAttachment(blob, { filename, mimeType }) → sendMessage({ attachmentUrl }). In MessageList, image attachments render as thumbnails and other files as a tappable document card; emoji-only messages render large and bubble-less.
  • ChannelList — the caller's channels with unread + mention badges.
  • TypingIndicator, PresenceBadge — live presence/typing.

Hold-to-select: drive your own action bar

MessageList only renders the in-place highlight + quick-reactions. The contextual action bar (reply / copy / delete / …) is yours to render — read useMessageSelection() in your header and swap the title for actions while a message is held, WhatsApp-style:

ChatHeader.tsx
import { useMessageSelection, useReplyDraft, useMessageActions } from "@cariosan/react-native";
import * as Clipboard from "expo-clipboard";
 
function ChatHeader({ channelId }: { channelId: string }) {
  const selection = useMessageSelection();
  const replyDraft = useReplyDraft();
  const { deleteMessage } = useMessageActions(channelId);
  const msg = selection?.selected;
  if (!msg) return <ContactTitle />; // normal header
 
  return (
    <ActionBar
      onClose={selection.clear}
      onReply={() => { replyDraft?.setReplyTo(msg); selection.clear(); }}
      onCopy={() => { void Clipboard.setStringAsync(msg.content); selection.clear(); }}
      onDelete={() => { void deleteMessage(msg.id); selection.clear(); }} // your own messages
    />
  );
}

deleteMessage is author-only (the server rejects deleting others' messages), so only show Delete when msg.user.id/external_id matches the current user. Copy needs a clipboard module — expo-clipboard works in Expo; the kit stays dependency-free.

Wrap your screen in a safe area

The kit components fill their parent and don't add device insets — that's the app's job. Without one, MessageInput's text field and send button sit behind the Android navigation bar (and the iOS home indicator) and can't be tapped. Use react-native-safe-area-context (bundled with Expo) — not react-native's SafeAreaView, which is iOS-only:

App.tsx
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
 
export default function App() {
  return (
    <SafeAreaProvider>
      <SafeAreaView style={{ flex: 1 }} edges={["top", "bottom"]}>
        {/* ChatProvider → MessageList / MessageInput … */}
      </SafeAreaView>
    </SafeAreaProvider>
  );
}

Hooks

The kit re-exports every hook from @cariosan/react (they're DOM-free) — useMessages, useMessageSender, useOutgoing, useChannels, useTyping, usePresence, useConnectionStatus, useReadReceipts, useMentions, useReplyDraft, useSearch, plus ChatProvider/useCariosan. Build fully custom RN UI on the hooks while reusing all the subscription/cursor plumbing. See the React hooks reference — the API is identical.

Styling

Components ship a sensible dark-slate default styled with StyleSheet. React Native has no CSS, so theming is via standard RN style props / forking the components — there's no stylesheet import like the web kit.

One core, every platform

@cariosan/client and the hooks are the single source of truth. The web (@cariosan/react) and mobile (@cariosan/react-native) kits differ only in their rendered components — so a feature shipped to the core lands on both.

Was this page helpful?

On this page