diff --git a/frontend/app/api/pools/messages/route.ts b/frontend/app/api/pools/messages/route.ts new file mode 100644 index 0000000..d413af6 --- /dev/null +++ b/frontend/app/api/pools/messages/route.ts @@ -0,0 +1,160 @@ +/** + * /api/pools/messages — Pool group chat API + * + * GET /api/pools/messages?pool_id=&wallet=&cursor= + * Returns the 50 most recent messages, optionally before `cursor` for + * infinite-scroll pagination. Verifies the requesting wallet is a member. + * + * POST /api/pools/messages { pool_id, wallet_address, message } + * Inserts a new message. Enforces member check, length cap, and a + * per-sender 3-second DB-backed rate limit so it works correctly across + * all serverless instances (no shared in-memory state). + */ + +import { getAdminClient } from "@/lib/supabase-admin" +import { NextRequest, NextResponse } from "next/server" +import { readLimiter } from "@/lib/rate-limit" +import { CHAT_MESSAGE_MAX_LENGTH, CHAT_RATE_LIMIT_MS } from "@/lib/constants" + +const PAGE_SIZE = 50 + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async function isMember(poolId: string, wallet: string): Promise { + const { data } = await getAdminClient() + .from("pool_members") + .select("id") + .eq("pool_id", poolId) + .eq("member_address", wallet.toLowerCase()) + .maybeSingle() + return data !== null +} + +/** + * DB-backed rate limit: fetch the sender's most recent message timestamp for + * this pool. Works correctly across all serverless instances because it reads + * from the shared database rather than a module-scoped Map. + * + * Returns the number of milliseconds the caller must still wait, or 0 if they + * are allowed to send now. + */ +async function getRateLimitWaitMs(poolId: string, wallet: string): Promise { + const { data } = await getAdminClient() + .from("pool_messages") + .select("created_at") + .eq("pool_id", poolId) + .eq("sender_address", wallet.toLowerCase()) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle() + + if (!data) return 0 + + const lastMs = new Date(data.created_at).getTime() + const elapsed = Date.now() - lastMs + return elapsed < CHAT_RATE_LIMIT_MS ? CHAT_RATE_LIMIT_MS - elapsed : 0 +} + +// ── GET ─────────────────────────────────────────────────────────────────────── + +export async function GET(req: NextRequest) { + const limited = readLimiter(req) + if (limited) return limited + + const { searchParams } = req.nextUrl + const poolId = searchParams.get("pool_id") + const wallet = searchParams.get("wallet")?.toLowerCase() + const cursor = searchParams.get("cursor") // ISO timestamp — load messages before this + + if (!poolId) return NextResponse.json({ error: "pool_id required" }, { status: 400 }) + if (!wallet) return NextResponse.json({ error: "wallet required" }, { status: 400 }) + + // Verify membership before returning any messages (mirrors the RLS policy). + const member = await isMember(poolId, wallet) + if (!member) { + return NextResponse.json({ error: "Not a member of this pool" }, { status: 403 }) + } + + let query = getAdminClient() + .from("pool_messages") + .select("id, pool_id, sender_address, message, created_at") + .eq("pool_id", poolId) + .order("created_at", { ascending: false }) + .limit(PAGE_SIZE) + + if (cursor) { + query = query.lt("created_at", cursor) + } + + const { data, error } = await query + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + // Return in chronological order so the UI can simply append. + const messages = (data ?? []).reverse() + return NextResponse.json({ messages, hasMore: (data ?? []).length === PAGE_SIZE }) +} + +// ── POST ────────────────────────────────────────────────────────────────────── + +export async function POST(req: NextRequest) { + let body: { pool_id?: string; wallet_address?: string; message?: string } + try { + body = await req.json() + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) + } + + const { pool_id, wallet_address, message } = body + const wallet = wallet_address?.toLowerCase() + + if (!pool_id) return NextResponse.json({ error: "pool_id required" }, { status: 400 }) + if (!wallet) return NextResponse.json({ error: "wallet_address required" }, { status: 400 }) + if (!message || message.trim().length === 0) { + return NextResponse.json({ error: "message required" }, { status: 400 }) + } + + // Length cap + if (message.length > CHAT_MESSAGE_MAX_LENGTH) { + return NextResponse.json( + { error: `Message exceeds ${CHAT_MESSAGE_MAX_LENGTH} character limit` }, + { status: 422 } + ) + } + + // DB-backed per-sender rate limit — safe across all serverless instances + const waitMs = await getRateLimitWaitMs(pool_id, wallet) + if (waitMs > 0) { + return NextResponse.json( + { + error: "TOO_MANY_REQUESTS", + message: `Please wait ${Math.ceil(waitMs / 1000)} second(s) before sending another message.`, + retryAfterMs: waitMs, + }, + { + status: 429, + headers: { "Retry-After": String(Math.ceil(waitMs / 1000)) }, + } + ) + } + + // Verify membership (server-enforced, not just UI-gated) + const member = await isMember(pool_id, wallet) + if (!member) { + return NextResponse.json({ error: "Not a member of this pool" }, { status: 403 }) + } + + const { data, error } = await getAdminClient() + .from("pool_messages") + .insert({ + pool_id, + sender_address: wallet, + message: message.trim(), + }) + .select("id, pool_id, sender_address, message, created_at") + .single() + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + return NextResponse.json({ message: data }, { status: 201 }) +} diff --git a/frontend/app/dashboard/group/[id]/GroupClient.tsx b/frontend/app/dashboard/group/[id]/GroupClient.tsx index 72c46b2..ca9a2a7 100644 --- a/frontend/app/dashboard/group/[id]/GroupClient.tsx +++ b/frontend/app/dashboard/group/[id]/GroupClient.tsx @@ -7,6 +7,7 @@ import { GroupMembers } from "@/components/group/group-members" import { GroupActivity } from "@/components/group/group-activity" import { GroupActions } from "@/components/group/group-actions" import { RotationalTimelineContainer } from "@/components/group/rotational-timeline-container" +import { PoolChat } from "@/components/group/pool-chat" import { Button } from "@/components/ui/button" import { ArrowLeft } from "lucide-react" import Link from "next/link" @@ -20,6 +21,7 @@ interface Pool { type: "rotational" | "target" | "flexible" contract_address: string token_address: string + pool_members?: { member_address: string }[] } const isPendingAddress = (addr: string) => !addr || addr === "pending_deployment" @@ -92,6 +94,12 @@ export default function GroupClient({ params }: { params: Promise<{ id: string } ? pool.contract_address : pool.id + // Determine membership: check the pool_members list returned by /api/pools + const isMember = + !!address && + (pool.pool_members?.some((m) => m.member_address.toLowerCase() === address.toLowerCase()) ?? + false) + return (
@@ -104,13 +112,14 @@ export default function GroupClient({ params }: { params: Promise<{ id: string }
- {/* ── Left column: details + timeline + activity ──────────────── */} + {/* ── Left column: details + timeline + activity + chat ───────── */}
{pool.type === "rotational" && ( )} +
{/* ── Right column: actions + members ──────────────────────────── */} diff --git a/frontend/components/group/pool-chat.tsx b/frontend/components/group/pool-chat.tsx new file mode 100644 index 0000000..787a76f --- /dev/null +++ b/frontend/components/group/pool-chat.tsx @@ -0,0 +1,346 @@ +"use client" + +/** + * PoolChat — Real-time group discussion panel for a single pool. + * + * Features: + * - Live message delivery via Supabase Realtime (no polling) + * - Infinite scroll — "Load earlier messages" when hasMore is true + * - Optimistic send with rollback on failure + * - Client-side 3-second rate-limit guard with countdown badge + * - Message length cap enforced in the textarea + * - Auto-scroll to newest message on initial load and on new arrivals + * - Non-member wall: shows a friendly empty state if wallet not in pool + * - Fully mobile-responsive + */ + +import { useEffect, useRef, useState, type KeyboardEvent } from "react" +import { useStellar } from "@/components/web3-provider" +import { usePoolChat, type PoolMessage } from "@/hooks/usePoolChat" +import { Card, CardContent, CardHeader } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { CHAT_MESSAGE_MAX_LENGTH } from "@/lib/constants" +import { MessageSquare, Send, ChevronUp, AlertCircle, Loader2, Clock, Wifi } from "lucide-react" +import { cn } from "@/lib/utils" + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function formatTime(iso: string): string { + const d = new Date(iso) + const now = new Date() + const diffMs = now.getTime() - d.getTime() + const diffMins = Math.floor(diffMs / 60_000) + + if (diffMins < 1) return "just now" + if (diffMins < 60) return `${diffMins}m ago` + const diffHours = Math.floor(diffMins / 60) + if (diffHours < 24) return `${diffHours}h ago` + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) +} + +function shortAddress(addr: string): string { + if (addr.length <= 12) return addr + return `${addr.slice(0, 6)}…${addr.slice(-4)}` +} + +/** A single chat bubble */ +function MessageBubble({ msg, isOwn }: { msg: PoolMessage; isOwn: boolean }) { + const isOptimistic = msg.id.startsWith("optimistic-") + + return ( +
+ {/* Sender label (only for others' messages) */} + {!isOwn && ( + + {shortAddress(msg.sender_address)} + + )} +
+ {msg.message} +
+ + {isOptimistic ? ( + + Sending… + + ) : ( + formatTime(msg.created_at) + )} + +
+ ) +} + +/** Skeleton placeholder rows while loading */ +function MessageSkeletons() { + return ( +
+ {[...Array(4)].map((_, i) => ( +
+ +
+ ))} +
+ ) +} + +/** Empty state shown when no messages have been sent yet */ +function EmptyChat() { + return ( +
+ +

No messages yet. Be the first to say something!

+
+ ) +} + +// ── Main component ──────────────────────────────────────────────────────────── + +interface PoolChatProps { + poolId: string + /** Whether the current wallet is a verified member (passed from GroupDetails / GroupClient) */ + isMember: boolean +} + +export function PoolChat({ poolId, isMember }: PoolChatProps) { + const { address: walletAddress } = useStellar() + + const { + messages, + loading, + loadingOlder, + hasMore, + sendError, + isSending, + sendMessage, + loadOlderMessages, + rateLimited, + rateLimitRemainingMs, + realtimeStatus, + } = usePoolChat({ + poolId, + walletAddress: isMember ? (walletAddress ?? null) : null, + }) + + const [draft, setDraft] = useState("") + const bottomRef = useRef(null) + const listRef = useRef(null) + const prevScrollHeightRef = useRef(0) + + // Auto-scroll to bottom when new messages arrive (not when loading older ones) + const isFirstLoad = useRef(true) + useEffect(() => { + if (loading) return + if (isFirstLoad.current) { + bottomRef.current?.scrollIntoView({ behavior: "instant" }) + isFirstLoad.current = false + return + } + const list = listRef.current + if (!list) return + // Only auto-scroll if the user is already near the bottom + const nearBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 120 + if (nearBottom) { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }) + } + }, [messages, loading]) + + // Preserve scroll position when older messages are prepended + useEffect(() => { + if (!loadingOlder) return + const list = listRef.current + if (list) prevScrollHeightRef.current = list.scrollHeight + }, [loadingOlder]) + + useEffect(() => { + if (loadingOlder) return + const list = listRef.current + if (list && prevScrollHeightRef.current > 0) { + list.scrollTop = list.scrollHeight - prevScrollHeightRef.current + prevScrollHeightRef.current = 0 + } + }, [messages, loadingOlder]) + + const handleSend = () => { + if (!draft.trim() || rateLimited) return + sendMessage(draft) + setDraft("") + } + + const handleKeyDown = (e: KeyboardEvent) => { + // Send on Enter; Shift+Enter inserts a newline + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + const charsLeft = CHAT_MESSAGE_MAX_LENGTH - draft.length + + // ── Non-member wall ───────────────────────────────────────────────────── + if (!isMember) { + return ( + + + +

+ Only members of this pool can view and participate in the discussion. +

+
+
+ ) + } + + // ── Main UI ───────────────────────────────────────────────────────────── + return ( + + +
+
+ + Discussion +
+
+ + {realtimeStatus} +
+
+
+ + {/* Message list */} +
+ {/* Load earlier button */} + {hasMore && ( +
+ +
+ )} + + {loading ? ( + + ) : messages.length === 0 ? ( + + ) : ( + messages.map((msg) => ( + + )) + )} + +
+
+ + {/* Send error banner */} + {sendError && ( +
+ + {sendError} +
+ )} + + {/* Input area */} +
+
+
+