> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shipnative.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Realtime Features

> Build chat apps, activity feeds, and presence tracking

Shipnative includes ready-to-use hooks for common realtime patterns like chat, presence tracking, and live updates.

## Backend Comparison

| Feature | Supabase | Convex |
| - | - | - |
| **Realtime Mechanism** | PostgreSQL Changes | Built-in reactivity |
| **Setup Required** | Enable per table | Automatic |
| **Subscriptions** | Manual setup | Automatic with `useQuery` |
| **Typing Indicators** | Via Presence channel | Via mutations |
| **Presence** | Via Presence API | Via queries |

***

## Quick Start

> [tab] Supabase
>
> ```typescript theme={null}
> import { useRealtimeMessages } from '@/hooks/useRealtimeMessages'
>
> function ChatRoom({ channelId }: { channelId: string }) {
>   const { messages, sendMessage, isConnected } = useRealtimeMessages({ channelId })
>
>   return (
>     <View>
>       {messages.map(msg => <Text key={msg.id}>{msg.content}</Text>)}
>       <Button onPress={() => sendMessage('Hello!')} title="Send" />
>     </View>
>   )
> }
> ```

> [tab] Convex
>
> ```typescript theme={null}
> import { useQuery, useMutation } from '@/hooks/convex'
> import { api } from '@convex/_generated/api'
>
> function ChatRoom({ channelId }: { channelId: string }) {
>   // Messages auto-update in real-time
>   const messages = useQuery(api.messages.list, { channelId })
>   const sendMessage = useMutation(api.messages.send)
>
>   return (
>     <View>
>       {messages?.map(msg => <Text key={msg._id}>{msg.content}</Text>)}
>       <Button
>         onPress={() => sendMessage({ channelId, content: 'Hello!' })}
>         title="Send"
>       />
>     </View>
>   )
> }
> ```

***

## Chat with Supabase

### useRealtimeMessages

Full-featured chat hook with typing indicators.

```typescript theme={null}
const {
  messages,           // Current messages
  sendMessage,        // Send a new message
  updateMessage,      // Edit a message
  deleteMessage,      // Delete a message
  typingUsers,        // Users currently typing
  setTyping,          // Broadcast typing state
  isConnected,        // Connection status
  loading,            // Loading state
  error,              // Error state
  refresh,            // Refresh messages
} = useRealtimeMessages({
  channelId: 'room-123',
  maxMessages: 100,
  onNewMessage: (msg) => playNotificationSound(),
  onTypingChange: (users) => console.log('Typing:', users),
})
```

> [accordion] Full Chat Example
>
> ```typescript theme={null}
> function ChatRoom({ channelId }: { channelId: string }) {
>   const [inputText, setInputText] = useState('')
>   const {
>     messages,
>     sendMessage,
>     typingUsers,
>     setTyping,
>     isConnected,
>     loading,
>   } = useRealtimeMessages({
>     channelId,
>     onNewMessage: () => Haptics.impactAsync(),
>   })
>
>   const handleSend = async () => {
>     if (!inputText.trim()) return
>     const { error } = await sendMessage(inputText)
>     if (!error) setInputText('')
>   }
>
>   const handleInputChange = (text: string) => {
>     setInputText(text)
>     setTyping(text.length > 0)
>   }
>
>   if (loading) return <LoadingSpinner />
>
>   return (
>     <View style={{ flex: 1 }}>
>       {!isConnected && <Banner text="Reconnecting..." />}
>
>       <FlatList
>         data={messages}
>         renderItem={({ item }) => (
>           <MessageBubble
>             content={item.content}
>             isOwn={item.user_id === currentUserId}
>             timestamp={item.created_at}
>           />
>         )}
>         keyExtractor={(item) => item.id}
>         inverted
>       />
>
>       {typingUsers.length > 0 && (
>         <Text style={styles.typing}>
>           {typingUsers.join(', ')} typing...
>         </Text>
>       )}
>
>       <View style={styles.inputRow}>
>         <TextInput
>           value={inputText}
>           onChangeText={handleInputChange}
>           placeholder="Type a message..."
>         />
>         <Button onPress={handleSend} title="Send" />
>       </View>
>     </View>
>   )
> }
> ```

***

### useRealtimePresence

Track online users with status indicators.

