From e2e932804bd44d3aa3ef046501f94f49fad4693b Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sun, 14 Jun 2026 09:45:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(06):=20Phase=206=20=E2=80=94=20Direct=20Me?= =?UTF-8?q?ssages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Schema: DmPolicy enum (EVERYONE/FOLLOWERS_ONLY), dmPolicy on Pet, Conversation + Message models - conversationsRouter: list (inbox), getOrCreate (with DmPolicy + block check), unreadCount - messagesRouter: list (3s poll, auto mark-read), send (+ MESSAGE notification), markRead - /messages inbox page with unread badges + last message preview - /messages/[conversationId] thread with bubble UI, day dividers, Enter to send - Message button on pet profiles (ProfileActions) → getOrCreate → navigate to thread - Sidebar: Messages link activated - Pet edit page: DM Privacy setting (Everyone / Only pets I follow) Co-Authored-By: Claude Sonnet 4.6 --- prisma/schema.prisma | 45 +++ .../(app)/messages/[conversationId]/page.tsx | 46 +++ src/app/(app)/messages/page.tsx | 116 +++++++ src/app/(app)/pets/[petId]/ProfileActions.tsx | 40 ++- src/app/(app)/pets/[petId]/edit/page.tsx | 58 ++++ src/components/layout/Sidebar.tsx | 2 +- src/components/messages/MessageThread.tsx | 250 +++++++++++++++ src/trpc/routers/_app.ts | 3 + src/trpc/routers/messages.ts | 284 ++++++++++++++++++ src/trpc/routers/pets.ts | 2 + 10 files changed, 843 insertions(+), 3 deletions(-) create mode 100644 src/app/(app)/messages/[conversationId]/page.tsx create mode 100644 src/app/(app)/messages/page.tsx create mode 100644 src/components/messages/MessageThread.tsx create mode 100644 src/trpc/routers/messages.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 60ea129..917a605 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -41,6 +41,7 @@ model Pet { bio String? adoptionStory String? avatarKey String? // Supabase Storage object key, NOT a full URL + dmPolicy DmPolicy @default(EVERYONE) // who can send this pet DMs createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -63,9 +64,21 @@ model Pet { receivedNotifications Notification[] @relation("ReceivedNotifications") sentNotifications Notification[] @relation("SentNotifications") + // Phase 6 back-relations + conversationsAsA Conversation[] @relation("ConvPetA") + conversationsAsB Conversation[] @relation("ConvPetB") + sentMessages Message[] @relation("MessageSender") + @@index([ownerId]) } +// ─── Phase 6 Enums ─────────────────────────────────────────────────────────── + +enum DmPolicy { + EVERYONE // anyone can send a DM + FOLLOWERS_ONLY // only pets that this pet follows can send a DM +} + // ─── Phase 2 Enums ─────────────────────────────────────────────────────────── enum PostType { @@ -296,6 +309,38 @@ model VideoPost { @@index([muxAssetId]) } +// ─── Phase 6 Models ────────────────────────────────────────────────────────── + +model Conversation { + id String @id @default(cuid()) + petA Pet @relation("ConvPetA", fields: [petAId], references: [id], onDelete: Cascade) + petAId String + petB Pet @relation("ConvPetB", fields: [petBId], references: [id], onDelete: Cascade) + petBId String + messages Message[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // updated on every new message for inbox ordering + + @@unique([petAId, petBId]) // petAId is always < petBId (sorted on create) — deduplication + @@index([petAId]) + @@index([petBId]) +} + +model Message { + id String @id @default(cuid()) + conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + conversationId String + senderPet Pet @relation("MessageSender", fields: [senderPetId], references: [id]) + senderPetId String + body String @db.VarChar(1000) + readAt DateTime? // null = unread by recipient + + createdAt DateTime @default(now()) + + @@index([conversationId, createdAt(sort: Asc)]) + @@index([senderPetId]) +} + // ─── Phase 4 Enums ─────────────────────────────────────────────────────────── enum NotificationType { diff --git a/src/app/(app)/messages/[conversationId]/page.tsx b/src/app/(app)/messages/[conversationId]/page.tsx new file mode 100644 index 0000000..0279bab --- /dev/null +++ b/src/app/(app)/messages/[conversationId]/page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { useTRPC } from "@/trpc/client"; +import { useActivePet } from "@/context/ActivePetContext"; +import { MessageThread } from "@/components/messages/MessageThread"; + +export default function ConversationPage() { + const params = useParams<{ conversationId: string }>(); + const conversationId = params.conversationId; + const trpc = useTRPC(); + const { activePetId } = useActivePet(); + + // Load conversations to get the other pet's info + const { data: convList, isLoading } = useQuery({ + ...trpc.conversations.list.queryOptions({ petId: activePetId ?? "" }), + enabled: !!activePetId, + }); + + const conversation = convList?.find((c) => c.id === conversationId); + + if (isLoading || !activePetId) { + return ( +
+ +
+ ); + } + + if (!conversation) { + return ( +
+ Conversation not found. +
+ ); + } + + return ( + + ); +} diff --git a/src/app/(app)/messages/page.tsx b/src/app/(app)/messages/page.tsx new file mode 100644 index 0000000..6b9e9e0 --- /dev/null +++ b/src/app/(app)/messages/page.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { MessageCircle, Loader2 } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { useTRPC } from "@/trpc/client"; +import { useActivePet } from "@/context/ActivePetContext"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { getAvatarUrl } from "@/lib/avatar-url"; + +function formatRelativeTime(date: Date | string): string { + const diffMs = Date.now() - new Date(date).getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + if (diffMins < 1) return "now"; + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 7) return `${diffDays}d`; + return new Date(date).toLocaleDateString(); +} + +export default function MessagesPage() { + const trpc = useTRPC(); + const router = useRouter(); + const { activePetId } = useActivePet(); + + const { data, isLoading } = useQuery({ + ...trpc.conversations.list.queryOptions({ petId: activePetId ?? "" }), + enabled: !!activePetId, + refetchInterval: 10_000, + }); + + const conversations = data ?? []; + + return ( +
+
+

+

+
+ + {isLoading && ( +
+ +
+ )} + + {!isLoading && conversations.length === 0 && ( +
+ +

No messages yet

+

+ Visit a pet's profile and tap Message to start a conversation. +

+
+ )} + + {!isLoading && conversations.length > 0 && ( +
    + {conversations.map((conv) => { + const avatarUrl = getAvatarUrl(conv.otherPet.avatarKey); + const hasUnread = conv.unreadCount > 0; + + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/app/(app)/pets/[petId]/ProfileActions.tsx b/src/app/(app)/pets/[petId]/ProfileActions.tsx index e41b55d..2b24b2e 100644 --- a/src/app/(app)/pets/[petId]/ProfileActions.tsx +++ b/src/app/(app)/pets/[petId]/ProfileActions.tsx @@ -1,8 +1,12 @@ "use client"; import { useState } from "react"; -import { MoreHorizontal } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { MoreHorizontal, MessageCircle, Loader2 } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; import { useActivePet } from "@/context/ActivePetContext"; +import { useTRPC } from "@/trpc/client"; import { FollowButton } from "@/components/social/FollowButton"; import { BlockDialog } from "@/components/safety/BlockDialog"; import { ReportSheet } from "@/components/safety/ReportSheet"; @@ -37,9 +41,18 @@ interface ProfileActionsProps { export function ProfileActions({ petId, petName, ownerId }: ProfileActionsProps) { const { activePetId } = useActivePet(); + const router = useRouter(); + const trpc = useTRPC(); const [blockOpen, setBlockOpen] = useState(false); const [reportOpen, setReportOpen] = useState(false); + const getOrCreateMutation = useMutation( + trpc.conversations.getOrCreate.mutationOptions({ + onSuccess: (conv) => router.push(`/messages/${conv.id}`), + onError: (err) => toast.error(err.message ?? "Cannot start conversation."), + }) + ); + // activePetId may be null during SSR hydration — skip rendering interactive elements if (!activePetId) return null; @@ -47,7 +60,7 @@ export function ProfileActions({ petId, petName, ownerId }: ProfileActionsProps) return (
- {/* FollowButton — hidden on own pet (FollowButton also guards this internally) */} + {/* FollowButton — hidden on own pet */} {!isOwnPet && ( )} + {/* Message button — opens or creates a DM conversation */} + {!isOwnPet && ( + + )} + {/* Options menu — only show for other pets' profiles */} {!isOwnPet && ( <> diff --git a/src/app/(app)/pets/[petId]/edit/page.tsx b/src/app/(app)/pets/[petId]/edit/page.tsx index 2bc7ed1..452b39d 100644 --- a/src/app/(app)/pets/[petId]/edit/page.tsx +++ b/src/app/(app)/pets/[petId]/edit/page.tsx @@ -10,6 +10,14 @@ import { PetForm } from "@/components/pet/PetForm"; import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Dialog, DialogContent, @@ -39,6 +47,7 @@ export default function EditPetPage() { const queryClient = useQueryClient(); const { activePetId, setActivePet } = useActivePet(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [dmPolicy, setDmPolicy] = useState<"EVERYONE" | "FOLLOWERS_ONLY">("EVERYONE"); const { data: pet, @@ -48,6 +57,11 @@ export default function EditPetPage() { ...trpc.pets.byId.queryOptions({ petId }), }); + // Sync dmPolicy from fetched pet + useEffect(() => { + if (pet?.dmPolicy) setDmPolicy(pet.dmPolicy as "EVERYONE" | "FOLLOWERS_ONLY"); + }, [pet?.dmPolicy]); + // Gracefully handle ownership violation — redirect instead of showing error screen useEffect(() => { if (isError) { @@ -55,6 +69,12 @@ export default function EditPetPage() { } }, [isError, router]); + const updateDmPolicyMutation = useMutation({ + ...trpc.pets.update.mutationOptions(), + onSuccess: () => toast.success("Privacy setting saved"), + onError: () => toast.error("Failed to save privacy setting"), + }); + const deleteMutation = useMutation({ ...trpc.pets.delete.mutationOptions(), onSuccess: async () => { @@ -101,6 +121,44 @@ export default function EditPetPage() { onSuccess={() => router.push(`/pets/${pet.id}`)} /> + {/* Privacy settings */} + +
+

Privacy

+
+ +
+ + +
+

+ {dmPolicy === "FOLLOWERS_ONLY" + ? "Only pets that you follow can send you direct messages." + : "Any pet on PawFeed can send you direct messages."} +

+
+
+ {/* Destructive zone */}
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 44e3d7a..0f3b256 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -46,7 +46,7 @@ const navLinks = [ { href: "/explore", label: "Explore", icon: Compass, active: true, special: false }, { href: "/search", label: "Search", icon: Search, active: true, special: false }, { href: "/notifications", label: "Notifications", icon: Bell, active: true, special: true }, - { href: "/messages", label: "Messages", icon: MessageCircle, active: false, special: false }, + { href: "/messages", label: "Messages", icon: MessageCircle, active: true, special: false }, ]; /** diff --git a/src/components/messages/MessageThread.tsx b/src/components/messages/MessageThread.tsx new file mode 100644 index 0000000..5198c5b --- /dev/null +++ b/src/components/messages/MessageThread.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, Send, Loader2 } from "lucide-react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useTRPC } from "@/trpc/client"; +import { useActivePet } from "@/context/ActivePetContext"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { getAvatarUrl } from "@/lib/avatar-url"; +import Link from "next/link"; + +function formatTime(date: Date | string) { + return new Date(date).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function formatDay(date: Date | string) { + const d = new Date(date); + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + if (d.toDateString() === today.toDateString()) return "Today"; + if (d.toDateString() === yesterday.toDateString()) return "Yesterday"; + return d.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric" }); +} + +interface MessageThreadProps { + conversationId: string; + otherPet: { id: string; name: string; avatarKey: string | null }; +} + +export function MessageThread({ conversationId, otherPet }: MessageThreadProps) { + const trpc = useTRPC(); + const router = useRouter(); + const queryClient = useQueryClient(); + const { activePetId } = useActivePet(); + const bottomRef = useRef(null); + const [body, setBody] = useState(""); + + const { data, isLoading } = useQuery({ + ...trpc.messages.list.queryOptions({ + conversationId, + petId: activePetId ?? "", + limit: 50, + }), + enabled: !!activePetId, + refetchInterval: 3000, // poll every 3s for new messages + }); + + const sendMutation = useMutation( + trpc.messages.send.mutationOptions({ + onSuccess: () => { + setBody(""); + void queryClient.invalidateQueries( + trpc.messages.list.queryOptions({ conversationId, petId: activePetId ?? "", limit: 50 }) + ); + void queryClient.invalidateQueries( + trpc.conversations.list.queryOptions({ petId: activePetId ?? "" }) + ); + void queryClient.invalidateQueries( + trpc.conversations.unreadCount.queryOptions({ petId: activePetId ?? "" }) + ); + }, + onError: () => toast.error("Failed to send message. Try again."), + }) + ); + + // Scroll to bottom on new messages + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [data?.messages]); + + const handleSend = useCallback(() => { + if (!body.trim() || !activePetId) return; + sendMutation.mutate({ + conversationId, + senderPetId: activePetId, + body: body.trim(), + }); + }, [body, activePetId, conversationId, sendMutation]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend] + ); + + const messages = data?.messages ?? []; + const otherAvatarUrl = getAvatarUrl(otherPet.avatarKey); + + // Group messages by day + const grouped: Array<{ day: string; messages: typeof messages }> = []; + for (const msg of messages) { + const day = formatDay(msg.createdAt); + const last = grouped[grouped.length - 1]; + if (last && last.day === day) { + last.messages.push(msg); + } else { + grouped.push({ day, messages: [msg] }); + } + } + + return ( +
+ {/* Header */} +
+ + + + {otherAvatarUrl && } + + {otherPet.name[0]?.toUpperCase() ?? "?"} + + + {otherPet.name} + +
+ + {/* Message list */} +
+ {isLoading && ( +
+ +
+ )} + + {!isLoading && messages.length === 0 && ( +
+ + {otherAvatarUrl && } + + {otherPet.name[0]?.toUpperCase() ?? "?"} + + +

{otherPet.name}

+

Say hello 👋

+
+ )} + + {grouped.map(({ day, messages: dayMsgs }) => ( +
+ {/* Day divider */} +
+
+ {day} +
+
+ +
+ {dayMsgs.map((msg, i) => { + const isOwn = msg.senderPetId === activePetId; + const prevMsg = dayMsgs[i - 1]; + const showAvatar = !isOwn && (!prevMsg || prevMsg.senderPetId !== msg.senderPetId); + + return ( +
+ {/* Other pet avatar — only shown at first bubble in a run */} + {!isOwn && ( +
+ {showAvatar && ( + + {otherAvatarUrl && ( + + )} + + {otherPet.name[0]?.toUpperCase() ?? "?"} + + + )} +
+ )} + +
+
+ {msg.body} +
+ + {formatTime(msg.createdAt)} + +
+
+ ); + })} +
+
+ ))} + +
+
+ + {/* Composer */} +
+
+