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.