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
Section titled “Client code”import { useAerostack } from '@aerostack/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> )}