Bỏ qua

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

  1. Cần hiển thị online users ở đâu?
  2. Có cần track activity status không?
  3. Multi-device support cần không?