ExamplesLive Presence

Live Presence

Track who is online in real time. Users announce their presence when they join, update it as they interact, and Aerostack automatically cleans up when they disconnect.

Demonstrates: channel.track(), presence:join, presence:update, presence:leave, auto-cleanup on disconnect

Source: examples/socket-hub/src/examples/LivePresence/

No server code required for this example — presence is entirely handled by the Aerostack WebSocket layer.


Client code

import { useAerostack } from '@aerostack/react'
import { useEffect, useRef, useState } from 'react'
 
interface PeerState {
  userId: string
  name: string
  color: string
  status: 'online' | 'away'
  joinedAt: number
}
 
interface PresenceEvent {
  type: 'join' | 'update' | 'leave'
  userId: string
  name: string
  timestamp: number
}
 
const COLORS = ['#FF5733', '#3498DB', '#2ECC71', '#F39C12', '#9B59B6', '#E74C3C', '#1ABC9C']
const NAMES = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace']
 
function randomFrom<T>(arr: T[]): T {
  return arr[Math.floor(Math.random() * arr.length)]
}
 
export function LivePresence() {
  const { realtime } = useAerostack()
  const [peers, setPeers] = useState<PeerState[]>([])
  const [events, setEvents] = useState<PresenceEvent[]>([])
 
  const myId = useRef(`user-${Math.random().toString(36).slice(2, 8)}`)
  const myName = useRef(randomFrom(NAMES))
  const myColor = useRef(randomFrom(COLORS))
 
  const addEvent = (type: PresenceEvent['type'], userId: string, name: string) => {
    setEvents(prev => [
      { type, userId, name, timestamp: Date.now() },
      ...prev.slice(0, 19),  // keep last 20 events
    ])
  }
 
  useEffect(() => {
    const channel = realtime.channel('presence/demo-room')
 
    channel
      .on('presence:join', ({ data }) => {
        setPeers(prev => {
          if (prev.find(p => p.userId === data.userId)) return prev
          return [...prev, data]
        })
        addEvent('join', data.userId, data.name)
      })
      .on('presence:update', ({ data }) => {
        setPeers(prev => prev.map(p =>
          p.userId === data.userId ? { ...p, ...data } : p
        ))
      })
      .on('presence:leave', ({ data }) => {
        setPeers(prev => prev.filter(p => p.userId !== data.userId))
        addEvent('leave', data.userId, data.name)
      })
      .subscribe()
 
    // Announce our presence
    channel.track({
      userId: myId.current,
      name: myName.current,
      color: myColor.current,
      status: 'online',
      joinedAt: Date.now(),
    })
 
    // Go "away" after 30s of inactivity
    let awayTimer: ReturnType<typeof setTimeout>
    const resetAwayTimer = () => {
      clearTimeout(awayTimer)
      channel.track({
        userId: myId.current,
        name: myName.current,
        color: myColor.current,
        status: 'online',
        joinedAt: Date.now(),
      })
      awayTimer = setTimeout(() => {
        channel.track({
          userId: myId.current,
          name: myName.current,
          color: myColor.current,
          status: 'away',
          joinedAt: Date.now(),
        })
      }, 30_000)
    }
 
    document.addEventListener('mousemove', resetAwayTimer)
    document.addEventListener('keydown', resetAwayTimer)
 
    return () => {
      clearTimeout(awayTimer)
      document.removeEventListener('mousemove', resetAwayTimer)
      document.removeEventListener('keydown', resetAwayTimer)
      channel.untrack()
      channel.unsubscribe()
    }
  }, [realtime])
 
  return (
    <div className="max-w-2xl mx-auto p-6 space-y-8">
      <div>
        <h2 className="text-xl font-bold text-white mb-4">
          Online ({peers.length})
        </h2>
        <div className="flex gap-3 flex-wrap">
          {peers.map(peer => (
            <div
              key={peer.userId}
              className="flex items-center gap-2 px-4 py-2 rounded-full text-white text-sm font-medium"
              style={{ background: peer.color + '33', border: `1px solid ${peer.color}66` }}
            >
              <span
                className={`w-2 h-2 rounded-full ${peer.status === 'online' ? 'bg-green-400' : 'bg-yellow-400'}`}
              />
              <span style={{ color: peer.color }}>{peer.name}</span>
              {peer.userId === myId.current && (
                <span className="text-xs opacity-60">(you)</span>
              )}
            </div>
          ))}
        </div>
      </div>
 
      <div>
        <h2 className="text-xl font-bold text-white mb-4">Event log</h2>
        <div className="space-y-1">
          {events.map((event, i) => (
            <div key={i} className="text-sm text-gray-400">
              <span className="text-gray-600 mr-2">
                {new Date(event.timestamp).toLocaleTimeString()}
              </span>
              <span className={
                event.type === 'join' ? 'text-green-400' :
                event.type === 'leave' ? 'text-red-400' :
                'text-yellow-400'
              }>
                {event.name} {event.type === 'join' ? 'joined' : event.type === 'leave' ? 'left' : 'updated'}
              </span>
            </div>
          ))}
        </div>
      </div>
    </div>
  )
}

How auto-cleanup works

  • User’s browser connects via WebSocket and calls channel.track(state)
  • Aerostack stores the presence state in memory
  • When the connection closes (tab closes, network loss, etc.), Aerostack starts a 90-second grace period
  • If the user reconnects within 90s, their presence is restored
  • After 90s, Aerostack emits presence:leave to all subscribers and removes the state