Добавлены новые зависимости для работы с эмодзи и улучшена структура базы данных. Реализована модель сообщений и обновлены компоненты для поддержки новых функций мессенджера. Обновлены запросы и мутации для работы с сообщениями и чатом.
This commit is contained in:
254
src/components/ui/voice-recorder.tsx
Normal file
254
src/components/ui/voice-recorder.tsx
Normal file
@ -0,0 +1,254 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Mic, MicOff, Square, Send, Trash2 } from 'lucide-react'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
|
||||
interface VoiceRecorderProps {
|
||||
onSendVoice: (audioUrl: string, duration: number) => void
|
||||
}
|
||||
|
||||
export function VoiceRecorder({ onSendVoice }: VoiceRecorderProps) {
|
||||
const { user } = useAuth()
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [recordedAudio, setRecordedAudio] = useState<string | null>(null)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [permission, setPermission] = useState<'granted' | 'denied' | 'prompt'>('prompt')
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем доступность микрофона
|
||||
if (typeof navigator !== 'undefined' && navigator.mediaDevices) {
|
||||
navigator.permissions.query({ name: 'microphone' as PermissionName }).then((result) => {
|
||||
setPermission(result.state as 'granted' | 'denied' | 'prompt')
|
||||
}).catch(() => {
|
||||
// Если permissions API недоступен, оставляем prompt
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
setPermission('granted')
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: 'audio/webm;codecs=opus'
|
||||
})
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
audioChunksRef.current = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data)
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' })
|
||||
const audioUrl = URL.createObjectURL(audioBlob)
|
||||
setRecordedAudio(audioUrl)
|
||||
|
||||
// Останавливаем все треки для освобождения микрофона
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
|
||||
// Останавливаем таймер
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
mediaRecorder.start()
|
||||
setIsRecording(true)
|
||||
setDuration(0)
|
||||
|
||||
// Запускаем таймер записи
|
||||
intervalRef.current = setInterval(() => {
|
||||
setDuration(prev => prev + 1)
|
||||
}, 1000)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error accessing microphone:', error)
|
||||
setPermission('denied')
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop()
|
||||
setIsRecording(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop()
|
||||
setIsRecording(false)
|
||||
}
|
||||
setRecordedAudio(null)
|
||||
setDuration(0)
|
||||
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const playRecording = () => {
|
||||
if (recordedAudio) {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.currentTime = 0
|
||||
}
|
||||
|
||||
audioRef.current = new Audio(recordedAudio)
|
||||
audioRef.current.play()
|
||||
setIsPlaying(true)
|
||||
|
||||
audioRef.current.onended = () => {
|
||||
setIsPlaying(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sendVoiceMessage = async () => {
|
||||
if (!recordedAudio || !user?.id) return
|
||||
|
||||
try {
|
||||
// Конвертируем Blob в File для загрузки
|
||||
const response = await fetch(recordedAudio)
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], `voice-${Date.now()}.webm`, { type: 'audio/webm' })
|
||||
|
||||
// Загружаем в S3
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('userId', user.id)
|
||||
|
||||
const uploadResponse = await fetch('/api/upload-voice', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Failed to upload voice message')
|
||||
}
|
||||
|
||||
const result = await uploadResponse.json()
|
||||
|
||||
// Отправляем голосовое сообщение
|
||||
onSendVoice(result.url, duration)
|
||||
|
||||
// Очищаем состояние
|
||||
setRecordedAudio(null)
|
||||
setDuration(0)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending voice message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
if (permission === 'denied') {
|
||||
return (
|
||||
<div className="text-white/60 text-xs text-center p-2">
|
||||
Доступ к микрофону запрещен
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{!recordedAudio ? (
|
||||
// Состояние записи
|
||||
<>
|
||||
{!isRecording ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={startRecording}
|
||||
className="text-white/60 hover:text-white hover:bg-white/10 p-2"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center space-x-2 bg-red-500/20 rounded-lg px-3 py-2">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full animate-pulse" />
|
||||
<span className="text-white text-sm font-mono">
|
||||
{formatDuration(duration)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={stopRecording}
|
||||
className="text-white/80 hover:text-white p-1"
|
||||
>
|
||||
<Square className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={cancelRecording}
|
||||
className="text-red-400 hover:text-red-300 p-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Состояние воспроизведения и отправки
|
||||
<div className="flex items-center space-x-2 bg-blue-500/20 rounded-lg px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={playRecording}
|
||||
className="text-blue-300 hover:text-blue-200 p-1"
|
||||
>
|
||||
{isPlaying ? <MicOff className="h-3 w-3" /> : <Mic className="h-3 w-3" />}
|
||||
</Button>
|
||||
<span className="text-white text-sm font-mono">
|
||||
{formatDuration(duration)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={sendVoiceMessage}
|
||||
className="text-green-400 hover:text-green-300 p-1"
|
||||
>
|
||||
<Send className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRecordedAudio(null)}
|
||||
className="text-red-400 hover:text-red-300 p-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user