diff --git a/firestore.rules b/firestore.rules index a9e8bf0..01f5dfd 100644 --- a/firestore.rules +++ b/firestore.rules @@ -154,14 +154,15 @@ service cloud.firestore { } function isValidMessage(data) { - return data.keys().hasAll(['senderId', 'senderName', 'receiverId', 'receiverName', 'text', 'createdAt', 'read']) - && data.keys().size() == 7 + return data.keys().hasAll(['senderId', 'senderName', 'receiverId', 'receiverName', 'text', 'createdAt', 'read', 'conversationId']) + && data.keys().size() == 8 && data.senderId == request.auth.uid && data.senderName is string && data.senderName.size() > 0 && data.senderName.size() <= 100 && data.receiverId is string && data.receiverId.size() > 0 && data.receiverId.size() <= 128 && data.receiverName is string && data.receiverName.size() > 0 && data.receiverName.size() <= 100 && data.text is string && data.text.size() > 0 && data.text.size() <= 5000 - && data.read is bool; + && data.read is bool + && data.conversationId is string && data.conversationId.size() > 0 && data.conversationId.size() <= 260; } match /messages/{messageId} { @@ -169,8 +170,15 @@ service cloud.firestore { && isValidId(messageId) && isValidMessage(incoming()) && incoming().createdAt == request.time; - - allow read, delete: if isSignedIn() + + // Allow listing for conversation thread queries (by conversationId, senderId or receiverId) + allow list: if isSignedIn() + && ( + resource.data.receiverId == request.auth.uid + || resource.data.senderId == request.auth.uid + ); + + allow get, delete: if isSignedIn() && (resource.data.receiverId == request.auth.uid || resource.data.senderId == request.auth.uid); allow update: if isSignedIn() diff --git a/src/features/messages/components/ChatThread.tsx b/src/features/messages/components/ChatThread.tsx new file mode 100644 index 0000000..0e48933 --- /dev/null +++ b/src/features/messages/components/ChatThread.tsx @@ -0,0 +1,280 @@ +import { Send, Trash2, ArrowLeft } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; + +import type { Conversation, Message } from "../model/messages.types"; + +interface ChatThreadProps { + conversation: Conversation; + currentUserId: string; + _currentUserName?: string; + onSend: (text: string) => Promise; + onDelete: (messageId: string) => Promise; + onBack?: () => void; +} + +function formatMessageTime(ts: { toMillis: () => number } | null): string { + if (!ts) return "…"; + const date = new Date(ts.toMillis()); + return date.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" }); +} + +function formatDateSeparator(ts: { toMillis: () => number } | null): string { + if (!ts) return ""; + const date = new Date(ts.toMillis()); + const now = new Date(); + const isToday = + date.getDate() === now.getDate() && + date.getMonth() === now.getMonth() && + date.getFullYear() === now.getFullYear(); + if (isToday) return "HOJE"; + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + const isYesterday = + date.getDate() === yesterday.getDate() && + date.getMonth() === yesterday.getMonth() && + date.getFullYear() === yesterday.getFullYear(); + if (isYesterday) return "ONTEM"; + return date.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" }); +} + +function groupMessagesByDay(messages: Message[]): Array<{ date: string; messages: Message[] }> { + const groups: Array<{ date: string; messages: Message[] }> = []; + for (const msg of messages) { + const label = formatDateSeparator(msg.createdAt); + const last = groups[groups.length - 1]; + if (!last || last.date !== label) { + groups.push({ date: label, messages: [msg] }); + } else { + last.messages.push(msg); + } + } + return groups; +} + +export function ChatThread({ + conversation, + currentUserId, + onSend, + onDelete, + onBack, +}: ChatThreadProps) { + const [text, setText] = useState(""); + const [sending, setSending] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const bottomRef = useRef(null); + const textareaRef = useRef(null); + + // Auto-scroll to bottom whenever messages change + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [conversation.messages]); + + const handleSend = async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = text.trim(); + if (!trimmed || sending) return; + setSending(true); + try { + await onSend(trimmed); + setText(""); + textareaRef.current?.focus(); + } finally { + setSending(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(e as unknown as React.FormEvent); + } + }; + + const groups = groupMessagesByDay(conversation.messages); + + return ( +
+ {/* Thread Header */} +
+ {onBack && ( + + )} +
+ {conversation.partnerName.charAt(0)} +
+
+

+ {conversation.partnerName} +

+

+ {conversation.messages.length} mensagem{conversation.messages.length !== 1 ? "s" : ""} +

+
+
+ + {/* Messages Area */} +
+ {conversation.messages.length === 0 ? ( +
+

+ Nenhuma mensagem ainda. +
+ Mande a primeira mensagem! +

+
+ ) : ( + <> + {groups.map((group) => ( +
+ {/* Date Separator */} +
+
+ + {group.date} + +
+
+ + {group.messages.map((msg) => { + const isMine = msg.senderId === currentUserId; + return ( + + +
+
+

+ {msg.text} +

+ + {/* Delete button (only for own messages) */} + {isMine && ( + + )} +
+ +
+ + {formatMessageTime(msg.createdAt)} + + {isMine && ( + + {msg.read ? "✓✓" : "✓"} + + )} +
+
+
+
+ ); + })} +
+ ))} +
+ + )} +
+ + {/* Input Area */} +
+