|
|
|
@@ -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<HTMLAudioElement>(null)
|
|
|
|
|
const animRef = useRef<number>(0)
|
|
|
|
|
const progRef = useRef<HTMLInputElement>(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<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 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 (
|
|
|
|
|
<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" />
|
|
|
|
|
<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" />}
|
|
|
|
|
</button>
|
|
|
|
|
<div className="flex-1 h-1.5 rounded-full bg-white/30 relative overflow-hidden">
|
|
|
|
|
<div className="h-full rounded-full bg-white transition-all duration-100" style={{ width: `${pct}%` }} />
|
|
|
|
|
<div className="flex-1 flex flex-col gap-0.5 min-w-0">
|
|
|
|
|
<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>
|
|
|
|
|
<span className="text-[11px] tabular-nums opacity-70 w-8 text-right">{fmt(displayDuration)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
@@ -113,6 +139,11 @@ export default function ChatsPage() {
|
|
|
|
|
const [isPaused, setIsPaused] = useState(false)
|
|
|
|
|
const [recordingDuration, setRecordingDuration] = useState(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 [messageAttachments, setMessageAttachments] = useState<Map<string, { name: string; type: string; url: string }[]>>(new Map())
|
|
|
|
|
const [replyingTo, setReplyingTo] = useState<any>(null)
|
|
|
|
@@ -136,9 +167,23 @@ export default function ChatsPage() {
|
|
|
|
|
const resizeStartRef = useRef({ x: 0, width: 0 })
|
|
|
|
|
const blobUrlsRef = 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 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 ta = textareaRef.current
|
|
|
|
|
if (!ta) return
|
|
|
|
@@ -265,6 +310,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) => {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
setIsResizing(true)
|
|
|
|
@@ -291,12 +395,17 @@ export default function ChatsPage() {
|
|
|
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
|
|
|
|
}, [conversations])
|
|
|
|
|
|
|
|
|
|
const handleEmojiSelect = (emoji: { native: string }) => {
|
|
|
|
|
setMessageInput((prev) => prev + emoji.native)
|
|
|
|
|
const handleEmojiSelect = (emoji: string) => {
|
|
|
|
|
setMessageInput((prev) => prev + emoji)
|
|
|
|
|
setShowEmojiPicker(false)
|
|
|
|
|
setTimeout(() => autoResizeTextarea(), 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleMediaSelect = (content: string) => {
|
|
|
|
|
addMessageToChat(content)
|
|
|
|
|
setShowEmojiPicker(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
|
|
|
|
|
|
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
@@ -317,7 +426,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 }[]) => {
|
|
|
|
|
if (!activeChat || !user) return
|
|
|
|
|
const payload = voice ? "[Voice message]" : content
|
|
|
|
|
const payload = content
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/conversations/${activeChat}/messages`, {
|
|
|
|
|
method: "POST",
|
|
|
|
@@ -411,40 +520,71 @@ export default function ChatsPage() {
|
|
|
|
|
setAttachments([])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDeleteMessage = (messageId: string) => {
|
|
|
|
|
const files = messageAttachments.get(messageId)
|
|
|
|
|
if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) })
|
|
|
|
|
const voice = voiceMessages.get(messageId)
|
|
|
|
|
if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) }
|
|
|
|
|
setConversationMessages((prev) => {
|
|
|
|
|
const next = new Map(prev)
|
|
|
|
|
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
|
|
|
|
|
next.set(activeChat || "", msgs)
|
|
|
|
|
return next
|
|
|
|
|
})
|
|
|
|
|
setConversations((prev) =>
|
|
|
|
|
prev.map((conv) =>
|
|
|
|
|
conv.id === activeChat
|
|
|
|
|
? { ...conv, lastMessage: conversationMessages.get(activeChat || "")?.filter((m) => m.id !== messageId).at(-1)?.content ?? conv.lastMessage }
|
|
|
|
|
: conv
|
|
|
|
|
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)
|
|
|
|
|
if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) })
|
|
|
|
|
const voice = voiceMessages.get(messageId)
|
|
|
|
|
if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) }
|
|
|
|
|
|
|
|
|
|
// Remove from local state
|
|
|
|
|
setConversationMessages((prev) => {
|
|
|
|
|
const next = new Map(prev)
|
|
|
|
|
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
|
|
|
|
|
next.set(activeChat || "", msgs)
|
|
|
|
|
return next
|
|
|
|
|
})
|
|
|
|
|
setConversations((prev) =>
|
|
|
|
|
prev.map((conv) =>
|
|
|
|
|
conv.id === activeChat
|
|
|
|
|
? { ...conv, lastMessage: prevMessages.filter((m) => m.id !== messageId).at(-1)?.content ?? conv.lastMessage }
|
|
|
|
|
: 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 () => {
|
|
|
|
|
try {
|
|
|
|
|
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 = []
|
|
|
|
|
recorder.ondataavailable = (e) => {
|
|
|
|
|
if (e.data.size > 0) recordingChunksRef.current.push(e.data)
|
|
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
blobUrlsRef.current.push(url)
|
|
|
|
|
addMessageToChat("", { url, duration: recordingDurationRef.current })
|
|
|
|
|
setPreviewBlob(blob)
|
|
|
|
|
setPreviewUrl(url)
|
|
|
|
|
setPreviewDuration(recordingDurationRef.current)
|
|
|
|
|
stream.getTracks().forEach((t) => t.stop())
|
|
|
|
|
setRecordingDuration(0)
|
|
|
|
|
}
|
|
|
|
@@ -452,9 +592,12 @@ export default function ChatsPage() {
|
|
|
|
|
recorder.start()
|
|
|
|
|
setIsRecording(true)
|
|
|
|
|
setIsPaused(false)
|
|
|
|
|
} catch {
|
|
|
|
|
console.warn("Failed to start recording in chats page")
|
|
|
|
|
toast.error("Microphone access denied")
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
if (err?.name === "NotAllowedError" || err?.name === "PermissionDeniedError") {
|
|
|
|
|
toast.error("Microphone access is required to record voice notes.")
|
|
|
|
|
} else {
|
|
|
|
|
toast.error("Failed to start recording. Please check your microphone.")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -488,6 +631,60 @@ export default function ChatsPage() {
|
|
|
|
|
setRecordingDuration(0)
|
|
|
|
|
recordingDurationRef.current = 0
|
|
|
|
|
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(() => {
|
|
|
|
@@ -564,7 +761,7 @@ export default function ChatsPage() {
|
|
|
|
|
<span className="text-sm font-medium truncate">{person.name}</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
|
|
|
|
|
</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>
|
|
|
|
|
{!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" />
|
|
|
|
@@ -676,7 +873,17 @@ export default function ChatsPage() {
|
|
|
|
|
<div className="space-y-4 min-h-0">
|
|
|
|
|
{messages.map((msg) => {
|
|
|
|
|
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 (
|
|
|
|
|
<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)}>
|
|
|
|
@@ -743,13 +950,22 @@ export default function ChatsPage() {
|
|
|
|
|
<p className="text-[11px] text-white/60 truncate">{replyInfo.repliedToContent}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{voiceUrl ? (
|
|
|
|
|
<div>
|
|
|
|
|
<VoiceMessagePlayer src={voiceUrl.url} initialDuration={voiceUrl.duration} />
|
|
|
|
|
{msg.content && (
|
|
|
|
|
<p className="mt-1 text-sm text-black">{msg.content}</p>
|
|
|
|
|
{gifData ? (
|
|
|
|
|
<div className="max-w-[280px]">
|
|
|
|
|
<img src={gifData.url} alt="GIF" className="w-full rounded-lg" style={{ aspectRatio: `${gifData.w}/${gifData.h}` }} loading="lazy" />
|
|
|
|
|
</div>
|
|
|
|
|
) : 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>
|
|
|
|
|
) : voiceUrl ? (
|
|
|
|
|
<div>
|
|
|
|
|
<VoiceMessagePlayer src={voiceUrl.url} initialDuration={voiceUrl.duration} isOwn={isMe} />
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{(() => {
|
|
|
|
@@ -866,6 +1082,22 @@ export default function ChatsPage() {
|
|
|
|
|
<Square className="h-3.5 w-3.5 fill-current" />
|
|
|
|
|
</Button>
|
|
|
|
|
</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} />
|
|
|
|
@@ -878,7 +1110,7 @@ export default function ChatsPage() {
|
|
|
|
|
</Button>
|
|
|
|
|
{showEmojiPicker && (
|
|
|
|
|
<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>
|
|
|
|
|