Case Study: Realtime Collaboration¶
Tổng quan¶
Xây dựng ứng dụng collaboration với chat và presence features.
Yêu cầu¶
- Real-time chat messages
- User presence (online/offline)
- Typing indicators
- Message history
- Notification sound
Kết quả mong đợi¶
- Real-time chat hoạt động
- Presence tracking
- Typing indicators
- Responsive design
Kiến trúc¶
┌─────────────────────────────────────────────────────────────┐
│ REALTIME COLLABORATION ARCHITECTURE │
├─────────────────────────────────────────────────────────────┤
│ │
│ Users (Multiple clients) │
│ │ │
│ ▼ │
│ WebSocket Connections │
│ │ │
│ ▼ │
│ Supabase Realtime │
│ │ │
│ ├──▶ postgres_changes │
│ │ └── Subscribe to messages table │
│ │ │
│ ├──▶ broadcast │
│ │ └── Typing indicators (ephemeral) │
│ │ │
│ └──▶ presence │
│ └── Online users tracking │
│ │
│ PostgreSQL Database │
│ ├── messages (persisted) │
│ ├── channels │
│ └── channel_members │
│ │
└─────────────────────────────────────────────────────────────┘
Realtime Features Comparison¶
| Feature | Use Case | Persistence |
|---|---|---|
| postgres_changes | Chat messages | Yes (in DB) |
| broadcast | Typing indicators, cursor position | No |
| presence | Online users, user status | Session only |
Database Schema¶
-- supabase/migrations/001_chat_tables.sql
-- Channels (chat rooms)
CREATE TABLE public.channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
is_private BOOLEAN DEFAULT false,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Channel members
CREATE TABLE public.channel_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')),
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, user_id)
);
-- Messages
CREATE TABLE public.messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_messages_channel ON messages(channel_id, created_at DESC);
CREATE INDEX idx_channel_members_user ON channel_members(user_id);
-- Enable RLS
ALTER TABLE channels ENABLE ROW LEVEL SECURITY;
ALTER TABLE channel_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- RLS Policies
-- Channels: Members can view
CREATE POLICY "Channel members can view channel"
ON channels FOR SELECT
USING (
NOT is_private OR
EXISTS (
SELECT 1 FROM channel_members
WHERE channel_id = channels.id
AND user_id = auth.uid()
)
);
-- Members: Can view own memberships
CREATE POLICY "View channel members"
ON channel_members FOR SELECT
USING (
EXISTS (
SELECT 1 FROM channel_members cm
WHERE cm.channel_id = channel_members.channel_id
AND cm.user_id = auth.uid()
)
);
-- Messages: Members can view and send
CREATE POLICY "Channel members can view messages"
ON messages FOR SELECT
USING (
EXISTS (
SELECT 1 FROM channel_members
WHERE channel_id = messages.channel_id
AND user_id = auth.uid()
)
);
CREATE POLICY "Channel members can send messages"
ON messages FOR INSERT
WITH CHECK (
auth.uid() = user_id AND
EXISTS (
SELECT 1 FROM channel_members
WHERE channel_id = messages.channel_id
AND user_id = auth.uid()
)
);
-- Enable Realtime
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
Implementation¶
Project Structure¶
chat-app/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ └── channels/
│ └── [id]/
│ └── page.tsx # Chat room
├── components/
│ ├── chat/
│ │ ├── MessageList.tsx
│ │ ├── MessageInput.tsx
│ │ ├── TypingIndicator.tsx
│ │ └── OnlineUsers.tsx
│ └── layout/
│ └── ChannelSidebar.tsx
├── hooks/
│ ├── use-messages.ts
│ ├── use-presence.ts
│ └── use-typing.ts
└── lib/
└── supabase/
Message List with Realtime¶
// components/chat/MessageList.tsx
'use client';
import { useEffect, useState, useRef } from 'react';
import { createClient } from '@/lib/supabase/client';
import { formatDistanceToNow } from 'date-fns';
interface Message {
id: string;
content: string;
created_at: string;
user_id: string;
user: {
email: string;
full_name: string;
};
}
interface MessageListProps {
channelId: string;
initialMessages: Message[];
currentUserId: string;
}
export function MessageList({
channelId,
initialMessages,
currentUserId
}: MessageListProps) {
const [messages, setMessages] = useState<Message[]>(initialMessages);
const bottomRef = useRef<HTMLDivElement>(null);
const supabase = createClient();
useEffect(() => {
// Subscribe to new messages
const channel = supabase
.channel(`messages:${channelId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `channel_id=eq.${channelId}`,
},
async (payload) => {
// Fetch user info for new message
const { data: user } = await supabase
.from('profiles')
.select('email, full_name')
.eq('id', payload.new.user_id)
.single();
const newMessage = {
...payload.new,
user,
} as Message;
setMessages((prev) => [...prev, newMessage]);
// Play notification sound if not own message
if (payload.new.user_id !== currentUserId) {
new Audio('/notification.mp3').play().catch(() => {});
}
}
)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'messages',
filter: `channel_id=eq.${channelId}`,
},
(payload) => {
setMessages((prev) =>
prev.map((msg) =>
msg.id === payload.new.id
? { ...msg, content: payload.new.content }
: msg
)
);
}
)
.on(
'postgres_changes',
{
event: 'DELETE',
schema: 'public',
table: 'messages',
filter: `channel_id=eq.${channelId}`,
},
(payload) => {
setMessages((prev) =>
prev.filter((msg) => msg.id !== payload.old.id)
);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [channelId, currentUserId, supabase]);
// Auto-scroll to bottom on new message
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message) => (
<MessageBubble
key={message.id}
message={message}
isOwn={message.user_id === currentUserId}
/>
))}
<div ref={bottomRef} />
</div>
);
}
function MessageBubble({
message,
isOwn
}: {
message: Message;
isOwn: boolean;
}) {
return (
<div className={`flex ${isOwn ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[70%] rounded-lg p-3 ${
isOwn ? 'bg-blue-600 text-white' : 'bg-gray-100'
}`}
>
{!isOwn && (
<p className="text-xs font-medium text-gray-500 mb-1">
{message.user?.full_name || message.user?.email}
</p>
)}
<p>{message.content}</p>
<p className={`text-xs mt-1 ${isOwn ? 'text-blue-200' : 'text-gray-400'}`}>
{formatDistanceToNow(new Date(message.created_at), { addSuffix: true })}
</p>
</div>
</div>
);
}
Typing Indicator with Broadcast¶
// hooks/use-typing.ts
'use client';
import { useEffect, useState, useCallback } from 'react';
import { createClient } from '@/lib/supabase/client';
interface TypingUser {
userId: string;
userName: string;
}
export function useTyping(channelId: string, currentUser: { id: string; name: string }) {
const [typingUsers, setTypingUsers] = useState<TypingUser[]>([]);
const supabase = createClient();
useEffect(() => {
const channel = supabase.channel(`typing:${channelId}`);
channel
.on('broadcast', { event: 'typing' }, ({ payload }) => {
if (payload.userId === currentUser.id) return;
setTypingUsers((prev) => {
const exists = prev.some((u) => u.userId === payload.userId);
if (exists) return prev;
return [...prev, { userId: payload.userId, userName: payload.userName }];
});
// Remove after 3 seconds
setTimeout(() => {
setTypingUsers((prev) =>
prev.filter((u) => u.userId !== payload.userId)
);
}, 3000);
})
.on('broadcast', { event: 'stop_typing' }, ({ payload }) => {
setTypingUsers((prev) =>
prev.filter((u) => u.userId !== payload.userId)
);
})
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [channelId, currentUser.id, supabase]);
const sendTyping = useCallback(() => {
supabase.channel(`typing:${channelId}`).send({
type: 'broadcast',
event: 'typing',
payload: {
userId: currentUser.id,
userName: currentUser.name,
},
});
}, [channelId, currentUser, supabase]);
const stopTyping = useCallback(() => {
supabase.channel(`typing:${channelId}`).send({
type: 'broadcast',
event: 'stop_typing',
payload: { userId: currentUser.id },
});
}, [channelId, currentUser.id, supabase]);
return { typingUsers, sendTyping, stopTyping };
}
// components/chat/TypingIndicator.tsx
interface TypingIndicatorProps {
typingUsers: { userName: string }[];
}
export function TypingIndicator({ typingUsers }: TypingIndicatorProps) {
if (typingUsers.length === 0) return null;
const text =
typingUsers.length === 1
? `${typingUsers[0].userName} is typing...`
: typingUsers.length === 2
? `${typingUsers[0].userName} and ${typingUsers[1].userName} are typing...`
: `${typingUsers.length} people are typing...`;
return (
<div className="px-4 py-2 text-sm text-gray-500 flex items-center gap-2">
<div className="flex gap-1">
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-100" />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-200" />
</div>
{text}
</div>
);
}
Online Users with Presence¶
// hooks/use-presence.ts
'use client';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
interface OnlineUser {
id: string;
email: string;
name: string;
online_at: string;
}
export function usePresence(channelId: string, currentUser: OnlineUser) {
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
const supabase = createClient();
useEffect(() => {
const channel = supabase.channel(`presence:${channelId}`);
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
const users: OnlineUser[] = [];
for (const key in state) {
const presence = state[key][0] as any;
users.push({
id: presence.id,
email: presence.email,
name: presence.name,
online_at: presence.online_at,
});
}
setOnlineUsers(users);
})
.on('presence', { event: 'join' }, ({ newPresences }) => {
console.log('User joined:', newPresences);
})
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
console.log('User left:', leftPresences);
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
id: currentUser.id,
email: currentUser.email,
name: currentUser.name,
online_at: new Date().toISOString(),
});
}
});
return () => {
channel.untrack();
supabase.removeChannel(channel);
};
}, [channelId, currentUser, supabase]);
return onlineUsers;
}
// components/chat/OnlineUsers.tsx
interface OnlineUsersProps {
users: { id: string; name: string }[];
currentUserId: string;
}
export function OnlineUsers({ users, currentUserId }: OnlineUsersProps) {
return (
<div className="p-4 border-l">
<h3 className="text-sm font-semibold text-gray-500 mb-3">
Online ({users.length})
</h3>
<ul className="space-y-2">
{users.map((user) => (
<li key={user.id} className="flex items-center gap-2">
<span className="w-2 h-2 bg-green-500 rounded-full" />
<span className="text-sm">
{user.name}
{user.id === currentUserId && ' (you)'}
</span>
</li>
))}
</ul>
</div>
);
}
Message Input¶
// components/chat/MessageInput.tsx
'use client';
import { useState, useRef, useEffect } from 'react';
import { createClient } from '@/lib/supabase/client';
interface MessageInputProps {
channelId: string;
onTyping: () => void;
onStopTyping: () => void;
}
export function MessageInput({
channelId,
onTyping,
onStopTyping
}: MessageInputProps) {
const [content, setContent] = useState('');
const [sending, setSending] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout>();
const supabase = createClient();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setContent(e.target.value);
// Trigger typing indicator
onTyping();
// Clear existing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Stop typing after 2 seconds of inactivity
typingTimeoutRef.current = setTimeout(() => {
onStopTyping();
}, 2000);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim() || sending) return;
setSending(true);
onStopTyping();
try {
const { error } = await supabase.from('messages').insert({
channel_id: channelId,
content: content.trim(),
});
if (error) throw error;
setContent('');
} catch (error) {
console.error('Error sending message:', error);
} finally {
setSending(false);
}
};
useEffect(() => {
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, []);
return (
<form onSubmit={handleSubmit} className="p-4 border-t">
<div className="flex gap-2">
<input
type="text"
value={content}
onChange={handleChange}
placeholder="Type a message..."
className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="submit"
disabled={!content.trim() || sending}
className="px-6 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{sending ? '...' : 'Send'}
</button>
</div>
</form>
);
}
Chat Room Page¶
// app/channels/[id]/page.tsx
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { ChatRoom } from '@/components/chat/ChatRoom';
export default async function ChannelPage({
params
}: {
params: { id: string }
}) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect('/login');
// Check membership
const { data: membership } = await supabase
.from('channel_members')
.select('*')
.eq('channel_id', params.id)
.eq('user_id', user.id)
.single();
if (!membership) redirect('/channels');
// Fetch initial messages
const { data: messages } = await supabase
.from('messages')
.select(`
id,
content,
created_at,
user_id,
user:profiles!user_id(email, full_name)
`)
.eq('channel_id', params.id)
.order('created_at', { ascending: true })
.limit(50);
// Fetch user profile
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('id', user.id)
.single();
return (
<ChatRoom
channelId={params.id}
initialMessages={messages || []}
currentUser={{
id: user.id,
email: user.email!,
name: profile?.full_name || user.email!,
}}
/>
);
}
Kết quả¶
Features hoàn thành¶
- Real-time messaging với postgres_changes
- Typing indicators với broadcast
- Online users với presence
- Message history persisted
- Auto-scroll và notifications
Performance¶
- WebSocket connection: Single connection for all features
- Message latency: < 100ms
- Presence sync: < 500ms
Chi phí¶
- Supabase Pro: $25/month
- Concurrent connections: 500 included
- Total: $25/month
Lessons Learned¶
What Worked¶
- postgres_changes cho persisted data
- broadcast cho ephemeral data (typing)
- presence cho real-time user tracking
- Single channel per feature = clean separation
Challenges¶
- Handling reconnections gracefully
- Debouncing typing indicators
- Managing subscription cleanup
- Handling large message history
Best Practices¶
// 1. Always cleanup subscriptions
useEffect(() => {
const channel = supabase.channel('...');
// ... subscribe
return () => {
supabase.removeChannel(channel);
};
}, []);
// 2. Debounce typing indicators
const sendTyping = useDebouncedCallback(() => {
channel.send({ type: 'broadcast', event: 'typing', ... });
}, 500);
// 3. Handle reconnection
channel.subscribe((status) => {
if (status === 'SUBSCRIBED') {
// Resync presence
channel.track({ ... });
}
});