diff --git a/public/uploads/voice/.gitkeep b/public/uploads/voice/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/public/uploads/voice/.gitkeep @@ -0,0 +1 @@ + diff --git a/public/uploads/voice/014de089-eec7-4c56-8db4-1d9ba479572b.webm b/public/uploads/voice/014de089-eec7-4c56-8db4-1d9ba479572b.webm new file mode 100644 index 0000000..f876e5d Binary files /dev/null and b/public/uploads/voice/014de089-eec7-4c56-8db4-1d9ba479572b.webm differ diff --git a/signaling-server.mjs b/signaling-server.mjs index 7e1ddd6..44b7532 100644 --- a/signaling-server.mjs +++ b/signaling-server.mjs @@ -143,6 +143,25 @@ io.on("connection", (socket) => { 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 }) => { diff --git a/src/app/(dashboard)/chats/page.tsx b/src/app/(dashboard)/chats/page.tsx index a6bceaa..9a06a09 100644 --- a/src/app/(dashboard)/chats/page.tsx +++ b/src/app/(dashboard)/chats/page.tsx @@ -17,7 +17,7 @@ import { import { Search, Send, Phone, MoreHorizontal, Paperclip, 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" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, @@ -28,38 +28,35 @@ import { Textarea } from "@/components/ui/textarea" import { useTheme } from "next-themes" import { useUser } from "@/providers/user-provider" import { toast } from "sonner" +import { io, Socket } from "socket.io-client" import VoiceCallModal from "@/components/chats/voice-call-modal" -import data from "@emoji-mart/data" -import Picker from "@emoji-mart/react" +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 [current, setCurrent] = useState(0) const [duration, setDuration] = useState(initialDuration) const audioRef = useRef(null) const animRef = useRef(0) + const progRef = useRef(null) useEffect(() => { const audio = audioRef.current if (!audio) return - const onLoaded = () => { - if (isFinite(audio.duration)) setDuration(audio.duration) - } + const onLoaded = () => { if (isFinite(audio.duration)) setDuration(audio.duration) } const onEnded = () => { setPlaying(false); setCurrent(0) } audio.addEventListener("loadedmetadata", onLoaded) audio.addEventListener("ended", onEnded) - return () => { - audio.removeEventListener("loadedmetadata", onLoaded) - audio.removeEventListener("ended", onEnded) - } + return () => { audio.removeEventListener("loadedmetadata", onLoaded); audio.removeEventListener("ended", onEnded) } }, [src]) useEffect(() => { if (!playing) { cancelAnimationFrame(animRef.current); return } - const tick = () => { - if (audioRef.current) setCurrent(audioRef.current.currentTime) - animRef.current = requestAnimationFrame(tick) - } + const tick = () => { if (audioRef.current) setCurrent(audioRef.current.currentTime); animRef.current = requestAnimationFrame(tick) } animRef.current = requestAnimationFrame(tick) return () => cancelAnimationFrame(animRef.current) }, [playing]) @@ -67,26 +64,55 @@ function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDura const toggle = () => { if (!audioRef.current) return 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) => { + 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 pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0 - const fmt = (s: number) => { - const secs = isFinite(s) ? Math.max(0, Math.floor(s)) : 0 - return `${Math.floor(secs / 60)}:${(secs % 60).toString().padStart(2, "0")}` - } + 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 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 ( -
+