diff --git a/apps/web/app/(app)/messages/[id]/page.tsx b/apps/web/app/(app)/messages/[id]/page.tsx new file mode 100644 index 0000000..abcd34b --- /dev/null +++ b/apps/web/app/(app)/messages/[id]/page.tsx @@ -0,0 +1,44 @@ +import { notFound } from "next/navigation"; +import { headers } from "next/headers"; +import Link from "next/link"; +import { auth } from "@/lib/auth/server"; +import { db, conversations, users, eq } from "@epicure/db"; +import { ConversationsList } from "@/components/social/conversations-list"; +import { MessageThread } from "@/components/social/message-thread"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { isParticipant, otherParticipantId } from "@/lib/messaging"; + +type Params = { params: Promise<{ id: string }> }; + +export default async function ConversationPage({ params }: Params) { + const { id } = await params; + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return null; + + const conversation = await db.query.conversations.findFirst({ where: eq(conversations.id, id) }); + if (!conversation || !isParticipant(conversation, session.user.id)) notFound(); + + const otherId = otherParticipantId(conversation, session.user.id); + const other = await db.query.users.findFirst({ where: eq(users.id, otherId) }); + if (!other) notFound(); + + return ( +
+
+ +
+
+
+ + + {other.avatarUrl && } + {other.name.slice(0, 2).toUpperCase()} + + {other.name} + +
+ +
+
+ ); +} diff --git a/apps/web/app/(app)/messages/page.tsx b/apps/web/app/(app)/messages/page.tsx new file mode 100644 index 0000000..e9f2635 --- /dev/null +++ b/apps/web/app/(app)/messages/page.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from "next"; +import { MessageCircle } from "lucide-react"; +import { ConversationsList } from "@/components/social/conversations-list"; + +export const metadata: Metadata = { title: "Messages — Epicure" }; + +export default function MessagesPage() { + return ( +
+
+ +
+
+
+ +

Select a conversation

+
+
+
+ ); +} diff --git a/apps/web/app/(app)/u/[username]/page.tsx b/apps/web/app/(app)/u/[username]/page.tsx index 6cbe689..4541b7b 100644 --- a/apps/web/app/(app)/u/[username]/page.tsx +++ b/apps/web/app/(app)/u/[username]/page.tsx @@ -17,6 +17,7 @@ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { FollowButton } from "@/components/social/follow-button"; import { BlockButton } from "@/components/social/block-button"; +import { MessageButton } from "@/components/social/message-button"; import { getPublicUrl } from "@/lib/storage"; type Params = { params: Promise<{ username: string }> }; @@ -113,6 +114,7 @@ export default async function UserProfilePage({ params }: Params) { targetUsername={user.username!} initialFollowing={isFollowing} /> + {!isBlocked && } )} diff --git a/apps/web/app/api/v1/conversations/[id]/messages/route.ts b/apps/web/app/api/v1/conversations/[id]/messages/route.ts new file mode 100644 index 0000000..4cfcd27 --- /dev/null +++ b/apps/web/app/api/v1/conversations/[id]/messages/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, conversations, conversationReads, messages, eq, asc } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; +import { applyRateLimit } from "@/lib/rate-limit"; +import { isParticipant, otherParticipantId } from "@/lib/messaging"; +import { isBlockedEitherWay } from "@/lib/blocks"; +import { randomUUID } from "crypto"; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +const Schema = z.object({ content: z.string().min(1).max(4000) }); + +export async function GET(_req: NextRequest, { params }: RouteContext) { + const { session, response } = await requireSession(); + if (response) return response; + const { id } = await params; + + const conversation = await db.query.conversations.findFirst({ where: eq(conversations.id, id) }); + if (!conversation || !isParticipant(conversation, session!.user.id)) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const rows = await db + .select() + .from(messages) + .where(eq(messages.conversationId, id)) + .orderBy(asc(messages.createdAt)) + .limit(200); + + await db + .insert(conversationReads) + .values({ conversationId: id, userId: session!.user.id, lastReadAt: new Date() }) + .onConflictDoUpdate({ + target: [conversationReads.conversationId, conversationReads.userId], + set: { lastReadAt: new Date() }, + }); + + return NextResponse.json({ messages: rows }); +} + +export async function POST(req: NextRequest, { params }: RouteContext) { + const { session, response } = await requireSession(); + if (response) return response; + const { id } = await params; + + const limited = await applyRateLimit(`rl:message:${session!.user.id}`, 30, 60); + if (limited) return limited; + + const conversation = await db.query.conversations.findFirst({ where: eq(conversations.id, id) }); + if (!conversation || !isParticipant(conversation, session!.user.id)) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const otherId = otherParticipantId(conversation, session!.user.id); + if (await isBlockedEitherWay(session!.user.id, otherId)) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const body = await req.json() as unknown; + const parsed = Schema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + const messageId = randomUUID(); + await db.insert(messages).values({ + id: messageId, + conversationId: id, + senderId: session!.user.id, + content: parsed.data.content, + }); + await db.update(conversations).set({ lastMessageAt: new Date() }).where(eq(conversations.id, id)); + + return NextResponse.json({ id: messageId }, { status: 201 }); +} diff --git a/apps/web/app/api/v1/conversations/route.ts b/apps/web/app/api/v1/conversations/route.ts new file mode 100644 index 0000000..9b1d10d --- /dev/null +++ b/apps/web/app/api/v1/conversations/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, conversations, conversationReads, messages, users, eq, or, desc, and, gt, sql } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; +import { applyRateLimit } from "@/lib/rate-limit"; +import { findOrCreateConversation, otherParticipantId } from "@/lib/messaging"; +import { isBlockedEitherWay } from "@/lib/blocks"; +import { randomUUID } from "crypto"; + +const CreateSchema = z.object({ + username: z.string().min(1), + content: z.string().min(1).max(4000).optional(), +}); + +export async function GET() { + const { session, response } = await requireSession(); + if (response) return response; + + const rows = await db + .select() + .from(conversations) + .where(or(eq(conversations.userAId, session!.user.id), eq(conversations.userBId, session!.user.id))) + .orderBy(desc(conversations.lastMessageAt)) + .limit(50); + + const result = await Promise.all( + rows.map(async (conv) => { + const otherId = otherParticipantId(conv, session!.user.id); + const [other, lastMessage, readRow] = await Promise.all([ + db.query.users.findFirst({ + where: eq(users.id, otherId), + columns: { id: true, name: true, username: true, avatarUrl: true }, + }), + db.query.messages.findFirst({ + where: eq(messages.conversationId, conv.id), + orderBy: (m, { desc: d }) => [d(m.createdAt)], + }), + db.query.conversationReads.findFirst({ + where: and(eq(conversationReads.conversationId, conv.id), eq(conversationReads.userId, session!.user.id)), + }), + ]); + + const unreadRow = await db + .select({ total: sql`count(*)` }) + .from(messages) + .where( + and( + eq(messages.conversationId, conv.id), + readRow ? gt(messages.createdAt, readRow.lastReadAt) : sql`true`, + sql`${messages.senderId} != ${session!.user.id}` + ) + ); + + return { + id: conv.id, + otherUser: other, + lastMessage: lastMessage?.content ?? null, + lastMessageAt: conv.lastMessageAt, + unreadCount: Number(unreadRow[0]?.total ?? 0), + }; + }) + ); + + return NextResponse.json({ conversations: result }); +} + +export async function POST(req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + + const limited = await applyRateLimit(`rl:message:${session!.user.id}`, 30, 60); + if (limited) return limited; + + const body = await req.json() as unknown; + const parsed = CreateSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + const target = await db.query.users.findFirst({ where: eq(users.username, parsed.data.username) }); + if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (target.id === session!.user.id) return NextResponse.json({ error: "Cannot message yourself" }, { status: 400 }); + + if (await isBlockedEitherWay(session!.user.id, target.id)) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const conversation = await findOrCreateConversation(session!.user.id, target.id); + + if (parsed.data.content) { + await db.insert(messages).values({ + id: randomUUID(), + conversationId: conversation.id, + senderId: session!.user.id, + content: parsed.data.content, + }); + await db.update(conversations).set({ lastMessageAt: new Date() }).where(eq(conversations.id, conversation.id)); + } + + return NextResponse.json({ conversationId: conversation.id }, { status: 201 }); +} diff --git a/apps/web/components/layout/nav.tsx b/apps/web/components/layout/nav.tsx index f69fae3..570fe21 100644 --- a/apps/web/components/layout/nav.tsx +++ b/apps/web/components/layout/nav.tsx @@ -22,6 +22,7 @@ import { } from "@/components/ui/sheet"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { NotificationBell } from "@/components/social/notification-bell"; +import { MessagesNavLink } from "@/components/social/messages-nav-link"; import { authClient } from "@/lib/auth/client"; import { useTranslations } from "next-intl"; @@ -101,6 +102,7 @@ export function Nav() { ))}
+ diff --git a/apps/web/components/social/conversations-list.tsx b/apps/web/components/social/conversations-list.tsx new file mode 100644 index 0000000..e3d700f --- /dev/null +++ b/apps/web/components/social/conversations-list.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; + +type ConversationSummary = { + id: string; + otherUser: { id: string; name: string; username: string | null; avatarUrl: string | null } | null; + lastMessage: string | null; + lastMessageAt: string; + unreadCount: number; +}; + +export function ConversationsList() { + const pathname = usePathname(); + const [conversations, setConversations] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + async function load() { + const res = await fetch("/api/v1/conversations"); + if (res.ok && !cancelled) { + const data = (await res.json()) as { conversations: ConversationSummary[] }; + setConversations(data.conversations); + } + if (!cancelled) setLoading(false); + } + void load(); + const interval = setInterval(load, 10000); + return () => { cancelled = true; clearInterval(interval); }; + }, []); + + if (loading) return

