Merge origin/main into local (conflict resolved in chats/page.tsx)
This commit is contained in:
Binary file not shown.
@@ -6,8 +6,21 @@
|
|||||||
|
|
||||||
import { execSync, spawn } from "node:child_process"
|
import { execSync, spawn } from "node:child_process"
|
||||||
import { platform } from "node:os"
|
import { platform } from "node:os"
|
||||||
|
import { statSync } from "node:fs"
|
||||||
|
|
||||||
function detectPython() {
|
function detectPython() {
|
||||||
|
const commonPaths = [
|
||||||
|
`${process.env.LOCALAPPDATA}\\Programs\\Python\\Python313\\python.exe`,
|
||||||
|
`${process.env.LOCALAPPDATA}\\Programs\\Python\\Python312\\python.exe`,
|
||||||
|
`${process.env.ProgramFiles}\\Python313\\python.exe`,
|
||||||
|
`${process.env.ProgramFiles}\\Python312\\python.exe`,
|
||||||
|
]
|
||||||
|
for (const p of commonPaths) {
|
||||||
|
try {
|
||||||
|
statSync(p)
|
||||||
|
return p
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
|
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
|
||||||
for (const cmd of candidates) {
|
for (const cmd of candidates) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
|
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
|
||||||
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
||||||
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2,
|
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
DialogFooter, DialogClose,
|
DialogFooter, DialogClose,
|
||||||
@@ -129,6 +130,7 @@ export default function ChatsPage() {
|
|||||||
const [activeChat, setActiveChat] = useState<string | null>(null)
|
const [activeChat, setActiveChat] = useState<string | null>(null)
|
||||||
const [messageInput, setMessageInput] = useState("")
|
const [messageInput, setMessageInput] = useState("")
|
||||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||||
|
const [showAttachMenu, setShowAttachMenu] = useState(false)
|
||||||
const [attachments, setAttachments] = useState<File[]>([])
|
const [attachments, setAttachments] = useState<File[]>([])
|
||||||
const [panelWidth, setPanelWidth] = useState(320)
|
const [panelWidth, setPanelWidth] = useState(320)
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
@@ -160,6 +162,7 @@ export default function ChatsPage() {
|
|||||||
const [searchingUsers, setSearchingUsers] = useState(false)
|
const [searchingUsers, setSearchingUsers] = useState(false)
|
||||||
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
|
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
|
||||||
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
|
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
|
||||||
|
<<<<<<< HEAD
|
||||||
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false)
|
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false)
|
||||||
const [scheduleTitle, setScheduleTitle] = useState("")
|
const [scheduleTitle, setScheduleTitle] = useState("")
|
||||||
const [scheduleDate, setScheduleDate] = useState("")
|
const [scheduleDate, setScheduleDate] = useState("")
|
||||||
@@ -167,9 +170,14 @@ export default function ChatsPage() {
|
|||||||
const [scheduleDuration, setScheduleDuration] = useState("30")
|
const [scheduleDuration, setScheduleDuration] = useState("30")
|
||||||
const [scheduleType, setScheduleType] = useState<string>("meeting")
|
const [scheduleType, setScheduleType] = useState<string>("meeting")
|
||||||
const [scheduleSaving, setScheduleSaving] = useState(false)
|
const [scheduleSaving, setScheduleSaving] = useState(false)
|
||||||
|
=======
|
||||||
|
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null)
|
||||||
|
>>>>>>> 21868fe336a205bcd2ef4a16283753f8dd3052bc
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const isSendingRef = useRef(false)
|
||||||
|
const attachMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||||
const recordingChunksRef = useRef<Blob[]>([])
|
const recordingChunksRef = useRef<Blob[]>([])
|
||||||
@@ -262,6 +270,17 @@ const formatPreviewContent = (content: string) => {
|
|||||||
setMessageAttachments((a) => {
|
setMessageAttachments((a) => {
|
||||||
const n = new Map(a)
|
const n = new Map(a)
|
||||||
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
|
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
|
return n
|
||||||
})
|
})
|
||||||
setReplyMap((r) => {
|
setReplyMap((r) => {
|
||||||
@@ -311,6 +330,26 @@ const formatPreviewContent = (content: string) => {
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
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
|
// revoke blob URLs on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -418,14 +457,18 @@ const formatPreviewContent = (content: string) => {
|
|||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files ?? [])
|
const raw = Array.from(e.target.files ?? [])
|
||||||
const oversized = files.filter((f) => f.size > MAX_FILE_SIZE)
|
const oversized = raw.filter((f) => f.size > MAX_FILE_SIZE)
|
||||||
if (oversized.length > 0) {
|
if (oversized.length > 0) {
|
||||||
toast.error(`${oversized[0].name} exceeds the 10MB file size limit`)
|
toast.error(`${oversized[0].name} exceeds the 10MB file size limit`)
|
||||||
if (e.target) e.target.value = ""
|
if (e.target) e.target.value = ""
|
||||||
return
|
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 = ""
|
if (e.target) e.target.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +478,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 }[]) => {
|
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
|
if (!activeChat || !user) return
|
||||||
const payload = content
|
const payload = voice
|
||||||
|
? "[Voice message]"
|
||||||
|
: fileAttachments && fileAttachments.length > 0
|
||||||
|
? JSON.stringify({ text: content, fileAttachments })
|
||||||
|
: content
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/conversations/${activeChat}/messages`, {
|
const res = await fetch(`/api/conversations/${activeChat}/messages`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -454,7 +501,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
setConversations((prev) => {
|
setConversations((prev) => {
|
||||||
const updated = prev.map((conv) =>
|
const updated = prev.map((conv) =>
|
||||||
conv.id === activeChat
|
conv.id === activeChat
|
||||||
? { ...conv, lastMessage: content, lastMessageTime: "Just now" }
|
? { ...conv, lastMessage: getDisplayContent(content), lastMessageTime: "Just now" }
|
||||||
: conv
|
: conv
|
||||||
)
|
)
|
||||||
const idx = updated.findIndex((c) => c.id === activeChat)
|
const idx = updated.findIndex((c) => c.id === activeChat)
|
||||||
@@ -496,37 +543,47 @@ const formatPreviewContent = (content: string) => {
|
|||||||
const handleSend = async (e: React.FormEvent) => {
|
const handleSend = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!messageInput.trim() && attachments.length === 0) return
|
if (!messageInput.trim() && attachments.length === 0) return
|
||||||
if (editingMessage) {
|
if (isSendingRef.current) return
|
||||||
setConversationMessages((prev) => {
|
// Check blocked files before sending
|
||||||
const next = new Map(prev)
|
if (attachments.some((f) => hasBlockedCodeExtension(f.name))) {
|
||||||
const msgs = (next.get(activeChat || "") || []).map((m) =>
|
toast.error("Remove blocked file types before sending")
|
||||||
m.id === editingMessage.id ? { ...m, content: messageInput.trim() } : m
|
|
||||||
)
|
|
||||||
next.set(activeChat || "", msgs)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
setEditingMessage(null)
|
|
||||||
setMessageInput("")
|
|
||||||
setTimeout(() => autoResizeTextarea(), 0)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const fileAttachments = attachments.length > 0
|
isSendingRef.current = true
|
||||||
? attachments.map((f) => {
|
try {
|
||||||
const url = URL.createObjectURL(f)
|
if (editingMessage) {
|
||||||
blobUrlsRef.current.push(url)
|
setConversationMessages((prev) => {
|
||||||
return { name: f.name, type: f.type, url }
|
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
|
||||||
})
|
})
|
||||||
: undefined
|
setEditingMessage(null)
|
||||||
if (replyingTo) {
|
setMessageInput("")
|
||||||
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
|
setTimeout(() => autoResizeTextarea(), 0)
|
||||||
await addMessageToChat(messageInput.trim(), undefined, { senderName: replySender, content: replyingTo.content }, fileAttachments)
|
return
|
||||||
setReplyingTo(null)
|
}
|
||||||
} else {
|
const fileAttachments = attachments.length > 0
|
||||||
await addMessageToChat(messageInput.trim(), undefined, undefined, fileAttachments)
|
? 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
|
||||||
|
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([])
|
||||||
|
} finally {
|
||||||
|
isSendingRef.current = false
|
||||||
}
|
}
|
||||||
setMessageInput("")
|
|
||||||
setTimeout(() => autoResizeTextarea(), 0)
|
|
||||||
setAttachments([])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteMessage = async (messageId: string) => {
|
const handleDeleteMessage = async (messageId: string) => {
|
||||||
@@ -565,7 +622,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
setConversations((prev) =>
|
setConversations((prev) =>
|
||||||
prev.map((conv) =>
|
prev.map((conv) =>
|
||||||
conv.id === activeChat
|
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
|
: conv
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -721,6 +778,21 @@ const formatPreviewContent = (content: string) => {
|
|||||||
|
|
||||||
const isImageFile = (file: File) => file.type.startsWith("image/")
|
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 (
|
return (
|
||||||
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
|
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
|
||||||
{/* Conversations list - left panel */}
|
{/* Conversations list - left panel */}
|
||||||
@@ -990,26 +1062,30 @@ const formatPreviewContent = (content: string) => {
|
|||||||
{files.map((file, i) =>
|
{files.map((file, i) =>
|
||||||
file.type.startsWith("image/") ? (
|
file.type.startsWith("image/") ? (
|
||||||
<div key={i} className="relative group">
|
<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">
|
<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" />
|
<Download className="h-3.5 w-3.5 text-white" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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">
|
||||||
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<button type="button" className="flex items-center gap-2 flex-1 min-w-0 text-left" onClick={() => window.open(file.url, "_blank")} title="Open">
|
||||||
<span className="flex-1 min-w-0 truncate">{file.name}</span>
|
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<Download className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<span className="flex-1 min-w-0 truncate">{file.name}</span>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
})()}
|
})()}
|
||||||
{isOnlyEmoji(msg.content) ? (
|
{isOnlyEmoji(getDisplayContent(msg.content)) ? (
|
||||||
<span className="text-4xl leading-none">{msg.content.trim()}</span>
|
<span className="text-4xl leading-none">{getDisplayContent(msg.content).trim()}</span>
|
||||||
) : (
|
) : (
|
||||||
msg.content
|
getDisplayContent(msg.content)
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1078,9 +1154,27 @@ const formatPreviewContent = (content: string) => {
|
|||||||
)}
|
)}
|
||||||
<form onSubmit={handleSend} className="flex items-center gap-2">
|
<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} />
|
<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">
|
||||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => setShowAttachMenu(!showAttachMenu)}>
|
||||||
</Button>
|
<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">
|
<div className="relative flex-1">
|
||||||
{isRecording ? (
|
{isRecording ? (
|
||||||
<div className="flex h-9 items-center gap-1 rounded-md border bg-muted/30 px-2">
|
<div className="flex h-9 items-center gap-1 rounded-md border bg-muted/30 px-2">
|
||||||
@@ -1115,7 +1209,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
</div>
|
</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}>
|
<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)}>
|
<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" />
|
<Smile className="h-4 w-4 text-muted-foreground" />
|
||||||
@@ -1132,7 +1226,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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" />}
|
{editingMessage ? <Pencil className="h-4 w-4" /> : <Send className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -1165,6 +1259,20 @@ const formatPreviewContent = (content: string) => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 */}
|
{/* Avatar preview dialog */}
|
||||||
<Dialog open={!!previewAvatarUrl} onOpenChange={(o) => { if (!o) setPreviewAvatarUrl(null) }}>
|
<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">
|
<DialogContent className="sm:max-w-sm p-0 overflow-hidden bg-transparent border-0 shadow-none">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"
|
|||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
import { hasBlockedCodeExtension } from "@/lib/blocked-extensions"
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
@@ -103,6 +104,18 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
|
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server-side blocked code extension check
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
if (parsed.fileAttachments && Array.isArray(parsed.fileAttachments)) {
|
||||||
|
for (const f of parsed.fileAttachments) {
|
||||||
|
if (f.name && hasBlockedCodeExtension(f.name)) {
|
||||||
|
return NextResponse.json({ error: `Blocked file type: ${f.name}` }, { status: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`INSERT INTO messages (conversation_id, sender_id, content)
|
`INSERT INTO messages (conversation_id, sender_id, content)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
@@ -160,3 +173,62 @@ function formatTime(date: Date): string {
|
|||||||
return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
|
return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
|
||||||
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const body = await _request.json()
|
||||||
|
const messageId = body.messageId
|
||||||
|
if (!messageId) return NextResponse.json({ error: "messageId required" }, { status: 400 })
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND sender_id = $2 AND conversation_id = $3 RETURNING id`,
|
||||||
|
[messageId, user.id, id],
|
||||||
|
)
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Message not found or not yours" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete message error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to delete message" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
const messageId = body.messageId
|
||||||
|
const newContent = body.content
|
||||||
|
if (!messageId || !newContent?.trim()) {
|
||||||
|
return NextResponse.json({ error: "messageId and content required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE messages SET content = $1, updated_at = NOW() WHERE id = $2 AND sender_id = $3 AND conversation_id = $4 AND deleted_at IS NULL RETURNING id`,
|
||||||
|
[newContent.trim(), messageId, user.id, id],
|
||||||
|
)
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Message not found or not yours" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Edit message error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to edit message" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ export async function GET() {
|
|||||||
c.id,
|
c.id,
|
||||||
c.updated_at,
|
c.updated_at,
|
||||||
cp_me.last_read_at,
|
cp_me.last_read_at,
|
||||||
u.id AS other_user_id,
|
u.id AS other_user_id,
|
||||||
u.first_name || ' ' || u.last_name AS other_user_name,
|
u.first_name || ' ' || u.last_name AS other_user_name,
|
||||||
u.email AS other_user_email,
|
u.email AS other_user_email,
|
||||||
u.avatar_url AS other_user_avatar_url,
|
u.phone AS other_user_phone,
|
||||||
(SELECT content FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message,
|
u.avatar_url AS other_user_avatar_url,
|
||||||
(SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time,
|
(SELECT content FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message,
|
||||||
|
(SELECT created_at FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message_time,
|
||||||
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
|
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
|
||||||
FROM conversations c
|
FROM conversations c
|
||||||
JOIN conversation_participants cp_me ON cp_me.conversation_id = c.id AND cp_me.user_id = $1
|
JOIN conversation_participants cp_me ON cp_me.conversation_id = c.id AND cp_me.user_id = $1
|
||||||
|
|||||||
Reference in New Issue
Block a user