```typescript theme={null}
const {
  presentUsers,       // List of online users
  userCount,          // Number of users online
  isConnected,        // Connection status
  updateStatus,       // Update your status
  updateCustomData,   // Update custom presence data
  isUserOnline,       // Check if specific user is online
  getUserPresence,    // Get user's presence state
} = useRealtimePresence({
  channelName: 'room:123',
  initialStatus: 'online',
  customData: { currentScreen: 'chat' },
  onUserJoin: (user) => console.log(`${user.user_id} joined`),
  onUserLeave: (userId) => console.log(`${userId} left`),
})
```

***

### useRealtimeSubscription

Generic hook for subscribing to any table changes.

```typescript theme={null}
const { isConnected, error, reconnect, disconnect } = useRealtimeSubscription<Order>({
  table: 'orders',
  event: 'INSERT',  // 'INSERT' | 'UPDATE' | 'DELETE' | '*'
  filter: { column: 'user_id', value: userId },
  onInsert: (order) => toast.success(`New order #${order.id}!`),
  onUpdate: (order, oldOrder) => console.log('Updated:', order),
  onDelete: (oldOrder) => console.log('Deleted:', oldOrder.id),
})
```

***

## Chat with Convex

With Convex, queries are automatically reactive - no manual subscription setup needed.

### Messages Query

```typescript theme={null}
// convex/messages.ts
import { query, mutation } from './_generated/server'
import { v } from 'convex/values'

export const list = query({
  args: { channelId: v.string() },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('messages')
      .withIndex('by_channel', (q) => q.eq('channelId', args.channelId))
      .order('desc')
      .take(100)
  },
})

export const send = mutation({
  args: { channelId: v.string(), content: v.string() },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error('Not authenticated')

    return await ctx.db.insert('messages', {
      channelId: args.channelId,
      content: args.content,
      userId: identity.subject,
      createdAt: Date.now(),
    })
  },
})
```

### Using in Components

```typescript theme={null}
import { useQuery, useMutation } from '@/hooks/convex'
import { api } from '@convex/_generated/api'

function ChatRoom({ channelId }: { channelId: string }) {
  const messages = useQuery(api.messages.list, { channelId })
  const sendMessage = useMutation(api.messages.send)
  const [inputText, setInputText] = useState('')

  // messages automatically updates when new messages arrive!

  const handleSend = async () => {
    if (!inputText.trim()) return
    await sendMessage({ channelId, content: inputText })
    setInputText('')
  }

  if (messages === undefined) return <LoadingSpinner />

  return (
    <View style={{ flex: 1 }}>
      <FlatList
        data={messages}
        renderItem={({ item }) => (
          <MessageBubble
            content={item.content}
            isOwn={item.userId === currentUserId}
            timestamp={item.createdAt}
          />
        )}
        keyExtractor={(item) => item._id}
        inverted
      />

      <View style={styles.inputRow}>
        <TextInput
          value={inputText}
          onChangeText={setInputText}
          placeholder="Type a message..."
        />
        <Button onPress={handleSend} title="Send" />
      </View>
    </View>
  )
}
```

### Presence with Convex

The boilerplate includes ready-to-use presence functions in `convex/realtime.ts`:

```typescript theme={null}
import { useQuery, useMutation } from '@/hooks/convex'
import { api } from '@convex/_generated/api'

function OnlineUsers({ channel }: { channel: string }) {
  // Get all users in this channel (auto-updates!)
  const presence = useQuery(api.realtime.getPresence, { channel })

  // Join/leave channel
  const joinPresence = useMutation(api.realtime.joinPresence)
  const leavePresence = useMutation(api.realtime.leavePresence)
  const updatePresence = useMutation(api.realtime.updatePresence)

  useEffect(() => {
    // Join when component mounts
    joinPresence({ channel, state: { status: 'online' } })

    // Leave when component unmounts
    return () => {
      leavePresence({ channel })
    }
  }, [channel])

  return (
    <View>
      <Text>{presence?.count} users online</Text>
      {presence?.users.map(user => (
        <Text key={user.presenceId}>{user.user?.name}</Text>
      ))}
    </View>
  )
}
```

### Presence Functions

| Function | Description |
| - | - |
| `joinPresence({ channel, state })` | Join a channel with optional state |
| `updatePresence({ channel, state })` | Update your presence state (heartbeat) |
| `leavePresence({ channel })` | Leave a channel |
| `getPresence({ channel })` | Get all users in a channel (reactive) |

### Custom Presence State

Store any data with your presence:

```typescript theme={null}
// Track cursor position
await updatePresence({
  channel: 'document:123',
  state: {
    cursor: { x: 100, y: 200 },
    status: 'editing',
    color: '#ff0000',
  }
})
```

### Broadcast with Convex

Send ephemeral messages to channel subscribers:

```typescript theme={null}
import { useQuery, useMutation } from '@/hooks/convex'
import { api } from '@convex/_generated/api'

