tasq/.claude/skills/moai-platform-supabase/modules/realtime-presence.md

355 lines
7.6 KiB
Markdown

---
name: realtime-presence
description: Real-time subscriptions and presence tracking for collaborative features
parent-skill: moai-platform-supabase
version: 1.0.0
updated: 2026-01-06
---
# Real-time and Presence Module
## Overview
Supabase provides real-time capabilities through Postgres Changes (database change notifications) and Presence (user state tracking) for building collaborative applications.
## Postgres Changes Subscription
### Basic Setup
Subscribe to all changes on a table:
```typescript
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
const channel = supabase.channel('db-changes')
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'messages' },
(payload) => console.log('Change:', payload)
)
.subscribe()
```
### Event Types
Available events:
- `INSERT` - New row added
- `UPDATE` - Row modified
- `DELETE` - Row removed
- `*` - All events
### Filtered Subscriptions
Filter changes by specific conditions:
```typescript
supabase.channel('project-updates')
.on('postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'projects',
filter: `id=eq.${projectId}`
},
(payload) => handleProjectUpdate(payload.new)
)
.subscribe()
```
### Multiple Tables
Subscribe to multiple tables on one channel:
```typescript
const channel = supabase.channel('app-changes')
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'tasks' },
handleTaskChange
)
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'comments' },
handleCommentChange
)
.subscribe()
```
## Presence Tracking
### Presence State Interface
```typescript
interface PresenceState {
user_id: string
online_at: string
typing?: boolean
cursor?: { x: number; y: number }
}
```
### Channel Setup with Presence
```typescript
const channel = supabase.channel('room:collaborative-doc', {
config: { presence: { key: userId } }
})
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState<PresenceState>()
console.log('Online users:', Object.keys(state))
})
.on('presence', { event: 'join' }, ({ key, newPresences }) => {
console.log('User joined:', key, newPresences)
})
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
console.log('User left:', key, leftPresences)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: userId,
online_at: new Date().toISOString()
})
}
})
```
### Update Presence State
Update user presence in real-time:
```typescript
// Track typing status
await channel.track({ typing: true })
// Track cursor position
await channel.track({ cursor: { x: 100, y: 200 } })
// Clear typing after timeout
setTimeout(async () => {
await channel.track({ typing: false })
}, 1000)
```
## Collaborative Features
### Collaborative Cursors
```typescript
interface CursorState {
user_id: string
user_name: string
cursor: { x: number; y: number }
color: string
}
function setupCollaborativeCursors(documentId: string, userId: string, userName: string) {
const channel = supabase.channel(`cursors:${documentId}`, {
config: { presence: { key: userId } }
})
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7']
const userColor = colors[Math.abs(userId.hashCode()) % colors.length]
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState<CursorState>()
renderCursors(Object.values(state).flat())
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: userId,
user_name: userName,
cursor: { x: 0, y: 0 },
color: userColor
})
}
})
// Track mouse movement
document.addEventListener('mousemove', async (e) => {
await channel.track({
user_id: userId,
user_name: userName,
cursor: { x: e.clientX, y: e.clientY },
color: userColor
})
})
return channel
}
```
### Live Editing Indicators
```typescript
interface EditingState {
user_id: string
user_name: string
editing_field: string | null
}
function setupFieldLocking(formId: string) {
const channel = supabase.channel(`form:${formId}`, {
config: { presence: { key: currentUserId } }
})
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState<EditingState>()
updateFieldLocks(Object.values(state).flat())
})
.subscribe()
return {
startEditing: async (fieldName: string) => {
await channel.track({
user_id: currentUserId,
user_name: currentUserName,
editing_field: fieldName
})
},
stopEditing: async () => {
await channel.track({
user_id: currentUserId,
user_name: currentUserName,
editing_field: null
})
}
}
}
```
## Broadcast Messages
Send arbitrary messages to channel subscribers:
```typescript
const channel = supabase.channel('room:chat')
// Subscribe to broadcasts
channel
.on('broadcast', { event: 'message' }, ({ payload }) => {
console.log('Received:', payload)
})
.subscribe()
// Send broadcast
await channel.send({
type: 'broadcast',
event: 'message',
payload: { text: 'Hello everyone!', sender: userId }
})
```
## Subscription Management
### Unsubscribe
```typescript
// Unsubscribe from specific channel
await supabase.removeChannel(channel)
// Unsubscribe from all channels
await supabase.removeAllChannels()
```
### Subscription Status
```typescript
channel.subscribe((status) => {
switch (status) {
case 'SUBSCRIBED':
console.log('Connected to channel')
break
case 'CLOSED':
console.log('Channel closed')
break
case 'CHANNEL_ERROR':
console.log('Channel error')
break
case 'TIMED_OUT':
console.log('Connection timed out')
break
}
})
```
## React Integration
### Custom Hook for Presence
```typescript
import { useEffect, useState } from 'react'
import { supabase } from './supabase/client'
export function usePresence<T>(channelName: string, userId: string, initialState: T) {
const [presences, setPresences] = useState<Record<string, T[]>>({})
useEffect(() => {
const channel = supabase.channel(channelName, {
config: { presence: { key: userId } }
})
channel
.on('presence', { event: 'sync' }, () => {
setPresences(channel.presenceState<T>())
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track(initialState)
}
})
return () => {
supabase.removeChannel(channel)
}
}, [channelName, userId])
const updatePresence = async (state: Partial<T>) => {
const channel = supabase.getChannels().find(c => c.topic === channelName)
if (channel) {
await channel.track({ ...initialState, ...state } as T)
}
}
return { presences, updatePresence }
}
```
### Usage
```typescript
function CollaborativeEditor({ documentId, userId }) {
const { presences, updatePresence } = usePresence(
`doc:${documentId}`,
userId,
{ user_id: userId, typing: false, cursor: null }
)
return (
<div>
{Object.values(presences).flat().map(p => (
<Cursor key={p.user_id} position={p.cursor} />
))}
</div>
)
}
```
## Context7 Query Examples
For latest real-time documentation:
Topic: "realtime postgres_changes subscription"
Topic: "presence tracking channel"
Topic: "broadcast messages supabase"
---
Related Modules:
- typescript-patterns.md - Client architecture
- auth-integration.md - Authenticated subscriptions