From aaccc1d324a89b138e6d0e7d8f97d2ff74eb2f9b Mon Sep 17 00:00:00 2001 From: senmalong Date: Tue, 23 Jun 2026 23:03:00 +0100 Subject: [PATCH] Build real-time collaborative pool chat/comments feature per group (#90) - Add pool_messages schema with RLS policies restricting read/write to pool members - Enforce message length limit (<= 1000 characters) and rate limiting (max 1 message per 3 seconds per sender) via DB trigger - Implement automated trigger to notify other pool members of new chat messages - Expose pool_messages and update Supabase Client initialization to support dynamic wallet headers - Create GroupDiscussion UI component featuring real-time message syncing, local rate-limit lockout, and mobile responsiveness - Integrate 'Discussion' tab inside group detail page layout next to Activity Log - Configure Playwright E2E coverage for chat functionality --- .../app/dashboard/group/[id]/GroupClient.tsx | 15 +- frontend/app/dashboard/group/[id]/page.tsx | 23 +- .../components/group/group-discussion.tsx | 367 ++++++++++++++++++ frontend/e2e/discussion.spec.ts | 133 +++++++ frontend/hooks/useNotifications.ts | 2 +- frontend/lib/supabase.ts | 36 ++ frontend/playwright.config.ts | 4 +- .../20260623000000_pool_messages.sql | 148 +++++++ 8 files changed, 719 insertions(+), 9 deletions(-) create mode 100644 frontend/components/group/group-discussion.tsx create mode 100644 frontend/e2e/discussion.spec.ts create mode 100644 supabase/migrations/20260623000000_pool_messages.sql diff --git a/frontend/app/dashboard/group/[id]/GroupClient.tsx b/frontend/app/dashboard/group/[id]/GroupClient.tsx index 27c4ed6..6833472 100644 --- a/frontend/app/dashboard/group/[id]/GroupClient.tsx +++ b/frontend/app/dashboard/group/[id]/GroupClient.tsx @@ -12,6 +12,8 @@ import Link from "next/link" import { fetchIsPaused, fetchPoolAdmin } from "@/hooks/useJointSaveContracts" import { useStellar } from "@/components/web3-provider" import { useRecentPools } from "@/hooks/useRecentPools" +import { GroupDiscussion } from "@/components/group/group-discussion" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" interface Pool { id: string @@ -106,7 +108,18 @@ export default function GroupClient({ params }: { params: Promise<{ id: string } {/* ── Left column: details + activity ──────────────────────────── */}
- + + + Activity Log + Discussion + + + + + + + +
{/* ── Right column: actions + members ──────────────────────────── */} diff --git a/frontend/app/dashboard/group/[id]/page.tsx b/frontend/app/dashboard/group/[id]/page.tsx index 5832347..79e9324 100644 --- a/frontend/app/dashboard/group/[id]/page.tsx +++ b/frontend/app/dashboard/group/[id]/page.tsx @@ -5,6 +5,8 @@ import { DashboardHeader } from "@/components/dashboard/dashboard-header"; import { GroupDetails } from "@/components/group/group-details"; import { GroupMembers } from "@/components/group/group-members"; import { GroupActivity } from "@/components/group/group-activity"; +import { GroupDiscussion } from "@/components/group/group-discussion"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { GroupActions } from "@/components/group/group-actions"; import { YieldDashboard } from "@/components/group/yield-dashboard"; import { Button } from "@/components/ui/button"; @@ -104,11 +106,22 @@ export default function GroupPage({
- + + + Activity Log + Discussion + + + + + + + +
diff --git a/frontend/components/group/group-discussion.tsx b/frontend/components/group/group-discussion.tsx new file mode 100644 index 0000000..d4a8576 --- /dev/null +++ b/frontend/components/group/group-discussion.tsx @@ -0,0 +1,367 @@ +"use client" + +import { useState, useEffect, useRef, useCallback } from "react" +import { Card } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +import { AlertCircle, Send, MessageSquare, Loader2, Lock, Sparkles } from "lucide-react" +import { getSupabaseClient } from "@/lib/supabase" +import { useToast } from "@/hooks/use-toast" +import { motion, AnimatePresence } from "framer-motion" + +interface GroupDiscussionProps { + groupId: string + walletAddress: string | null +} + +interface Message { + id: string + pool_id: string + sender_address: string + message: string + created_at: string +} + +export function GroupDiscussion({ groupId, walletAddress }: GroupDiscussionProps) { + const [messages, setMessages] = useState([]) + const [inputText, setInputText] = useState("") + const [isLoadingHistory, setIsLoadingHistory] = useState(false) + const [isSending, setIsSending] = useState(false) + const [error, setError] = useState(null) + const [rateLimitSeconds, setRateLimitSeconds] = useState(0) + + const { toast } = useToast() + const scrollAreaRef = useRef(null) + const messagesEndRef = useRef(null) + + // Scroll to bottom helper + const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { + setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior }) + }, 50) + }, []) + + // Fetch initial chat history + const fetchMessages = useCallback(async () => { + if (!walletAddress) return + setIsLoadingHistory(true) + setError(null) + + try { + const client = getSupabaseClient(walletAddress) + if (!client) throw new Error("Supabase client not initialized") + + const { data, error: dbError } = await client + .from("pool_messages") + .select("*") + .eq("pool_id", groupId) + .order("created_at", { ascending: true }) + + if (dbError) { + console.error("Fetch messages error:", dbError) + setError("Failed to load chat history. Ensure you are an active member of this pool.") + } else { + setMessages(data || []) + scrollToBottom("auto") + } + } catch (err: any) { + console.error("Fetch messages catch:", err) + setError(err.message || "An unexpected error occurred.") + } finally { + setIsLoadingHistory(false) + } + }, [groupId, walletAddress, scrollToBottom]) + + // Setup Real-time listener and fetch history + useEffect(() => { + if (!walletAddress) return + + fetchMessages() + + const client = getSupabaseClient(walletAddress) + if (!client) return + + const channel = client + .channel(`pool_messages:${groupId}`) + .on( + "postgres_changes", + { + event: "INSERT", + schema: "public", + table: "pool_messages", + filter: `pool_id=eq.${groupId}`, + }, + (payload: any) => { + const newMessage = payload.new as Message + setMessages((prev) => { + // Avoid duplicate additions + if (prev.some((m) => m.id === newMessage.id)) return prev + return [...prev, newMessage] + }) + scrollToBottom() + } + ) + .subscribe() + + return () => { + client.removeChannel(channel) + } + }, [groupId, walletAddress, fetchMessages, scrollToBottom]) + + // Decrement rate limit timer + useEffect(() => { + if (rateLimitSeconds <= 0) return + const timer = setTimeout(() => { + setRateLimitSeconds((prev) => prev - 1) + }, 1000) + return () => clearTimeout(timer) + }, [rateLimitSeconds]) + + // Format addresses for display + const formatAddress = (addr: string) => { + return `${addr.slice(0, 6)}...${addr.slice(-4)}` + } + + // Handle message submission + const handleSendMessage = async (e?: React.FormEvent) => { + if (e) e.preventDefault() + + const trimmed = inputText.trim() + if (!trimmed || !walletAddress) return + if (trimmed.length > 1000) { + toast({ + title: "Message too long", + description: "Messages cannot exceed 1000 characters.", + variant: "destructive", + }) + return + } + + if (rateLimitSeconds > 0) { + toast({ + title: "Slow down", + description: `Please wait ${rateLimitSeconds} seconds before sending another message.`, + variant: "destructive", + }) + return + } + + setIsSending(true) + try { + const client = getSupabaseClient(walletAddress) + if (!client) throw new Error("Supabase client not initialized") + + const { error: insertError } = await client + .from("pool_messages") + .insert([ + { + pool_id: groupId, + sender_address: walletAddress.toLowerCase(), + message: trimmed, + }, + ]) + + if (insertError) { + throw insertError + } + + setInputText("") + setRateLimitSeconds(3) // 3 seconds rate limit + scrollToBottom() + } catch (err: any) { + console.error("Send message error:", err) + const errorMsg = err.message || "" + + if (errorMsg.includes("Rate limit")) { + toast({ + title: "Rate limit exceeded", + description: "Please wait 3 seconds between messages.", + variant: "destructive", + }) + } else { + toast({ + title: "Failed to send", + description: "Only active members of this pool can send messages.", + variant: "destructive", + }) + } + } finally { + setIsSending(false) + } + } + + // Handle keypress inside textarea (Enter to send, Shift+Enter for newline) + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleSendMessage() + } + } + + if (!walletAddress) { + return ( + +
+ +
+
+

Wallet Connection Required

+

+ Please connect your wallet at the top of the dashboard to view and participate in this pool's discussions. +

+
+
+ ) + } + + return ( + + {/* Discussion Header */} +
+
+ + Pool Discussion +
+
+ + + + + Live Chat +
+
+ + {/* Message Area */} + + {isLoadingHistory ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ) : error ? ( +
+ +

Access Restricted

+

+ {error} +

+
+ ) : messages.length === 0 ? ( +
+
+ +
+

Welcome to the Pool!

+

+ This chat is secure and private to pool members. Start by saying hello to your fellow members! +

+
+ ) : ( +
+ + {messages.map((msg) => { + const isMe = msg.sender_address.toLowerCase() === walletAddress.toLowerCase() + return ( + + + + {msg.sender_address.slice(2, 4).toUpperCase()} + + + +
+
+ + {formatAddress(msg.sender_address)} + + {isMe && ( + + You + + )} +
+ +
+ {msg.message} +
+ + + {new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + +
+
+ ) + })} +
+
+
+ )} + + + {/* Input Form */} + {!error && ( +
+
+