Presence
Presence là gì?
Track Online Users
┌─────────────────────────────────────────────────────────────┐
│ PRESENCE │
├─────────────────────────────────────────────────────────────┤
│ │
│ Channel: "room:project-123" │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Presence State │ │
│ │ │ │
│ │ user_1: { online_at: "...", name: "Alice" } │ │
│ │ user_2: { online_at: "...", name: "Bob" } │ │
│ │ user_3: { online_at: "...", name: "Charlie" } │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Events: │
│ - sync: Full state update │
│ - join: User joined │
│ - leave: User left │
│ │
│ Use cases: │
│ - Online users list │
│ - "Who's viewing this document" │
│ - Active collaborators │
│ │
└─────────────────────────────────────────────────────────────┘
Basic Presence
Track and Sync
const channel = supabase.channel('room:lobby');
// Listen for presence sync
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
console.log('Online users:', state);
});
// Listen for join events
channel.on('presence', { event: 'join' }, ({ key, newPresences }) => {
console.log('User joined:', key, newPresences);
});
// Listen for leave events
channel.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
console.log('User left:', key, leftPresences);
});
// Subscribe and track
await channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
// Track this user's presence
await channel.track({
user_id: userId,
name: userName,
online_at: new Date().toISOString(),
});
}
});
Presence State Structure
Understanding State
// presenceState() returns:
{
"user-123": [
{
user_id: "user-123",
name: "Alice",
online_at: "2024-01-15T10:00:00Z",
presence_ref: "abc123"
}
],
"user-456": [
{
user_id: "user-456",
name: "Bob",
online_at: "2024-01-15T10:05:00Z",
presence_ref: "def456"
}
]
}
// Note: Key is the first property of tracked object
// Array allows same user on multiple devices
Get Online Users List
function getOnlineUsers(presenceState: any) {
const users = [];
for (const [key, presences] of Object.entries(presenceState)) {
// Get first presence (or combine multiple)
const presence = (presences as any[])[0];
users.push({
userId: presence.user_id,
name: presence.name,
onlineAt: presence.online_at,
});
}
return users;
}
React Hook for Presence
usePresence Hook
import { useEffect, useState, useRef } from 'react';
import { createClient } from '@/lib/supabase/client';
import { RealtimeChannel } from '@supabase/supabase-js';
type UserPresence = {
userId: string;
name: string;
avatarUrl?: string;
onlineAt: string;
};
export function usePresence(roomId: string, currentUser: {
id: string;
name: string;
avatarUrl?: string;
}) {
const [onlineUsers, setOnlineUsers] = useState<UserPresence[]>([]);
const channelRef = useRef<RealtimeChannel | null>(null);
const supabase = createClient();
useEffect(() => {
const channel = supabase.channel(`presence:${roomId}`);
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
const users = Object.values(state)
.flat()
.map((p: any) => ({
userId: p.userId,
name: p.name,
avatarUrl: p.avatarUrl,
onlineAt: p.onlineAt,
}));
setOnlineUsers(users);
});
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
userId: currentUser.id,
name: currentUser.name,
avatarUrl: currentUser.avatarUrl,
onlineAt: new Date().toISOString(),
});
}
});
channelRef.current = channel;
return () => {
channel.untrack();
supabase.removeChannel(channel);
};
}, [roomId, currentUser.id]);
return onlineUsers;
}
Using the Hook
function OnlineUsers({ roomId }: { roomId: string }) {
const currentUser = useCurrentUser(); // Your auth hook
const onlineUsers = usePresence(roomId, {
id: currentUser.id,
name: currentUser.name,
avatarUrl: currentUser.avatarUrl,
});
return (
<div className="online-users">
<h3>Online ({onlineUsers.length})</h3>
<ul>
{onlineUsers.map((user) => (
<li key={user.userId}>
<img
src={user.avatarUrl || '/default-avatar.png'}
alt={user.name}
/>
<span>{user.name}</span>
{user.userId === currentUser.id && <span>(you)</span>}
</li>
))}
</ul>
</div>
);
}
Update Presence
Track Status Changes
// Update presence state
await channel.track({
userId: userId,
name: userName,
status: 'active', // active, away, busy
onlineAt: new Date().toISOString(),
});
// Later, update status
await channel.track({
userId: userId,
name: userName,
status: 'away',
onlineAt: new Date().toISOString(),
});
Activity Detection
function useActivityDetection(channel: RealtimeChannel, user: User) {
useEffect(() => {
let timeout: NodeJS.Timeout;
const setActive = () => {
channel.track({
userId: user.id,
name: user.name,
status: 'active',
lastActive: new Date().toISOString(),
});
clearTimeout(timeout);
timeout = setTimeout(() => {
channel.track({
userId: user.id,
name: user.name,
status: 'away',
lastActive: new Date().toISOString(),
});
}, 60000); // 1 minute inactivity
};
// Track activity
window.addEventListener('mousemove', setActive);
window.addEventListener('keydown', setActive);
return () => {
window.removeEventListener('mousemove', setActive);
window.removeEventListener('keydown', setActive);
clearTimeout(timeout);
};
}, [channel, user]);
}
Multiple Devices
Same User, Multiple Sessions
// User opens app on phone and laptop
// presenceState shows both:
{
"user-123": [
{
userId: "user-123",
device: "laptop",
presence_ref: "ref1"
},
{
userId: "user-123",
device: "phone",
presence_ref: "ref2"
}
]
}
// Track with device info
await channel.track({
userId: user.id,
name: user.name,
device: detectDevice(), // 'mobile', 'desktop', 'tablet'
});
Document Viewers Pattern
"Who's viewing this document"
function DocumentViewers({ documentId }: { documentId: string }) {
const [viewers, setViewers] = useState<any[]>([]);
const supabase = createClient();
const { user } = useAuth();
useEffect(() => {
const channel = supabase.channel(`doc:${documentId}`);
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
const viewerList = Object.values(state).flat();
setViewers(viewerList);
});
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
id: user.id,
name: user.name,
avatar: user.avatar,
viewingSince: new Date().toISOString(),
});
}
});
return () => {
channel.untrack();
supabase.removeChannel(channel);
};
}, [documentId, user]);
// Show max 3 avatars + count
const displayViewers = viewers.slice(0, 3);
const remainingCount = Math.max(0, viewers.length - 3);
return (
<div className="viewers">
{displayViewers.map((v) => (
<img
key={v.id}
src={v.avatar}
alt={v.name}
title={v.name}
className="viewer-avatar"
/>
))}
{remainingCount > 0 && (
<span className="more-viewers">+{remainingCount}</span>
)}
</div>
);
}
Tổng kết
Presence Events
| Event |
Description |
sync |
Full state update (use presenceState()) |
join |
User joined the channel |
leave |
User left the channel |
Key Methods
// Subscribe and track
await channel.subscribe();
await channel.track({ ...userData });
// Get current state
const state = channel.presenceState();
// Update presence
await channel.track({ ...updatedData });
// Leave/untrack
await channel.untrack();
Best Practices
✅ Include user ID in tracked data
✅ Handle multi-device scenarios
✅ Cleanup on unmount (untrack)
✅ Show activity status (active/away)
✅ Limit displayed avatars (show +N)
Q&A
- Cần hiển thị online users ở đâu?
- Có cần track activity status không?
- Multi-device support cần không?