Loading…

; + if (conversations.length === 0) { + return

No conversations yet. Visit a profile to say hi.

; + } + + return ( +
+ {conversations.map((c) => ( + + + {c.otherUser?.avatarUrl && } + {(c.otherUser?.name ?? "?").slice(0, 2).toUpperCase()} + +
+
+

0 && "font-semibold")}> + {c.otherUser?.name ?? "Unknown"} +

+ {c.unreadCount > 0 && ( + + {c.unreadCount > 9 ? "9+" : c.unreadCount} + + )} +
+

0 ? "text-foreground" : "text-muted-foreground")}> + {c.lastMessage ?? "No messages yet"} +

+
+ + ))} +
+ ); +} diff --git a/apps/web/components/social/message-button.tsx b/apps/web/components/social/message-button.tsx new file mode 100644 index 0000000..eea49cf --- /dev/null +++ b/apps/web/components/social/message-button.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { MessageCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export function MessageButton({ targetUsername }: { targetUsername: string }) { + const router = useRouter(); + const [loading, setLoading] = useState(false); + + async function startConversation() { + setLoading(true); + try { + const res = await fetch("/api/v1/conversations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: targetUsername }), + }); + if (!res.ok) { + const err = await res.json() as { error?: string }; + toast.error(err.error ?? "Failed to start conversation"); + return; + } + const { conversationId } = await res.json() as { conversationId: string }; + router.push(`/messages/${conversationId}`); + } finally { + setLoading(false); + } + } + + return ( + + ); +} diff --git a/apps/web/components/social/message-thread.tsx b/apps/web/components/social/message-thread.tsx new file mode 100644 index 0000000..0f34802 --- /dev/null +++ b/apps/web/components/social/message-thread.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { Send } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +type Message = { + id: string; + content: string; + senderId: string; + createdAt: string; +}; + +export function MessageThread({ + conversationId, + currentUserId, +}: { + conversationId: string; + currentUserId: string; +}) { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [content, setContent] = useState(""); + const [sending, setSending] = useState(false); + const bottomRef = useRef(null); + + const load = useCallback(async () => { + const res = await fetch(`/api/v1/conversations/${conversationId}/messages`); + if (res.ok) { + const data = (await res.json()) as { messages: Message[] }; + setMessages(data.messages); + } + setLoading(false); + }, [conversationId]); + + useEffect(() => { + void load(); + const interval = setInterval(() => { void load(); }, 5000); + return () => clearInterval(interval); + }, [load]); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages.length]); + + async function send() { + if (!content.trim()) return; + setSending(true); + try { + const res = await fetch(`/api/v1/conversations/${conversationId}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: content.trim() }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})) as { error?: string }; + toast.error(err.error ?? "Failed to send"); + return; + } + setContent(""); + await load(); + } finally { + setSending(false); + } + } + + return ( +
+
+ {loading ? ( +

Loading…

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

No messages yet. Say hi!

+ ) : ( + messages.map((m) => { + const isOwn = m.senderId === currentUserId; + return ( +
+
+ {m.content} +
+
+ ); + }) + )} +
+
+
+