add video
This commit is contained in:
@@ -33,7 +33,7 @@ export class UpdateChatRoomDto {
|
|||||||
|
|
||||||
export class SendChatMessageDto {
|
export class SendChatMessageDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'FILE', 'EMOJI', 'POLL'])
|
@IsIn(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'VIDEO', 'VIDEO_NOTE', 'FILE', 'EMOJI', 'POLL'])
|
||||||
type!: string;
|
type!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -110,6 +110,39 @@ export function AdminInspectorMessageBody({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === 'VIDEO' && message.storageKey) {
|
||||||
|
const media = mediaUrls[message.storageKey];
|
||||||
|
if (media?.blobUrl) {
|
||||||
|
return (
|
||||||
|
<video key={message.id} src={media.blobUrl} controls className="max-h-56 max-w-full rounded-xl" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p key={message.id} className="text-sm text-[#667085]">
|
||||||
|
🎬 Видео
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'VIDEO_NOTE' && message.storageKey) {
|
||||||
|
const media = mediaUrls[message.storageKey];
|
||||||
|
if (media?.blobUrl) {
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
key={message.id}
|
||||||
|
src={media.blobUrl}
|
||||||
|
controls
|
||||||
|
className="h-40 w-40 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p key={message.id} className="text-sm text-[#667085]">
|
||||||
|
⭕ Кружок
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (message.type === 'POLL') {
|
if (message.type === 'POLL') {
|
||||||
return (
|
return (
|
||||||
<p key={message.id} className="text-sm text-[#667085]">
|
<p key={message.id} className="text-sm text-[#667085]">
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function useAdminChatMedia(token: string | null, roomId: string | null, m
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !roomId) return;
|
if (!token || !roomId) return;
|
||||||
messages.forEach((message) => {
|
messages.forEach((message) => {
|
||||||
if (message.storageKey && message.type === 'IMAGE') {
|
if (message.storageKey && (message.type === 'IMAGE' || message.type === 'VIDEO' || message.type === 'VIDEO_NOTE')) {
|
||||||
void loadMedia(message);
|
void loadMedia(message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
218
apps/frontend/components/chat/chat-video-capture-dialog.tsx
Normal file
218
apps/frontend/components/chat/chat-video-capture-dialog.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Loader2, Video, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type ChatVideoCaptureMode = 'video' | 'note';
|
||||||
|
|
||||||
|
const MAX_DURATION_MS: Record<ChatVideoCaptureMode, number> = {
|
||||||
|
note: 60_000,
|
||||||
|
video: 5 * 60_000
|
||||||
|
};
|
||||||
|
|
||||||
|
function pickRecorderMimeType() {
|
||||||
|
const candidates = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'];
|
||||||
|
return candidates.find((type) => MediaRecorder.isTypeSupported(type)) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimer(ms: number) {
|
||||||
|
const total = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
const mins = Math.floor(total / 60);
|
||||||
|
const secs = total % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatVideoCaptureDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
mode: ChatVideoCaptureMode;
|
||||||
|
onClose: () => void;
|
||||||
|
onCaptured: (file: File, durationMs: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatVideoCaptureDialog({ open, mode, onClose, onCaptured }: ChatVideoCaptureDialogProps) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<BlobPart[]>([]);
|
||||||
|
const startedAtRef = useRef<number | null>(null);
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const [initializing, setInitializing] = useState(false);
|
||||||
|
const [recording, setRecording] = useState(false);
|
||||||
|
const [elapsedMs, setElapsedMs] = useState(0);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const stopStream = useCallback(() => {
|
||||||
|
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
window.clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
if (recorderRef.current && recorderRef.current.state !== 'inactive') {
|
||||||
|
recorderRef.current.stop();
|
||||||
|
}
|
||||||
|
recorderRef.current = null;
|
||||||
|
chunksRef.current = [];
|
||||||
|
startedAtRef.current = null;
|
||||||
|
setRecording(false);
|
||||||
|
setElapsedMs(0);
|
||||||
|
stopStream();
|
||||||
|
}, [stopStream]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
cleanup();
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setInitializing(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: mode === 'note' ? 'user' : 'environment' },
|
||||||
|
audio: true
|
||||||
|
});
|
||||||
|
if (cancelled) {
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
streamRef.current = stream;
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = stream;
|
||||||
|
await videoRef.current.play();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError('Не удалось получить доступ к камере и микрофону');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setInitializing(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
}, [cleanup, mode, open]);
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
recorderRef.current?.stop();
|
||||||
|
recorderRef.current = null;
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
window.clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
setRecording(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRecording() {
|
||||||
|
const stream = streamRef.current;
|
||||||
|
if (!stream || recording) return;
|
||||||
|
|
||||||
|
const mimeType = pickRecorderMimeType();
|
||||||
|
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
|
||||||
|
chunksRef.current = [];
|
||||||
|
recorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) chunksRef.current.push(event.data);
|
||||||
|
};
|
||||||
|
recorder.onstop = () => {
|
||||||
|
const durationMs = startedAtRef.current ? Date.now() - startedAtRef.current : 0;
|
||||||
|
startedAtRef.current = null;
|
||||||
|
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'video/webm' });
|
||||||
|
const extension = blob.type.includes('mp4') ? 'mp4' : 'webm';
|
||||||
|
const prefix = mode === 'note' ? 'video-note' : 'video';
|
||||||
|
const file = new File([blob], `${prefix}-${Date.now()}.${extension}`, { type: blob.type || 'video/webm' });
|
||||||
|
onCaptured(file, durationMs);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
recorderRef.current = recorder;
|
||||||
|
startedAtRef.current = Date.now();
|
||||||
|
setElapsedMs(0);
|
||||||
|
setRecording(true);
|
||||||
|
recorder.start(250);
|
||||||
|
|
||||||
|
timerRef.current = window.setInterval(() => {
|
||||||
|
const startedAt = startedAtRef.current;
|
||||||
|
if (!startedAt) return;
|
||||||
|
const nextElapsed = Date.now() - startedAt;
|
||||||
|
setElapsedMs(nextElapsed);
|
||||||
|
if (nextElapsed >= MAX_DURATION_MS[mode]) {
|
||||||
|
stopRecording();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = mode === 'note' ? 'Запись кружка' : 'Запись видео';
|
||||||
|
const hint = mode === 'note' ? 'Короткое круглое видеосообщение до 1 минуты' : 'Видеосообщение до 5 минут';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||||
|
<DialogContent className="overflow-hidden rounded-[24px] p-0 sm:max-w-[420px]">
|
||||||
|
<div className="border-b border-[#eceef4] px-5 py-4">
|
||||||
|
<DialogHeader className="mb-0">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<p className="text-sm text-[#667085]">{hint}</p>
|
||||||
|
</DialogHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-5 py-5">
|
||||||
|
{error ? (
|
||||||
|
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative overflow-hidden bg-black',
|
||||||
|
mode === 'note' ? 'h-[240px] w-[240px] rounded-full' : 'h-[280px] w-full max-w-[360px] rounded-2xl'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<video ref={videoRef} className="h-full w-full object-cover" playsInline muted />
|
||||||
|
{initializing ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/35">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-white" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-2 text-sm font-medium text-[#667085]">
|
||||||
|
<Video className="h-4 w-4" />
|
||||||
|
{recording ? formatTimer(elapsedMs) : 'Готовы к записи'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-[#eceef4] px-5 py-3">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose}>
|
||||||
|
<X className="mr-1 h-4 w-4" />
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={Boolean(error) || initializing}
|
||||||
|
className={cn(recording && 'bg-red-500 hover:bg-red-600')}
|
||||||
|
onClick={() => (recording ? stopRecording() : startRecording())}
|
||||||
|
>
|
||||||
|
{recording ? 'Остановить' : 'Начать запись'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
apps/frontend/components/chat/chat-video-note-player.tsx
Normal file
97
apps/frontend/components/chat/chat-video-note-player.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Loader2, Pause, Play } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function formatDuration(seconds: number) {
|
||||||
|
if (!Number.isFinite(seconds) || seconds <= 0) return '0:00';
|
||||||
|
const whole = Math.floor(seconds);
|
||||||
|
const mins = Math.floor(whole / 60);
|
||||||
|
const secs = whole % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatVideoNotePlayer({
|
||||||
|
src,
|
||||||
|
durationMs,
|
||||||
|
variant = 'theirs'
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
durationMs?: number;
|
||||||
|
variant?: 'mine' | 'theirs';
|
||||||
|
}) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
const onLoaded = () => {
|
||||||
|
if (Number.isFinite(video.duration) && video.duration > 0) {
|
||||||
|
setDuration(video.duration);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
const onPlay = () => setPlaying(true);
|
||||||
|
const onPause = () => setPlaying(false);
|
||||||
|
const onEnded = () => setPlaying(false);
|
||||||
|
|
||||||
|
video.addEventListener('loadedmetadata', onLoaded);
|
||||||
|
video.addEventListener('play', onPlay);
|
||||||
|
video.addEventListener('pause', onPause);
|
||||||
|
video.addEventListener('ended', onEnded);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
video.removeEventListener('loadedmetadata', onLoaded);
|
||||||
|
video.removeEventListener('play', onPlay);
|
||||||
|
video.removeEventListener('pause', onPause);
|
||||||
|
video.removeEventListener('ended', onEnded);
|
||||||
|
};
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
function togglePlayback() {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
if (video.paused) {
|
||||||
|
void video.play();
|
||||||
|
} else {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={togglePlayback}
|
||||||
|
className={cn(
|
||||||
|
'group relative h-[240px] w-[240px] overflow-hidden rounded-full shadow-md transition hover:shadow-lg',
|
||||||
|
variant === 'mine' ? 'ring-2 ring-[#7ea06a]/40' : 'ring-2 ring-[#dce3ec]'
|
||||||
|
)}
|
||||||
|
aria-label={playing ? 'Пауза' : 'Воспроизвести кружок'}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={src}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center bg-black/20 transition group-hover:bg-black/30">
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-10 w-10 animate-spin text-white" />
|
||||||
|
) : playing ? (
|
||||||
|
<Pause className="h-10 w-10 text-white drop-shadow" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-10 w-10 text-white drop-shadow" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/55 px-2 py-0.5 text-[11px] text-white">
|
||||||
|
{formatDuration(duration)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
apps/frontend/components/chat/chat-video-player.tsx
Normal file
107
apps/frontend/components/chat/chat-video-player.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Loader2, Pause, Play } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function formatDuration(seconds: number) {
|
||||||
|
if (!Number.isFinite(seconds) || seconds <= 0) return '0:00';
|
||||||
|
const whole = Math.floor(seconds);
|
||||||
|
const mins = Math.floor(whole / 60);
|
||||||
|
const secs = whole % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatVideoPlayer({
|
||||||
|
src,
|
||||||
|
durationMs,
|
||||||
|
fileName,
|
||||||
|
variant = 'theirs'
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
durationMs?: number;
|
||||||
|
fileName?: string;
|
||||||
|
variant?: 'mine' | 'theirs';
|
||||||
|
}) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
const onLoaded = () => {
|
||||||
|
if (Number.isFinite(video.duration) && video.duration > 0) {
|
||||||
|
setDuration(video.duration);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
const onPlay = () => setPlaying(true);
|
||||||
|
const onPause = () => setPlaying(false);
|
||||||
|
const onEnded = () => setPlaying(false);
|
||||||
|
|
||||||
|
video.addEventListener('loadedmetadata', onLoaded);
|
||||||
|
video.addEventListener('play', onPlay);
|
||||||
|
video.addEventListener('pause', onPause);
|
||||||
|
video.addEventListener('ended', onEnded);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
video.removeEventListener('loadedmetadata', onLoaded);
|
||||||
|
video.removeEventListener('play', onPlay);
|
||||||
|
video.removeEventListener('pause', onPause);
|
||||||
|
video.removeEventListener('ended', onEnded);
|
||||||
|
};
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
function togglePlayback() {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
if (video.paused) {
|
||||||
|
void video.play();
|
||||||
|
} else {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative max-w-[320px] overflow-hidden rounded-xl',
|
||||||
|
variant === 'mine' ? 'bg-[#d9f7c8]/40' : 'bg-black/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={src}
|
||||||
|
className="max-h-72 w-full bg-black object-contain"
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
onClick={togglePlayback}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={togglePlayback}
|
||||||
|
className="absolute inset-0 flex items-center justify-center bg-black/15 transition hover:bg-black/25"
|
||||||
|
aria-label={playing ? 'Пауза' : 'Воспроизвести'}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-10 w-10 animate-spin text-white" />
|
||||||
|
) : playing ? (
|
||||||
|
<Pause className="h-10 w-10 text-white drop-shadow" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-10 w-10 text-white drop-shadow" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="absolute bottom-2 left-2 rounded-full bg-black/55 px-2 py-0.5 text-[11px] text-white">
|
||||||
|
{formatDuration(duration)}
|
||||||
|
</div>
|
||||||
|
{fileName ? (
|
||||||
|
<div className="truncate px-2 py-1 text-xs text-[#667085]" title={fileName}>
|
||||||
|
{fileName}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Camera,
|
Camera,
|
||||||
Check,
|
Check,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
Circle,
|
||||||
FileText,
|
FileText,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
|
Video,
|
||||||
Volume2,
|
Volume2,
|
||||||
VolumeX,
|
VolumeX,
|
||||||
X
|
X
|
||||||
@@ -58,6 +60,9 @@ import { InlineEditableTitle } from '@/components/chat/inline-editable-title';
|
|||||||
import { RepliedMessagePreview, scrollToChatMessage } from '@/components/chat/replied-message-preview';
|
import { RepliedMessagePreview, scrollToChatMessage } from '@/components/chat/replied-message-preview';
|
||||||
import { TypingIndicator } from '@/components/chat/typing-indicator';
|
import { TypingIndicator } from '@/components/chat/typing-indicator';
|
||||||
import { VoiceMessagePlayer } from '@/components/chat/voice-message-player';
|
import { VoiceMessagePlayer } from '@/components/chat/voice-message-player';
|
||||||
|
import { ChatVideoCaptureDialog, type ChatVideoCaptureMode } from '@/components/chat/chat-video-capture-dialog';
|
||||||
|
import { ChatVideoNotePlayer } from '@/components/chat/chat-video-note-player';
|
||||||
|
import { ChatVideoPlayer } from '@/components/chat/chat-video-player';
|
||||||
import { NotificationBell } from '@/components/notifications/notification-bell';
|
import { NotificationBell } from '@/components/notifications/notification-bell';
|
||||||
import { UserMenu } from '@/components/id/user-menu';
|
import { UserMenu } from '@/components/id/user-menu';
|
||||||
import { UserAvatar } from '@/components/id/user-avatar';
|
import { UserAvatar } from '@/components/id/user-avatar';
|
||||||
@@ -313,6 +318,8 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
|
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
|
||||||
const [mediaDropVisible, setMediaDropVisible] = useState(false);
|
const [mediaDropVisible, setMediaDropVisible] = useState(false);
|
||||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
const [videoCaptureOpen, setVideoCaptureOpen] = useState(false);
|
||||||
|
const [videoCaptureMode, setVideoCaptureMode] = useState<ChatVideoCaptureMode>('note');
|
||||||
|
|
||||||
const mediaComposer = useChatMediaComposer();
|
const mediaComposer = useChatMediaComposer();
|
||||||
|
|
||||||
@@ -326,6 +333,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
const mediaUrlsRef = useRef<Record<string, LoadedChatMedia>>({});
|
const mediaUrlsRef = useRef<Record<string, LoadedChatMedia>>({});
|
||||||
const mediaLoadInFlightRef = useRef<Set<string>>(new Set());
|
const mediaLoadInFlightRef = useRef<Set<string>>(new Set());
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||||
const dragDepthRef = useRef(0);
|
const dragDepthRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -722,7 +730,15 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
|
|
||||||
let meta = parseMessageMetadata(message.metadataJson);
|
let meta = parseMessageMetadata(message.metadataJson);
|
||||||
let fileName = meta.fileName || message.content || undefined;
|
let fileName = meta.fileName || message.content || undefined;
|
||||||
let mimeType = message.mimeType || (message.type === 'VOICE' ? 'audio/webm' : message.type === 'AUDIO' ? 'audio/mpeg' : undefined);
|
let mimeType =
|
||||||
|
message.mimeType ||
|
||||||
|
(message.type === 'VOICE'
|
||||||
|
? 'audio/webm'
|
||||||
|
: message.type === 'AUDIO'
|
||||||
|
? 'audio/mpeg'
|
||||||
|
: message.type === 'VIDEO' || message.type === 'VIDEO_NOTE'
|
||||||
|
? 'video/webm'
|
||||||
|
: undefined);
|
||||||
const query = new URLSearchParams({ storageKey });
|
const query = new URLSearchParams({ storageKey });
|
||||||
if (fileName) query.set('fileName', fileName);
|
if (fileName) query.set('fileName', fileName);
|
||||||
|
|
||||||
@@ -1328,26 +1344,36 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadChatFile(file: File, options?: { voice?: boolean; durationMs?: number }) {
|
async function uploadChatFile(file: File, options?: { voice?: boolean; videoNote?: boolean; durationMs?: number }) {
|
||||||
if (!token || !activeRoomId || !activeRoom) return;
|
if (!token || !activeRoomId || !activeRoom) return;
|
||||||
if (!options?.voice) {
|
const contentType = resolveChatContentType(file);
|
||||||
|
const isDirectUpload = Boolean(options?.voice || options?.videoNote || contentType.startsWith('video/'));
|
||||||
|
if (!isDirectUpload) {
|
||||||
queueMediaFiles([file]);
|
queueMediaFiles([file]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
const contentType = resolveChatContentType(file);
|
const messageType = detectChatMessageType(file, { voice: options?.voice, videoNote: options?.videoNote });
|
||||||
const messageType = detectChatMessageType(file, { voice: options?.voice });
|
|
||||||
const metadata = {
|
const metadata = {
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
...(options?.durationMs ? { durationMs: options.durationMs } : {})
|
...(options?.durationMs ? { durationMs: options.durationMs } : {}),
|
||||||
|
...(options?.videoNote ? { videoNote: true } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
let uploadFile = file;
|
let uploadFile = file;
|
||||||
let uploadContentType = contentType;
|
let uploadContentType = contentType;
|
||||||
let messageContent: string | undefined =
|
let messageContent: string | undefined =
|
||||||
messageType === 'VOICE' ? 'Голосовое сообщение' : messageType === 'FILE' ? file.name : undefined;
|
messageType === 'VOICE'
|
||||||
|
? 'Голосовое сообщение'
|
||||||
|
: messageType === 'VIDEO'
|
||||||
|
? 'Видео'
|
||||||
|
: messageType === 'VIDEO_NOTE'
|
||||||
|
? 'Видеосообщение'
|
||||||
|
: messageType === 'FILE'
|
||||||
|
? file.name
|
||||||
|
: undefined;
|
||||||
let isEncrypted = false;
|
let isEncrypted = false;
|
||||||
|
|
||||||
if (activeRoomIsE2E) {
|
if (activeRoomIsE2E) {
|
||||||
@@ -2084,6 +2110,27 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (message.type === 'VIDEO') {
|
||||||
|
const metaDuration = e2ePayload?.durationMs ?? parseMessageMetadata(message.metadataJson).durationMs;
|
||||||
|
return (
|
||||||
|
<ChatVideoPlayer
|
||||||
|
src={media.blobUrl}
|
||||||
|
durationMs={typeof metaDuration === 'number' ? metaDuration : undefined}
|
||||||
|
fileName={fileName}
|
||||||
|
variant={mine ? 'mine' : 'theirs'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (message.type === 'VIDEO_NOTE') {
|
||||||
|
const metaDuration = e2ePayload?.durationMs ?? parseMessageMetadata(message.metadataJson).durationMs;
|
||||||
|
return (
|
||||||
|
<ChatVideoNotePlayer
|
||||||
|
src={media.blobUrl}
|
||||||
|
durationMs={typeof metaDuration === 'number' ? metaDuration : undefined}
|
||||||
|
variant={mine ? 'mine' : 'theirs'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2333,16 +2380,42 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<>
|
||||||
className={cn('h-11 w-11 rounded-full p-0', recording && 'bg-red-500 hover:bg-red-600')}
|
<Button
|
||||||
onMouseDown={() => void startVoiceRecording()}
|
size="sm"
|
||||||
onMouseUp={stopVoiceRecording}
|
variant="ghost"
|
||||||
onMouseLeave={stopVoiceRecording}
|
className="h-10 w-10 rounded-xl p-0"
|
||||||
onTouchStart={() => void startVoiceRecording()}
|
title="Записать кружок"
|
||||||
onTouchEnd={stopVoiceRecording}
|
onClick={() => {
|
||||||
>
|
setVideoCaptureMode('note');
|
||||||
<Mic className="h-4 w-4" />
|
setVideoCaptureOpen(true);
|
||||||
</Button>
|
}}
|
||||||
|
>
|
||||||
|
<Circle className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-10 w-10 rounded-xl p-0"
|
||||||
|
title="Записать видео"
|
||||||
|
onClick={() => {
|
||||||
|
setVideoCaptureMode('video');
|
||||||
|
setVideoCaptureOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Video className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className={cn('h-11 w-11 rounded-full p-0', recording && 'bg-red-500 hover:bg-red-600')}
|
||||||
|
onMouseDown={() => void startVoiceRecording()}
|
||||||
|
onMouseUp={stopVoiceRecording}
|
||||||
|
onMouseLeave={stopVoiceRecording}
|
||||||
|
onTouchStart={() => void startVoiceRecording()}
|
||||||
|
onTouchEnd={stopVoiceRecording}
|
||||||
|
>
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2355,6 +2428,18 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={videoInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept="video/*"
|
||||||
|
onChange={(event) => {
|
||||||
|
const files = Array.from(event.target.files ?? []);
|
||||||
|
files.forEach((file) => void uploadChatFile(file));
|
||||||
|
event.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -2368,6 +2453,18 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ChatVideoCaptureDialog
|
||||||
|
open={videoCaptureOpen}
|
||||||
|
mode={videoCaptureMode}
|
||||||
|
onClose={() => setVideoCaptureOpen(false)}
|
||||||
|
onCaptured={(file, durationMs) => {
|
||||||
|
void uploadChatFile(file, {
|
||||||
|
videoNote: videoCaptureMode === 'note',
|
||||||
|
durationMs
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<ChatMediaComposeDialog
|
<ChatMediaComposeDialog
|
||||||
open={mediaComposer.open}
|
open={mediaComposer.open}
|
||||||
items={mediaComposer.items}
|
items={mediaComposer.items}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function useChatRoomMedia(token: string | null, roomId: string | null, me
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || !roomId) return;
|
if (!token || !roomId) return;
|
||||||
messages.forEach((message) => {
|
messages.forEach((message) => {
|
||||||
if (message.storageKey && message.type === 'IMAGE') {
|
if (message.storageKey && (message.type === 'IMAGE' || message.type === 'VIDEO' || message.type === 'VIDEO_NOTE')) {
|
||||||
void loadMedia(message);
|
void loadMedia(message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,9 +70,13 @@ export async function uploadChatMediaBatch(params: {
|
|||||||
let messageContent: string | undefined =
|
let messageContent: string | undefined =
|
||||||
index === 0 && caption?.trim()
|
index === 0 && caption?.trim()
|
||||||
? caption.trim()
|
? caption.trim()
|
||||||
: messageType === 'FILE'
|
: messageType === 'VIDEO'
|
||||||
? item.file.name
|
? 'Видео'
|
||||||
: undefined;
|
: messageType === 'VIDEO_NOTE'
|
||||||
|
? 'Видеосообщение'
|
||||||
|
: messageType === 'FILE'
|
||||||
|
? item.file.name
|
||||||
|
: undefined;
|
||||||
let isEncrypted = false;
|
let isEncrypted = false;
|
||||||
|
|
||||||
if (isE2E) {
|
if (isE2E) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type ChatAttachmentKind = 'IMAGE' | 'AUDIO' | 'VOICE' | 'FILE';
|
export type ChatAttachmentKind = 'IMAGE' | 'AUDIO' | 'VOICE' | 'VIDEO' | 'VIDEO_NOTE' | 'FILE';
|
||||||
|
|
||||||
const EXTENSION_TO_MIME: Record<string, string> = {
|
const EXTENSION_TO_MIME: Record<string, string> = {
|
||||||
jpg: 'image/jpeg',
|
jpg: 'image/jpeg',
|
||||||
@@ -18,6 +18,9 @@ const EXTENSION_TO_MIME: Record<string, string> = {
|
|||||||
flac: 'audio/flac',
|
flac: 'audio/flac',
|
||||||
mp4: 'video/mp4',
|
mp4: 'video/mp4',
|
||||||
mov: 'video/quicktime',
|
mov: 'video/quicktime',
|
||||||
|
mkv: 'video/x-matroska',
|
||||||
|
avi: 'video/x-msvideo',
|
||||||
|
'3gp': 'video/3gpp',
|
||||||
pdf: 'application/pdf',
|
pdf: 'application/pdf',
|
||||||
doc: 'application/msword',
|
doc: 'application/msword',
|
||||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
@@ -44,7 +47,9 @@ const MIME_ALIASES: Record<string, string> = {
|
|||||||
'image/jpg': 'image/jpeg',
|
'image/jpg': 'image/jpeg',
|
||||||
'image/pjpeg': 'image/jpeg',
|
'image/pjpeg': 'image/jpeg',
|
||||||
'application/x-pdf': 'application/pdf',
|
'application/x-pdf': 'application/pdf',
|
||||||
'application/x-zip-compressed': 'application/zip'
|
'application/x-zip-compressed': 'application/zip',
|
||||||
|
'video/x-matroska': 'video/x-matroska',
|
||||||
|
'video/3gpp': 'video/3gpp'
|
||||||
};
|
};
|
||||||
|
|
||||||
function extractFileExtension(value?: string | null) {
|
function extractFileExtension(value?: string | null) {
|
||||||
@@ -72,21 +77,26 @@ export function resolveChatContentType(file: Pick<File, 'type' | 'name'>) {
|
|||||||
return aliased || 'application/octet-stream';
|
return aliased || 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inferChatMessageType(contentType: string, options?: { voice?: boolean }): ChatAttachmentKind {
|
export function inferChatMessageType(contentType: string, options?: { voice?: boolean; videoNote?: boolean }): ChatAttachmentKind {
|
||||||
const normalized = resolveChatContentType({ type: contentType, name: '' });
|
const normalized = resolveChatContentType({ type: contentType, name: '' });
|
||||||
|
if (options?.videoNote && normalized.startsWith('video/')) return 'VIDEO_NOTE';
|
||||||
if (normalized.startsWith('image/')) return 'IMAGE';
|
if (normalized.startsWith('image/')) return 'IMAGE';
|
||||||
if (normalized.startsWith('video/')) return 'FILE';
|
if (normalized.startsWith('video/')) return 'VIDEO';
|
||||||
if (normalized.startsWith('audio/')) {
|
if (normalized.startsWith('audio/')) {
|
||||||
return options?.voice ? 'VOICE' : 'AUDIO';
|
return options?.voice ? 'VOICE' : 'AUDIO';
|
||||||
}
|
}
|
||||||
return 'FILE';
|
return 'FILE';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectChatMessageType(file: File, options?: { voice?: boolean }) {
|
export function detectChatMessageType(file: File, options?: { voice?: boolean; videoNote?: boolean }) {
|
||||||
const contentType = resolveChatContentType(file);
|
const contentType = resolveChatContentType(file);
|
||||||
return inferChatMessageType(contentType, options);
|
return inferChatMessageType(contentType, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isVideoFile(file: Pick<File, 'type' | 'name'>) {
|
||||||
|
return resolveChatContentType(file).startsWith('video/');
|
||||||
|
}
|
||||||
|
|
||||||
export function formatFileSize(bytes?: number) {
|
export function formatFileSize(bytes?: number) {
|
||||||
if (!bytes || bytes <= 0) return '';
|
if (!bytes || bytes <= 0) return '';
|
||||||
if (bytes < 1024) return `${bytes} Б`;
|
if (bytes < 1024) return `${bytes} Б`;
|
||||||
@@ -95,9 +105,9 @@ export function formatFileSize(bytes?: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseMessageMetadata(metadataJson?: string) {
|
export function parseMessageMetadata(metadataJson?: string) {
|
||||||
if (!metadataJson) return {} as { fileName?: string; fileSize?: number; durationMs?: number };
|
if (!metadataJson) return {} as { fileName?: string; fileSize?: number; durationMs?: number; videoNote?: boolean };
|
||||||
try {
|
try {
|
||||||
return JSON.parse(metadataJson) as { fileName?: string; fileSize?: number; durationMs?: number };
|
return JSON.parse(metadataJson) as { fileName?: string; fileSize?: number; durationMs?: number; videoNote?: boolean };
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -111,6 +121,7 @@ export function fileIconLabel(fileName?: string, mimeType?: string) {
|
|||||||
if (['ppt', 'pptx'].includes(extension)) return 'PPT';
|
if (['ppt', 'pptx'].includes(extension)) return 'PPT';
|
||||||
if (['zip', 'rar', '7z'].includes(extension)) return 'ZIP';
|
if (['zip', 'rar', '7z'].includes(extension)) return 'ZIP';
|
||||||
if (extension === 'apk') return 'APK';
|
if (extension === 'apk') return 'APK';
|
||||||
|
if (['mp4', 'mov', 'webm', 'mkv'].includes(extension) || mimeType?.startsWith('video/')) return 'VID';
|
||||||
if (extension) return extension.toUpperCase().slice(0, 4);
|
if (extension) return extension.toUpperCase().slice(0, 4);
|
||||||
return 'FILE';
|
return 'FILE';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export function getMessagePreviewText(message: ChatMessage, visibleText?: string
|
|||||||
const text = getMessageCopyText(message, visibleText);
|
const text = getMessageCopyText(message, visibleText);
|
||||||
if (text && !looksLikeEncryptedPayload(text)) return text;
|
if (text && !looksLikeEncryptedPayload(text)) return text;
|
||||||
if (message.type === 'IMAGE') return '📷 Фото';
|
if (message.type === 'IMAGE') return '📷 Фото';
|
||||||
|
if (message.type === 'VIDEO') return '🎬 Видео';
|
||||||
|
if (message.type === 'VIDEO_NOTE') return '⭕ Кружок';
|
||||||
if (message.type === 'VOICE') return '🎤 Голосовое';
|
if (message.type === 'VOICE') return '🎤 Голосовое';
|
||||||
if (message.type === 'AUDIO') return '🎵 Аудио';
|
if (message.type === 'AUDIO') return '🎵 Аудио';
|
||||||
if (message.type === 'FILE') return '📄 Файл';
|
if (message.type === 'FILE') return '📄 Файл';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { decryptBinary, decryptDirectMessage, encryptBinary, encryptDirectMessage } from '@/lib/e2e-crypto';
|
import { decryptBinary, decryptDirectMessage, encryptBinary, encryptDirectMessage } from '@/lib/e2e-crypto';
|
||||||
|
|
||||||
export type E2EMessageKind = 'text' | 'emoji' | 'image' | 'voice' | 'audio' | 'file';
|
export type E2EMessageKind = 'text' | 'emoji' | 'image' | 'voice' | 'audio' | 'video' | 'video_note' | 'file';
|
||||||
|
|
||||||
export interface E2EMessagePayload {
|
export interface E2EMessagePayload {
|
||||||
kind: E2EMessageKind;
|
kind: E2EMessageKind;
|
||||||
@@ -44,6 +44,10 @@ export function e2eKindFromMessageType(type: string): E2EMessageKind {
|
|||||||
return 'voice';
|
return 'voice';
|
||||||
case 'AUDIO':
|
case 'AUDIO':
|
||||||
return 'audio';
|
return 'audio';
|
||||||
|
case 'VIDEO':
|
||||||
|
return 'video';
|
||||||
|
case 'VIDEO_NOTE':
|
||||||
|
return 'video_note';
|
||||||
case 'FILE':
|
case 'FILE':
|
||||||
return 'file';
|
return 'file';
|
||||||
default:
|
default:
|
||||||
@@ -64,6 +68,10 @@ export function readableE2EPayload(payload: E2EMessagePayload | null) {
|
|||||||
return 'Аудио';
|
return 'Аудио';
|
||||||
case 'image':
|
case 'image':
|
||||||
return 'Фото';
|
return 'Фото';
|
||||||
|
case 'video':
|
||||||
|
return 'Видео';
|
||||||
|
case 'video_note':
|
||||||
|
return 'Кружок';
|
||||||
case 'file':
|
case 'file':
|
||||||
return payload.fileName ?? 'Файл';
|
return payload.fileName ?? 'Файл';
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -479,6 +479,8 @@ export class AdminUserInsightsService {
|
|||||||
}
|
}
|
||||||
if (type === 'IMAGE') return '[Изображение]';
|
if (type === 'IMAGE') return '[Изображение]';
|
||||||
if (type === 'AUDIO' || type === 'VOICE') return '[Аудио]';
|
if (type === 'AUDIO' || type === 'VOICE') return '[Аудио]';
|
||||||
|
if (type === 'VIDEO') return '[Видео]';
|
||||||
|
if (type === 'VIDEO_NOTE') return '[Кружок]';
|
||||||
if (type === 'FILE') return '[Файл]';
|
if (type === 'FILE') return '[Файл]';
|
||||||
if (type === 'POLL') return '[Опрос]';
|
if (type === 'POLL') return '[Опрос]';
|
||||||
if (type === 'SYSTEM') return '[Системное сообщение]';
|
if (type === 'SYSTEM') return '[Системное сообщение]';
|
||||||
|
|||||||
@@ -468,7 +468,7 @@ export class ChatService {
|
|||||||
if (room.type === 'E2E' && type === 'POLL') {
|
if (room.type === 'E2E' && type === 'POLL') {
|
||||||
throw new BadRequestException('Опросы недоступны в секретном E2E чате');
|
throw new BadRequestException('Опросы недоступны в секретном E2E чате');
|
||||||
}
|
}
|
||||||
const allowedTypes = new Set(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'FILE', 'EMOJI', 'POLL']);
|
const allowedTypes = new Set(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'VIDEO', 'VIDEO_NOTE', 'FILE', 'EMOJI', 'POLL']);
|
||||||
if (!allowedTypes.has(type)) {
|
if (!allowedTypes.has(type)) {
|
||||||
throw new BadRequestException('Неподдерживаемый тип сообщения');
|
throw new BadRequestException('Неподдерживаемый тип сообщения');
|
||||||
}
|
}
|
||||||
@@ -480,13 +480,13 @@ export class ChatService {
|
|||||||
if (poll.options.length < 2) {
|
if (poll.options.length < 2) {
|
||||||
throw new BadRequestException('В опросе должно быть минимум 2 варианта');
|
throw new BadRequestException('В опросе должно быть минимум 2 варианта');
|
||||||
}
|
}
|
||||||
} else if (type !== 'IMAGE' && type !== 'AUDIO' && type !== 'VOICE' && type !== 'FILE') {
|
} else if (type !== 'IMAGE' && type !== 'AUDIO' && type !== 'VOICE' && type !== 'VIDEO' && type !== 'VIDEO_NOTE' && type !== 'FILE') {
|
||||||
if (!content?.trim()) {
|
if (!content?.trim()) {
|
||||||
throw new BadRequestException('Сообщение не может быть пустым');
|
throw new BadRequestException('Сообщение не может быть пустым');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((type === 'IMAGE' || type === 'AUDIO' || type === 'VOICE' || type === 'FILE') && !storageKey) {
|
if ((type === 'IMAGE' || type === 'AUDIO' || type === 'VOICE' || type === 'VIDEO' || type === 'VIDEO_NOTE' || type === 'FILE') && !storageKey) {
|
||||||
throw new BadRequestException('Не указан файл для отправки');
|
throw new BadRequestException('Не указан файл для отправки');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,11 +1048,15 @@ export class ChatService {
|
|||||||
? '🎤 Голосовое'
|
? '🎤 Голосовое'
|
||||||
: message.type === 'AUDIO'
|
: message.type === 'AUDIO'
|
||||||
? '🎵 Аудио'
|
? '🎵 Аудио'
|
||||||
: message.type === 'FILE'
|
: message.type === 'VIDEO'
|
||||||
? '📄 Файл'
|
? '🎬 Видео'
|
||||||
: message.type === 'IMAGE'
|
: message.type === 'VIDEO_NOTE'
|
||||||
? '📷 Фото'
|
? '⭕ Кружок'
|
||||||
: '📎 Медиа';
|
: message.type === 'FILE'
|
||||||
|
? '📄 Файл'
|
||||||
|
: message.type === 'IMAGE'
|
||||||
|
? '📷 Фото'
|
||||||
|
: '📎 Медиа';
|
||||||
|
|
||||||
for (const member of room.members) {
|
for (const member of room.members) {
|
||||||
if (member.userId === senderId) continue;
|
if (member.userId === senderId) continue;
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const MIME_ALIASES: Record<string, string> = {
|
|||||||
'application/vnd.android.package-archive': 'application/vnd.android.package-archive'
|
'application/vnd.android.package-archive': 'application/vnd.android.package-archive'
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChatAttachmentKind = 'IMAGE' | 'AUDIO' | 'VOICE' | 'FILE';
|
export type ChatAttachmentKind = 'IMAGE' | 'AUDIO' | 'VOICE' | 'VIDEO' | 'VIDEO_NOTE' | 'FILE';
|
||||||
|
|
||||||
export function normalizeChatContentType(contentType?: string | null, fileName?: string | null) {
|
export function normalizeChatContentType(contentType?: string | null, fileName?: string | null) {
|
||||||
const raw = (contentType ?? '').trim().toLowerCase();
|
const raw = (contentType ?? '').trim().toLowerCase();
|
||||||
@@ -181,10 +181,11 @@ export function contentTypeForStorageKey(storageKey: string) {
|
|||||||
return 'application/octet-stream';
|
return 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inferChatMessageType(contentType: string, options?: { voice?: boolean }): ChatAttachmentKind {
|
export function inferChatMessageType(contentType: string, options?: { voice?: boolean; videoNote?: boolean }): ChatAttachmentKind {
|
||||||
const normalized = normalizeChatContentType(contentType);
|
const normalized = normalizeChatContentType(contentType);
|
||||||
|
if (options?.videoNote && normalized.startsWith('video/')) return 'VIDEO_NOTE';
|
||||||
if (normalized.startsWith('image/')) return 'IMAGE';
|
if (normalized.startsWith('image/')) return 'IMAGE';
|
||||||
if (normalized.startsWith('video/')) return 'FILE';
|
if (normalized.startsWith('video/')) return 'VIDEO';
|
||||||
if (normalized.startsWith('audio/')) {
|
if (normalized.startsWith('audio/')) {
|
||||||
return options?.voice ? 'VOICE' : 'AUDIO';
|
return options?.voice ? 'VOICE' : 'AUDIO';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user