function RealtimeUpdates({ channel }: { channel: string }) {
  // Subscribe to broadcasts (auto-updates!)
  const broadcasts = useQuery(api.realtime.subscribeBroadcast, {
    channel,
    since: Date.now() - 30000, // Last 30 seconds
  })

  // Send a broadcast
  const broadcast = useMutation(api.realtime.broadcast)

  const sendCursorMove = (x: number, y: number) => {
    broadcast({
      channel,
      event: 'cursor_move',
      payload: { x, y },
    })
  }

  return (
    <View>
      {broadcasts?.messages.map(msg => (
        <Text key={msg.id}>
          {msg.event}: {JSON.stringify(msg.payload)}
        </Text>
      ))}
    </View>
  )
}
```

### Broadcast Functions

| Function | Description |
| - | - |
| `broadcast({ channel, event, payload })` | Send a message to all subscribers |
| `subscribeBroadcast({ channel, since })` | Get recent messages (reactive) |

> [!TIP]
>
> **Auto-Cleanup:** Broadcast messages expire after 30 seconds and are automatically cleaned up by a cron job. This keeps your database lean for ephemeral data like cursor positions.

***

## Database Setup

> [tab] Supabase
>
> ### Messages Table
>
> ```sql theme={null}
> CREATE TABLE public.messages (
>     id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
>     channel_id TEXT NOT NULL,
>     user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
>     content TEXT NOT NULL,
>     metadata JSONB DEFAULT '{}',
>     created_at TIMESTAMPTZ DEFAULT NOW(),
>     updated_at TIMESTAMPTZ
> );
>
> ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
>
> CREATE POLICY "Users can read messages in their channels"
>   ON public.messages FOR SELECT TO authenticated USING (true);
>
> CREATE POLICY "Users can insert their own messages"
>   ON public.messages FOR INSERT TO authenticated
>   WITH CHECK (auth.uid() = user_id);
>
> -- Enable realtime
> ALTER PUBLICATION supabase_realtime ADD TABLE public.messages;
>
> CREATE INDEX idx_messages_channel_id ON public.messages(channel_id);
> CREATE INDEX idx_messages_created_at ON public.messages(created_at DESC);
> ```
>
> ### Enable Realtime
>
> In Supabase Dashboard:
>
> 1. Go to **Database** → **Replication**
> 2. Under "Realtime", enable for your tables
> 3. Or run: `ALTER PUBLICATION supabase_realtime ADD TABLE your_table;`

> [tab] Convex
>
> ### Realtime Schema (Pre-configured)
>
> The boilerplate includes presence and broadcast tables in `convex/schema.ts`:
>
> ```typescript theme={null}
> // Presence tracking
> presence: defineTable({
>   channel: v.string(),
>   userId: v.id("users"),
>   state: v.optional(v.any()),
>   joinedAt: v.number(),
>   lastSeenAt: v.number(),
> })
>   .index("by_channel", ["channel"])
>   .index("by_channel_user", ["channel", "userId"])
>   .index("by_lastSeenAt", ["lastSeenAt"]),
>
> // Broadcast messages
> broadcasts: defineTable({
>   channel: v.string(),
>   event: v.string(),
>   payload: v.any(),
>   senderId: v.optional(v.id("users")),
>   createdAt: v.number(),
>   expiresAt: v.number(),
> })
>   .index("by_channel", ["channel"])
>   .index("by_channel_created", ["channel", "createdAt"])
>   .index("by_expiresAt", ["expiresAt"]),
> ```
>
> ### Automatic Cleanup
>
> Cron jobs in `convex/crons.ts` handle cleanup:
>
> * **Presence cleanup**: Every minute, removes stale presence older than 60 seconds
> * **Broadcast cleanup**: Every 5 minutes, removes expired broadcast messages
>
> Convex handles realtime automatically - no additional setup needed!

***

## Mock Mode

All realtime hooks work in mock mode for development without backend credentials:

* Messages are stored locally and persist during the session
* Presence shows the current user as online
* Subscriptions simulate connected state

> [!TIP]
>
> **Testing Tip:** Use mock mode to build and test your UI before connecting to a real backend.

***

## TypeScript Types

Import types for your realtime data:

```typescript theme={null}
import type {
  RealtimeMessage,
  RealtimeMessageWithUser,
  RealtimeActivity,
  PresenceState,
  TypingState,
} from '@/types/realtime'
```

See `apps/app/app/types/realtime.ts` for all available types.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.