From 437cc4c0266138117db602d466243699a4266cfb Mon Sep 17 00:00:00 2001 From: sherifatolanike Date: Thu, 22 Oct 2026 10:19:29 +0000 Subject: [PATCH 1/2] feat: complete in-app messaging features --- src/components/auth/SignInForm.tsx | 11 + src/components/layout/Navbar.tsx | 3 + .../messaging/api/messagingService.ts | 52 ++++ .../messaging/components/ConversationMenu.tsx | 108 +++++++ .../components/MessageOwnerButton.tsx | 84 +++++ .../messaging/components/MessagesLink.tsx | 52 ++++ .../components/ReportConversationDialog.tsx | 229 ++++++++++++++ .../hooks/__tests__/useLiveMessages.test.tsx | 288 ++++++++++++++++++ .../useMarkConversationRead.test.tsx | 128 ++++++++ .../hooks/__tests__/useSendMessage.test.tsx | 163 ++++++++++ .../__tests__/useUnreadMessageCount.test.tsx | 100 ++++++ src/features/messaging/hooks/index.ts | 10 + .../messaging/hooks/useBlockConversation.ts | 79 +++++ .../messaging/hooks/useConversation.ts | 23 ++ .../messaging/hooks/useConversations.ts | 23 ++ .../messaging/hooks/useCurrentUser.ts | 58 ++++ .../messaging/hooks/useLiveMessages.ts | 170 +++++++++++ .../hooks/useMarkConversationRead.ts | 71 +++++ .../messaging/hooks/useReportConversation.ts | 102 +++++++ .../messaging/hooks/useSendMessage.ts | 108 +++++++ .../messaging/hooks/useStartConversation.ts | 74 +++++ .../messaging/hooks/useUnreadMessageCount.ts | 24 ++ src/features/messaging/schemas/index.ts | 1 + .../messaging/schemas/messagingSchemas.ts | 52 ++++ src/features/messaging/types/index.ts | 1 + src/features/messaging/types/message.ts | 44 +++ src/hooks/useApiMutation.ts | 6 + src/mocks/handlers/index.ts | 2 + src/mocks/handlers/messaging.ts | 256 ++++++++++++++++ src/pages/CustodyTimelinePage.tsx | 12 +- src/pages/PetlistingdetailsPage.tsx | 3 + 31 files changed, 2335 insertions(+), 2 deletions(-) create mode 100644 src/features/messaging/api/messagingService.ts create mode 100644 src/features/messaging/components/ConversationMenu.tsx create mode 100644 src/features/messaging/components/MessageOwnerButton.tsx create mode 100644 src/features/messaging/components/MessagesLink.tsx create mode 100644 src/features/messaging/components/ReportConversationDialog.tsx create mode 100644 src/features/messaging/hooks/__tests__/useLiveMessages.test.tsx create mode 100644 src/features/messaging/hooks/__tests__/useMarkConversationRead.test.tsx create mode 100644 src/features/messaging/hooks/__tests__/useSendMessage.test.tsx create mode 100644 src/features/messaging/hooks/__tests__/useUnreadMessageCount.test.tsx create mode 100644 src/features/messaging/hooks/index.ts create mode 100644 src/features/messaging/hooks/useBlockConversation.ts create mode 100644 src/features/messaging/hooks/useConversation.ts create mode 100644 src/features/messaging/hooks/useConversations.ts create mode 100644 src/features/messaging/hooks/useCurrentUser.ts create mode 100644 src/features/messaging/hooks/useLiveMessages.ts create mode 100644 src/features/messaging/hooks/useMarkConversationRead.ts create mode 100644 src/features/messaging/hooks/useReportConversation.ts create mode 100644 src/features/messaging/hooks/useSendMessage.ts create mode 100644 src/features/messaging/hooks/useStartConversation.ts create mode 100644 src/features/messaging/hooks/useUnreadMessageCount.ts create mode 100644 src/features/messaging/schemas/index.ts create mode 100644 src/features/messaging/schemas/messagingSchemas.ts create mode 100644 src/features/messaging/types/index.ts create mode 100644 src/features/messaging/types/message.ts create mode 100644 src/mocks/handlers/messaging.ts diff --git a/src/components/auth/SignInForm.tsx b/src/components/auth/SignInForm.tsx index b4b5e05f..2dd2f2ae 100644 --- a/src/components/auth/SignInForm.tsx +++ b/src/components/auth/SignInForm.tsx @@ -74,6 +74,17 @@ export function SignInForm() { password: formData.password, }); localStorage.setItem("auth_token", response.token); + // Persist the logged-in user's id so messaging can identify the + // current user (e.g. to hide "Message owner" on your own listings). + localStorage.setItem( + "petad_user", + JSON.stringify({ + id: response.user.id, + email: response.user.email, + fullName: response.user.fullName, + role: response.user.role, + }), + ); const returnTo = getReturnTo(searchParams); sessionStorage.removeItem("petad_return_to"); diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 7a076981..4ef880f3 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -7,6 +7,7 @@ import { useRoleGuard } from "../../hooks/useRoleGuard"; import { DeferredNotificationCentreDropdown } from "../notifications"; import { ThemeToggle } from "../theme-toggle"; import { FreighterWalletButton } from "../wallet/FreighterWalletButton"; +import { MessagesLink } from "../../features/messaging/components/MessagesLink"; const baseNavLinks = [ { label: "Home", path: "/home", icon: House }, @@ -86,6 +87,8 @@ export function Navbar() { + + diff --git a/src/features/messaging/api/messagingService.ts b/src/features/messaging/api/messagingService.ts new file mode 100644 index 00000000..de31975a --- /dev/null +++ b/src/features/messaging/api/messagingService.ts @@ -0,0 +1,52 @@ +import { apiClient } from "../../lib/api-client"; +import type { Conversation, Message } from "../types"; + +/** + * messagingService + * + * Real-time messaging API calls via the api-client. + */ +export const messagingService = { + /** GET /api/conversations — conversations for the current user. */ + async listConversations(): Promise { + return apiClient.get("/conversations"); + }, + + /** GET /api/conversations/:id — single conversation with its messages. */ + async getConversation(conversationId: string): Promise { + return apiClient.get(`/conversations/${conversationId}`); + }, + + /** POST /api/conversations — start a conversation for a pet + owner. */ + async createConversation(petId: string, ownerId: string): Promise { + return apiClient.post("/conversations", { petId, ownerId }); + }, + + /** POST /api/conversations/:id/messages — send a message. */ + async sendMessage(conversationId: string, body: string): Promise { + return apiClient.post(`/conversations/${conversationId}/messages`, { + conversationId, + body, + }); + }, + + /** PATCH /api/conversations/:id/read — mark conversation as read. */ + async markRead(conversationId: string): Promise { + return apiClient.patch(`/conversations/${conversationId}/read`); + }, + + /** POST /api/users/block — block a conversation partner. */ + async blockUser(ownerId: string): Promise { + return apiClient.post("/users/block", { ownerId }); + }, + + /** POST /api/users/unblock — lift a previously issued block. */ + async unblockUser(ownerId: string): Promise { + return apiClient.post("/users/unblock", { ownerId }); + }, + + /** POST /api/reports — file a report against a conversation partner. */ + async report(payload: unknown): Promise<{ reportId?: string }> { + return apiClient.post<{ reportId?: string }>("/reports", payload); + }, +}; diff --git a/src/features/messaging/components/ConversationMenu.tsx b/src/features/messaging/components/ConversationMenu.tsx new file mode 100644 index 00000000..2e6486b9 --- /dev/null +++ b/src/features/messaging/components/ConversationMenu.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { MoreHorizontal, Block, Flag } from "lucide-react"; +import { useBlockConversation } from "../hooks/useBlockConversation"; +import { ReportConversationDialog } from "./ReportConversationDialog"; +import type { Conversation } from "../types"; + +interface ConversationMenuProps { + conversation: Conversation; + currentUserId: string; + className?: string; +} + +/** + * ConversationMenu + * + * Overflow menu on a conversation thread offering block / report actions. + * + * - Block: persists locally + via API, hides the composer, offers unblock + * - Report: opens the report dialog (reason + optional details, Zod validated) + */ +export function ConversationMenu({ + conversation, + currentUserId, + className = "", +}: ConversationMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const [reportOpen, setReportOpen] = useState(false); + const { isBlocked, block, unblock, isBlocking } = useBlockConversation(); + + const counterpartyId = conversation.ownerId; + const alreadyBlocked = isBlocked(counterpartyId); + + return ( +
+ + + {isOpen && ( +
+ {alreadyBlocked ? ( + + ) : ( + <> + + + + )} +
+ )} + + setReportOpen(false)} + conversation={conversation} + currentUserId={currentUserId} + /> +
+ ); +} diff --git a/src/features/messaging/components/MessageOwnerButton.tsx b/src/features/messaging/components/MessageOwnerButton.tsx new file mode 100644 index 00000000..4b4169b5 --- /dev/null +++ b/src/features/messaging/components/MessageOwnerButton.tsx @@ -0,0 +1,84 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { MessageCircle } from "lucide-react"; +import { useStartConversation } from "../hooks/useStartConversation"; +import { useCurrentUser } from "../hooks/useCurrentUser"; + +interface MessageOwnerButtonProps { + petId: string; + ownerId: string; + className?: string; +} + +/** + * MessageOwnerButton + * + * "Message owner" button for pet and custody detail pages. + * + * - Reuses an existing conversation for the same pet + owner pair + * - De-duplicates rapid repeated clicks via `useStartConversation` + * - Hidden when the current user is the listing owner + * - Redirects logged-out users to /login with a return URL + */ +export function MessageOwnerButton({ + petId, + ownerId, + className = "", +}: MessageOwnerButtonProps) { + const navigate = useNavigate(); + const { user } = useCurrentUser(); + const { startConversation, isPending } = useStartConversation(); + + const handleLoginRedirect = useCallback(() => { + navigate( + `/login?returnTo=${encodeURIComponent(window.location.pathname)}`, + { replace: true }, + ); + }, [navigate]); + + const handleStartConversation = useCallback(() => { + void startConversation({ petId, ownerId }); + }, [startConversation, petId, ownerId]); + + // Not logged in — redirect to login with a return URL so the user can + // resume the conversation flow after signing in. + if (!user) { + return ( + + ); + } + + // Your own listing — hide the button. + if (user.id === ownerId) { + return null; + } + + return ( + + ); +} diff --git a/src/features/messaging/components/MessagesLink.tsx b/src/features/messaging/components/MessagesLink.tsx new file mode 100644 index 00000000..3de5582a --- /dev/null +++ b/src/features/messaging/components/MessagesLink.tsx @@ -0,0 +1,52 @@ +import { Link, useLocation } from "react-router-dom"; +import { MessageCircle } from "lucide-react"; +import { useUnreadMessageCount } from "../hooks/useUnreadMessageCount"; + +/** Maximum unread badge number before the UI switches to the `99+` cap. */ +export const MESSAGES_UNREAD_BADGE_CAP = 99; + +/** + * MessagesLink + * + * Navigation link to the conversations inbox with a live unread-message badge. + * + * - Badge is capped at `99+` + * - Accessible label reads `Messages, N unread` + * - Count is derived from the conversation cache, so it updates when messages + * arrive or conversations are marked read + */ +export function MessagesLink() { + const location = useLocation(); + const unreadCount = useUnreadMessageCount(); + const displayCount = Math.min(unreadCount, MESSAGES_UNREAD_BADGE_CAP); + const isActive = location.pathname === "/messages" || + location.pathname.startsWith("/messages/"); + + const ariaLabel = + unreadCount === 0 + ? "Messages" + : `Messages, ${unreadCount} unread`; + + return ( + + + {unreadCount > 0 ? ( + + ) : null} + + ); +} diff --git a/src/features/messaging/components/ReportConversationDialog.tsx b/src/features/messaging/components/ReportConversationDialog.tsx new file mode 100644 index 00000000..49f52de4 --- /dev/null +++ b/src/features/messaging/components/ReportConversationDialog.tsx @@ -0,0 +1,229 @@ +import { useState, useCallback, useId } from "react"; +import { Flag, CheckCircle } from "lucide-react"; +import { + reportConversationSchema, + REPORT_REASONS, + REPORT_REASON_LABELS, + type ReportConversationInput, +} from "../schemas/messagingSchemas"; +import { useReportConversation } from "../hooks/useReportConversation"; +import type { Conversation } from "../types"; + +interface ReportConversationDialogProps { + isOpen: boolean; + onClose: () => void; + conversation: Conversation; + currentUserId: string; +} + +/** + * ReportConversationDialog + * + * Report a conversation partner with reason selection and optional details. + * + * - Validates with Zod (reason required, details capped at 1000 chars) + * - Confirms success without exposing the reported user's private data + * - Persists the report so it isn't re-submitted on refresh + */ +export function ReportConversationDialog({ + isOpen, + onClose, + conversation, + currentUserId, +}: ReportConversationDialogProps) { + const [step, setStep] = useState<"form" | "confirmation">("form"); + const [formError, setFormError] = useState(null); + const [reason, setReason] = useState(""); + const [details, setDetails] = useState(""); + const titleId = useId(); + + const { + reportConversation, + isReporting, + error, + reportConfirmation, + clearConfirmation, + } = useReportConversation(); + + const handleClose = useCallback(() => { + setStep("form"); + setFormError(null); + setReason(""); + setDetails(""); + clearConfirmation(); + onClose(); + }, [onClose, clearConfirmation]); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setFormError(null); + + const payload: ReportConversationInput = { + conversationId: conversation.id, + reporterId: currentUserId, + ownerId: conversation.ownerId, + reason, + details: details.trim() || undefined, + }; + + const parsed = reportConversationSchema.safeParse(payload); + if (!parsed.success) { + setFormError("Please select a reason before submitting."); + return; + } + + try { + await new Promise((resolve, reject) => { + reportConversation(parsed.data); + const start = Date.now(); + const poll = () => { + if (reportConfirmation) { + resolve(); + } else if (error) { + reject(error); + } else if (Date.now() - start > 30_000) { + reject(new Error("Request timed out")); + } else { + setTimeout(poll, 50); + } + }; + poll(); + }); + setStep("confirmation"); + } catch { + setFormError("Failed to submit your report. Please try again."); + } + }, + [conversation.id, conversation.ownerId, currentUserId, reason, details, reportConversation, error, reportConfirmation], + ); + + if (!isOpen) return null; + + const isConfirmation = step === "confirmation"; + const isFormValid = REPORT_REASONS.includes(reason as ReportConversationInput["reason"]); + + return ( +
+
e.stopPropagation()} + > + + + {isConfirmation ? ( +
+
+ +
+

+ Report submitted +

+

+ {reportConfirmation ?? "Our team will review your report."} +

+ +
+ ) : ( +
+
+

+ Report this user +

+

+ Help us understand what happened. Your report is reviewed + privately — the other party won’t see your details. +

+
+ +
+ + + {!isFormValid && reason !== "" && ( +

Please select a valid reason.

+ )} +
+ +
+ +