1adc4806fa
Security Fixes
File Change
.env.local Placeholder JWT secret (rotate to random value: openssl rand -base64 64)
src/middleware.ts Removed /api/auth/me bypass; API routes return JSON 401 (not 302 redirect)
src/lib/auth.ts sameSite: "lax" → "strict" (create + destroy cookie); SVG name chars are pre-computed (no injection)
src/lib/avatar.ts Added sanitizeName() — strips non-alphanumeric/safe chars, max 50 chars
src/app/api/users/route.ts POST restricted to super_admin; validates role against whitelist ["sales","admin","super_admin","dev"]; validates active defaults to true
src/app/api/users/[id]/route.ts DELETE restricted to super_admin; blocks self-deletion
src/app/api/leads/[id]/route.ts GET: non-admin users filtered by assigned_to; PATCH: ownership check + score validated 0-100 range
src/app/api/leads/route.ts DELETE: ownership + admin check; GET: ownership filter for non-admin users
src/app/api/conversations/[id]/messages/route.ts GET/POST: verify user is a conversation_participant before access
rust-ai/src/main.rs CORS: AllowOrigin::list(["localhost:3006", "127.0.0.1:3006"]) (was Any)
Performance Fixes
File Change
src/app/api/leads/route.ts Added limit (default 50), offset query params; ownership filter in SQL
src/app/api/users/route.ts Added limit (default 50), offset query params
src/app/api/conversations/route.ts Added LIMIT 50
src/app/api/leads/[id]/notes/route.ts Added limit (default 50), offset query params
src/app/api/conversations/[id]/messages/route.ts Added limit (default 100), offset, before query params
src/app/(dashboard)/chats/page.tsx Pruned to 5 cached conversations; useMemo wraps messages/conversation/filteredConversations; otherParticipant moved outside component; added 10MB file size limit; recordingChunksRef cleared on discard
src/app/(dashboard)/leads/[id]/page.tsx Optimistic status update rolls back on fetch failure
database/migrations/003_chat.sql Added composite index idx_messages_conversation_created on (conversation_id, created_at DESC)
Code Quality Fixes
File Change
src/lib/ai.ts Removed dead getInstructions()/updateInstructions() (called nonexistent /ai/instructions)
src/lib/auth.ts bcrypt.hash(password, 12) — kept (acceptable, OWASP-recommended)
src/app/login/page.tsx "Forgot password?" button now shows alert to contact admin (was no-op)
src/components/users/user-form-dialog.tsx Password min 6 → 8 chars
26 silent catch {} blocks across 16 files (see below) Added console.warn("...") context messages
1001 lines
47 KiB
TypeScript
1001 lines
47 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
|
|
import { cn } from "@/lib/utils"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu"
|
|
import {
|
|
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
|
|
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
|
CornerDownRight, Forward, Pencil, Download,
|
|
} from "lucide-react"
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
|
DialogFooter, DialogClose,
|
|
} from "@/components/ui/dialog"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { useTheme } from "next-themes"
|
|
import { useUser } from "@/providers/user-provider"
|
|
import { toast } from "sonner"
|
|
import data from "@emoji-mart/data"
|
|
import Picker from "@emoji-mart/react"
|
|
|
|
function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDuration: number }) {
|
|
const [playing, setPlaying] = useState(false)
|
|
const [current, setCurrent] = useState(0)
|
|
const [duration, setDuration] = useState(initialDuration)
|
|
const audioRef = useRef<HTMLAudioElement>(null)
|
|
const animRef = useRef<number>(0)
|
|
|
|
useEffect(() => {
|
|
const audio = audioRef.current
|
|
if (!audio) return
|
|
const onLoaded = () => {
|
|
if (isFinite(audio.duration)) setDuration(audio.duration)
|
|
}
|
|
const onEnded = () => { setPlaying(false); setCurrent(0) }
|
|
audio.addEventListener("loadedmetadata", onLoaded)
|
|
audio.addEventListener("ended", onEnded)
|
|
return () => {
|
|
audio.removeEventListener("loadedmetadata", onLoaded)
|
|
audio.removeEventListener("ended", onEnded)
|
|
}
|
|
}, [src])
|
|
|
|
useEffect(() => {
|
|
if (!playing) { cancelAnimationFrame(animRef.current); return }
|
|
const tick = () => {
|
|
if (audioRef.current) setCurrent(audioRef.current.currentTime)
|
|
animRef.current = requestAnimationFrame(tick)
|
|
}
|
|
animRef.current = requestAnimationFrame(tick)
|
|
return () => cancelAnimationFrame(animRef.current)
|
|
}, [playing])
|
|
|
|
const toggle = () => {
|
|
if (!audioRef.current) return
|
|
if (playing) { audioRef.current.pause(); setPlaying(false) }
|
|
else { audioRef.current.play(); setPlaying(true) }
|
|
}
|
|
|
|
const displayDuration = isFinite(duration) && duration > 0 ? duration : initialDuration
|
|
const pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0
|
|
const fmt = (s: number) => {
|
|
const secs = isFinite(s) ? Math.max(0, Math.floor(s)) : 0
|
|
return `${Math.floor(secs / 60)}:${(secs % 60).toString().padStart(2, "0")}`
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-2 min-w-[180px]">
|
|
<audio ref={audioRef} src={src} preload="metadata" />
|
|
<button type="button" onClick={toggle} className="h-8 w-8 shrink-0 rounded-full flex items-center justify-center hover:bg-black/10 transition-colors">
|
|
{playing ? <Pause className="h-4 w-4 fill-current" /> : <Play className="h-4 w-4 ml-0.5 fill-current" />}
|
|
</button>
|
|
<div className="flex-1 h-1.5 rounded-full bg-white/30 relative overflow-hidden">
|
|
<div className="h-full rounded-full bg-white transition-all duration-100" style={{ width: `${pct}%` }} />
|
|
</div>
|
|
<span className="text-[11px] tabular-nums opacity-70 w-8 text-right">{fmt(displayDuration)}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const MAX_CACHED_CONVERSATIONS = 5
|
|
const otherParticipant = (conv: any) => conv.otherUser
|
|
|
|
export default function ChatsPage() {
|
|
const { theme } = useTheme()
|
|
const { user } = useUser()
|
|
const [activeChat, setActiveChat] = useState<string | null>(null)
|
|
const [messageInput, setMessageInput] = useState("")
|
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
|
const [attachments, setAttachments] = useState<File[]>([])
|
|
const [panelWidth, setPanelWidth] = useState(320)
|
|
const [isResizing, setIsResizing] = useState(false)
|
|
const [reportDialogOpen, setReportDialogOpen] = useState(false)
|
|
const [reportReason, setReportReason] = useState("")
|
|
const [forwardDialogOpen, setForwardDialogOpen] = useState(false)
|
|
const [forwardMessage, setForwardMessage] = useState<any>(null)
|
|
const [forwardSearch, setForwardSearch] = useState("")
|
|
const [searchQuery, setSearchQuery] = useState("")
|
|
const [isRecording, setIsRecording] = useState(false)
|
|
const [isPaused, setIsPaused] = useState(false)
|
|
const [recordingDuration, setRecordingDuration] = useState(0)
|
|
const recordingDurationRef = useRef(0)
|
|
const [voiceMessages, setVoiceMessages] = useState<Map<string, { url: string; duration: number }>>(new Map())
|
|
const [messageAttachments, setMessageAttachments] = useState<Map<string, { name: string; type: string; url: string }[]>>(new Map())
|
|
const [replyingTo, setReplyingTo] = useState<any>(null)
|
|
const [editingMessage, setEditingMessage] = useState<any>(null)
|
|
const [replyMap, setReplyMap] = useState<Map<string, { repliedToSender: string; repliedToContent: string }>>(new Map())
|
|
// message status tracking removed — now using msg.read from API
|
|
const [conversations, setConversations] = useState<any[]>([])
|
|
const [conversationMessages, setConversationMessages] = useState<Map<string, any[]>>(new Map())
|
|
const [loadingChats, setLoadingChats] = useState(true)
|
|
const [searchResults, setSearchResults] = useState<any[]>([])
|
|
const [searchingUsers, setSearchingUsers] = useState(false)
|
|
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
|
|
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
|
const recordingChunksRef = useRef<Blob[]>([])
|
|
const resizeStartRef = useRef({ x: 0, width: 0 })
|
|
const blobUrlsRef = useRef<string[]>([])
|
|
const conversationOrderRef = useRef<string[]>([])
|
|
|
|
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
|
|
|
|
const autoResizeTextarea = useCallback(() => {
|
|
const ta = textareaRef.current
|
|
if (!ta) return
|
|
ta.style.height = "auto"
|
|
ta.style.height = `${ta.scrollHeight}px`
|
|
}, [])
|
|
|
|
if (!user) return null
|
|
|
|
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
|
|
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
|
|
const filteredConversations = useMemo(() => {
|
|
if (!searchQuery.trim()) return conversations
|
|
return conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
}, [conversations, searchQuery])
|
|
|
|
// Fetch conversations from API
|
|
useEffect(() => {
|
|
const fetchConvs = async () => {
|
|
try {
|
|
const res = await fetch("/api/conversations")
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
const convs = data.conversations || []
|
|
setConversations(convs)
|
|
setUnreadMap(new Map(convs.map((c: any) => [c.id, c.unread])))
|
|
if (convs.length > 0) setActiveChat(convs[0].id)
|
|
}
|
|
} catch {
|
|
console.warn("Failed to fetch conversations in chats page")
|
|
} finally {
|
|
setLoadingChats(false)
|
|
}
|
|
}
|
|
fetchConvs()
|
|
}, [])
|
|
|
|
// Fetch messages when active chat changes, and mark as read
|
|
useEffect(() => {
|
|
if (!activeChat) return
|
|
conversationOrderRef.current = [
|
|
activeChat,
|
|
...conversationOrderRef.current.filter((id) => id !== activeChat),
|
|
]
|
|
const fetchMsgs = async () => {
|
|
try {
|
|
const res = await fetch(`/api/conversations/${activeChat}/messages`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setConversationMessages((prev) => {
|
|
const next = new Map(prev)
|
|
next.set(activeChat, data.messages || [])
|
|
const recent = conversationOrderRef.current.slice(0, MAX_CACHED_CONVERSATIONS)
|
|
const recentSet = new Set(recent)
|
|
for (const key of next.keys()) {
|
|
if (!recentSet.has(key)) next.delete(key)
|
|
}
|
|
const validMsgIds = new Set<string>()
|
|
for (const msgs of next.values()) {
|
|
for (const m of msgs) validMsgIds.add(m.id)
|
|
}
|
|
setVoiceMessages((v) => {
|
|
const n = new Map(v)
|
|
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
|
|
return n
|
|
})
|
|
setMessageAttachments((a) => {
|
|
const n = new Map(a)
|
|
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
|
|
return n
|
|
})
|
|
setReplyMap((r) => {
|
|
const n = new Map(r)
|
|
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
|
|
return n
|
|
})
|
|
return next
|
|
})
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
// Mark conversation as read
|
|
fetch(`/api/conversations/${activeChat}/read`, { method: "POST" }).catch(() => {})
|
|
setUnreadMap((prev) => { const m = new Map(prev); m.set(activeChat, 0); return m })
|
|
}
|
|
fetchMsgs()
|
|
}, [activeChat])
|
|
|
|
// Search users when search query changes
|
|
useEffect(() => {
|
|
const q = searchQuery.trim()
|
|
if (!q) { setSearchResults([]); return }
|
|
const timer = setTimeout(async () => {
|
|
setSearchingUsers(true)
|
|
try {
|
|
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
const existingIds = new Set(conversations.map((c) => otherParticipant(c).id))
|
|
setSearchResults(data.users.filter((u: any) => !existingIds.has(u.id)))
|
|
}
|
|
} catch { console.warn("Failed to search users in chats page") }
|
|
setSearchingUsers(false)
|
|
}, 300)
|
|
return () => clearTimeout(timer)
|
|
}, [searchQuery, conversations])
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) {
|
|
setShowEmojiPicker(false)
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", handleClickOutside)
|
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
}, [])
|
|
|
|
// revoke blob URLs on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
blobUrlsRef.current.forEach((url) => URL.revokeObjectURL(url))
|
|
blobUrlsRef.current = []
|
|
}
|
|
}, [])
|
|
|
|
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault()
|
|
setIsResizing(true)
|
|
resizeStartRef.current = { x: e.clientX, width: panelWidth }
|
|
}, [panelWidth])
|
|
|
|
useEffect(() => {
|
|
if (!isResizing) return
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
const delta = e.clientX - resizeStartRef.current.x
|
|
const newWidth = Math.max(240, Math.min(560, resizeStartRef.current.width + delta))
|
|
setPanelWidth(newWidth)
|
|
}
|
|
const handleMouseUp = () => setIsResizing(false)
|
|
document.addEventListener("mousemove", handleMouseMove)
|
|
document.addEventListener("mouseup", handleMouseUp)
|
|
return () => {
|
|
document.removeEventListener("mousemove", handleMouseMove)
|
|
document.removeEventListener("mouseup", handleMouseUp)
|
|
}
|
|
}, [isResizing])
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
|
}, [conversations])
|
|
|
|
const handleEmojiSelect = (emoji: { native: string }) => {
|
|
setMessageInput((prev) => prev + emoji.native)
|
|
setShowEmojiPicker(false)
|
|
setTimeout(() => autoResizeTextarea(), 0)
|
|
}
|
|
|
|
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(e.target.files ?? [])
|
|
const oversized = files.filter((f) => f.size > MAX_FILE_SIZE)
|
|
if (oversized.length > 0) {
|
|
toast.error(`${oversized[0].name} exceeds the 10MB file size limit`)
|
|
if (e.target) e.target.value = ""
|
|
return
|
|
}
|
|
setAttachments((prev) => [...prev, ...files])
|
|
if (e.target) e.target.value = ""
|
|
}
|
|
|
|
const removeAttachment = (index: number) => {
|
|
setAttachments((prev) => prev.filter((_, i) => i !== index))
|
|
}
|
|
|
|
const addMessageToChat = async (content: string, voice?: { url: string; duration: number }, replyTo?: { senderName: string; content: string }, fileAttachments?: { name: string; type: string; url: string }[]) => {
|
|
if (!activeChat || !user) return
|
|
const payload = voice ? "[Voice message]" : content
|
|
try {
|
|
const res = await fetch(`/api/conversations/${activeChat}/messages`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ content: payload }),
|
|
})
|
|
if (!res.ok) return
|
|
const data = await res.json()
|
|
const msg = data.message
|
|
setConversationMessages((prev) => {
|
|
const next = new Map(prev)
|
|
const existing = next.get(activeChat) || []
|
|
next.set(activeChat, [...existing, msg])
|
|
return next
|
|
})
|
|
setConversations((prev) => {
|
|
const updated = prev.map((conv) =>
|
|
conv.id === activeChat
|
|
? { ...conv, lastMessage: content, lastMessageTime: "Just now" }
|
|
: conv
|
|
)
|
|
const idx = updated.findIndex((c) => c.id === activeChat)
|
|
if (idx > 0) {
|
|
const [item] = updated.splice(idx, 1)
|
|
updated.unshift(item)
|
|
}
|
|
return updated
|
|
})
|
|
setUnreadMap((prev) => { const m = new Map(prev); m.set(activeChat, 0); return m })
|
|
await fetch(`/api/conversations/${activeChat}/read`, { method: "POST" })
|
|
if (voice) {
|
|
setVoiceMessages((prev) => {
|
|
const next = new Map(prev)
|
|
next.set(msg.id, voice)
|
|
return next
|
|
})
|
|
}
|
|
if (replyTo) {
|
|
setReplyMap((prev) => {
|
|
const next = new Map(prev)
|
|
next.set(msg.id, { repliedToSender: replyTo.senderName, repliedToContent: replyTo.content })
|
|
return next
|
|
})
|
|
}
|
|
if (fileAttachments && fileAttachments.length > 0) {
|
|
setMessageAttachments((prev) => {
|
|
const next = new Map(prev)
|
|
next.set(msg.id, fileAttachments)
|
|
return next
|
|
})
|
|
}
|
|
} catch {
|
|
console.warn("Failed to send message in chats page")
|
|
toast.error("Failed to send message")
|
|
}
|
|
}
|
|
|
|
const handleSend = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (!messageInput.trim() && attachments.length === 0) return
|
|
if (editingMessage) {
|
|
setConversationMessages((prev) => {
|
|
const next = new Map(prev)
|
|
const msgs = (next.get(activeChat || "") || []).map((m) =>
|
|
m.id === editingMessage.id ? { ...m, content: messageInput.trim() } : m
|
|
)
|
|
next.set(activeChat || "", msgs)
|
|
return next
|
|
})
|
|
setEditingMessage(null)
|
|
setMessageInput("")
|
|
setTimeout(() => autoResizeTextarea(), 0)
|
|
return
|
|
}
|
|
const fileAttachments = attachments.length > 0
|
|
? attachments.map((f) => {
|
|
const url = URL.createObjectURL(f)
|
|
blobUrlsRef.current.push(url)
|
|
return { name: f.name, type: f.type, url }
|
|
})
|
|
: undefined
|
|
if (replyingTo) {
|
|
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
|
|
await addMessageToChat(messageInput.trim(), undefined, { senderName: replySender, content: replyingTo.content }, fileAttachments)
|
|
setReplyingTo(null)
|
|
} else {
|
|
await addMessageToChat(messageInput.trim(), undefined, undefined, fileAttachments)
|
|
}
|
|
setMessageInput("")
|
|
setTimeout(() => autoResizeTextarea(), 0)
|
|
setAttachments([])
|
|
}
|
|
|
|
const handleDeleteMessage = (messageId: string) => {
|
|
const files = messageAttachments.get(messageId)
|
|
if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) })
|
|
const voice = voiceMessages.get(messageId)
|
|
if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) }
|
|
setConversationMessages((prev) => {
|
|
const next = new Map(prev)
|
|
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
|
|
next.set(activeChat || "", msgs)
|
|
return next
|
|
})
|
|
setConversations((prev) =>
|
|
prev.map((conv) =>
|
|
conv.id === activeChat
|
|
? { ...conv, lastMessage: conversationMessages.get(activeChat || "")?.filter((m) => m.id !== messageId).at(-1)?.content ?? conv.lastMessage }
|
|
: conv
|
|
)
|
|
)
|
|
toast.success("Message deleted")
|
|
}
|
|
|
|
const startRecording = async () => {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm" })
|
|
recordingChunksRef.current = []
|
|
recorder.ondataavailable = (e) => {
|
|
if (e.data.size > 0) recordingChunksRef.current.push(e.data)
|
|
}
|
|
recorder.onstop = () => {
|
|
const blob = new Blob(recordingChunksRef.current, { type: "audio/webm" })
|
|
const url = URL.createObjectURL(blob)
|
|
blobUrlsRef.current.push(url)
|
|
addMessageToChat("", { url, duration: recordingDurationRef.current })
|
|
stream.getTracks().forEach((t) => t.stop())
|
|
setRecordingDuration(0)
|
|
}
|
|
mediaRecorderRef.current = recorder
|
|
recorder.start()
|
|
setIsRecording(true)
|
|
setIsPaused(false)
|
|
} catch {
|
|
console.warn("Failed to start recording in chats page")
|
|
toast.error("Microphone access denied")
|
|
}
|
|
}
|
|
|
|
const togglePauseRecording = () => {
|
|
const recorder = mediaRecorderRef.current
|
|
if (!recorder || recorder.state === "inactive") return
|
|
if (recorder.state === "recording") {
|
|
recorder.pause()
|
|
setIsPaused(true)
|
|
} else {
|
|
recorder.resume()
|
|
setIsPaused(false)
|
|
}
|
|
}
|
|
|
|
const stopRecording = () => {
|
|
mediaRecorderRef.current?.stop()
|
|
setIsRecording(false)
|
|
setIsPaused(false)
|
|
}
|
|
|
|
const discardRecording = () => {
|
|
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
|
mediaRecorderRef.current.ondataavailable = null
|
|
mediaRecorderRef.current.onstop = null
|
|
mediaRecorderRef.current.stop()
|
|
mediaRecorderRef.current.stream?.getTracks().forEach((t) => t.stop())
|
|
}
|
|
setIsRecording(false)
|
|
setIsPaused(false)
|
|
setRecordingDuration(0)
|
|
recordingDurationRef.current = 0
|
|
recordingChunksRef.current = []
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!isRecording) return
|
|
recordingDurationRef.current = 0
|
|
setRecordingDuration(0)
|
|
const interval = setInterval(() => {
|
|
recordingDurationRef.current += 1
|
|
setRecordingDuration(recordingDurationRef.current)
|
|
}, 1000)
|
|
return () => clearInterval(interval)
|
|
}, [isRecording])
|
|
|
|
const formatDuration = (secs: number) => {
|
|
const m = Math.floor(secs / 60)
|
|
const s = secs % 60
|
|
return `${m}:${s.toString().padStart(2, "0")}`
|
|
}
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
if (bytes < 1024) return bytes + " B"
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
|
|
return (bytes / (1024 * 1024)).toFixed(1) + " MB"
|
|
}
|
|
|
|
const isImageFile = (file: File) => file.type.startsWith("image/")
|
|
|
|
return (
|
|
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
|
|
{/* Conversations list - left panel */}
|
|
<div
|
|
className="flex flex-col border-r shrink-0 overflow-hidden"
|
|
style={{ width: panelWidth }}
|
|
>
|
|
<div className="p-4 border-b space-y-3">
|
|
<h2 className="text-lg font-semibold">Chats</h2>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search conversations..." className="h-9 pl-9" />
|
|
</div>
|
|
</div>
|
|
<ScrollArea className="flex-1">
|
|
{filteredConversations.map((conv) => {
|
|
const person = otherParticipant(conv)
|
|
const isActive = conv.id === activeChat
|
|
return (
|
|
<button
|
|
key={conv.id}
|
|
onClick={() => {
|
|
setActiveChat(conv.id)
|
|
setUnreadMap((prev) => { const m = new Map(prev); m.set(conv.id, 0); return m })
|
|
setConversations((prev) => {
|
|
const idx = prev.findIndex((c) => c.id === conv.id)
|
|
if (idx > 0) {
|
|
const u = [...prev]
|
|
const [item] = u.splice(idx, 1)
|
|
u.unshift(item)
|
|
return u
|
|
}
|
|
return prev
|
|
})
|
|
}}
|
|
className={cn(
|
|
"w-full flex items-start gap-3 p-4 text-left transition-colors hover:bg-muted/50 relative",
|
|
isActive && "bg-muted"
|
|
)}
|
|
>
|
|
<Avatar className="h-10 w-10 shrink-0">
|
|
<AvatarImage src={person.avatar} />
|
|
<AvatarFallback>{person.name?.charAt(0) || "?"}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-sm font-medium truncate">{person.name}</span>
|
|
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground truncate mt-0.5">{conv.lastMessage}</p>
|
|
</div>
|
|
{!isActive && (unreadMap.get(conv.id) || 0) > 0 && (
|
|
<div className="shrink-0 h-3 w-3 rounded-full bg-red-500 animate-pulse shadow-[0_0_10px_#ef4444] self-center" />
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
{searchResults.length > 0 && (
|
|
<>
|
|
{searchQuery && <div className="px-4 py-2 text-xs font-medium text-muted-foreground">New conversation</div>}
|
|
{searchResults.map((person: any) => (
|
|
<button
|
|
key={person.id}
|
|
onClick={async () => {
|
|
try {
|
|
const res = await fetch("/api/conversations", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ userId: person.id }),
|
|
})
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
const conv = data.conversation || conversations.find((c) => c.id === data.conversationId)
|
|
if (conv) {
|
|
setConversations((prev) => [conv, ...prev])
|
|
setUnreadMap((prev) => { const m = new Map(prev); m.set(conv.id, 0); return m })
|
|
setActiveChat(conv.id)
|
|
setSearchQuery("")
|
|
}
|
|
}
|
|
} catch { console.warn("Failed to create conversation in chats page") }
|
|
}}
|
|
className="w-full flex items-center gap-3 p-4 text-left transition-colors hover:bg-muted/50"
|
|
>
|
|
<div className="relative">
|
|
<Avatar className="h-10 w-10 shrink-0">
|
|
<AvatarImage src={person.avatar} />
|
|
<AvatarFallback>{person.name?.charAt(0) || "?"}</AvatarFallback>
|
|
</Avatar>
|
|
<span className="absolute -bottom-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground font-bold">+</span>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<span className="text-sm font-medium truncate block">{person.name}</span>
|
|
<span className="text-xs text-muted-foreground truncate block">{person.email}</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
{filteredConversations.length === 0 && searchResults.length === 0 && searchQuery && !searchingUsers && (
|
|
<div className="p-4 text-center text-sm text-muted-foreground">No conversations found</div>
|
|
)}
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
{/* Resize handle */}
|
|
<div
|
|
className="w-1.5 cursor-col-resize shrink-0 relative group hover:bg-primary/20 transition-colors"
|
|
onMouseDown={handleResizeStart}
|
|
>
|
|
<div className="absolute inset-y-0 -left-1 -right-1" />
|
|
</div>
|
|
|
|
{/* Chat area - right panel */}
|
|
{conversation ? (
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|
{/* Chat header */}
|
|
<div className="flex items-center justify-between gap-4 px-6 h-16 border-b shrink-0">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<Avatar className="h-9 w-9 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(otherParticipant(conversation).avatar)}>
|
|
<AvatarImage src={otherParticipant(conversation).avatar} />
|
|
<AvatarFallback>{otherParticipant(conversation).name?.charAt(0) || "?"}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">{otherParticipant(conversation).name}</p>
|
|
<p className="text-xs text-muted-foreground truncate">{otherParticipant(conversation).role}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Voice calling coming soon")}>
|
|
<Phone className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
|
|
<Video className="h-4 w-4" />
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-44">
|
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => setReportDialogOpen(true)}>
|
|
<Flag className="mr-2 h-4 w-4" /> Report
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => toast.info("Blocked")}>
|
|
<Ban className="mr-2 h-4 w-4" /> Block
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem className="text-destructive" onClick={() => toast.info("Conversation deleted")}>
|
|
<Trash2 className="mr-2 h-4 w-4" /> Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div className="flex-1 overflow-y-auto p-6" style={{ scrollbarWidth: "thin", scrollbarColor: "hsl(var(--muted-foreground) / 0.3) transparent" }}>
|
|
<div className="space-y-4 min-h-0">
|
|
{messages.map((msg) => {
|
|
const isMe = msg.senderId === user?.id
|
|
const voiceUrl = voiceMessages.get(msg.id)
|
|
return (
|
|
<div key={msg.id} className={cn("group flex gap-3", isMe && "flex-row-reverse")}>
|
|
<Avatar className="h-8 w-8 mt-0.5 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(msg.senderAvatar)}>
|
|
<AvatarImage src={msg.senderAvatar} />
|
|
<AvatarFallback className={cn("text-xs", isMe && "bg-primary text-primary-foreground")}>
|
|
{msg.senderName?.charAt(0) || "?"}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className={cn("max-w-[70%]", isMe && "items-end flex flex-col")}>
|
|
<div
|
|
className={cn(
|
|
"rounded-2xl px-5 py-3 text-base relative",
|
|
isMe ? "bg-primary text-primary-foreground rounded-tr-sm" : "bg-muted rounded-tl-sm"
|
|
)}
|
|
>
|
|
{!isMe && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button type="button" className="absolute -right-8 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 flex items-center justify-center rounded-full hover:bg-sky-400/20">
|
|
<MoreHorizontal className="h-3.5 w-3.5 text-sky-400" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" side="top" className="w-32">
|
|
<DropdownMenuItem onClick={() => setReplyingTo(msg)}>
|
|
<CornerDownRight className="mr-2 h-4 w-4" /> Reply
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => { setForwardMessage(msg); setForwardDialogOpen(true) }}>
|
|
<Forward className="mr-2 h-4 w-4" /> Forward
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
{isMe && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button type="button" className="absolute -left-8 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 flex items-center justify-center rounded-full hover:bg-sky-400/20">
|
|
<MoreHorizontal className="h-3.5 w-3.5 text-sky-400" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" side="top" className="w-32">
|
|
<DropdownMenuItem onClick={() => { setEditingMessage(msg); setMessageInput(msg.content) }}>
|
|
<Pencil className="mr-2 h-4 w-4" /> Edit
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setReplyingTo(msg)}>
|
|
<CornerDownRight className="mr-2 h-4 w-4" /> Reply
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => { setForwardMessage(msg); setForwardDialogOpen(true) }}>
|
|
<Forward className="mr-2 h-4 w-4" /> Forward
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem className="text-destructive" onClick={() => handleDeleteMessage(msg.id)}>
|
|
<Trash2 className="mr-2 h-4 w-4" /> Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
{(() => {
|
|
const replyInfo = replyMap.get(msg.id)
|
|
return (
|
|
<>
|
|
{replyInfo && (
|
|
<div className={cn("mb-1.5 pl-2 border-l-2 overflow-hidden bg-black/70 rounded-sm text-white", isMe ? "border-white/40" : "border-primary/40")}>
|
|
<p className="text-[11px] font-medium truncate">{replyInfo.repliedToSender}</p>
|
|
<p className="text-[11px] text-white/60 truncate">{replyInfo.repliedToContent}</p>
|
|
</div>
|
|
)}
|
|
{voiceUrl ? (
|
|
<div>
|
|
<VoiceMessagePlayer src={voiceUrl.url} initialDuration={voiceUrl.duration} />
|
|
{msg.content && (
|
|
<p className="mt-1 text-sm text-black">{msg.content}</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{(() => {
|
|
const files = messageAttachments.get(msg.id)
|
|
return files && files.length > 0 ? (
|
|
<div className="space-y-1.5 mb-1.5">
|
|
{files.map((file, i) =>
|
|
file.type.startsWith("image/") ? (
|
|
<div key={i} className="relative group">
|
|
<img src={file.url} alt={file.name} className="max-w-full rounded-lg max-h-60 object-cover cursor-pointer" onClick={() => window.open(file.url, "_blank")} />
|
|
<a href={file.url} download={file.name} className="absolute top-2 right-2 h-7 w-7 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<Download className="h-3.5 w-3.5 text-white" />
|
|
</a>
|
|
</div>
|
|
) : (
|
|
<a key={i} href={file.url} download={file.name} className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm hover:bg-muted/80 transition-colors">
|
|
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<span className="flex-1 min-w-0 truncate">{file.name}</span>
|
|
<Download className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
</a>
|
|
)
|
|
)}
|
|
</div>
|
|
) : null
|
|
})()}
|
|
{isOnlyEmoji(msg.content) ? (
|
|
<span className="text-4xl leading-none">{msg.content.trim()}</span>
|
|
) : (
|
|
msg.content
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
})()}
|
|
</div>
|
|
<span className="flex items-center gap-1 text-xs text-muted-foreground mt-1.5 px-1">
|
|
{msg.timestamp}
|
|
{isMe && (
|
|
msg.read
|
|
? <CheckCheck className="h-3 w-3 text-blue-400" />
|
|
: <Check className="h-3 w-3 text-muted-foreground/60" />
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="p-4 border-t shrink-0 space-y-2">
|
|
{attachments.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{attachments.map((file, i) => (
|
|
<div key={i} className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-1.5 text-sm max-w-[200px]">
|
|
{isImageFile(file) ? (
|
|
<Image className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<span className="truncate">{file.name}</span>
|
|
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(file.size)}</span>
|
|
<Button variant="ghost" size="icon" className="h-5 w-5 -mr-1 shrink-0" onClick={() => removeAttachment(i)}>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{/* Reply bar */}
|
|
{replyingTo && (
|
|
<div className="flex items-center gap-2 rounded-lg border-l-4 border-primary bg-muted/50 px-3 py-2 text-sm">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-xs font-medium text-primary">
|
|
Replying to {replyingTo.senderId === user?.id ? "yourself" : otherParticipant(conversation).name}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground truncate">{replyingTo.content}</p>
|
|
</div>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setReplyingTo(null)}>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{editingMessage && (
|
|
<div className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-2 text-sm">
|
|
<Pencil className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
<span className="text-xs text-muted-foreground">Editing message</span>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0 ml-auto" onClick={() => { setEditingMessage(null); setMessageInput(""); setTimeout(() => autoResizeTextarea(), 0) }}>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<form onSubmit={handleSend} className="flex items-center gap-2">
|
|
<input ref={fileInputRef} type="file" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt" className="hidden" onChange={handleFileSelect} />
|
|
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => fileInputRef.current?.click()}>
|
|
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
|
</Button>
|
|
<div className="relative flex-1">
|
|
{isRecording ? (
|
|
<div className="flex h-9 items-center gap-1 rounded-md border bg-muted/30 px-2">
|
|
<span className={cn("h-2 w-2 rounded-full", isPaused ? "bg-muted-foreground" : "bg-destructive animate-pulse")} />
|
|
<span className="text-sm tabular-nums font-medium text-muted-foreground">{formatDuration(recordingDuration)}</span>
|
|
<div className="flex-1" />
|
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground hover:text-muted-foreground" onClick={togglePauseRecording}>
|
|
{isPaused ? <Play className="h-3.5 w-3.5 fill-current ml-0.5" /> : <Pause className="h-3.5 w-3.5 fill-current" />}
|
|
</Button>
|
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-destructive hover:text-destructive" onClick={discardRecording}>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-destructive hover:text-destructive" onClick={stopRecording}>
|
|
<Square className="h-3.5 w-3.5 fill-current" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<textarea ref={textareaRef} value={messageInput} onChange={(e) => { setMessageInput(e.target.value); autoResizeTextarea() }} onPaste={(e) => { if (!e.clipboardData) return; const items = Array.from(e.clipboardData.items).filter((item) => item.type.startsWith("image/")); if (items.length > 0) { e.preventDefault(); const files = items.map((item) => item.getAsFile()).filter((f): f is File => f !== null); setAttachments((prev) => [...prev, ...files]) } }} placeholder={editingMessage ? "Edit message..." : "Type a message..."} className="w-full resize-none overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 min-h-[36px] max-h-[200px] pr-16" style={{ height: "auto" }} rows={1} />
|
|
<div className="absolute right-0 top-0 bottom-0 flex items-center pr-1" ref={emojiPickerRef}>
|
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setShowEmojiPicker(!showEmojiPicker)}>
|
|
<Smile className="h-4 w-4 text-muted-foreground" />
|
|
</Button>
|
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={startRecording}>
|
|
<Mic className="h-4 w-4 text-muted-foreground" />
|
|
</Button>
|
|
{showEmojiPicker && (
|
|
<div className="absolute bottom-full right-0 mb-2 z-50">
|
|
<Picker data={data} onEmojiSelect={handleEmojiSelect} theme={theme === "dark" ? "dark" : "light"} previewPosition="none" skinTonePosition="none" set="native" emojiSize={36} maxFrequentRows={2} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
<Button type="submit" size="icon" className="h-9 w-9 shrink-0" disabled={!messageInput.trim() && attachments.length === 0}>
|
|
{editingMessage ? <Pencil className="h-4 w-4" /> : <Send className="h-4 w-4" />}
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
|
<p>Select a conversation to start chatting</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Resize overlay */}
|
|
{isResizing && <div className="fixed inset-0 z-50 cursor-col-resize" />}
|
|
|
|
{/* Report dialog */}
|
|
<Dialog open={reportDialogOpen} onOpenChange={setReportDialogOpen}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Report {conversation ? otherParticipant(conversation).name : "User"}</DialogTitle>
|
|
<DialogDescription>Let us know why you're reporting this conversation.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3 py-2">
|
|
<Label htmlFor="reason">Reason</Label>
|
|
<Textarea id="reason" placeholder="Describe the issue..." value={reportReason} onChange={(e) => setReportReason(e.target.value)} rows={4} />
|
|
</div>
|
|
<DialogFooter>
|
|
<DialogClose asChild><Button variant="outline">Cancel</Button></DialogClose>
|
|
<Button onClick={() => { toast.success("Report submitted"); setReportReason(""); setReportDialogOpen(false) }} disabled={!reportReason.trim()}>Submit Report</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Avatar preview dialog */}
|
|
<Dialog open={!!previewAvatarUrl} onOpenChange={(o) => { if (!o) setPreviewAvatarUrl(null) }}>
|
|
<DialogContent className="sm:max-w-sm p-0 overflow-hidden bg-transparent border-0 shadow-none">
|
|
{previewAvatarUrl && (
|
|
<img
|
|
src={previewAvatarUrl}
|
|
alt="Profile picture"
|
|
className="w-full h-auto max-h-[80vh] object-contain rounded-2xl"
|
|
/>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Forward dialog */}
|
|
<Dialog open={forwardDialogOpen} onOpenChange={setForwardDialogOpen}>
|
|
<DialogContent className="sm:max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>Forward message</DialogTitle>
|
|
<DialogDescription>Select who to forward this message to.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input value={forwardSearch} onChange={(e) => setForwardSearch(e.target.value)} placeholder="Search contacts..." className="h-9 pl-9" />
|
|
</div>
|
|
<ScrollArea className="max-h-64">
|
|
<div className="space-y-0.5">
|
|
{conversations
|
|
.filter((conv) => {
|
|
const person = otherParticipant(conv)
|
|
return person.name.toLowerCase().includes(forwardSearch.toLowerCase())
|
|
})
|
|
.map((conv) => {
|
|
const person = otherParticipant(conv)
|
|
return (
|
|
<button
|
|
key={conv.id}
|
|
type="button"
|
|
onClick={async () => {
|
|
const msg = forwardMessage
|
|
if (!msg || !user) return
|
|
const newContent = `📨 Forwarded: ${msg.content}`
|
|
try {
|
|
const res = await fetch(`/api/conversations/${conv.id}/messages`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ content: newContent }),
|
|
})
|
|
if (res.ok) {
|
|
setForwardDialogOpen(false)
|
|
setForwardSearch("")
|
|
toast.success("Message forwarded")
|
|
}
|
|
} catch {
|
|
console.warn("Failed to forward message in chats page")
|
|
toast.error("Failed to forward message")
|
|
}
|
|
}}
|
|
className="w-full flex items-center gap-3 rounded-lg p-2.5 text-left transition-colors hover:bg-muted"
|
|
>
|
|
<Avatar className="h-9 w-9 shrink-0">
|
|
<AvatarFallback>{person.name.charAt(0)}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium truncate">{person.name}</p>
|
|
<p className="text-xs text-muted-foreground truncate">{person.email || ""}</p>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
{forwardSearch && conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(forwardSearch.toLowerCase())).length === 0 && (
|
|
<p className="text-sm text-muted-foreground text-center py-4">No contacts found</p>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|