feat(06): Phase 6 — Direct Messages
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
1fc69add4d
commit
e2e932804b
10 files changed
+843
-3
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center h-[100dvh]">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!conversation) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[100dvh] text-muted-foreground text-sm">
|
||||
Conversation not found.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageThread
|
||||
conversationId={conversationId}
|
||||
otherPet={conversation.otherPet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="max-w-[640px] mx-auto">
|
||||
<div className="px-4 py-5 border-b">
|
||||
<h1 className="text-xl font-semibold flex items-center gap-2">
|
||||
<MessageCircle className="h-5 w-5 text-orange-500" aria-hidden="true" />
|
||||
Messages
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && conversations.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 py-24 text-muted-foreground">
|
||||
<MessageCircle className="h-12 w-12 opacity-25" />
|
||||
<p className="text-sm font-medium">No messages yet</p>
|
||||
<p className="text-xs text-center max-w-[220px]">
|
||||
Visit a pet's profile and tap <strong>Message</strong> to start a conversation.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && conversations.length > 0 && (
|
||||
<ul>
|
||||
{conversations.map((conv) => {
|
||||
const avatarUrl = getAvatarUrl(conv.otherPet.avatarKey);
|
||||
const hasUnread = conv.unreadCount > 0;
|
||||
|
||||
return (
|
||||
<li key={conv.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/messages/${conv.id}`)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
{/* Avatar */}
|
||||
<Avatar className="w-12 h-12 shrink-0">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={conv.otherPet.name} />}
|
||||
<AvatarFallback className="text-sm bg-muted">
|
||||
{conv.otherPet.name[0]?.toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className={`text-sm truncate ${hasUnread ? "font-semibold" : "font-medium"}`}>
|
||||
{conv.otherPet.name}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{formatRelativeTime(conv.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 mt-0.5">
|
||||
<p className={`text-xs truncate ${hasUnread ? "text-foreground" : "text-muted-foreground"}`}>
|
||||
{conv.lastMessage
|
||||
? conv.lastMessage.senderPetId === activePetId
|
||||
? `You: ${conv.lastMessage.body}`
|
||||
: conv.lastMessage.body
|
||||
: "Start the conversation"}
|
||||
</p>
|
||||
{hasUnread && (
|
||||
<Badge className="shrink-0 bg-orange-500 text-white text-xs px-1.5 min-w-[20px] h-5 flex items-center justify-center rounded-full">
|
||||
{conv.unreadCount > 99 ? "99+" : conv.unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center gap-3 mb-6">
|
||||
{/* FollowButton — hidden on own pet (FollowButton also guards this internally) */}
|
||||
{/* FollowButton — hidden on own pet */}
|
||||
{!isOwnPet && (
|
||||
<FollowButton
|
||||
followerPetId={activePetId}
|
||||
@@ -56,6 +69,29 @@ export function ProfileActions({ petId, petName, ownerId }: ProfileActionsProps)
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Message button — opens or creates a DM conversation */}
|
||||
{!isOwnPet && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
getOrCreateMutation.mutate({
|
||||
senderPetId: activePetId,
|
||||
recipientPetId: petId,
|
||||
})
|
||||
}
|
||||
disabled={getOrCreateMutation.isPending}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{getOrCreateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
)}
|
||||
Message
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Options menu — only show for other pets' profiles */}
|
||||
{!isOwnPet && (
|
||||
<>
|
||||
|
||||
@@ -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 */}
|
||||
<Separator className="my-8" />
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-sm font-medium">Privacy</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dm-policy">Who can message {pet.name}?</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
value={dmPolicy}
|
||||
onValueChange={(v) => setDmPolicy(v as "EVERYONE" | "FOLLOWERS_ONLY")}
|
||||
>
|
||||
<SelectTrigger id="dm-policy" className="w-[220px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EVERYONE">Everyone</SelectItem>
|
||||
<SelectItem value="FOLLOWERS_ONLY">Only pets I follow</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={updateDmPolicyMutation.isPending || dmPolicy === pet.dmPolicy}
|
||||
onClick={() =>
|
||||
updateDmPolicyMutation.mutate({ petId: pet.id, dmPolicy })
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{dmPolicy === "FOLLOWERS_ONLY"
|
||||
? "Only pets that you follow can send you direct messages."
|
||||
: "Any pet on PawFeed can send you direct messages."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Destructive zone */}
|
||||
<Separator className="my-8" />
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<HTMLDivElement>(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<HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<div className="flex flex-col h-[100dvh] max-h-[100dvh]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b bg-card shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="p-2 -ml-2 rounded-full hover:bg-muted transition-colors"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<Link href={`/pets/${otherPet.id}`} className="flex items-center gap-2 hover:opacity-80">
|
||||
<Avatar className="w-8 h-8">
|
||||
{otherAvatarUrl && <AvatarImage src={otherAvatarUrl} alt={otherPet.name} />}
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{otherPet.name[0]?.toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm font-semibold">{otherPet.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Message list */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-10">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && messages.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-20 text-muted-foreground">
|
||||
<Avatar className="w-16 h-16">
|
||||
{otherAvatarUrl && <AvatarImage src={otherAvatarUrl} alt={otherPet.name} />}
|
||||
<AvatarFallback className="text-xl bg-muted">
|
||||
{otherPet.name[0]?.toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<p className="text-sm font-medium mt-2">{otherPet.name}</p>
|
||||
<p className="text-xs">Say hello 👋</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped.map(({ day, messages: dayMsgs }) => (
|
||||
<div key={day}>
|
||||
{/* Day divider */}
|
||||
<div className="flex items-center gap-2 my-3">
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
<span className="text-xs text-muted-foreground shrink-0">{day}</span>
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{dayMsgs.map((msg, i) => {
|
||||
const isOwn = msg.senderPetId === activePetId;
|
||||
const prevMsg = dayMsgs[i - 1];
|
||||
const showAvatar = !isOwn && (!prevMsg || prevMsg.senderPetId !== msg.senderPetId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex items-end gap-2 ${isOwn ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
{/* Other pet avatar — only shown at first bubble in a run */}
|
||||
{!isOwn && (
|
||||
<div className="w-7 h-7 shrink-0">
|
||||
{showAvatar && (
|
||||
<Avatar className="w-7 h-7">
|
||||
{otherAvatarUrl && (
|
||||
<AvatarImage src={otherAvatarUrl} alt={otherPet.name} />
|
||||
)}
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{otherPet.name[0]?.toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`max-w-[72%] ${isOwn ? "items-end" : "items-start"} flex flex-col`}>
|
||||
<div
|
||||
className={`px-3 py-2 rounded-2xl text-sm leading-snug whitespace-pre-wrap break-words ${
|
||||
isOwn
|
||||
? "bg-orange-500 text-white rounded-br-sm"
|
||||
: "bg-muted text-foreground rounded-bl-sm"
|
||||
}`}
|
||||
>
|
||||
{msg.body}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground mt-0.5 px-1">
|
||||
{formatTime(msg.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
<div className="shrink-0 border-t bg-card px-4 py-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Message…"
|
||||
maxLength={1000}
|
||||
rows={1}
|
||||
className="resize-none text-sm min-h-[40px] max-h-[120px] flex-1"
|
||||
disabled={sendMutation.isPending}
|
||||
style={{ overflowY: "auto" }}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSend}
|
||||
disabled={!body.trim() || sendMutation.isPending}
|
||||
className="h-10 w-10 shrink-0 bg-orange-500 hover:bg-orange-600 text-white rounded-full"
|
||||
aria-label="Send message"
|
||||
>
|
||||
{sendMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{body.length > 900 && (
|
||||
<p className="text-xs text-muted-foreground text-right mt-1">
|
||||
{1000 - body.length} characters left
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { exploreRouter } from "@/trpc/routers/explore";
|
||||
import { searchRouter } from "@/trpc/routers/search";
|
||||
import { notificationsRouter } from "@/trpc/routers/notifications";
|
||||
import { videosRouter } from "@/trpc/routers/videos";
|
||||
import { conversationsRouter, messagesRouter } from "@/trpc/routers/messages";
|
||||
|
||||
export const appRouter = router({
|
||||
pets: petsRouter,
|
||||
@@ -33,6 +34,8 @@ export const appRouter = router({
|
||||
search: searchRouter,
|
||||
notifications: notificationsRouter,
|
||||
videos: videosRouter,
|
||||
conversations: conversationsRouter,
|
||||
messages: messagesRouter,
|
||||
});
|
||||
|
||||
// Export the type for use in the client
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Messages tRPC router — Phase 6 Direct Messages.
|
||||
*
|
||||
* Two sub-routers exported:
|
||||
* conversationsRouter — list, getOrCreate, unreadCount
|
||||
* messagesRouter — list, send, markRead
|
||||
*
|
||||
* Security:
|
||||
* - All procedures verify pet ownership via ctx.userId before accessing data
|
||||
* - DmPolicy check: FOLLOWERS_ONLY blocks DMs from non-followers
|
||||
* - Block check: blocked pets cannot initiate or receive DMs
|
||||
* - Conversation IDs are sorted (petAId < petBId) to prevent duplicates
|
||||
*/
|
||||
|
||||
async function assertPetOwnership(prisma: PrismaClient, petId: string, userId: string) {
|
||||
const pet = await prisma.pet.findUnique({ where: { id: petId } });
|
||||
if (!pet || pet.ownerId !== userId) throw new TRPCError({ code: "FORBIDDEN" });
|
||||
return pet;
|
||||
}
|
||||
|
||||
/** Verify that petId is a participant of the conversation and belongs to ctx.userId */
|
||||
async function assertParticipant(
|
||||
prisma: PrismaClient,
|
||||
conversationId: string,
|
||||
petId: string,
|
||||
userId: string
|
||||
) {
|
||||
const [conv] = await Promise.all([
|
||||
prisma.conversation.findUnique({ where: { id: conversationId } }),
|
||||
assertPetOwnership(prisma, petId, userId),
|
||||
]);
|
||||
if (!conv || (conv.petAId !== petId && conv.petBId !== petId)) {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
return conv;
|
||||
}
|
||||
|
||||
export const conversationsRouter = router({
|
||||
/**
|
||||
* List all conversations for a pet, ordered by most recent message.
|
||||
* Returns the other pet's info, last message preview, and unread count.
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(z.object({ petId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
|
||||
|
||||
const convs = await ctx.prisma.conversation.findMany({
|
||||
where: { OR: [{ petAId: input.petId }, { petBId: input.petId }] },
|
||||
include: {
|
||||
petA: { select: { id: true, name: true, avatarKey: true } },
|
||||
petB: { select: { id: true, name: true, avatarKey: true } },
|
||||
messages: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
messages: {
|
||||
where: { senderPetId: { not: input.petId }, readAt: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
|
||||
return convs.map((c) => ({
|
||||
id: c.id,
|
||||
otherPet: c.petAId === input.petId ? c.petB : c.petA,
|
||||
lastMessage: c.messages[0] ?? null,
|
||||
unreadCount: c._count.messages,
|
||||
updatedAt: c.updatedAt,
|
||||
}));
|
||||
}),
|
||||
|
||||
/**
|
||||
* Find or create a conversation between two pets.
|
||||
* Enforces DmPolicy and block checks before creating.
|
||||
* Pet IDs are sorted alphabetically to ensure uniqueness.
|
||||
*/
|
||||
getOrCreate: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
senderPetId: z.string().min(1),
|
||||
recipientPetId: z.string().min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertPetOwnership(ctx.prisma, input.senderPetId, ctx.userId);
|
||||
|
||||
if (input.senderPetId === input.recipientPetId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot message yourself." });
|
||||
}
|
||||
|
||||
const [recipient, block] = await Promise.all([
|
||||
ctx.prisma.pet.findUnique({
|
||||
where: { id: input.recipientPetId },
|
||||
select: { id: true, dmPolicy: true },
|
||||
}),
|
||||
ctx.prisma.block.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ blockerPetId: input.senderPetId, blockedPetId: input.recipientPetId },
|
||||
{ blockerPetId: input.recipientPetId, blockedPetId: input.senderPetId },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!recipient) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (block) throw new TRPCError({ code: "FORBIDDEN", message: "Cannot send messages to this pet." });
|
||||
|
||||
// DmPolicy check
|
||||
if (recipient.dmPolicy === "FOLLOWERS_ONLY") {
|
||||
const follow = await ctx.prisma.follow.findUnique({
|
||||
where: {
|
||||
followerPetId_followeePetId: {
|
||||
followerPetId: input.recipientPetId,
|
||||
followeePetId: input.senderPetId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!follow) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "This pet only accepts messages from pets they follow.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort IDs to ensure uniqueness constraint ([petAId, petBId])
|
||||
const [petAId, petBId] = [input.senderPetId, input.recipientPetId].sort();
|
||||
|
||||
const conversation = await ctx.prisma.conversation.upsert({
|
||||
where: { petAId_petBId: { petAId, petBId } },
|
||||
update: {},
|
||||
create: { petAId, petBId },
|
||||
});
|
||||
|
||||
return conversation;
|
||||
}),
|
||||
|
||||
/** Total unread message count across all conversations — used for inbox badge. */
|
||||
unreadCount: protectedProcedure
|
||||
.input(z.object({ petId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
|
||||
|
||||
const count = await ctx.prisma.message.count({
|
||||
where: {
|
||||
conversation: {
|
||||
OR: [{ petAId: input.petId }, { petBId: input.petId }],
|
||||
},
|
||||
senderPetId: { not: input.petId },
|
||||
readAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
return { count };
|
||||
}),
|
||||
});
|
||||
|
||||
export const messagesRouter = router({
|
||||
/**
|
||||
* Paginated messages in a conversation, oldest-first (cursor on createdAt).
|
||||
* Marks messages sent by the other pet as read on load.
|
||||
*/
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
conversationId: z.string().min(1),
|
||||
petId: z.string().min(1),
|
||||
cursor: z.string().nullish(), // ISO createdAt for pagination
|
||||
limit: z.number().min(1).max(50).default(30),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertParticipant(ctx.prisma, input.conversationId, input.petId, ctx.userId);
|
||||
|
||||
const messages = await ctx.prisma.message.findMany({
|
||||
where: {
|
||||
conversationId: input.conversationId,
|
||||
...(input.cursor ? { createdAt: { gt: new Date(input.cursor) } } : {}),
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: input.limit + 1,
|
||||
include: {
|
||||
senderPet: { select: { id: true, name: true, avatarKey: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const hasNextPage = messages.length > input.limit;
|
||||
const items = hasNextPage ? messages.slice(0, input.limit) : messages;
|
||||
const nextCursor = hasNextPage ? items[items.length - 1].createdAt.toISOString() : null;
|
||||
|
||||
// Mark received messages as read (fire-and-forget)
|
||||
ctx.prisma.message.updateMany({
|
||||
where: {
|
||||
conversationId: input.conversationId,
|
||||
senderPetId: { not: input.petId },
|
||||
readAt: null,
|
||||
},
|
||||
data: { readAt: new Date() },
|
||||
}).catch(() => {});
|
||||
|
||||
return { messages: items, nextCursor };
|
||||
}),
|
||||
|
||||
/** Send a message. Fires MESSAGE notification fire-and-forget. */
|
||||
send: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
conversationId: z.string().min(1),
|
||||
senderPetId: z.string().min(1),
|
||||
body: z.string().min(1).max(1000),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const conv = await assertParticipant(
|
||||
ctx.prisma,
|
||||
input.conversationId,
|
||||
input.senderPetId,
|
||||
ctx.userId
|
||||
);
|
||||
|
||||
const recipientPetId =
|
||||
conv.petAId === input.senderPetId ? conv.petBId : conv.petAId;
|
||||
|
||||
const [message] = await ctx.prisma.$transaction([
|
||||
ctx.prisma.message.create({
|
||||
data: {
|
||||
conversationId: input.conversationId,
|
||||
senderPetId: input.senderPetId,
|
||||
body: input.body,
|
||||
},
|
||||
include: { senderPet: { select: { id: true, name: true, avatarKey: true } } },
|
||||
}),
|
||||
// Touch conversation updatedAt so inbox re-orders correctly
|
||||
ctx.prisma.conversation.update({
|
||||
where: { id: input.conversationId },
|
||||
data: { updatedAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
|
||||
// MESSAGE notification — fire-and-forget
|
||||
ctx.prisma.notification.create({
|
||||
data: {
|
||||
recipientPetId,
|
||||
type: "MESSAGE",
|
||||
actorPetId: input.senderPetId,
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
return message;
|
||||
}),
|
||||
|
||||
/** Mark all received messages in a conversation as read. */
|
||||
markRead: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
conversationId: z.string().min(1),
|
||||
petId: z.string().min(1),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertParticipant(ctx.prisma, input.conversationId, input.petId, ctx.userId);
|
||||
|
||||
await ctx.prisma.message.updateMany({
|
||||
where: {
|
||||
conversationId: input.conversationId,
|
||||
senderPetId: { not: input.petId },
|
||||
readAt: null,
|
||||
},
|
||||
data: { readAt: new Date() },
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -117,6 +117,7 @@ export const petsRouter = router({
|
||||
bio: z.string().max(150).optional().nullable(),
|
||||
adoptionStory: z.string().max(500).optional().nullable(),
|
||||
breedId: z.string().optional().nullable(),
|
||||
dmPolicy: z.enum(["EVERYONE", "FOLLOWERS_ONLY"]).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
@@ -131,6 +132,7 @@ export const petsRouter = router({
|
||||
if (data.adoptionStory !== undefined)
|
||||
updateData.adoptionStory = data.adoptionStory;
|
||||
if (data.breedId !== undefined) updateData.breedId = data.breedId;
|
||||
if (data.dmPolicy !== undefined) updateData.dmPolicy = data.dmPolicy;
|
||||
|
||||
return ctx.prisma.pet.update({
|
||||
where: { id: petId },
|
||||
|
||||
Reference in new issue
Block a user