fix
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', 'VIDEO', 'VIDEO_NOTE', 'FILE', 'EMOJI', 'POLL'])
|
@IsIn(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'VIDEO', 'VIDEO_NOTE', 'FILE', 'EMOJI', 'POLL', 'LOCATION'])
|
||||||
type!: string;
|
type!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { FormEvent, Suspense, useCallback, useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import { ChevronLeft, Download, KeyRound, Loader2, Mail, Network, Phone, QrCode, ShieldCheck, ShieldQuestion, UserRound } from 'lucide-react';
|
import { ChevronLeft, DoorOpen, Download, KeyRound, Loader2, Mail, Network, Phone, QrCode, ShieldCheck, ShieldQuestion, UserRound } from 'lucide-react';
|
||||||
|
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ function LoginPageContent() {
|
|||||||
router.replace('/');
|
router.replace('/');
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const { identifyLogin, sendLoginOtp, verifyLoginOtp, loginWithPassword, loginWithLdap, beginTotpLogin, verifyTotpLogin, completePin, applyLoginAuth } = useAuth();
|
const { identifyLogin, sendLoginOtp, verifyLoginOtp, loginWithPassword, loginWithLdap, beginTotpLogin, verifyTotpLogin, completePin, applyLoginAuth, logout } = useAuth();
|
||||||
|
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
@@ -1473,7 +1473,23 @@ function LoginPageContent() {
|
|||||||
|
|
||||||
{step === 'pin' && pendingSessionId ? (
|
{step === 'pin' && pendingSessionId ? (
|
||||||
|
|
||||||
<form onSubmit={handlePinSubmit} className="space-y-3">
|
<form onSubmit={handlePinSubmit} className="relative space-y-3">
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
setPendingSessionId(null);
|
||||||
|
setPin('');
|
||||||
|
setStep('identify');
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
title="Выйти из аккаунта"
|
||||||
|
aria-label="Выйти из аккаунта"
|
||||||
|
className="absolute -top-1 right-0 flex h-9 w-9 items-center justify-center rounded-full text-[#b9bdc9] transition hover:bg-white/10 hover:text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<DoorOpen className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<PinInput
|
<PinInput
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { FormEvent, useState } from 'react';
|
import { FormEvent, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { ChevronLeft, UserRound } from 'lucide-react';
|
import { ChevronLeft, DoorOpen, UserRound } from 'lucide-react';
|
||||||
import { BrandLogo } from '@/components/id/brand-logo';
|
import { BrandLogo } from '@/components/id/brand-logo';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { useToast } from '@/components/id/toast-provider';
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
@@ -19,7 +19,7 @@ type Step = 'contact' | 'otp' | 'pin';
|
|||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { sendLoginOtp, verifyLoginOtp, completePin } = useAuth();
|
const { sendLoginOtp, verifyLoginOtp, completePin, logout } = useAuth();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [authTab, setAuthTab] = useState<'email' | 'phone'>('email');
|
const [authTab, setAuthTab] = useState<'email' | 'phone'>('email');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -166,7 +166,22 @@ export default function RegisterPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === 'pin' && pendingSessionId ? (
|
{step === 'pin' && pendingSessionId ? (
|
||||||
<form onSubmit={handlePinSubmit} className="mt-8 space-y-3">
|
<form onSubmit={handlePinSubmit} className="relative mt-8 space-y-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
setPendingSessionId(null);
|
||||||
|
setPin('');
|
||||||
|
setStep('contact');
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
title="Выйти из аккаунта"
|
||||||
|
aria-label="Выйти из аккаунта"
|
||||||
|
className="absolute -top-1 right-0 flex h-9 w-9 items-center justify-center rounded-full text-[#b9bdc9] transition hover:bg-white/10 hover:text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<DoorOpen className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
<p className="text-center text-sm text-[#b9bdc9]">Установите PIN для завершения регистрации</p>
|
<p className="text-center text-sm text-[#b9bdc9]">Установите PIN для завершения регистрации</p>
|
||||||
<PinInput
|
<PinInput
|
||||||
className="h-[58px] border-[#555762] bg-transparent text-center text-lg tracking-[0.4em] text-white placeholder:text-[#8f92a0]"
|
className="h-[58px] border-[#555762] bg-transparent text-center text-lg tracking-[0.4em] text-white placeholder:text-[#8f92a0]"
|
||||||
|
|||||||
103
apps/frontend/components/chat/chat-location-card.tsx
Normal file
103
apps/frontend/components/chat/chat-location-card.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import { ExternalLink, MapPin } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
let iconsFixed = false;
|
||||||
|
|
||||||
|
function fixLeafletIcons() {
|
||||||
|
if (iconsFixed || typeof window === 'undefined') return;
|
||||||
|
iconsFixed = true;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||||
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||||
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
popupAnchor: [1, -34],
|
||||||
|
shadowSize: [41, 41]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMapsUrl(latitude: number, longitude: number) {
|
||||||
|
return `https://yandex.ru/maps/?pt=${longitude},${latitude}&z=16&l=map`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatLocationCard({
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
address,
|
||||||
|
label,
|
||||||
|
variant = 'theirs'
|
||||||
|
}: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
address?: string;
|
||||||
|
label?: string;
|
||||||
|
variant?: 'mine' | 'theirs';
|
||||||
|
}) {
|
||||||
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapInstance = useRef<L.Map | null>(null);
|
||||||
|
|
||||||
|
const title = label?.trim() || address?.trim() || `${latitude.toFixed(5)}, ${longitude.toFixed(5)}`;
|
||||||
|
const mapsUrl = buildMapsUrl(latitude, longitude);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fixLeafletIcons();
|
||||||
|
if (!mapRef.current || mapInstance.current) return;
|
||||||
|
|
||||||
|
const map = L.map(mapRef.current, {
|
||||||
|
zoomControl: false,
|
||||||
|
attributionControl: true,
|
||||||
|
dragging: false,
|
||||||
|
scrollWheelZoom: false,
|
||||||
|
doubleClickZoom: false,
|
||||||
|
boxZoom: false,
|
||||||
|
keyboard: false,
|
||||||
|
touchZoom: false
|
||||||
|
}).setView([latitude, longitude], 15);
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
L.marker([latitude, longitude]).addTo(map);
|
||||||
|
mapInstance.current = map;
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
map.invalidateSize();
|
||||||
|
});
|
||||||
|
resizeObserver.observe(mapRef.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
map.remove();
|
||||||
|
mapInstance.current = null;
|
||||||
|
};
|
||||||
|
}, [latitude, longitude]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={mapsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
data-chat-interactive="true"
|
||||||
|
className={cn(
|
||||||
|
'block w-[min(100%,280px)] overflow-hidden rounded-2xl border transition hover:shadow-md',
|
||||||
|
variant === 'mine' ? 'border-[#7ea06a]/35 bg-white' : 'border-[#dce3ec] bg-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div ref={mapRef} className="h-[160px] w-full" />
|
||||||
|
<div className="flex items-start gap-2 px-3 py-2.5">
|
||||||
|
<MapPin className={cn('mt-0.5 h-4 w-4 shrink-0', variant === 'mine' ? 'text-[#7ea06a]' : 'text-[#3390ec]')} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-[#1f2430]">{title}</p>
|
||||||
|
{address && label ? <p className="truncate text-xs text-[#667085]">{address}</p> : null}
|
||||||
|
</div>
|
||||||
|
<ExternalLink className="mt-0.5 h-4 w-4 shrink-0 text-[#667085]" />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
apps/frontend/components/chat/chat-location-share-dialog.tsx
Normal file
174
apps/frontend/components/chat/chat-location-share-dialog.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Loader2, LocateFixed, MapPin, Send } from 'lucide-react';
|
||||||
|
import { AddressMapPicker } from '@/components/addresses/address-map-picker';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { reverseGeocode } from '@/lib/geocoding';
|
||||||
|
|
||||||
|
export interface ChatLocationSharePayload {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
address?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatLocationShareDialog({
|
||||||
|
open,
|
||||||
|
sending,
|
||||||
|
onOpenChange,
|
||||||
|
onSend,
|
||||||
|
onLocateError
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
sending?: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSend: (payload: ChatLocationSharePayload) => Promise<void>;
|
||||||
|
onLocateError?: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const [latitude, setLatitude] = useState<number | undefined>();
|
||||||
|
const [longitude, setLongitude] = useState<number | undefined>();
|
||||||
|
const [address, setAddress] = useState('');
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [locating, setLocating] = useState(false);
|
||||||
|
const [resolvingAddress, setResolvingAddress] = useState(false);
|
||||||
|
|
||||||
|
const resetState = useCallback(() => {
|
||||||
|
setLatitude(undefined);
|
||||||
|
setLongitude(undefined);
|
||||||
|
setAddress('');
|
||||||
|
setLabel('');
|
||||||
|
setLocating(false);
|
||||||
|
setResolvingAddress(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
resetState();
|
||||||
|
}
|
||||||
|
}, [open, resetState]);
|
||||||
|
|
||||||
|
const handlePick = useCallback(async (lat: number, lng: number) => {
|
||||||
|
setLatitude(lat);
|
||||||
|
setLongitude(lng);
|
||||||
|
setResolvingAddress(true);
|
||||||
|
try {
|
||||||
|
const parsed = await reverseGeocode(lat, lng);
|
||||||
|
if (parsed?.fullAddress) {
|
||||||
|
setAddress(parsed.fullAddress);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setResolvingAddress(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleUseCurrentLocation() {
|
||||||
|
if (!navigator.geolocation) return;
|
||||||
|
setLocating(true);
|
||||||
|
try {
|
||||||
|
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||||
|
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 12_000,
|
||||||
|
maximumAge: 30_000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await handlePick(position.coords.latitude, position.coords.longitude);
|
||||||
|
} catch {
|
||||||
|
// ошибка геолокации обрабатывается на уровне вызывающего через toast
|
||||||
|
throw new Error('Не удалось определить текущее местоположение');
|
||||||
|
} finally {
|
||||||
|
setLocating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (latitude == null || longitude == null) return;
|
||||||
|
await onSend({
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
address: address.trim() || undefined,
|
||||||
|
label: label.trim() || undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSend = latitude != null && longitude != null && !sending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-[min(92vw,480px)]">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-[#f4f5f8]">
|
||||||
|
<MapPin className="h-6 w-6 text-[#3390ec]" />
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-center">Отправить геопозицию</DialogTitle>
|
||||||
|
<p className="text-center text-sm text-[#6b6f7b]">
|
||||||
|
Выберите точку на карте или используйте текущее местоположение
|
||||||
|
</p>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={(event) => void handleSubmit(event)} className="space-y-3">
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-[#dce3ec]">
|
||||||
|
<AddressMapPicker latitude={latitude} longitude={longitude} onPick={(lat, lng) => void handlePick(lat, lng)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl"
|
||||||
|
disabled={locating || sending}
|
||||||
|
onClick={() => {
|
||||||
|
void handleUseCurrentLocation().catch((error) => {
|
||||||
|
onLocateError?.(error instanceof Error ? error.message : 'Не удалось определить текущее местоположение');
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <LocateFixed className="mr-2 h-4 w-4" />}
|
||||||
|
Моё местоположение
|
||||||
|
</Button>
|
||||||
|
{resolvingAddress ? (
|
||||||
|
<span className="flex items-center text-xs text-[#667085]">
|
||||||
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||||
|
Определяем адрес...
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
value={label}
|
||||||
|
onChange={(event) => setLabel(event.target.value)}
|
||||||
|
placeholder="Подпись (необязательно)"
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
disabled={sending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
value={address}
|
||||||
|
onChange={(event) => setAddress(event.target.value)}
|
||||||
|
placeholder="Адрес"
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
disabled={sending || resolvingAddress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" size="lg" className="w-full rounded-[18px]" disabled={!canSend}>
|
||||||
|
{sending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Отправляем...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
Отправить
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Loader2, Pause, Play } from 'lucide-react';
|
import { Loader2, Pause, Play } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const SIZE = 240;
|
||||||
|
const RING_WIDTH = 4;
|
||||||
|
const RADIUS = (SIZE - RING_WIDTH) / 2;
|
||||||
|
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||||
|
|
||||||
function formatDuration(seconds: number) {
|
function formatDuration(seconds: number) {
|
||||||
if (!Number.isFinite(seconds) || seconds <= 0) return '0:00';
|
if (!Number.isFinite(seconds) || seconds <= 0) return '0:00';
|
||||||
const whole = Math.floor(seconds);
|
const whole = Math.floor(seconds);
|
||||||
@@ -12,6 +17,16 @@ function formatDuration(seconds: number) {
|
|||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function progressFromPointer(clientX: number, clientY: number, rect: DOMRect) {
|
||||||
|
const cx = rect.left + rect.width / 2;
|
||||||
|
const cy = rect.top + rect.height / 2;
|
||||||
|
const dx = clientX - cx;
|
||||||
|
const dy = clientY - cy;
|
||||||
|
let angle = Math.atan2(dy, dx) + Math.PI / 2;
|
||||||
|
if (angle < 0) angle += Math.PI * 2;
|
||||||
|
return Math.min(Math.max(angle / (Math.PI * 2), 0), 1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ChatVideoNotePlayer({
|
export function ChatVideoNotePlayer({
|
||||||
src,
|
src,
|
||||||
durationMs,
|
durationMs,
|
||||||
@@ -21,11 +36,27 @@ export function ChatVideoNotePlayer({
|
|||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
variant?: 'mine' | 'theirs';
|
variant?: 'mine' | 'theirs';
|
||||||
}) {
|
}) {
|
||||||
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
const scrubbingRef = useRef(false);
|
||||||
|
const wasPlayingRef = useRef(false);
|
||||||
|
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [scrubbing, setScrubbing] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0);
|
const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0);
|
||||||
|
|
||||||
|
const ringColor = variant === 'mine' ? '#7ea06a' : '#3390ec';
|
||||||
|
|
||||||
|
const seekTo = useCallback((ratio: number) => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return;
|
||||||
|
const next = Math.min(Math.max(ratio, 0), 1);
|
||||||
|
video.currentTime = next * video.duration;
|
||||||
|
setProgress(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
@@ -36,26 +67,64 @@ export function ChatVideoNotePlayer({
|
|||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
if (scrubbingRef.current || !video.duration) return;
|
||||||
|
setProgress(video.currentTime / video.duration);
|
||||||
|
};
|
||||||
const onPlay = () => setPlaying(true);
|
const onPlay = () => setPlaying(true);
|
||||||
const onPause = () => setPlaying(false);
|
const onPause = () => setPlaying(false);
|
||||||
const onEnded = () => setPlaying(false);
|
const onEnded = () => {
|
||||||
|
setPlaying(false);
|
||||||
|
setProgress(0);
|
||||||
|
video.currentTime = 0;
|
||||||
|
};
|
||||||
|
|
||||||
video.addEventListener('loadedmetadata', onLoaded);
|
video.addEventListener('loadedmetadata', onLoaded);
|
||||||
|
video.addEventListener('timeupdate', onTimeUpdate);
|
||||||
video.addEventListener('play', onPlay);
|
video.addEventListener('play', onPlay);
|
||||||
video.addEventListener('pause', onPause);
|
video.addEventListener('pause', onPause);
|
||||||
video.addEventListener('ended', onEnded);
|
video.addEventListener('ended', onEnded);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
video.removeEventListener('loadedmetadata', onLoaded);
|
video.removeEventListener('loadedmetadata', onLoaded);
|
||||||
|
video.removeEventListener('timeupdate', onTimeUpdate);
|
||||||
video.removeEventListener('play', onPlay);
|
video.removeEventListener('play', onPlay);
|
||||||
video.removeEventListener('pause', onPause);
|
video.removeEventListener('pause', onPause);
|
||||||
video.removeEventListener('ended', onEnded);
|
video.removeEventListener('ended', onEnded);
|
||||||
};
|
};
|
||||||
}, [src]);
|
}, [src]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handlePointerMove(event: PointerEvent) {
|
||||||
|
if (!scrubbingRef.current || !rootRef.current) return;
|
||||||
|
const rect = rootRef.current.getBoundingClientRect();
|
||||||
|
seekTo(progressFromPointer(event.clientX, event.clientY, rect));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp() {
|
||||||
|
if (!scrubbingRef.current) return;
|
||||||
|
scrubbingRef.current = false;
|
||||||
|
setScrubbing(false);
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video && wasPlayingRef.current) {
|
||||||
|
void video.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('pointermove', handlePointerMove);
|
||||||
|
window.addEventListener('pointerup', handlePointerUp);
|
||||||
|
window.addEventListener('pointercancel', handlePointerUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', handlePointerMove);
|
||||||
|
window.removeEventListener('pointerup', handlePointerUp);
|
||||||
|
window.removeEventListener('pointercancel', handlePointerUp);
|
||||||
|
};
|
||||||
|
}, [seekTo]);
|
||||||
|
|
||||||
function togglePlayback() {
|
function togglePlayback() {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video || scrubbingRef.current) return;
|
||||||
if (video.paused) {
|
if (video.paused) {
|
||||||
void video.play();
|
void video.play();
|
||||||
} else {
|
} else {
|
||||||
@@ -63,15 +132,36 @@ export function ChatVideoNotePlayer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleScrubStart(event: React.PointerEvent<HTMLDivElement>) {
|
||||||
|
if (!rootRef.current) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
scrubbingRef.current = true;
|
||||||
|
setScrubbing(true);
|
||||||
|
const video = videoRef.current;
|
||||||
|
wasPlayingRef.current = Boolean(video && !video.paused);
|
||||||
|
video?.pause();
|
||||||
|
const rect = rootRef.current.getBoundingClientRect();
|
||||||
|
seekTo(progressFromPointer(event.clientX, event.clientY, rect));
|
||||||
|
}
|
||||||
|
|
||||||
|
const showControls = !playing || scrubbing || loading;
|
||||||
|
const remaining = duration * (1 - progress);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
type="button"
|
ref={rootRef}
|
||||||
onClick={togglePlayback}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative h-[240px] w-[240px] overflow-hidden rounded-full shadow-md transition hover:shadow-lg',
|
'group relative h-[240px] w-[240px] select-none',
|
||||||
|
variant === 'mine' ? 'text-[#7ea06a]' : 'text-[#3390ec]'
|
||||||
|
)}
|
||||||
|
data-chat-interactive="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative h-full w-full overflow-hidden rounded-full shadow-md transition hover:shadow-lg',
|
||||||
variant === 'mine' ? 'ring-2 ring-[#7ea06a]/40' : 'ring-2 ring-[#dce3ec]'
|
variant === 'mine' ? 'ring-2 ring-[#7ea06a]/40' : 'ring-2 ring-[#dce3ec]'
|
||||||
)}
|
)}
|
||||||
aria-label={playing ? 'Пауза' : 'Воспроизвести кружок'}
|
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -80,18 +170,65 @@ export function ChatVideoNotePlayer({
|
|||||||
playsInline
|
playsInline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
/>
|
/>
|
||||||
<span className="absolute inset-0 flex items-center justify-center bg-black/20 transition group-hover:bg-black/30">
|
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none absolute inset-0"
|
||||||
|
width={SIZE}
|
||||||
|
height={SIZE}
|
||||||
|
viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx={SIZE / 2}
|
||||||
|
cy={SIZE / 2}
|
||||||
|
r={RADIUS}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(255,255,255,0.28)"
|
||||||
|
strokeWidth={RING_WIDTH}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={SIZE / 2}
|
||||||
|
cy={SIZE / 2}
|
||||||
|
r={RADIUS}
|
||||||
|
fill="none"
|
||||||
|
stroke={ringColor}
|
||||||
|
strokeWidth={RING_WIDTH}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={CIRCUMFERENCE}
|
||||||
|
strokeDashoffset={CIRCUMFERENCE * (1 - progress)}
|
||||||
|
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
|
||||||
|
className="transition-[stroke-dashoffset] duration-75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-full"
|
||||||
|
onPointerDown={handleScrubStart}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={togglePlayback}
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 flex items-center justify-center rounded-full transition',
|
||||||
|
showControls ? 'bg-black/25 opacity-100' : 'bg-transparent opacity-0 group-hover:bg-black/20 group-hover:opacity-100'
|
||||||
|
)}
|
||||||
|
aria-label={playing ? 'Пауза' : 'Воспроизвести кружок'}
|
||||||
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader2 className="h-10 w-10 animate-spin text-white" />
|
<Loader2 className="h-10 w-10 animate-spin text-white drop-shadow" />
|
||||||
) : playing ? (
|
) : playing && !scrubbing ? (
|
||||||
<Pause className="h-10 w-10 text-white drop-shadow" />
|
<Pause className="h-10 w-10 text-white drop-shadow" />
|
||||||
) : (
|
) : (
|
||||||
<Play 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>
|
</button>
|
||||||
|
|
||||||
|
<span className="pointer-events-none 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(scrubbing || playing || progress > 0 ? remaining : duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ImageIcon,
|
ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
MapPin,
|
||||||
Mic,
|
Mic,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Pin,
|
Pin,
|
||||||
@@ -60,6 +61,8 @@ 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 { ChatLocationCard } from '@/components/chat/chat-location-card';
|
||||||
|
import { ChatLocationShareDialog, type ChatLocationSharePayload } from '@/components/chat/chat-location-share-dialog';
|
||||||
import { ChatVideoCaptureDialog, type ChatVideoCaptureMode } from '@/components/chat/chat-video-capture-dialog';
|
import { ChatVideoCaptureDialog, type ChatVideoCaptureMode } from '@/components/chat/chat-video-capture-dialog';
|
||||||
import { ChatVideoNotePlayer } from '@/components/chat/chat-video-note-player';
|
import { ChatVideoNotePlayer } from '@/components/chat/chat-video-note-player';
|
||||||
import { ChatVideoPlayer } from '@/components/chat/chat-video-player';
|
import { ChatVideoPlayer } from '@/components/chat/chat-video-player';
|
||||||
@@ -145,6 +148,7 @@ import {
|
|||||||
detectChatMessageType,
|
detectChatMessageType,
|
||||||
fileIconLabel,
|
fileIconLabel,
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
|
parseLocationMetadata,
|
||||||
parseMessageMetadata,
|
parseMessageMetadata,
|
||||||
resolveChatContentType
|
resolveChatContentType
|
||||||
} from '@/lib/chat-media';
|
} from '@/lib/chat-media';
|
||||||
@@ -320,6 +324,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
const [videoCaptureOpen, setVideoCaptureOpen] = useState(false);
|
const [videoCaptureOpen, setVideoCaptureOpen] = useState(false);
|
||||||
const [videoCaptureMode, setVideoCaptureMode] = useState<ChatVideoCaptureMode>('note');
|
const [videoCaptureMode, setVideoCaptureMode] = useState<ChatVideoCaptureMode>('note');
|
||||||
|
const [locationShareOpen, setLocationShareOpen] = useState(false);
|
||||||
|
|
||||||
const mediaComposer = useChatMediaComposer();
|
const mediaComposer = useChatMediaComposer();
|
||||||
|
|
||||||
@@ -1344,6 +1349,57 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendLocation(payload: ChatLocationSharePayload) {
|
||||||
|
if (!token || !activeRoomId || !activeRoom || sending) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const content =
|
||||||
|
payload.label?.trim() ||
|
||||||
|
payload.address?.trim() ||
|
||||||
|
`${payload.latitude.toFixed(6)}, ${payload.longitude.toFixed(6)}`;
|
||||||
|
const metadataJson = JSON.stringify({
|
||||||
|
latitude: payload.latitude,
|
||||||
|
longitude: payload.longitude,
|
||||||
|
address: payload.address,
|
||||||
|
label: payload.label
|
||||||
|
});
|
||||||
|
|
||||||
|
let messageContent = content;
|
||||||
|
let isEncrypted = false;
|
||||||
|
if (activeRoomIsE2E) {
|
||||||
|
messageContent = await encryptOutgoing({
|
||||||
|
kind: 'location',
|
||||||
|
text: content,
|
||||||
|
latitude: payload.latitude,
|
||||||
|
longitude: payload.longitude,
|
||||||
|
address: payload.address,
|
||||||
|
label: payload.label
|
||||||
|
});
|
||||||
|
isEncrypted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await sendChatMessage(
|
||||||
|
activeRoomId,
|
||||||
|
{
|
||||||
|
type: 'LOCATION',
|
||||||
|
content: messageContent,
|
||||||
|
isEncrypted,
|
||||||
|
metadataJson: activeRoomIsE2E ? undefined : metadataJson,
|
||||||
|
replyToId: replyToMessage?.id
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
appendMessage(message);
|
||||||
|
setLocationShareOpen(false);
|
||||||
|
setReplyToMessage(null);
|
||||||
|
void loadGroup();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(getApiErrorMessage(error, 'Не удалось отправить геопозицию') ?? 'Ошибка');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadChatFile(file: File, options?: { voice?: boolean; videoNote?: 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;
|
||||||
const contentType = resolveChatContentType(file);
|
const contentType = resolveChatContentType(file);
|
||||||
@@ -2051,6 +2107,22 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
) : message.type === 'LOCATION' ? (
|
||||||
|
(() => {
|
||||||
|
const locationMeta = parseLocationMetadata(message.metadataJson, e2ePayload);
|
||||||
|
if (!locationMeta || locationMeta.latitude == null || locationMeta.longitude == null) {
|
||||||
|
return <p className="text-sm text-[#667085]">Геопозиция недоступна</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ChatLocationCard
|
||||||
|
latitude={locationMeta.latitude}
|
||||||
|
longitude={locationMeta.longitude}
|
||||||
|
address={locationMeta.address}
|
||||||
|
label={locationMeta.label}
|
||||||
|
variant={mine ? 'mine' : 'theirs'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()
|
||||||
) : message.storageKey ? (
|
) : message.storageKey ? (
|
||||||
(() => {
|
(() => {
|
||||||
if (isAlbumGroup) {
|
if (isAlbumGroup) {
|
||||||
@@ -2337,6 +2409,18 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
<Button size="sm" variant="ghost" className="h-10 w-10 rounded-xl p-0" onClick={() => fileInputRef.current?.click()}>
|
<Button size="sm" variant="ghost" className="h-10 w-10 rounded-xl p-0" onClick={() => fileInputRef.current?.click()}>
|
||||||
<Paperclip className="h-4 w-4" />
|
<Paperclip className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{!activeRoomIsBot ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-10 w-10 rounded-xl p-0"
|
||||||
|
onClick={() => setLocationShareOpen(true)}
|
||||||
|
title="Отправить геопозицию"
|
||||||
|
aria-label="Отправить геопозицию"
|
||||||
|
>
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{!activeRoomIsE2E ? (
|
{!activeRoomIsE2E ? (
|
||||||
<Button size="sm" variant="ghost" className="hidden h-10 w-10 rounded-xl p-0 sm:flex" onClick={() => setPollOpen(true)}>
|
<Button size="sm" variant="ghost" className="hidden h-10 w-10 rounded-xl p-0 sm:flex" onClick={() => setPollOpen(true)}>
|
||||||
<BarChart3 className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
@@ -2465,6 +2549,14 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ChatLocationShareDialog
|
||||||
|
open={locationShareOpen}
|
||||||
|
sending={sending}
|
||||||
|
onOpenChange={setLocationShareOpen}
|
||||||
|
onSend={sendLocation}
|
||||||
|
onLocateError={(message) => showToast(message)}
|
||||||
|
/>
|
||||||
|
|
||||||
<ChatMediaComposeDialog
|
<ChatMediaComposeDialog
|
||||||
open={mediaComposer.open}
|
open={mediaComposer.open}
|
||||||
items={mediaComposer.items}
|
items={mediaComposer.items}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function PinLockModal({ open, isSubmitting, error, onSubmit, onLogout }:
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={() => undefined}>
|
<Dialog open={open} onOpenChange={() => undefined}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="relative [&>button]:hidden"
|
className="relative !top-[max(1.5rem,10vh)] !-translate-y-0 [&>button]:hidden"
|
||||||
onPointerDownOutside={(event) => event.preventDefault()}
|
onPointerDownOutside={(event) => event.preventDefault()}
|
||||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -104,15 +104,57 @@ export function formatFileSize(bytes?: number) {
|
|||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChatLocationMetadata {
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
address?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseMessageMetadata(metadataJson?: string) {
|
export function parseMessageMetadata(metadataJson?: string) {
|
||||||
if (!metadataJson) return {} as { fileName?: string; fileSize?: number; durationMs?: number; videoNote?: boolean };
|
if (!metadataJson) {
|
||||||
|
return {} as { fileName?: string; fileSize?: number; durationMs?: number; videoNote?: boolean } & ChatLocationMetadata;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(metadataJson) as { fileName?: string; fileSize?: number; durationMs?: number; videoNote?: boolean };
|
return JSON.parse(metadataJson) as {
|
||||||
|
fileName?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
videoNote?: boolean;
|
||||||
|
} & ChatLocationMetadata;
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseLocationMetadata(
|
||||||
|
metadataJson?: string,
|
||||||
|
e2ePayload?: { kind?: string; latitude?: number; longitude?: number; address?: string; label?: string; text?: string } | null
|
||||||
|
): ChatLocationMetadata | null {
|
||||||
|
if (e2ePayload?.kind === 'location') {
|
||||||
|
const { latitude, longitude } = e2ePayload;
|
||||||
|
if (typeof latitude === 'number' && typeof longitude === 'number') {
|
||||||
|
return {
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
address: e2ePayload.address,
|
||||||
|
label: e2ePayload.label ?? e2ePayload.text
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const meta = parseMessageMetadata(metadataJson);
|
||||||
|
if (typeof meta.latitude === 'number' && typeof meta.longitude === 'number') {
|
||||||
|
return {
|
||||||
|
latitude: meta.latitude,
|
||||||
|
longitude: meta.longitude,
|
||||||
|
address: meta.address,
|
||||||
|
label: meta.label
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function fileIconLabel(fileName?: string, mimeType?: string) {
|
export function fileIconLabel(fileName?: string, mimeType?: string) {
|
||||||
const extension = extractFileExtension(fileName);
|
const extension = extractFileExtension(fileName);
|
||||||
if (extension === 'pdf' || mimeType === 'application/pdf') return 'PDF';
|
if (extension === 'pdf' || mimeType === 'application/pdf') return 'PDF';
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function getMessagePreviewText(message: ChatMessage, visibleText?: string
|
|||||||
if (message.type === 'IMAGE') return '📷 Фото';
|
if (message.type === 'IMAGE') return '📷 Фото';
|
||||||
if (message.type === 'VIDEO') return '🎬 Видео';
|
if (message.type === 'VIDEO') return '🎬 Видео';
|
||||||
if (message.type === 'VIDEO_NOTE') return '⭕ Кружок';
|
if (message.type === 'VIDEO_NOTE') return '⭕ Кружок';
|
||||||
|
if (message.type === 'LOCATION') 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' | 'video' | 'video_note' | 'file';
|
export type E2EMessageKind = 'text' | 'emoji' | 'image' | 'voice' | 'audio' | 'video' | 'video_note' | 'file' | 'location';
|
||||||
|
|
||||||
export interface E2EMessagePayload {
|
export interface E2EMessagePayload {
|
||||||
kind: E2EMessageKind;
|
kind: E2EMessageKind;
|
||||||
@@ -10,6 +10,10 @@ export interface E2EMessagePayload {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
address?: string;
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function encryptE2EPayload(userId: string, peerPublicKey: string, payload: E2EMessagePayload) {
|
export async function encryptE2EPayload(userId: string, peerPublicKey: string, payload: E2EMessagePayload) {
|
||||||
@@ -50,6 +54,8 @@ export function e2eKindFromMessageType(type: string): E2EMessageKind {
|
|||||||
return 'video_note';
|
return 'video_note';
|
||||||
case 'FILE':
|
case 'FILE':
|
||||||
return 'file';
|
return 'file';
|
||||||
|
case 'LOCATION':
|
||||||
|
return 'location';
|
||||||
default:
|
default:
|
||||||
return 'text';
|
return 'text';
|
||||||
}
|
}
|
||||||
@@ -74,6 +80,8 @@ export function readableE2EPayload(payload: E2EMessagePayload | null) {
|
|||||||
return 'Кружок';
|
return 'Кружок';
|
||||||
case 'file':
|
case 'file':
|
||||||
return payload.fileName ?? 'Файл';
|
return payload.fileName ?? 'Файл';
|
||||||
|
case 'location':
|
||||||
|
return payload.label?.trim() || payload.address?.trim() || payload.text?.trim() || 'Геопозиция';
|
||||||
default:
|
default:
|
||||||
return '🔒 Зашифрованное сообщение';
|
return '🔒 Зашифрованное сообщение';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ export class AdminUserInsightsService {
|
|||||||
if (type === 'AUDIO' || type === 'VOICE') return '[Аудио]';
|
if (type === 'AUDIO' || type === 'VOICE') return '[Аудио]';
|
||||||
if (type === 'VIDEO') return '[Видео]';
|
if (type === 'VIDEO') return '[Видео]';
|
||||||
if (type === 'VIDEO_NOTE') return '[Кружок]';
|
if (type === 'VIDEO_NOTE') return '[Кружок]';
|
||||||
|
if (type === 'LOCATION') 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,11 +468,13 @@ 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', 'VIDEO', 'VIDEO_NOTE', 'FILE', 'EMOJI', 'POLL']);
|
const allowedTypes = new Set(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'VIDEO', 'VIDEO_NOTE', 'FILE', 'EMOJI', 'POLL', 'LOCATION']);
|
||||||
if (!allowedTypes.has(type)) {
|
if (!allowedTypes.has(type)) {
|
||||||
throw new BadRequestException('Неподдерживаемый тип сообщения');
|
throw new BadRequestException('Неподдерживаемый тип сообщения');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const metadata = metadataJson ? JSON.parse(metadataJson) : undefined;
|
||||||
|
|
||||||
if (type === 'POLL') {
|
if (type === 'POLL') {
|
||||||
if (!poll?.question?.trim() || !poll.options?.length) {
|
if (!poll?.question?.trim() || !poll.options?.length) {
|
||||||
throw new BadRequestException('Укажите вопрос и варианты опроса');
|
throw new BadRequestException('Укажите вопрос и варианты опроса');
|
||||||
@@ -480,6 +482,25 @@ export class ChatService {
|
|||||||
if (poll.options.length < 2) {
|
if (poll.options.length < 2) {
|
||||||
throw new BadRequestException('В опросе должно быть минимум 2 варианта');
|
throw new BadRequestException('В опросе должно быть минимум 2 варианта');
|
||||||
}
|
}
|
||||||
|
} else if (type === 'LOCATION') {
|
||||||
|
if (!isEncrypted) {
|
||||||
|
const lat = metadata?.latitude;
|
||||||
|
const lng = metadata?.longitude;
|
||||||
|
if (
|
||||||
|
typeof lat !== 'number' ||
|
||||||
|
typeof lng !== 'number' ||
|
||||||
|
!Number.isFinite(lat) ||
|
||||||
|
!Number.isFinite(lng) ||
|
||||||
|
lat < -90 ||
|
||||||
|
lat > 90 ||
|
||||||
|
lng < -180 ||
|
||||||
|
lng > 180
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Укажите корректные координаты для геолокации');
|
||||||
|
}
|
||||||
|
} else if (!content?.trim()) {
|
||||||
|
throw new BadRequestException('Зашифрованная геолокация не может быть пустой');
|
||||||
|
}
|
||||||
} else if (type !== 'IMAGE' && type !== 'AUDIO' && type !== 'VOICE' && type !== 'VIDEO' && type !== 'VIDEO_NOTE' && 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('Сообщение не может быть пустым');
|
||||||
@@ -494,8 +515,6 @@ export class ChatService {
|
|||||||
throw new BadRequestException('Некорректный ключ медиафайла');
|
throw new BadRequestException('Некорректный ключ медиафайла');
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = metadataJson ? JSON.parse(metadataJson) : undefined;
|
|
||||||
|
|
||||||
const message = await this.prisma.$transaction(async (tx) => {
|
const message = await this.prisma.$transaction(async (tx) => {
|
||||||
const created = await tx.chatMessage.create({
|
const created = await tx.chatMessage.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -742,14 +761,23 @@ export class ChatService {
|
|||||||
let forwardStorageKey = source.storageKey ?? undefined;
|
let forwardStorageKey = source.storageKey ?? undefined;
|
||||||
let forwardMimeType = source.mimeType ?? undefined;
|
let forwardMimeType = source.mimeType ?? undefined;
|
||||||
|
|
||||||
if (!['TEXT', 'EMOJI'].includes(source.type)) {
|
if (!['TEXT', 'EMOJI', 'LOCATION'].includes(source.type)) {
|
||||||
forwardType = 'TEXT';
|
forwardType = 'TEXT';
|
||||||
forwardContent = this.buildForwardPreview(source.type, source.content);
|
forwardContent = this.buildForwardPreview(source.type, source.content);
|
||||||
forwardStorageKey = undefined;
|
forwardStorageKey = undefined;
|
||||||
forwardMimeType = undefined;
|
forwardMimeType = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceMetadata = (source.metadata as Record<string, unknown> | null) ?? {};
|
||||||
const metadata = {
|
const metadata = {
|
||||||
|
...(source.type === 'LOCATION'
|
||||||
|
? {
|
||||||
|
latitude: sourceMetadata.latitude,
|
||||||
|
longitude: sourceMetadata.longitude,
|
||||||
|
address: sourceMetadata.address,
|
||||||
|
label: sourceMetadata.label
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
forwardedFrom: {
|
forwardedFrom: {
|
||||||
messageId: source.id,
|
messageId: source.id,
|
||||||
roomId: source.roomId,
|
roomId: source.roomId,
|
||||||
@@ -806,6 +834,8 @@ export class ChatService {
|
|||||||
return content ? `📄 Пересланный файл: ${content}` : '📄 Пересланный файл';
|
return content ? `📄 Пересланный файл: ${content}` : '📄 Пересланный файл';
|
||||||
case 'POLL':
|
case 'POLL':
|
||||||
return '📊 Пересланный опрос';
|
return '📊 Пересланный опрос';
|
||||||
|
case 'LOCATION':
|
||||||
|
return content?.trim() ? `📍 Пересланная геопозиция: ${content}` : '📍 Пересланная геопозиция';
|
||||||
default:
|
default:
|
||||||
return '↪️ Пересланное сообщение';
|
return '↪️ Пересланное сообщение';
|
||||||
}
|
}
|
||||||
@@ -1052,6 +1082,8 @@ export class ChatService {
|
|||||||
? '🎬 Видео'
|
? '🎬 Видео'
|
||||||
: message.type === 'VIDEO_NOTE'
|
: message.type === 'VIDEO_NOTE'
|
||||||
? '⭕ Кружок'
|
? '⭕ Кружок'
|
||||||
|
: message.type === 'LOCATION'
|
||||||
|
? '📍 Геопозиция'
|
||||||
: message.type === 'FILE'
|
: message.type === 'FILE'
|
||||||
? '📄 Файл'
|
? '📄 Файл'
|
||||||
: message.type === 'IMAGE'
|
: message.type === 'IMAGE'
|
||||||
|
|||||||
Reference in New Issue
Block a user