ExamplesGroup Chat

Group Chat

Multi-room chat with message history, presence tracking, and a participant list. Messages are persisted so new joiners can load previous conversations.

Demonstrates: channel.getHistory(), { persist: true }, presence in chat rooms, multi-room navigation

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


Client code

import { useAerostack } from '@aerostack/react'
import { useEffect, useRef, useState } from 'react'
 
const ROOMS = ['general', 'engineering', 'random', 'announcements']
const COLORS = ['#FF5733', '#3498DB', '#2ECC71', '#F39C12', '#9B59B6']
 
interface Message {
  id: string
  userId: string
  userName: string
  text: string
  timestamp: number
}
 
interface Participant {
  userId: string
  userName: string
  color: string
}
 
export function GroupChat() {
  const { realtime } = useAerostack()
  const [currentRoom, setCurrentRoom] = useState('general')
  const [messages, setMessages] = useState<Record<string, Message[]>>({})
  const [participants, setParticipants] = useState<Participant[]>([])
  const [inputText, setInputText] = useState('')
 
  const myId = useRef(`user-${Math.random().toString(36).slice(2, 8)}`)
  const myName = useRef(`User${Math.floor(Math.random() * 1000)}`)
  const myColor = useRef(COLORS[Math.floor(Math.random() * COLORS.length)])
  const channelRef = useRef<any>(null)
  const bottomRef = useRef<HTMLDivElement>(null)
 
  const joinRoom = async (roomId: string) => {
    // Leave previous room
    if (channelRef.current) {
      channelRef.current.untrack()
      channelRef.current.unsubscribe()
    }
 
    const channel = realtime.channel(`chat/${roomId}`)
    channelRef.current = channel
 
    // Load message history
    const history = await channel.getHistory(50)
    const loaded: Message[] = history.reverse().map(h => ({
      id: h.id,
      userId: h.user_id,
      userName: h.data.userName,
      text: h.data.text,
      timestamp: h.created_at,
    }))
    setMessages(prev => ({ ...prev, [roomId]: loaded }))
    setParticipants([])
 
    channel
      .on('message', ({ data, userId }) => {
        if (userId === myId.current) return // skip own (optimistic)
        setMessages(prev => ({
          ...prev,
          [roomId]: [...(prev[roomId] ?? []), data],
        }))
      })
      .on('presence:join', ({ data }) => {
        setParticipants(prev => {
          if (prev.find(p => p.userId === data.userId)) return prev
          return [...prev, data]
        })
      })
      .on('presence:leave', ({ data }) => {
        setParticipants(prev => prev.filter(p => p.userId !== data.userId))
      })
      .subscribe()
 
    // Announce presence in this room
    channel.track({
      userId: myId.current,
      userName: myName.current,
      color: myColor.current,
    })
 
    setCurrentRoom(roomId)
  }
 
  useEffect(() => {
    joinRoom('general')
    return () => {
      channelRef.current?.untrack()
      channelRef.current?.unsubscribe()
    }
  }, [realtime])
 
  // Auto-scroll
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages[currentRoom]])
 
  const sendMessage = () => {
    if (!inputText.trim()) return
 
    const msg: Message = {
      id: Date.now().toString(),
      userId: myId.current,
      userName: myName.current,
      text: inputText,
      timestamp: Date.now(),
    }
 
    // Optimistic
    setMessages(prev => ({
      ...prev,
      [currentRoom]: [...(prev[currentRoom] ?? []), msg],
    }))
    setInputText('')
 
    // Broadcast + persist
    channelRef.current?.publish('message', msg, { persist: true })
  }
 
  const roomMessages = messages[currentRoom] ?? []
 
  return (
    <div className="flex h-screen max-w-4xl mx-auto border border-gray-800 rounded-2xl overflow-hidden">
      {/* Sidebar */}
      <div className="w-48 bg-gray-950 border-r border-gray-800 p-4 flex flex-col gap-1">
        <div className="text-xs text-gray-500 uppercase mb-2 px-2">Rooms</div>
        {ROOMS.map(room => (
          <button
            key={room}
            onClick={() => joinRoom(room)}
            className={`text-left px-3 py-2 rounded-xl text-sm transition-colors ${
              room === currentRoom
                ? 'bg-blue-600 text-white font-medium'
                : 'text-gray-400 hover:bg-gray-800 hover:text-white'
            }`}
          >
            # {room}
          </button>
        ))}
      </div>
 
      {/* Chat area */}
      <div className="flex-1 flex flex-col">
        {/* Header */}
        <div className="px-6 py-4 border-b border-gray-800 flex items-center justify-between">
          <div>
            <h2 className="font-bold text-white"># {currentRoom}</h2>
            <div className="text-xs text-gray-500">{participants.length} online</div>
          </div>
          <div className="flex gap-1">
            {participants.slice(0, 5).map(p => (
              <div
                key={p.userId}
                className="w-7 h-7 rounded-full flex items-center justify-center text-xs text-white font-medium"
                style={{ background: p.color }}
                title={p.userName}
              >
                {p.userName[0]}
              </div>
            ))}
          </div>
        </div>
 
        {/* Messages */}
        <div className="flex-1 overflow-y-auto p-6 space-y-3">
          {roomMessages.map((msg, i) => {
            const isMe = msg.userId === myId.current
            const showName = i === 0 || roomMessages[i - 1].userId !== msg.userId
            return (
              <div key={msg.id}>
                {showName && !isMe && (
                  <div className="text-xs text-gray-500 mb-1">{msg.userName}</div>
                )}
                <div className={`flex ${isMe ? 'justify-end' : 'justify-start'}`}>
                  <div className={`max-w-sm px-4 py-2 rounded-2xl text-sm ${
                    isMe ? 'bg-blue-600 text-white' : 'bg-gray-800 text-gray-100'
                  }`}>
                    {msg.text}
                  </div>
                </div>
              </div>
            )
          })}
          <div ref={bottomRef} />
        </div>
 
        {/* Input */}
        <div className="px-6 py-4 border-t border-gray-800">
          <form
            onSubmit={e => { e.preventDefault(); sendMessage() }}
            className="flex gap-2"
          >
            <input
              value={inputText}
              onChange={e => setInputText(e.target.value)}
              placeholder={`Message #${currentRoom}`}
              className="flex-1 bg-gray-900 border border-gray-700 rounded-xl px-4 py-2 text-white text-sm"
            />
            <button
              type="submit"
              className="bg-blue-600 text-white px-5 py-2 rounded-xl text-sm font-medium"
            >
              Send
            </button>
          </form>
        </div>
      </div>
    </div>
  )
}

Messages use { persist: true } — they’re stored in D1 and loaded via getHistory() when you join a room. New users see the last 50 messages immediately.