This commit is contained in:
2026-06-26 16:37:44 +02:00
4 changed files with 245 additions and 54 deletions
+13
View File
@@ -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 {
+125 -20
View File
@@ -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, CornerDownRight, Forward, Pencil, Download, Undo2, 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,
@@ -126,6 +127,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)
@@ -157,9 +159,12 @@ 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)
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null)
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[]>([])
@@ -252,6 +257,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) => {
@@ -301,6 +317,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 () => {
@@ -408,14 +444,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 = ""
} }
@@ -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 }[]) => { 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",
@@ -444,7 +488,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)
@@ -486,6 +530,14 @@ 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 (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) { if (editingMessage) {
setConversationMessages((prev) => { setConversationMessages((prev) => {
const next = new Map(prev) const next = new Map(prev)
@@ -501,11 +553,10 @@ const formatPreviewContent = (content: string) => {
return return
} }
const fileAttachments = attachments.length > 0 const fileAttachments = attachments.length > 0
? attachments.map((f) => { ? await Promise.all(attachments.map(async (f) => {
const url = URL.createObjectURL(f) const url = await fileToDataURL(f)
blobUrlsRef.current.push(url)
return { name: f.name, type: f.type, url } return { name: f.name, type: f.type, url }
}) }))
: undefined : undefined
if (replyingTo) { if (replyingTo) {
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
@@ -517,6 +568,9 @@ const formatPreviewContent = (content: string) => {
setMessageInput("") setMessageInput("")
setTimeout(() => autoResizeTextarea(), 0) setTimeout(() => autoResizeTextarea(), 0)
setAttachments([]) setAttachments([])
} finally {
isSendingRef.current = false
}
} }
const handleDeleteMessage = async (messageId: string) => { const handleDeleteMessage = async (messageId: string) => {
@@ -555,7 +609,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
) )
) )
@@ -711,6 +765,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 */}
@@ -977,26 +1046,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">
<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" /> <FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 truncate">{file.name}</span> <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> </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)
)} )}
</> </>
)} )}
@@ -1065,9 +1138,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">
<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" /> <Paperclip className="h-4 w-4 text-muted-foreground" />
</Button> </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">
@@ -1102,7 +1193,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" />
@@ -1119,7 +1210,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>
@@ -1152,6 +1243,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 })
}
}
+3 -2
View File
@@ -16,9 +16,10 @@ export async function GET() {
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.phone AS other_user_phone,
u.avatar_url AS other_user_avatar_url, u.avatar_url AS other_user_avatar_url,
(SELECT content FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message, (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 ORDER BY created_at DESC LIMIT 1) AS last_message_time, (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