💬 PATRÓN CHAT EN TIEMPO REAL
🎯 ¿Qué es y por qué es crítico?
Un chat moderno no puede depender de recargar la página ni de polling lento. Requiere WebSockets, manejo de escritura en vivo (typing indicators), y envío optimista para que la app se sienta instantánea, incluso en redes inestables (3G).
[REQUIRED] REGLA: Todo envío de mensaje DEBE ser Optimistic. El mensaje aparece en pantalla al instante, antes de confirmarse en el servidor. Si falla, mostrar error y un botón de reintento.
Por qué: esperar la confirmación del servidor antes de mostrar un mensaje enviado introduce un retraso perceptible que ningún chat moderno tiene. Mostrarlo al instante y solo revertir si falla es lo que hace que la conversación se sienta fluida.
🗄️ ESQUEMA SQL & RLS (Supabase)
-- 1. Tablas
CREATE TABLE chat_rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE chat_participants (
room_id UUID REFERENCES chat_rooms(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
PRIMARY KEY (room_id, user_id)
);
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
room_id UUID REFERENCES chat_rooms(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id),
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
is_edited BOOLEAN DEFAULT FALSE
);
CREATE TABLE message_reads (
message_id UUID REFERENCES messages(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
read_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (message_id, user_id)
);
-- 2. Activar Realtime
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
-- 3. Row Level Security (RLS) - Crucial para seguridad
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Solo participantes pueden leer mensajes" ON messages
FOR SELECT USING (
EXISTS (SELECT 1 FROM chat_participants cp WHERE cp.room_id = messages.room_id AND cp.user_id = auth.uid())
);
CREATE POLICY "Solo participantes pueden enviar mensajes" ON messages
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM chat_participants cp WHERE cp.room_id = room_id AND cp.user_id = auth.uid())
AND auth.uid() = user_id
);💻 HOOK: useChat (Realtime + Broadcast)
import { useEffect, useState, useRef } from 'react'
import { supabase } from '@/lib/supabase'
export type Message = {
id: string
room_id: string
user_id: string
content: string
created_at: string
status?: 'sending' | 'sent' | 'error' // Propiedad del UI
}
export function useChat(roomId: string, currentUserId: string) {
const [messages, setMessages] = useState<Message[]>([])
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const channel = useRef<ReturnType<typeof supabase.channel> | null>(null)
// 1. Cargar historial y suscribirse
useEffect(() => {
const fetchHistory = async () => {
const { data } = await supabase
.from('messages')
.select('id, room_id, sender_id, body, created_at') // DB-001: nunca select('*')
.eq('room_id', roomId)
.order('created_at', { ascending: true })
if (data) setMessages(data)
}
fetchHistory()
// 2. Suscripción Realtime (WebSockets)
channel.current = supabase.channel(`room:${roomId}`)
// Escuchar nuevos mensajes en la DB
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}` },
(payload) => {
const newMsg = payload.new as Message
// Evitar duplicados si fue nuestro propio mensaje (ya insertado optimistamente)
setMessages(prev => {
const exists = prev.find(m => m.id === newMsg.id)
if (exists) return prev.map(m => m.id === newMsg.id ? { ...newMsg, status: 'sent' } : m)
return [...prev, { ...newMsg, status: 'sent' }]
})
}
)
// 3. Escuchar "Typing Indicator" (Broadcast, no toca la DB)
.on('broadcast', { event: 'typing' }, ({ payload }) => {
if (payload.userId !== currentUserId) {
setTypingUsers(prev => new Set(prev).add(payload.userId))
// Remover "escribiendo..." después de 3 segundos
setTimeout(() => {
setTypingUsers(prev => {
const newSet = new Set(prev)
newSet.delete(payload.userId)
return newSet
})
}, 3000)
}
})
.subscribe()
return () => { supabase.removeChannel(channel.current!) }
}, [roomId, currentUserId])
// 4. Enviar Mensaje (Optimistic)
const sendMessage = async (content: string) => {
const optimisticId = crypto.randomUUID()
const newMsg: Message = {
id: optimisticId,
room_id: roomId,
user_id: currentUserId,
content,
created_at: new Date().toISOString(),
status: 'sending'
}
// 1. Mostrar en UI instantáneamente
setMessages(prev => [...prev, newMsg])
// 2. Enviar a DB
const { error } = await supabase
.from('messages')
.insert([{ id: optimisticId, room_id: roomId, user_id: currentUserId, content }])
// 3. Manejo de error
if (error) {
setMessages(prev => prev.map(m => m.id === optimisticId ? { ...m, status: 'error' } : m))
setError('Error al enviar el mensaje.')
}
}
// 5. Emitir "Typing..."
const sendTypingEvent = () => {
channel.current?.send({
type: 'broadcast',
event: 'typing',
payload: { userId: currentUserId }
})
}
return { messages, typingUsers, sendMessage, sendTypingEvent, error }
}🎨 UI: COMPONENTE CHAT WINDOW (Virtualizado)
Para salas con miles de mensajes, es OBLIGATORIO usar listas virtualizadas (ej: react-virtuoso) para evitar destruir la RAM y los frames por segundo (FPS) del navegador.
import { useState, useRef, useEffect } from 'react'
import { Virtuoso } from 'react-virtuoso'
import { useChat } from './useChat'
export function ChatWindow({ roomId, currentUserId }) {
const { messages, typingUsers, sendMessage, sendTypingEvent } = useChat(roomId, currentUserId)
const [text, setText] = useState('')
const virtuosoRef = useRef<VirtuosoHandle>(null)
// Scroll automático abajo cuando llega un mensaje nuevo
useEffect(() => {
if (messages.length > 0) {
virtuosoRef.current?.scrollToIndex({ index: messages.length - 1, align: 'end', behavior: 'smooth' })
}
}, [messages.length])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!text.trim()) return
sendMessage(text)
setText('')
}
return (
<div className="flex flex-col h-[600px] border rounded-lg bg-gray-50">
{/* 1. Área de Mensajes (Virtualizada) */}
<div className="flex-1 overflow-hidden">
{messages.length === 0 ? (
<div className="h-full flex items-center justify-center text-gray-400">
No hay mensajes aún. ¡Di hola!
</div>
) : (
<Virtuoso
ref={virtuosoRef}
data={messages}
initialTopMostItemIndex={messages.length - 1}
itemContent={(index, msg) => {
const isMe = msg.user_id === currentUserId
return (
<div className={`p-4 flex ${isMe ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[70%] rounded-2xl p-3 ${
isMe ? 'bg-blue-600 text-white' : 'bg-white border text-gray-900'
} ${msg.status === 'error' ? 'border-red-500 opacity-50' : ''}`}>
<p>{msg.content}</p>
{/* Indicador de Status Optimistic */}
<div className="text-[10px] text-right mt-1 opacity-70">
{msg.status === 'sending' && 'Enviando...'}
{msg.status === 'error' && 'Fallo al enviar. Clic para reintentar.'}
{msg.status === 'sent' && new Date(msg.created_at).toLocaleTimeString()}
</div>
</div>
</div>
)
}}
/>
)}
</div>
{/* 2. Typing Indicator */}
{typingUsers.size > 0 && (
<div className="px-4 py-2 text-xs text-gray-500 italic">
Alguien está escribiendo...
</div>
)}
{/* 3. Input de Mensaje */}
<form onSubmit={handleSubmit} className="p-4 bg-white border-t flex gap-2">
<input
type="text"
value={text}
onChange={(e) => {
setText(e.target.value)
sendTypingEvent() // Emite el evento de broadcast
}}
placeholder="Escribe un mensaje..."
className="flex-1 p-2 border rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={!text.trim()}
className="bg-blue-600 text-white px-6 py-2 rounded-full disabled:opacity-50"
>
Enviar
</button>
</form>
</div>
)
}