In-Conference Chat Development
Chat within conference—text channel parallel to video call. Needs: private and group messages, reactions, history after completion, file attachments. Implemented via WebRTC Data channels or separate Socket.IO channel.
Option 1: LiveKit Data Messages
Simplest—use existing Data channel in LiveKit:
async function sendChatMessage(
room: Room,
text: string,
toParticipant?: string // undefined = to all
): Promise<void> {
const message = {
id: crypto.randomUUID(),
type: 'chat',
text,
senderName: room.localParticipant.name,
senderId: room.localParticipant.identity,
timestamp: Date.now(),
isPrivate: !!toParticipant,
};
const data = new TextEncoder().encode(JSON.stringify(message));
if (toParticipant) {
const participant = [...room.remoteParticipants.values()]
.find(p => p.identity === toParticipant);
if (participant) {
await room.localParticipant.publishData(data, {
reliable: true,
destinationIdentities: [toParticipant],
});
}
} else {
await room.localParticipant.publishData(data, { reliable: true });
}
}
// Receive messages
room.on('dataReceived', (payload: Uint8Array, participant?: RemoteParticipant) => {
const message = JSON.parse(new TextDecoder().decode(payload));
if (message.type === 'chat') {
addMessage(message);
}
if (message.type === 'reaction') {
addReaction(message.targetMessageId, message.emoji, participant?.name);
}
});
React Chat Component
interface ChatMessage {
id: string;
text: string;
senderName: string;
senderId: string;
timestamp: number;
isPrivate: boolean;
reactions: Record<string, string[]>;
}
function ConferenceChat({ room }: { room: Room }) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [text, setText] = useState('');
const [privateTo, setPrivateTo] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const addMessage = useCallback((msg: ChatMessage) => {
setMessages(prev => [...prev, { ...msg, reactions: {} }]);
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
const addReaction = useCallback((messageId: string, emoji: string, senderName: string) => {
setMessages(prev => prev.map(m => {
if (m.id !== messageId) return m;
const existing = m.reactions[emoji] ?? [];
return {
...m,
reactions: { ...m.reactions, [emoji]: [...existing, senderName] },
};
}));
}, []);
useEffect(() => {
const handler = (payload: Uint8Array) => {
const msg = JSON.parse(new TextDecoder().decode(payload));
if (msg.type === 'chat') addMessage(msg);
if (msg.type === 'reaction') addReaction(msg.targetMessageId, msg.emoji, msg.senderName);
};
room.on('dataReceived', handler);
return () => { room.off('dataReceived', handler); };
}, [room, addMessage, addReaction]);
const send = async () => {
if (!text.trim()) return;
await sendChatMessage(room, text, privateTo ?? undefined);
setText('');
};
const sendReaction = async (messageId: string, emoji: string) => {
const data = new TextEncoder().encode(JSON.stringify({
type: 'reaction',
targetMessageId: messageId,
emoji,
senderName: room.localParticipant.name,
}));
await room.localParticipant.publishData(data, { reliable: true });
addReaction(messageId, emoji, room.localParticipant.name ?? '');
};
return (
<div className="flex flex-col h-full bg-white border-l border-gray-200">
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map(msg => (
<MessageBubble
key={msg.id}
message={msg}
isOwnMessage={msg.senderId === room.localParticipant.identity}
onReact={(emoji) => sendReaction(msg.id, emoji)}
/>
))}
<div ref={bottomRef} />
</div>
{privateTo && (
<div className="px-4 py-1 bg-yellow-50 border-t border-yellow-200 flex justify-between">
<span className="text-sm text-yellow-700">Private message → {privateTo}</span>
<button onClick={() => setPrivateTo(null)} className="text-yellow-600 text-sm">✕</button>
</div>
)}
<div className="p-4 border-t border-gray-200 flex gap-2">
<input
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={e => e.key === 'Enter' && !e.shiftKey && (e.preventDefault(), send())}
placeholder={privateTo ? `Private message...` : 'Message all...'}
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button onClick={send} disabled={!text.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm disabled:opacity-50">
↑
</button>
</div>
</div>
);
}
function MessageBubble({ message, isOwnMessage, onReact }) {
const REACTIONS = ['👍', '❤️', '😂', '👏', '🎉'];
return (
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
{!isOwnMessage && (
<span className="text-xs text-gray-500 mb-1">{message.senderName}</span>
)}
<div className={`max-w-xs px-3 py-2 rounded-2xl text-sm ${
message.isPrivate ? 'bg-yellow-100 border border-yellow-300' :
isOwnMessage ? 'bg-blue-600 text-white' : 'bg-gray-100'
}`}>
{message.text}
{message.isPrivate && (
<span className="text-xs ml-2 opacity-60">🔒</span>
)}
</div>
<div className="flex gap-1 mt-1">
{Object.entries(message.reactions).map(([emoji, users]) => (
<span key={emoji} className="text-xs bg-gray-100 rounded-full px-2 py-0.5"
title={users.join(', ')}>
{emoji} {users.length}
</span>
))}
<button className="text-xs text-gray-400 hover:text-gray-600"
onClick={() => onReact('👍')}>+</button>
</div>
</div>
);
}
Saving Chat History
app.post('/api/webhooks/livekit', async (req, res) => {
const event = receiver.receive(req.body, req.headers['authorization']);
if (event.event === 'data_received') {
const msg = JSON.parse(new TextDecoder().decode(event.data));
if (msg.type === 'chat') {
await db.chatMessages.create({
roomName: event.room.name,
senderId: event.participant.identity,
text: msg.text,
isPrivate: msg.isPrivate,
timestamp: new Date(msg.timestamp),
});
}
}
res.status(200).end();
});
Timeline
Chat via LiveKit Data channels + reactions—1–2 days. With history and private messages—2–3 days.







