diff --git a/apps/api-gateway/src/dto/chat.dto.ts b/apps/api-gateway/src/dto/chat.dto.ts
index f2f6ebd..fa3242a 100644
--- a/apps/api-gateway/src/dto/chat.dto.ts
+++ b/apps/api-gateway/src/dto/chat.dto.ts
@@ -33,7 +33,7 @@ export class UpdateChatRoomDto {
export class SendChatMessageDto {
@IsString()
- @IsIn(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'FILE', 'EMOJI', 'POLL'])
+ @IsIn(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'VIDEO', 'VIDEO_NOTE', 'FILE', 'EMOJI', 'POLL'])
type!: string;
@IsOptional()
diff --git a/apps/frontend/components/admin/admin-inspector-message-body.tsx b/apps/frontend/components/admin/admin-inspector-message-body.tsx
index 8c090ed..f8bc08e 100644
--- a/apps/frontend/components/admin/admin-inspector-message-body.tsx
+++ b/apps/frontend/components/admin/admin-inspector-message-body.tsx
@@ -110,6 +110,39 @@ export function AdminInspectorMessageBody({
);
}
+ if (message.type === 'VIDEO' && message.storageKey) {
+ const media = mediaUrls[message.storageKey];
+ if (media?.blobUrl) {
+ return (
+
+ );
+ }
+ return (
+
+ 🎬 Видео
+
+ );
+ }
+
+ if (message.type === 'VIDEO_NOTE' && message.storageKey) {
+ const media = mediaUrls[message.storageKey];
+ if (media?.blobUrl) {
+ return (
+
+ );
+ }
+ return (
+
+ ⭕ Кружок
+
+ );
+ }
+
if (message.type === 'POLL') {
return (
diff --git a/apps/frontend/components/admin/use-admin-chat-media.ts b/apps/frontend/components/admin/use-admin-chat-media.ts
index 88cb4f0..c4cd753 100644
--- a/apps/frontend/components/admin/use-admin-chat-media.ts
+++ b/apps/frontend/components/admin/use-admin-chat-media.ts
@@ -97,7 +97,7 @@ export function useAdminChatMedia(token: string | null, roomId: string | null, m
useEffect(() => {
if (!token || !roomId) return;
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);
}
});
diff --git a/apps/frontend/components/chat/chat-video-capture-dialog.tsx b/apps/frontend/components/chat/chat-video-capture-dialog.tsx
new file mode 100644
index 0000000..ef41ed1
--- /dev/null
+++ b/apps/frontend/components/chat/chat-video-capture-dialog.tsx
@@ -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 = {
+ 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(null);
+ const streamRef = useRef(null);
+ const recorderRef = useRef(null);
+ const chunksRef = useRef([]);
+ const startedAtRef = useRef(null);
+ const timerRef = useRef(null);
+
+ const [initializing, setInitializing] = useState(false);
+ const [recording, setRecording] = useState(false);
+ const [elapsedMs, setElapsedMs] = useState(0);
+ const [error, setError] = useState(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 (
+
+ );
+}
diff --git a/apps/frontend/components/chat/chat-video-note-player.tsx b/apps/frontend/components/chat/chat-video-note-player.tsx
new file mode 100644
index 0000000..9918865
--- /dev/null
+++ b/apps/frontend/components/chat/chat-video-note-player.tsx
@@ -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(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 (
+
+ );
+}
diff --git a/apps/frontend/components/chat/chat-video-player.tsx b/apps/frontend/components/chat/chat-video-player.tsx
new file mode 100644
index 0000000..fa8ead6
--- /dev/null
+++ b/apps/frontend/components/chat/chat-video-player.tsx
@@ -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(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 (
+
+
+
+
+ {formatDuration(duration)}
+
+ {fileName ? (
+
+ {fileName}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/frontend/components/family/family-group-view.tsx b/apps/frontend/components/family/family-group-view.tsx
index 6aef786..b3bfae4 100644
--- a/apps/frontend/components/family/family-group-view.tsx
+++ b/apps/frontend/components/family/family-group-view.tsx
@@ -10,6 +10,7 @@ import {
Camera,
Check,
CheckCheck,
+ Circle,
FileText,
ImageIcon,
Loader2,
@@ -25,6 +26,7 @@ import {
Trash2,
UserPlus,
Users,
+ Video,
Volume2,
VolumeX,
X
@@ -58,6 +60,9 @@ import { InlineEditableTitle } from '@/components/chat/inline-editable-title';
import { RepliedMessagePreview, scrollToChatMessage } from '@/components/chat/replied-message-preview';
import { TypingIndicator } from '@/components/chat/typing-indicator';
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 { UserMenu } from '@/components/id/user-menu';
import { UserAvatar } from '@/components/id/user-avatar';
@@ -313,6 +318,8 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
const [chatToolsTab, setChatToolsTab] = useState('search');
const [mediaDropVisible, setMediaDropVisible] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(null);
+ const [videoCaptureOpen, setVideoCaptureOpen] = useState(false);
+ const [videoCaptureMode, setVideoCaptureMode] = useState('note');
const mediaComposer = useChatMediaComposer();
@@ -326,6 +333,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
const mediaUrlsRef = useRef>({});
const mediaLoadInFlightRef = useRef>(new Set());
const fileInputRef = useRef(null);
+ const videoInputRef = useRef(null);
const dragDepthRef = useRef(0);
useEffect(() => {
@@ -722,7 +730,15 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
let meta = parseMessageMetadata(message.metadataJson);
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 });
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 (!options?.voice) {
+ const contentType = resolveChatContentType(file);
+ const isDirectUpload = Boolean(options?.voice || options?.videoNote || contentType.startsWith('video/'));
+ if (!isDirectUpload) {
queueMediaFiles([file]);
return;
}
setSending(true);
try {
- const contentType = resolveChatContentType(file);
- const messageType = detectChatMessageType(file, { voice: options?.voice });
+ const messageType = detectChatMessageType(file, { voice: options?.voice, videoNote: options?.videoNote });
const metadata = {
fileName: file.name,
fileSize: file.size,
- ...(options?.durationMs ? { durationMs: options.durationMs } : {})
+ ...(options?.durationMs ? { durationMs: options.durationMs } : {}),
+ ...(options?.videoNote ? { videoNote: true } : {})
};
let uploadFile = file;
let uploadContentType = contentType;
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;
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 (
+
+ );
+ }
+ if (message.type === 'VIDEO_NOTE') {
+ const metaDuration = e2ePayload?.durationMs ?? parseMessageMetadata(message.metadataJson).durationMs;
+ return (
+
+ );
+ }
return (
: }
) : (
-
+ <>
+
+
+
+ >
)}
@@ -2355,6 +2428,18 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
)}
+ {
+ const files = Array.from(event.target.files ?? []);
+ files.forEach((file) => void uploadChatFile(file));
+ event.target.value = '';
+ }}
+ />
+
+ setVideoCaptureOpen(false)}
+ onCaptured={(file, durationMs) => {
+ void uploadChatFile(file, {
+ videoNote: videoCaptureMode === 'note',
+ durationMs
+ });
+ }}
+ />
+
{
if (!token || !roomId) return;
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);
}
});
diff --git a/apps/frontend/lib/chat-media-upload.ts b/apps/frontend/lib/chat-media-upload.ts
index 9acf954..bf7da4d 100644
--- a/apps/frontend/lib/chat-media-upload.ts
+++ b/apps/frontend/lib/chat-media-upload.ts
@@ -70,9 +70,13 @@ export async function uploadChatMediaBatch(params: {
let messageContent: string | undefined =
index === 0 && caption?.trim()
? caption.trim()
- : messageType === 'FILE'
- ? item.file.name
- : undefined;
+ : messageType === 'VIDEO'
+ ? 'Видео'
+ : messageType === 'VIDEO_NOTE'
+ ? 'Видеосообщение'
+ : messageType === 'FILE'
+ ? item.file.name
+ : undefined;
let isEncrypted = false;
if (isE2E) {
diff --git a/apps/frontend/lib/chat-media.ts b/apps/frontend/lib/chat-media.ts
index be68857..ce02d0f 100644
--- a/apps/frontend/lib/chat-media.ts
+++ b/apps/frontend/lib/chat-media.ts
@@ -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 = {
jpg: 'image/jpeg',
@@ -18,6 +18,9 @@ const EXTENSION_TO_MIME: Record = {
flac: 'audio/flac',
mp4: 'video/mp4',
mov: 'video/quicktime',
+ mkv: 'video/x-matroska',
+ avi: 'video/x-msvideo',
+ '3gp': 'video/3gpp',
pdf: 'application/pdf',
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
@@ -44,7 +47,9 @@ const MIME_ALIASES: Record = {
'image/jpg': 'image/jpeg',
'image/pjpeg': 'image/jpeg',
'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) {
@@ -72,21 +77,26 @@ export function resolveChatContentType(file: Pick) {
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: '' });
+ if (options?.videoNote && normalized.startsWith('video/')) return 'VIDEO_NOTE';
if (normalized.startsWith('image/')) return 'IMAGE';
- if (normalized.startsWith('video/')) return 'FILE';
+ if (normalized.startsWith('video/')) return 'VIDEO';
if (normalized.startsWith('audio/')) {
return options?.voice ? 'VOICE' : 'AUDIO';
}
return 'FILE';
}
-export function detectChatMessageType(file: File, options?: { voice?: boolean }) {
+export function detectChatMessageType(file: File, options?: { voice?: boolean; videoNote?: boolean }) {
const contentType = resolveChatContentType(file);
return inferChatMessageType(contentType, options);
}
+export function isVideoFile(file: Pick) {
+ return resolveChatContentType(file).startsWith('video/');
+}
+
export function formatFileSize(bytes?: number) {
if (!bytes || bytes <= 0) return '';
if (bytes < 1024) return `${bytes} Б`;
@@ -95,9 +105,9 @@ export function formatFileSize(bytes?: number) {
}
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 {
- 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 {
return {};
}
@@ -111,6 +121,7 @@ export function fileIconLabel(fileName?: string, mimeType?: string) {
if (['ppt', 'pptx'].includes(extension)) return 'PPT';
if (['zip', 'rar', '7z'].includes(extension)) return 'ZIP';
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);
return 'FILE';
}
diff --git a/apps/frontend/lib/chat-message-utils.ts b/apps/frontend/lib/chat-message-utils.ts
index 28766eb..c2ee471 100644
--- a/apps/frontend/lib/chat-message-utils.ts
+++ b/apps/frontend/lib/chat-message-utils.ts
@@ -56,6 +56,8 @@ export function getMessagePreviewText(message: ChatMessage, visibleText?: string
const text = getMessageCopyText(message, visibleText);
if (text && !looksLikeEncryptedPayload(text)) return text;
if (message.type === 'IMAGE') return '📷 Фото';
+ if (message.type === 'VIDEO') return '🎬 Видео';
+ if (message.type === 'VIDEO_NOTE') return '⭕ Кружок';
if (message.type === 'VOICE') return '🎤 Голосовое';
if (message.type === 'AUDIO') return '🎵 Аудио';
if (message.type === 'FILE') return '📄 Файл';
diff --git a/apps/frontend/lib/e2e-chat.ts b/apps/frontend/lib/e2e-chat.ts
index c65da9f..19203a6 100644
--- a/apps/frontend/lib/e2e-chat.ts
+++ b/apps/frontend/lib/e2e-chat.ts
@@ -1,6 +1,6 @@
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 {
kind: E2EMessageKind;
@@ -44,6 +44,10 @@ export function e2eKindFromMessageType(type: string): E2EMessageKind {
return 'voice';
case 'AUDIO':
return 'audio';
+ case 'VIDEO':
+ return 'video';
+ case 'VIDEO_NOTE':
+ return 'video_note';
case 'FILE':
return 'file';
default:
@@ -64,6 +68,10 @@ export function readableE2EPayload(payload: E2EMessagePayload | null) {
return 'Аудио';
case 'image':
return 'Фото';
+ case 'video':
+ return 'Видео';
+ case 'video_note':
+ return 'Кружок';
case 'file':
return payload.fileName ?? 'Файл';
default:
diff --git a/apps/sso-core/src/domain/admin-user-insights.service.ts b/apps/sso-core/src/domain/admin-user-insights.service.ts
index 1a7e03c..a5774e2 100644
--- a/apps/sso-core/src/domain/admin-user-insights.service.ts
+++ b/apps/sso-core/src/domain/admin-user-insights.service.ts
@@ -479,6 +479,8 @@ export class AdminUserInsightsService {
}
if (type === 'IMAGE') return '[Изображение]';
if (type === 'AUDIO' || type === 'VOICE') return '[Аудио]';
+ if (type === 'VIDEO') return '[Видео]';
+ if (type === 'VIDEO_NOTE') return '[Кружок]';
if (type === 'FILE') return '[Файл]';
if (type === 'POLL') return '[Опрос]';
if (type === 'SYSTEM') return '[Системное сообщение]';
diff --git a/apps/sso-core/src/domain/chat.service.ts b/apps/sso-core/src/domain/chat.service.ts
index e0cbff4..44e5317 100644
--- a/apps/sso-core/src/domain/chat.service.ts
+++ b/apps/sso-core/src/domain/chat.service.ts
@@ -468,7 +468,7 @@ export class ChatService {
if (room.type === 'E2E' && type === 'POLL') {
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)) {
throw new BadRequestException('Неподдерживаемый тип сообщения');
}
@@ -480,13 +480,13 @@ export class ChatService {
if (poll.options.length < 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()) {
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('Не указан файл для отправки');
}
@@ -1048,11 +1048,15 @@ export class ChatService {
? '🎤 Голосовое'
: message.type === 'AUDIO'
? '🎵 Аудио'
- : message.type === 'FILE'
- ? '📄 Файл'
- : message.type === 'IMAGE'
- ? '📷 Фото'
- : '📎 Медиа';
+ : message.type === 'VIDEO'
+ ? '🎬 Видео'
+ : message.type === 'VIDEO_NOTE'
+ ? '⭕ Кружок'
+ : message.type === 'FILE'
+ ? '📄 Файл'
+ : message.type === 'IMAGE'
+ ? '📷 Фото'
+ : '📎 Медиа';
for (const member of room.members) {
if (member.userId === senderId) continue;
diff --git a/apps/sso-core/src/infra/chat-media.util.ts b/apps/sso-core/src/infra/chat-media.util.ts
index abd333c..57dd9c5 100644
--- a/apps/sso-core/src/infra/chat-media.util.ts
+++ b/apps/sso-core/src/infra/chat-media.util.ts
@@ -57,7 +57,7 @@ const MIME_ALIASES: Record = {
'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) {
const raw = (contentType ?? '').trim().toLowerCase();
@@ -181,10 +181,11 @@ export function contentTypeForStorageKey(storageKey: string) {
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);
+ if (options?.videoNote && normalized.startsWith('video/')) return 'VIDEO_NOTE';
if (normalized.startsWith('image/')) return 'IMAGE';
- if (normalized.startsWith('video/')) return 'FILE';
+ if (normalized.startsWith('video/')) return 'VIDEO';
if (normalized.startsWith('audio/')) {
return options?.voice ? 'VOICE' : 'AUDIO';
}