Compare commits

...

3 Commits

Author SHA1 Message Date
Hannah_Bagga dfba947aa4 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 15:22:36 +02:00
Hannah_Bagga 66ae711cf0 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
# Conflicts:
#	src/app/(dashboard)/chats/page.tsx
2026-06-26 15:20:42 +02:00
Hannah_Bagga 827b0598e6 voice note and sticker pack and gifs 2026-06-26 15:18:09 +02:00
8 changed files with 776 additions and 59 deletions
+1
View File
@@ -0,0 +1 @@
+19
View File
@@ -143,6 +143,25 @@ io.on("connection", (socket) => {
io.to(targetSocketId).emit("call:busy", { from: userId }) io.to(targetSocketId).emit("call:busy", { from: userId })
} }
}) })
socket.on("message:deleted", async ({ conversationId, messageId, senderId }) => {
try {
const result = await pool.query(
`SELECT cp.user_id FROM conversation_participants cp
WHERE cp.conversation_id = $1 AND cp.user_id != $2`,
[conversationId, senderId || userId],
)
for (const row of result.rows) {
const targetSocketId = onlineUsers.get(row.user_id)
if (targetSocketId) {
io.to(targetSocketId).emit("message:deleted", { conversationId, messageId })
}
}
console.log(`[message:deleted] broadcast msg=${messageId} conv=${conversationId} by=${senderId || userId}`)
} catch {
console.warn(`[message:deleted] failed to broadcast msg=${messageId}`)
}
})
} }
socket.on("room:join", ({ roomId, displayName }) => { socket.on("room:join", ({ roomId, displayName }) => {
+276 -43
View File
@@ -17,7 +17,7 @@ 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, CornerDownRight, Forward, Pencil, Download, Undo2,
} from "lucide-react" } from "lucide-react"
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
@@ -28,37 +28,35 @@ import { Textarea } from "@/components/ui/textarea"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { useUser } from "@/providers/user-provider" import { useUser } from "@/providers/user-provider"
import { toast } from "sonner" import { toast } from "sonner"
import data from "@emoji-mart/data" import { io, Socket } from "socket.io-client"
import Picker from "@emoji-mart/react" import VoiceCallModal from "@/components/chats/voice-call-modal"
import MediaPicker from "@/components/chats/media-picker"
function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDuration: number }) { const activeAudioRef: { current: HTMLAudioElement | null } = { current: null }
const previewAudioRef: { current: HTMLAudioElement | null } = { current: null }
const previewAnimRef: { current: number } = { current: 0 }
function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; initialDuration: number; isOwn?: boolean }) {
const [playing, setPlaying] = useState(false) const [playing, setPlaying] = useState(false)
const [current, setCurrent] = useState(0) const [current, setCurrent] = useState(0)
const [duration, setDuration] = useState(initialDuration) const [duration, setDuration] = useState(initialDuration)
const audioRef = useRef<HTMLAudioElement>(null) const audioRef = useRef<HTMLAudioElement>(null)
const animRef = useRef<number>(0) const animRef = useRef<number>(0)
const progRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
const audio = audioRef.current const audio = audioRef.current
if (!audio) return if (!audio) return
const onLoaded = () => { const onLoaded = () => { if (isFinite(audio.duration)) setDuration(audio.duration) }
if (isFinite(audio.duration)) setDuration(audio.duration)
}
const onEnded = () => { setPlaying(false); setCurrent(0) } const onEnded = () => { setPlaying(false); setCurrent(0) }
audio.addEventListener("loadedmetadata", onLoaded) audio.addEventListener("loadedmetadata", onLoaded)
audio.addEventListener("ended", onEnded) audio.addEventListener("ended", onEnded)
return () => { return () => { audio.removeEventListener("loadedmetadata", onLoaded); audio.removeEventListener("ended", onEnded) }
audio.removeEventListener("loadedmetadata", onLoaded)
audio.removeEventListener("ended", onEnded)
}
}, [src]) }, [src])
useEffect(() => { useEffect(() => {
if (!playing) { cancelAnimationFrame(animRef.current); return } if (!playing) { cancelAnimationFrame(animRef.current); return }
const tick = () => { const tick = () => { if (audioRef.current) setCurrent(audioRef.current.currentTime); animRef.current = requestAnimationFrame(tick) }
if (audioRef.current) setCurrent(audioRef.current.currentTime)
animRef.current = requestAnimationFrame(tick)
}
animRef.current = requestAnimationFrame(tick) animRef.current = requestAnimationFrame(tick)
return () => cancelAnimationFrame(animRef.current) return () => cancelAnimationFrame(animRef.current)
}, [playing]) }, [playing])
@@ -66,26 +64,55 @@ function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDura
const toggle = () => { const toggle = () => {
if (!audioRef.current) return if (!audioRef.current) return
if (playing) { audioRef.current.pause(); setPlaying(false) } if (playing) { audioRef.current.pause(); setPlaying(false) }
else { audioRef.current.play(); setPlaying(true) } else {
if (activeAudioRef.current && activeAudioRef.current !== audioRef.current) { activeAudioRef.current.pause() }
activeAudioRef.current = audioRef.current
audioRef.current.play(); setPlaying(true)
}
}
const seek = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value)
if (audioRef.current) { audioRef.current.currentTime = val; setCurrent(val) }
}
const replay = () => {
if (!audioRef.current) return
audioRef.current.currentTime = 0; setCurrent(0)
if (!playing) { audioRef.current.play(); setPlaying(true) }
} }
const displayDuration = isFinite(duration) && duration > 0 ? duration : initialDuration const displayDuration = isFinite(duration) && duration > 0 ? duration : initialDuration
const pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0 const pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0
const fmt = (s: number) => { 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")}` }
const secs = isFinite(s) ? Math.max(0, Math.floor(s)) : 0
return `${Math.floor(secs / 60)}:${(secs % 60).toString().padStart(2, "0")}` const barCount = 28
} const waveform = Array.from({ length: barCount }, (_, i) => {
const peak = 0.15 + Math.sin(i * 1.1) * 0.35 + Math.sin(i * 2.3) * 0.2 + Math.sin(i * 0.7) * 0.3
return Math.max(0.15, Math.min(1, peak))
})
return ( return (
<div className="flex items-center gap-2 min-w-[180px]"> <div className="flex items-center gap-2 min-w-[200px] select-none">
<audio ref={audioRef} src={src} preload="metadata" /> <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"> <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" />} {playing ? <Pause className="h-4 w-4 fill-current" /> : <Play className="h-4 w-4 ml-0.5 fill-current" />}
</button> </button>
<div className="flex-1 h-1.5 rounded-full bg-white/30 relative overflow-hidden"> <div className="flex-1 flex flex-col gap-0.5 min-w-0">
<div className="h-full rounded-full bg-white transition-all duration-100" style={{ width: `${pct}%` }} /> <div className="flex items-center gap-1 h-8">
{waveform.map((h, i) => {
const filled = (i / barCount) <= (pct / 100)
return <div key={i} className="flex-1 rounded-full transition-colors duration-75" style={{ height: `${h * 100}%`, minHeight: 3, background: filled ? "currentColor" : "currentColor", opacity: filled ? 1 : 0.35 }} />
})}
</div>
<input type="range" ref={progRef} min={0} max={displayDuration || 1} step={0.1} value={Math.min(current, displayDuration)} onChange={seek} className="w-full h-1 cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-white/20 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-0" />
</div>
<div className="flex flex-col items-end gap-0.5 shrink-0">
<span className="text-[11px] tabular-nums opacity-70">{fmt(current)}</span>
<button type="button" onClick={replay} className="opacity-50 hover:opacity-100 transition-opacity">
<Undo2 className="h-2.5 w-2.5" />
</button>
</div> </div>
<span className="text-[11px] tabular-nums opacity-70 w-8 text-right">{fmt(displayDuration)}</span>
</div> </div>
) )
} }
@@ -112,6 +139,11 @@ export default function ChatsPage() {
const [isPaused, setIsPaused] = useState(false) const [isPaused, setIsPaused] = useState(false)
const [recordingDuration, setRecordingDuration] = useState(0) const [recordingDuration, setRecordingDuration] = useState(0)
const recordingDurationRef = useRef(0) const recordingDurationRef = useRef(0)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewDuration, setPreviewDuration] = useState(0)
const [isPreviewPlaying, setIsPreviewPlaying] = useState(false)
const [previewCurrent, setPreviewCurrent] = useState(0)
const [voiceMessages, setVoiceMessages] = useState<Map<string, { url: string; duration: number }>>(new Map()) 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 [messageAttachments, setMessageAttachments] = useState<Map<string, { name: string; type: string; url: string }[]>>(new Map())
const [replyingTo, setReplyingTo] = useState<any>(null) const [replyingTo, setReplyingTo] = useState<any>(null)
@@ -134,9 +166,23 @@ export default function ChatsPage() {
const resizeStartRef = useRef({ x: 0, width: 0 }) const resizeStartRef = useRef({ x: 0, width: 0 })
const blobUrlsRef = useRef<string[]>([]) const blobUrlsRef = useRef<string[]>([])
const conversationOrderRef = useRef<string[]>([]) const conversationOrderRef = useRef<string[]>([])
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const socketRef = useRef<Socket | null>(null)
const isDeletingRef = useRef(false)
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim()) const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
const formatPreviewContent = (content: string) => {
if (!content?.startsWith("{")) return content
try {
const p = JSON.parse(content)
if (p.gif) return "📷 GIF"
if (p.v) return "🎤 Voice message"
if (p.sticker) return "💮 Sticker"
} catch {}
return content
}
const autoResizeTextarea = useCallback(() => { const autoResizeTextarea = useCallback(() => {
const ta = textareaRef.current const ta = textareaRef.current
if (!ta) return if (!ta) return
@@ -263,6 +309,65 @@ export default function ChatsPage() {
} }
}, []) }, [])
// Poll for new messages every 4 seconds for real-time sync
useEffect(() => {
if (!activeChat) return
pollTimerRef.current = setInterval(async () => {
try {
const res = await fetch(`/api/conversations/${activeChat}/messages?limit=50`)
if (!res.ok) return
const data = await res.json()
const serverMsgs = data.messages || []
setConversationMessages((prev) => {
const existing = prev.get(activeChat) || []
if (serverMsgs.length === existing.length) return prev
const next = new Map(prev)
next.set(activeChat, serverMsgs)
return next
})
setConversations((prev) => {
const updated = [...prev]
const convIdx = updated.findIndex((c) => c.id === activeChat)
if (convIdx >= 0 && serverMsgs.length > 0) {
const last = serverMsgs[serverMsgs.length - 1]
updated[convIdx] = { ...updated[convIdx], lastMessage: last.content, lastMessageTime: last.timestamp }
}
return updated
})
} catch { /* polling silently */ }
}, 4000)
return () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current) }
}, [activeChat])
// Connect to signaling server for real-time message deletion events
useEffect(() => {
let socket: Socket | null = null
let cancelled = false
;(async () => {
try {
const res = await fetch("/api/auth/jwt")
if (!res.ok) return
const { token } = await res.json()
if (cancelled) return
socket = io(process.env.NEXT_PUBLIC_SIGNALING_URL || "http://localhost:3007", {
auth: { token },
transports: ["websocket", "polling"],
})
socketRef.current = socket
socket.on("message:deleted", ({ conversationId, messageId }: { conversationId: string; messageId: string }) => {
setConversationMessages((prev) => {
const existing = prev.get(conversationId)
if (!existing) return prev
const next = new Map(prev)
next.set(conversationId, existing.filter((m) => m.id !== messageId))
return next
})
})
} catch { /* socket connection failed silently */ }
})()
return () => { cancelled = true; socket?.disconnect(); socketRef.current = null }
}, [])
const handleResizeStart = useCallback((e: React.MouseEvent) => { const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
setIsResizing(true) setIsResizing(true)
@@ -289,12 +394,17 @@ export default function ChatsPage() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [conversations]) }, [conversations])
const handleEmojiSelect = (emoji: { native: string }) => { const handleEmojiSelect = (emoji: string) => {
setMessageInput((prev) => prev + emoji.native) setMessageInput((prev) => prev + emoji)
setShowEmojiPicker(false) setShowEmojiPicker(false)
setTimeout(() => autoResizeTextarea(), 0) setTimeout(() => autoResizeTextarea(), 0)
} }
const handleMediaSelect = (content: string) => {
addMessageToChat(content)
setShowEmojiPicker(false)
}
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>) => {
@@ -315,7 +425,7 @@ export default function ChatsPage() {
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 = voice ? "[Voice message]" : content const payload = content
try { try {
const res = await fetch(`/api/conversations/${activeChat}/messages`, { const res = await fetch(`/api/conversations/${activeChat}/messages`, {
method: "POST", method: "POST",
@@ -409,11 +519,33 @@ export default function ChatsPage() {
setAttachments([]) setAttachments([])
} }
const handleDeleteMessage = (messageId: string) => { const handleDeleteMessage = async (messageId: string) => {
if (!activeChat || !user) return
if (isDeletingRef.current) return
isDeletingRef.current = true
const prevMessages = conversationMessages.get(activeChat) || []
try {
const res = await fetch(`/api/conversations/${activeChat}/messages/${messageId}`, { method: "DELETE" })
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || "Delete failed")
}
// Broadcast deletion to other participants via Socket.io
const socket = socketRef.current
if (socket?.connected) {
socket.emit("message:deleted", { conversationId: activeChat, messageId, senderId: user.id })
}
// Revoke blob URLs for attachments and voice
const files = messageAttachments.get(messageId) const files = messageAttachments.get(messageId)
if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) }) if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) })
const voice = voiceMessages.get(messageId) const voice = voiceMessages.get(messageId)
if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) } if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) }
// Remove from local state
setConversationMessages((prev) => { setConversationMessages((prev) => {
const next = new Map(prev) const next = new Map(prev)
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId) const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
@@ -423,26 +555,35 @@ export default function ChatsPage() {
setConversations((prev) => setConversations((prev) =>
prev.map((conv) => prev.map((conv) =>
conv.id === activeChat conv.id === activeChat
? { ...conv, lastMessage: conversationMessages.get(activeChat || "")?.filter((m) => m.id !== messageId).at(-1)?.content ?? conv.lastMessage } ? { ...conv, lastMessage: prevMessages.filter((m) => m.id !== messageId).at(-1)?.content ?? conv.lastMessage }
: conv : conv
) )
) )
toast.success("Message deleted") toast.success("Message deleted")
} catch (err: any) {
toast.error(err?.message || "Unable to delete voice note. Please try again.")
} finally {
isDeletingRef.current = false
}
} }
const startRecording = async () => { const startRecording = async () => {
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm" }) const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : "audio/webm"
const recorder = new MediaRecorder(stream, { mimeType: mime })
recordingChunksRef.current = [] recordingChunksRef.current = []
recorder.ondataavailable = (e) => { recorder.ondataavailable = (e) => {
if (e.data.size > 0) recordingChunksRef.current.push(e.data) if (e.data.size > 0) recordingChunksRef.current.push(e.data)
} }
recorder.onstop = () => { recorder.onstop = () => {
const blob = new Blob(recordingChunksRef.current, { type: "audio/webm" }) if (recordingChunksRef.current.length === 0) return
const blob = new Blob(recordingChunksRef.current, { type: mime })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
blobUrlsRef.current.push(url) blobUrlsRef.current.push(url)
addMessageToChat("", { url, duration: recordingDurationRef.current }) setPreviewBlob(blob)
setPreviewUrl(url)
setPreviewDuration(recordingDurationRef.current)
stream.getTracks().forEach((t) => t.stop()) stream.getTracks().forEach((t) => t.stop())
setRecordingDuration(0) setRecordingDuration(0)
} }
@@ -450,9 +591,12 @@ export default function ChatsPage() {
recorder.start() recorder.start()
setIsRecording(true) setIsRecording(true)
setIsPaused(false) setIsPaused(false)
} catch { } catch (err: any) {
console.warn("Failed to start recording in chats page") if (err?.name === "NotAllowedError" || err?.name === "PermissionDeniedError") {
toast.error("Microphone access denied") toast.error("Microphone access is required to record voice notes.")
} else {
toast.error("Failed to start recording. Please check your microphone.")
}
} }
} }
@@ -486,6 +630,60 @@ export default function ChatsPage() {
setRecordingDuration(0) setRecordingDuration(0)
recordingDurationRef.current = 0 recordingDurationRef.current = 0
recordingChunksRef.current = [] recordingChunksRef.current = []
clearPreview()
}
const clearPreview = () => {
if (previewUrl) { URL.revokeObjectURL(previewUrl); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== previewUrl) }
setPreviewBlob(null)
setPreviewUrl(null)
setPreviewDuration(0)
setIsPreviewPlaying(false)
setPreviewCurrent(0)
}
const cancelVoicePreview = () => {
if (previewAudioRef.current) { previewAudioRef.current.pause(); previewAudioRef.current = null! }
cancelAnimationFrame(previewAnimRef.current)
clearPreview()
}
const togglePreviewPlay = () => {
if (!previewUrl) return
if (isPreviewPlaying) {
previewAudioRef.current?.pause()
setIsPreviewPlaying(false)
} else {
const audio = new Audio(previewUrl)
audio.onloadedmetadata = () => { if (isFinite(audio.duration)) setPreviewDuration(audio.duration) }
audio.onended = () => { setIsPreviewPlaying(false); setPreviewCurrent(0) }
audio.onerror = () => { toast.error("Failed to play preview"); setIsPreviewPlaying(false) }
previewAudioRef.current = audio
audio.play().then(() => setIsPreviewPlaying(true)).catch(() => toast.error("Failed to play preview"))
const tick = () => { if (previewAudioRef.current) setPreviewCurrent(previewAudioRef.current.currentTime); previewAnimRef.current = requestAnimationFrame(tick) }
cancelAnimationFrame(previewAnimRef.current)
previewAnimRef.current = requestAnimationFrame(tick)
}
}
const sendVoiceRecording = async () => {
if (!previewBlob || !previewUrl) return
try {
cancelAnimationFrame(previewAnimRef.current)
previewAudioRef.current?.pause()
const formData = new FormData()
const ext = previewBlob.type.includes("webm") ? "webm" : "webm"
formData.append("audio", previewBlob, `voice.${ext}`)
formData.append("duration", previewDuration.toString())
const uploadRes = await fetch("/api/upload/voice", { method: "POST", body: formData })
if (!uploadRes.ok) { toast.error("Failed to upload voice recording"); return }
const { url: serverUrl, duration } = await uploadRes.json()
const voicePayload = JSON.stringify({ v: true, u: serverUrl, d: duration || previewDuration })
await addMessageToChat(voicePayload, { url: serverUrl, duration: duration || previewDuration })
clearPreview()
} catch {
toast.error("Failed to send voice recording")
}
} }
useEffect(() => { useEffect(() => {
@@ -562,7 +760,7 @@ export default function ChatsPage() {
<span className="text-sm font-medium truncate">{person.name}</span> <span className="text-sm font-medium truncate">{person.name}</span>
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span> <span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
</div> </div>
<p className="text-xs text-muted-foreground truncate mt-0.5">{conv.lastMessage}</p> <p className="text-xs text-muted-foreground truncate mt-0.5">{formatPreviewContent(conv.lastMessage)}</p>
</div> </div>
{!isActive && (unreadMap.get(conv.id) || 0) > 0 && ( {!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" /> <div className="shrink-0 h-3 w-3 rounded-full bg-red-500 animate-pulse shadow-[0_0_10px_#ef4444] self-center" />
@@ -677,7 +875,17 @@ export default function ChatsPage() {
<div className="space-y-4 min-h-0"> <div className="space-y-4 min-h-0">
{messages.map((msg) => { {messages.map((msg) => {
const isMe = msg.senderId === user?.id const isMe = msg.senderId === user?.id
const voiceUrl = voiceMessages.get(msg.id) let voiceUrl = voiceMessages.get(msg.id)
let gifData: { url: string; w: number; h: number } | null = null
let stickerData: { url: string; w: number; h: number; emoji?: string } | null = null
if (msg.content?.startsWith("{")) {
try {
const p = JSON.parse(msg.content)
if (p.v && p.u) voiceUrl = { url: p.u, duration: p.d || 0 }
else if (p.gif) gifData = { url: p.gif, w: p.w || 320, h: p.h || 240 }
else if (p.sticker) stickerData = { url: p.sticker, w: p.w || 200, h: p.h || 200, emoji: p.id ? undefined : p.sticker }
} catch {}
}
return ( return (
<div key={msg.id} className={cn("group flex gap-3", isMe && "flex-row-reverse")}> <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)}> <Avatar className="h-8 w-8 mt-0.5 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(msg.senderAvatar)}>
@@ -744,13 +952,22 @@ export default function ChatsPage() {
<p className="text-[11px] text-white/60 truncate">{replyInfo.repliedToContent}</p> <p className="text-[11px] text-white/60 truncate">{replyInfo.repliedToContent}</p>
</div> </div>
)} )}
{voiceUrl ? ( {gifData ? (
<div> <div className="max-w-[280px]">
<VoiceMessagePlayer src={voiceUrl.url} initialDuration={voiceUrl.duration} /> <img src={gifData.url} alt="GIF" className="w-full rounded-lg" style={{ aspectRatio: `${gifData.w}/${gifData.h}` }} loading="lazy" />
{msg.content && ( </div>
<p className="mt-1 text-sm text-black">{msg.content}</p> ) : stickerData ? (
<div className="max-w-[200px]">
{stickerData.emoji ? (
<span className="text-7xl block text-center">{stickerData.emoji}</span>
) : (
<div className="w-full" dangerouslySetInnerHTML={{ __html: stickerData.url.replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
)} )}
</div> </div>
) : voiceUrl ? (
<div>
<VoiceMessagePlayer src={voiceUrl.url} initialDuration={voiceUrl.duration} isOwn={isMe} />
</div>
) : ( ) : (
<> <>
{(() => { {(() => {
@@ -867,6 +1084,22 @@ export default function ChatsPage() {
<Square className="h-3.5 w-3.5 fill-current" /> <Square className="h-3.5 w-3.5 fill-current" />
</Button> </Button>
</div> </div>
) : previewUrl ? (
<div className="flex h-9 items-center gap-2 rounded-md border bg-muted/30 px-3">
<button type="button" onClick={togglePreviewPlay} className="h-6 w-6 shrink-0 rounded-full flex items-center justify-center hover:bg-black/10 transition-colors">
{isPreviewPlaying ? <Pause className="h-3.5 w-3.5 fill-current" /> : <Play className="h-3.5 w-3.5 ml-0.5 fill-current" />}
</button>
<div className="flex-1 h-1 rounded-full bg-muted-foreground/20 relative overflow-hidden">
<div className="h-full rounded-full bg-primary transition-all duration-100" style={{ width: `${previewDuration > 0 ? (previewCurrent / previewDuration) * 100 : 0}%` }} />
</div>
<span className="text-xs tabular-nums text-muted-foreground w-8 text-right">{formatDuration(Math.floor(previewCurrent))}/{formatDuration(previewDuration)}</span>
<Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-destructive hover:text-destructive" onClick={cancelVoicePreview}>
<Trash2 className="h-3 w-3" />
</Button>
<Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-primary hover:text-primary" onClick={sendVoiceRecording}>
<Send className="h-3 w-3" />
</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} /> <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} />
@@ -879,7 +1112,7 @@ export default function ChatsPage() {
</Button> </Button>
{showEmojiPicker && ( {showEmojiPicker && (
<div className="absolute bottom-full right-0 mb-2 z-50"> <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} /> <MediaPicker onEmojiSelect={handleEmojiSelect} onMediaSelect={handleMediaSelect} onClose={() => setShowEmojiPicker(false)} theme={theme} />
</div> </div>
)} )}
</div> </div>
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { unlink } from "node:fs/promises"
import { join } from "node:path"
import { existsSync } from "node:fs"
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string; messageId: string }> },
) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id: conversationId, messageId } = await params
// Verify user is a participant in this conversation
const partCheck = await query(
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
[conversationId, user.id],
)
if (partCheck.rows.length === 0) {
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
}
// Fetch the message to verify it belongs to the sender and check if it's a voice message
const msgResult = await query(
`SELECT id, sender_id, content FROM messages WHERE id = $1 AND conversation_id = $2 AND deleted_at IS NULL`,
[messageId, conversationId],
)
if (msgResult.rows.length === 0) {
return NextResponse.json({ error: "Message not found" }, { status: 404 })
}
const message = msgResult.rows[0]
// Only the sender can delete their own message
if (message.sender_id !== user.id) {
return NextResponse.json({ error: "Cannot delete another user's message" }, { status: 403 })
}
// Delete the associated audio file if this is a voice message
let storageDeleted = false
try {
const content = message.content
if (content?.startsWith("{")) {
const parsed = JSON.parse(content)
if (parsed.v && parsed.u) {
const filename = parsed.u.replace(/^\/uploads\/voice\//, "")
const filePath = join(process.cwd(), "public", "uploads", "voice", filename)
if (existsSync(filePath)) {
await unlink(filePath)
storageDeleted = true
}
}
}
} catch {
// If file deletion fails, log but still proceed with DB deletion
console.warn(`[voice-delete] Failed to delete audio file for message ${messageId}`)
}
// Soft-delete the message in the database
const deleteResult = await query(
`UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND conversation_id = $2 RETURNING id`,
[messageId, conversationId],
)
const dbDeleted = deleteResult.rows.length > 0
console.log(
`[voice-delete] id=${messageId} conv=${conversationId} user=${user.id} db=${dbDeleted} storage=${storageDeleted}`,
)
return NextResponse.json({
success: true,
messageId,
dbDeleted,
storageDeleted,
})
} catch (error) {
console.error("[voice-delete] Error:", error)
return NextResponse.json({ error: "Unable to delete voice note. Please try again." }, { status: 500 })
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
const TENOR_API_KEY = process.env.TENOR_API_KEY || ""
const TENOR_BASE = "https://tenor.googleapis.com/v2"
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { searchParams } = new URL(request.url)
const q = searchParams.get("q") || ""
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
const pos = searchParams.get("pos") || ""
if (!TENOR_API_KEY) {
return NextResponse.json({
results: [],
error: "TENOR_API_KEY not configured",
noKey: true,
})
}
const endpoint = q
? `${TENOR_BASE}/search?q=${encodeURIComponent(q)}&key=${TENOR_API_KEY}&limit=${limit}&media_filter=minimal`
: `${TENOR_BASE}/featured?key=${TENOR_API_KEY}&limit=${limit}&media_filter=minimal`
const url = pos ? `${endpoint}&pos=${pos}` : endpoint
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
if (!res.ok) {
return NextResponse.json({ results: [], error: "Tenor API error" }, { status: 502 })
}
const data = await res.json()
const results = (data.results || []).map((item: any) => {
const media = item.media_formats?.gif || item.media_formats?.tinygif || {}
const preview = item.media_formats?.tinygif || item.media_formats?.gif || {}
return {
id: item.id,
title: item.title || "",
url: media.url || "",
previewUrl: preview.url || "",
width: media.dims?.[0] || 200,
height: media.dims?.[1] || 200,
}
})
return NextResponse.json({ results, next: data.next || "" })
} catch (error) {
console.error("GIF API error:", error)
return NextResponse.json({ results: [], error: "Failed to fetch GIFs" }, { status: 500 })
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { writeFile } from "node:fs/promises"
import { join } from "node:path"
import crypto from "node:crypto"
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const formData = await request.formData()
const file = formData.get("audio") as File | null
if (!file) return NextResponse.json({ error: "No audio file" }, { status: 400 })
const duration = parseFloat(formData.get("duration")?.toString() || "0")
const ext = file.name.endsWith(".webm") ? "webm" : "webm"
const filename = `${crypto.randomUUID()}.${ext}`
const buffer = Buffer.from(await file.arrayBuffer())
const savePath = join(process.cwd(), "public", "uploads", "voice", filename)
await writeFile(savePath, buffer)
return NextResponse.json({ url: `/uploads/voice/${filename}`, duration })
} catch (error) {
console.error("Voice upload error:", error)
return NextResponse.json({ error: "Upload failed" }, { status: 500 })
}
}
+294
View File
@@ -0,0 +1,294 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Search, Image, Sticker, X, Loader2, TrendingUp } from "lucide-react"
import data from "@emoji-mart/data"
import Picker from "@emoji-mart/react"
import { getStickerPacks, type StickerPack } from "@/data/stickers"
type Tab = "emoji" | "gif" | "sticker"
interface MediaPickerProps {
onEmojiSelect: (emoji: string) => void
onMediaSelect: (content: string) => void
onClose: () => void
theme?: string
}
const RECENT_GIFS_KEY = "recent-gifs"
const RECENT_STICKERS_KEY = "recent-stickers"
const MAX_RECENT = 12
function getRecent(key: string): string[] {
if (typeof window === "undefined") return []
try { return JSON.parse(localStorage.getItem(key) || "[]") } catch { return [] }
}
function addRecent(key: string, id: string) {
if (typeof window === "undefined") return
const items = [id, ...getRecent(key).filter((x) => x !== id)].slice(0, MAX_RECENT)
localStorage.setItem(key, JSON.stringify(items))
}
export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, theme }: MediaPickerProps) {
const [tab, setTab] = useState<Tab>("emoji")
const [gifQuery, setGifQuery] = useState("")
const [gifResults, setGifResults] = useState<any[]>([])
const [gifLoading, setGifLoading] = useState(false)
const [gifError, setGifError] = useState("")
const [gifNoKey, setGifNoKey] = useState(false)
const [gifNext, setGifNext] = useState("")
const [recentGifs, setRecentGifs] = useState<string[]>([])
const [recentStickers, setRecentStickers] = useState<string[]>([])
const [stickerPacks] = useState<StickerPack[]>(getStickerPacks)
const [activePack, setActivePack] = useState(stickerPacks[0]?.id || "")
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pickerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) onClose()
}
document.addEventListener("mousedown", handleClick)
return () => document.removeEventListener("mousedown", handleClick)
}, [onClose])
useEffect(() => { setRecentGifs(getRecent(RECENT_GIFS_KEY)) }, [])
useEffect(() => { setRecentStickers(getRecent(RECENT_STICKERS_KEY)) }, [])
const fetchGifs = useCallback(async (query: string, pos = "") => {
setGifLoading(true)
setGifError("")
try {
const params = new URLSearchParams({ limit: "20", pos })
if (query) params.set("q", query)
const res = await fetch(`/api/gifs?${params}`)
const data = await res.json()
if (data.noKey) {
setGifNoKey(true)
setGifResults([])
} else if (data.results) {
setGifResults((prev) => (pos ? [...prev, ...data.results] : data.results))
setGifNext(data.next || "")
setGifNoKey(false)
} else {
setGifError("No results found")
}
} catch {
setGifError("Failed to load GIFs")
}
setGifLoading(false)
}, [])
useEffect(() => {
if (tab !== "gif") return
if (searchTimer.current) clearTimeout(searchTimer.current)
if (gifQuery.trim()) {
searchTimer.current = setTimeout(() => fetchGifs(gifQuery.trim()), 400)
} else {
fetchGifs("")
}
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
}, [tab, gifQuery, fetchGifs])
const handleGifSelect = (gif: any) => {
const content = JSON.stringify({ gif: gif.url, w: gif.width, h: gif.height })
addRecent(RECENT_GIFS_KEY, gif.id)
setRecentGifs(getRecent(RECENT_GIFS_KEY))
onMediaSelect(content)
}
const handleStickerSelect = (sticker: any) => {
const content = sticker.svg
? JSON.stringify({ sticker: sticker.svg, w: 200, h: 200, id: sticker.id })
: JSON.stringify({ sticker: sticker.emoji, w: 120, h: 120, id: sticker.id })
addRecent(RECENT_STICKERS_KEY, sticker.id)
setRecentStickers(getRecent(RECENT_STICKERS_KEY))
onMediaSelect(content)
}
const activeStickers = stickerPacks.find((p) => p.id === activePack)?.stickers || []
const renderGifGrid = (items: any[], emptyMsg: string) => {
if (items.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
<Image className="h-8 w-8 opacity-40" />
<span>{emptyMsg}</span>
</div>
)
}
return (
<div className="grid grid-cols-2 gap-1.5">
{items.map((gif: any) => (
<button
key={gif.id}
type="button"
onClick={() => handleGifSelect(gif)}
className="rounded-lg overflow-hidden bg-muted/50 hover:ring-2 hover:ring-primary/50 transition-all aspect-video"
>
<img src={gif.previewUrl || gif.url} alt={gif.title || "GIF"} className="w-full h-full object-cover" loading="lazy" />
</button>
))}
</div>
)
}
return (
<div ref={pickerRef} className="w-[360px] rounded-xl border bg-popover shadow-xl overflow-hidden">
{/* Tabs */}
<div className="flex border-b">
<button type="button" onClick={() => setTab("emoji")} className={cn("flex-1 py-2.5 text-sm font-medium transition-colors relative", tab === "emoji" ? "text-foreground" : "text-muted-foreground hover:text-foreground")}>
Emoji
{tab === "emoji" && <div className="absolute bottom-0 left-4 right-4 h-0.5 bg-primary rounded-full" />}
</button>
<button type="button" onClick={() => setTab("gif")} className={cn("flex-1 py-2.5 text-sm font-medium transition-colors relative", tab === "gif" ? "text-foreground" : "text-muted-foreground hover:text-foreground")}>
GIFs
{tab === "gif" && <div className="absolute bottom-0 left-4 right-4 h-0.5 bg-primary rounded-full" />}
</button>
<button type="button" onClick={() => setTab("sticker")} className={cn("flex-1 py-2.5 text-sm font-medium transition-colors relative", tab === "sticker" ? "text-foreground" : "text-muted-foreground hover:text-foreground")}>
Stickers
{tab === "sticker" && <div className="absolute bottom-0 left-4 right-4 h-0.5 bg-primary rounded-full" />}
</button>
</div>
{/* Emoji Tab */}
{tab === "emoji" && (
<div className="max-h-[380px] overflow-hidden">
<Picker
data={data}
onEmojiSelect={(emoji: any) => { onEmojiSelect(emoji.native); onClose() }}
theme={theme === "dark" ? "dark" : "light"}
previewPosition="none"
skinTonePosition="none"
set="native"
emojiSize={32}
maxFrequentRows={2}
perLine={8}
/>
</div>
)}
{/* GIF Tab */}
{tab === "gif" && (
<div className="flex flex-col h-[380px]">
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={gifQuery}
onChange={(e) => setGifQuery(e.target.value)}
placeholder="Search GIFs..."
className="h-8 pl-8 text-sm"
/>
{gifQuery && (
<button type="button" onClick={() => setGifQuery("")} className="absolute right-2 top-1/2 -translate-y-1/2">
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{gifLoading && gifResults.length === 0 ? (
<div className="flex items-center justify-center h-48">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : gifNoKey ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
<Image className="h-8 w-8 opacity-40" />
<span>GIF search requires a Tenor API key</span>
<span className="text-xs opacity-60">Set TENOR_API_KEY in .env.local</span>
</div>
) : gifError ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
<TrendingUp className="h-8 w-8 opacity-40" />
<span>{gifError}</span>
<button type="button" onClick={() => fetchGifs(gifQuery)} className="text-xs text-primary hover:underline">Retry</button>
</div>
) : gifQuery.trim() ? (
renderGifGrid(gifResults, "No GIFs found")
) : recentGifs.length > 0 && gifResults.length === 0 ? (
<>
<div className="text-xs font-medium text-muted-foreground px-1 pb-2">Recent GIFs</div>
{renderGifGrid(
recentGifs.map((id) => gifResults.find((g: any) => g.id === id)).filter(Boolean),
"No recent GIFs"
)}
</>
) : (
<>
<div className="text-xs font-medium text-muted-foreground px-1 pb-2">Trending</div>
{renderGifGrid(gifResults, "No trending GIFs available")}
</>
)}
{gifNext && !gifLoading && (
<div className="flex justify-center pt-2 pb-1">
<Button type="button" variant="ghost" size="sm" className="text-xs h-7" onClick={() => fetchGifs(gifQuery, gifNext)}>
Load more
</Button>
</div>
)}
</div>
</div>
)}
{/* Stickers Tab */}
{tab === "sticker" && (
<div className="flex flex-col h-[380px]">
<div className="flex gap-1 p-2 border-b overflow-x-auto shrink-0">
{stickerPacks.map((pack) => (
<button
key={pack.id}
type="button"
onClick={() => setActivePack(pack.id)}
className={cn(
"px-3 py-1.5 text-xs rounded-md whitespace-nowrap transition-colors",
activePack === pack.id ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80 text-muted-foreground",
)}
>
{pack.name}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-2">
<div className="grid grid-cols-3 gap-2">
{activeStickers.map((sticker) => (
<button
key={sticker.id}
type="button"
onClick={() => handleStickerSelect(sticker)}
className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square"
>
{sticker.svg ? (
<div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sticker.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} />
) : (
<span className="text-5xl">{sticker.emoji}</span>
)}
</button>
))}
</div>
{recentStickers.length > 0 && (
<>
<div className="text-xs font-medium text-muted-foreground pt-4 pb-2">Recently Used</div>
<div className="grid grid-cols-3 gap-2">
{recentStickers.map((id) => {
const s = stickerPacks.flatMap((p) => p.stickers).find((x) => x.id === id)
if (!s) return null
return (
<button key={s.id} type="button" onClick={() => handleStickerSelect(s)} className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square opacity-70 hover:opacity-100">
{s.svg ? <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: s.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> : <span className="text-5xl">{s.emoji}</span>}
</button>
)
})}
</div>
</>
)}
</div>
</div>
)}
</div>
)
}