add video

This commit is contained in:
lendry
2026-07-07 12:46:43 +03:00
parent 911e76f232
commit 1bd95fa99e
15 changed files with 626 additions and 42 deletions

View File

@@ -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()

View File

@@ -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]">

View File

@@ -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);
} }
}); });

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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,6 +2380,31 @@ 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
size="sm"
variant="ghost"
className="h-10 w-10 rounded-xl p-0"
title="Записать кружок"
onClick={() => {
setVideoCaptureMode('note');
setVideoCaptureOpen(true);
}}
>
<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 <Button
className={cn('h-11 w-11 rounded-full p-0', recording && 'bg-red-500 hover:bg-red-600')} className={cn('h-11 w-11 rounded-full p-0', recording && 'bg-red-500 hover:bg-red-600')}
onMouseDown={() => void startVoiceRecording()} onMouseDown={() => void startVoiceRecording()}
@@ -2343,6 +2415,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
> >
<Mic className="h-4 w-4" /> <Mic className="h-4 w-4" />
</Button> </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}

View File

@@ -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);
} }
}); });

View File

@@ -70,6 +70,10 @@ 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 === 'VIDEO'
? 'Видео'
: messageType === 'VIDEO_NOTE'
? 'Видеосообщение'
: messageType === 'FILE' : messageType === 'FILE'
? item.file.name ? item.file.name
: undefined; : undefined;

View File

@@ -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';
} }

View 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 '📄 Файл';

View File

@@ -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:

View File

@@ -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 '[Системное сообщение]';

View File

@@ -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,6 +1048,10 @@ export class ChatService {
? '🎤 Голосовое' ? '🎤 Голосовое'
: message.type === 'AUDIO' : message.type === 'AUDIO'
? '🎵 Аудио' ? '🎵 Аудио'
: message.type === 'VIDEO'
? '🎬 Видео'
: message.type === 'VIDEO_NOTE'
? '⭕ Кружок'
: message.type === 'FILE' : message.type === 'FILE'
? '📄 Файл' ? '📄 Файл'
: message.type === 'IMAGE' : message.type === 'IMAGE'

View File

@@ -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';
} }