|
|
|
@@ -17,8 +17,9 @@ import {
|
|
|
|
|
import {
|
|
|
|
|
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
|
|
|
|
|
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
|
|
|
|
CornerDownRight, Forward, Pencil, Download, Undo2,
|
|
|
|
|
CornerDownRight, Forward, Pencil, Download, Undo2, FolderOpen, Mail,
|
|
|
|
|
} from "lucide-react"
|
|
|
|
|
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
|
|
|
|
import {
|
|
|
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
|
|
|
|
DialogFooter, DialogClose,
|
|
|
|
@@ -126,6 +127,7 @@ export default function ChatsPage() {
|
|
|
|
|
const [activeChat, setActiveChat] = useState<string | null>(null)
|
|
|
|
|
const [messageInput, setMessageInput] = useState("")
|
|
|
|
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
|
|
|
|
const [showAttachMenu, setShowAttachMenu] = useState(false)
|
|
|
|
|
const [attachments, setAttachments] = useState<File[]>([])
|
|
|
|
|
const [panelWidth, setPanelWidth] = useState(320)
|
|
|
|
|
const [isResizing, setIsResizing] = useState(false)
|
|
|
|
@@ -157,9 +159,12 @@ export default function ChatsPage() {
|
|
|
|
|
const [searchingUsers, setSearchingUsers] = useState(false)
|
|
|
|
|
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
|
|
|
|
|
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
|
|
|
|
|
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null)
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
|
|
|
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
const isSendingRef = useRef(false)
|
|
|
|
|
const attachMenuRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
|
|
|
|
const recordingChunksRef = useRef<Blob[]>([])
|
|
|
|
@@ -252,6 +257,17 @@ const formatPreviewContent = (content: string) => {
|
|
|
|
|
setMessageAttachments((a) => {
|
|
|
|
|
const n = new Map(a)
|
|
|
|
|
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
|
|
|
|
|
// Restore persisted file attachments from JSON content
|
|
|
|
|
for (const msgs of next.values()) {
|
|
|
|
|
for (const m of msgs) {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(m.content)
|
|
|
|
|
if (parsed.fileAttachments && Array.isArray(parsed.fileAttachments)) {
|
|
|
|
|
n.set(m.id, parsed.fileAttachments)
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return n
|
|
|
|
|
})
|
|
|
|
|
setReplyMap((r) => {
|
|
|
|
@@ -301,6 +317,26 @@ const formatPreviewContent = (content: string) => {
|
|
|
|
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
// Strip blocked files from attachments on every change
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (attachments.length === 0) return
|
|
|
|
|
const { allowed, rejected } = filterBlockedFiles(attachments)
|
|
|
|
|
if (rejected.length > 0) {
|
|
|
|
|
toast.error(`Blocked file type: ${rejected[0].name}`)
|
|
|
|
|
setAttachments(allowed)
|
|
|
|
|
}
|
|
|
|
|
}, [attachments])
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
|
|
|
if (attachMenuRef.current && !attachMenuRef.current.contains(e.target as Node)) {
|
|
|
|
|
setShowAttachMenu(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
document.addEventListener("mousedown", handleClickOutside)
|
|
|
|
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
// revoke blob URLs on unmount
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
@@ -408,14 +444,18 @@ const formatPreviewContent = (content: string) => {
|
|
|
|
|
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)
|
|
|
|
|
const raw = Array.from(e.target.files ?? [])
|
|
|
|
|
const oversized = raw.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])
|
|
|
|
|
const { allowed, rejected } = filterBlockedFiles(raw)
|
|
|
|
|
if (rejected.length > 0) {
|
|
|
|
|
toast.error(`Blocked file type: ${rejected[0].name}`)
|
|
|
|
|
}
|
|
|
|
|
setAttachments((prev) => [...prev, ...allowed])
|
|
|
|
|
if (e.target) e.target.value = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -425,7 +465,11 @@ const formatPreviewContent = (content: string) => {
|
|
|
|
|
|
|
|
|
|
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 = content
|
|
|
|
|
const payload = voice
|
|
|
|
|
? "[Voice message]"
|
|
|
|
|
: fileAttachments && fileAttachments.length > 0
|
|
|
|
|
? JSON.stringify({ text: content, fileAttachments })
|
|
|
|
|
: content
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/conversations/${activeChat}/messages`, {
|
|
|
|
|
method: "POST",
|
|
|
|
@@ -444,7 +488,7 @@ const formatPreviewContent = (content: string) => {
|
|
|
|
|
setConversations((prev) => {
|
|
|
|
|
const updated = prev.map((conv) =>
|
|
|
|
|
conv.id === activeChat
|
|
|
|
|
? { ...conv, lastMessage: content, lastMessageTime: "Just now" }
|
|
|
|
|
? { ...conv, lastMessage: getDisplayContent(content), lastMessageTime: "Just now" }
|
|
|
|
|
: conv
|
|
|
|
|
)
|
|
|
|
|
const idx = updated.findIndex((c) => c.id === activeChat)
|
|
|
|
@@ -486,6 +530,14 @@ const formatPreviewContent = (content: string) => {
|
|
|
|
|
const handleSend = async (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
if (!messageInput.trim() && attachments.length === 0) return
|
|
|
|
|
if (isSendingRef.current) return
|
|
|
|
|
// Check blocked files before sending
|
|
|
|
|
if (attachments.some((f) => hasBlockedCodeExtension(f.name))) {
|
|
|
|
|
toast.error("Remove blocked file types before sending")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
isSendingRef.current = true
|
|
|
|
|
try {
|
|
|
|
|
if (editingMessage) {
|
|
|
|
|
setConversationMessages((prev) => {
|
|
|
|
|
const next = new Map(prev)
|
|
|
|
@@ -501,11 +553,10 @@ const formatPreviewContent = (content: string) => {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const fileAttachments = attachments.length > 0
|
|
|
|
|
? attachments.map((f) => {
|
|
|
|
|
const url = URL.createObjectURL(f)
|
|
|
|
|
blobUrlsRef.current.push(url)
|
|
|
|
|
? await Promise.all(attachments.map(async (f) => {
|
|
|
|
|
const url = await fileToDataURL(f)
|
|
|
|
|
return { name: f.name, type: f.type, url }
|
|
|
|
|
})
|
|
|
|
|
}))
|
|
|
|
|
: undefined
|
|
|
|
|
if (replyingTo) {
|
|
|
|
|
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
|
|
|
|
@@ -517,6 +568,9 @@ const formatPreviewContent = (content: string) => {
|
|
|
|
|
setMessageInput("")
|
|
|
|
|
setTimeout(() => autoResizeTextarea(), 0)
|
|
|
|
|
setAttachments([])
|
|
|
|
|
} finally {
|
|
|
|
|
isSendingRef.current = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDeleteMessage = async (messageId: string) => {
|
|
|
|
@@ -555,7 +609,7 @@ const formatPreviewContent = (content: string) => {
|
|
|
|
|
setConversations((prev) =>
|
|
|
|
|
prev.map((conv) =>
|
|
|
|
|
conv.id === activeChat
|
|
|
|
|
? { ...conv, lastMessage: prevMessages.filter((m) => m.id !== messageId).at(-1)?.content ?? conv.lastMessage }
|
|
|
|
|
? { ...conv, lastMessage: getDisplayContent(prevMessages.filter((m) => m.id !== messageId).at(-1)?.content ?? "") }
|
|
|
|
|
: conv
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
@@ -711,6 +765,21 @@ const formatPreviewContent = (content: string) => {
|
|
|
|
|
|
|
|
|
|
const isImageFile = (file: File) => file.type.startsWith("image/")
|
|
|
|
|
|
|
|
|
|
const getDisplayContent = (content: string) => {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(content)
|
|
|
|
|
if (parsed.text !== undefined) return parsed.text
|
|
|
|
|
} catch {}
|
|
|
|
|
return content
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fileToDataURL = (file: File): Promise<string> => new Promise((resolve, reject) => {
|
|
|
|
|
const reader = new FileReader()
|
|
|
|
|
reader.onload = () => resolve(reader.result as string)
|
|
|
|
|
reader.onerror = reject
|
|
|
|
|
reader.readAsDataURL(file)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
|
|
|
|
|
{/* Conversations list - left panel */}
|
|
|
|
@@ -977,26 +1046,30 @@ const formatPreviewContent = (content: string) => {
|
|
|
|
|
{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")} />
|
|
|
|
|
<img src={file.url} alt={file.name} className="max-w-full rounded-lg max-h-60 object-cover cursor-pointer" onClick={() => setPreviewImageUrl(file.url)} />
|
|
|
|
|
<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">
|
|
|
|
|
<div key={i} className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm hover:bg-muted/80 transition-colors">
|
|
|
|
|
<button type="button" className="flex items-center gap-2 flex-1 min-w-0 text-left" onClick={() => window.open(file.url, "_blank")} title="Open">
|
|
|
|
|
<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" />
|
|
|
|
|
</button>
|
|
|
|
|
<a href={file.url} download={file.name} className="shrink-0 text-muted-foreground hover:text-foreground" title="Download">
|
|
|
|
|
<Download className="h-3.5 w-3.5" />
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
) : null
|
|
|
|
|
})()}
|
|
|
|
|
{isOnlyEmoji(msg.content) ? (
|
|
|
|
|
<span className="text-4xl leading-none">{msg.content.trim()}</span>
|
|
|
|
|
{isOnlyEmoji(getDisplayContent(msg.content)) ? (
|
|
|
|
|
<span className="text-4xl leading-none">{getDisplayContent(msg.content).trim()}</span>
|
|
|
|
|
) : (
|
|
|
|
|
msg.content
|
|
|
|
|
getDisplayContent(msg.content)
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
@@ -1065,9 +1138,27 @@ const formatPreviewContent = (content: string) => {
|
|
|
|
|
)}
|
|
|
|
|
<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()}>
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => setShowAttachMenu(!showAttachMenu)}>
|
|
|
|
|
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
</Button>
|
|
|
|
|
{showAttachMenu && (
|
|
|
|
|
<div ref={attachMenuRef} className="absolute bottom-full left-0 mb-2 z-50 bg-popover border rounded-lg shadow-lg p-1.5 min-w-[140px]">
|
|
|
|
|
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.setAttribute("accept", "image/*"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
|
|
|
|
|
<Image className="h-4 w-4" /> Photos
|
|
|
|
|
</button>
|
|
|
|
|
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.setAttribute("accept", ".pdf,.doc,.docx,.xls,.xlsx,.txt"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
|
|
|
|
|
<FileIcon className="h-4 w-4" /> Documents
|
|
|
|
|
</button>
|
|
|
|
|
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.removeAttribute("accept"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
|
|
|
|
|
<FolderOpen className="h-4 w-4" /> Files
|
|
|
|
|
</button>
|
|
|
|
|
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.setAttribute("accept", ".eml,.msg"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
|
|
|
|
|
<Mail className="h-4 w-4" /> Emails
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="relative flex-1">
|
|
|
|
|
{isRecording ? (
|
|
|
|
|
<div className="flex h-9 items-center gap-1 rounded-md border bg-muted/30 px-2">
|
|
|
|
@@ -1102,7 +1193,7 @@ const formatPreviewContent = (content: string) => {
|
|
|
|
|
</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} />
|
|
|
|
|
<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, ...filterBlockedFiles(files).allowed]) } }} 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" />
|
|
|
|
@@ -1119,7 +1210,7 @@ const formatPreviewContent = (content: string) => {
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<Button type="submit" size="icon" className="h-9 w-9 shrink-0" disabled={!messageInput.trim() && attachments.length === 0}>
|
|
|
|
|
<Button type="submit" size="icon" className="h-9 w-9 shrink-0" disabled={(!messageInput.trim() && attachments.length === 0) || attachments.some(f => hasBlockedCodeExtension(f.name))}>
|
|
|
|
|
{editingMessage ? <Pencil className="h-4 w-4" /> : <Send className="h-4 w-4" />}
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
@@ -1152,6 +1243,20 @@ const formatPreviewContent = (content: string) => {
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
{/* Image preview dialog */}
|
|
|
|
|
<Dialog open={!!previewImageUrl} onOpenChange={(o) => { if (!o) setPreviewImageUrl(null) }}>
|
|
|
|
|
<DialogContent className="sm:max-w-3xl p-0 overflow-hidden bg-transparent border-0 shadow-none">
|
|
|
|
|
<DialogTitle className="sr-only">Image preview</DialogTitle>
|
|
|
|
|
{previewImageUrl && (
|
|
|
|
|
<img
|
|
|
|
|
src={previewImageUrl}
|
|
|
|
|
alt="Image preview"
|
|
|
|
|
className="w-full h-auto max-h-[85vh] object-contain rounded-2xl"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</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">
|
|
|
|
|