Bỏ qua

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({ ... });
  }
});

Tài liệu tham khảo