From a4b4577c55ea443c0ebf73fabba876d7d63fcb79 Mon Sep 17 00:00:00 2001 From: lendry Date: Fri, 10 Jul 2026 10:37:22 +0300 Subject: [PATCH] fix --- apps/api-gateway/src/dto/chat.dto.ts | 2 +- apps/frontend/app/auth/login/page.tsx | 22 +- apps/frontend/app/auth/register/page.tsx | 21 +- .../components/chat/chat-location-card.tsx | 103 ++++++++++ .../chat/chat-location-share-dialog.tsx | 174 ++++++++++++++++ .../chat/chat-video-note-player.tsx | 193 +++++++++++++++--- .../components/family/family-group-view.tsx | 92 +++++++++ .../frontend/components/id/pin-lock-modal.tsx | 2 +- apps/frontend/lib/chat-media.ts | 46 ++++- apps/frontend/lib/chat-message-utils.ts | 1 + apps/frontend/lib/e2e-chat.ts | 10 +- .../src/domain/admin-user-insights.service.ts | 1 + apps/sso-core/src/domain/chat.service.ts | 50 ++++- 13 files changed, 669 insertions(+), 48 deletions(-) create mode 100644 apps/frontend/components/chat/chat-location-card.tsx create mode 100644 apps/frontend/components/chat/chat-location-share-dialog.tsx diff --git a/apps/api-gateway/src/dto/chat.dto.ts b/apps/api-gateway/src/dto/chat.dto.ts index fa3242a..dcdc4bf 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', 'VIDEO', 'VIDEO_NOTE', 'FILE', 'EMOJI', 'POLL']) + @IsIn(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'VIDEO', 'VIDEO_NOTE', 'FILE', 'EMOJI', 'POLL', 'LOCATION']) type!: string; @IsOptional() diff --git a/apps/frontend/app/auth/login/page.tsx b/apps/frontend/app/auth/login/page.tsx index c01ea2c..d5ec795 100644 --- a/apps/frontend/app/auth/login/page.tsx +++ b/apps/frontend/app/auth/login/page.tsx @@ -8,7 +8,7 @@ import { FormEvent, Suspense, useCallback, useEffect, useState } from 'react'; 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'; @@ -116,7 +116,7 @@ function LoginPageContent() { router.replace('/'); }, [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(); @@ -1473,7 +1473,23 @@ function LoginPageContent() { {step === 'pin' && pendingSessionId ? ( -
+ + + ('email'); const [email, setEmail] = useState(''); @@ -166,7 +166,22 @@ export default function RegisterPage() { ) : null} {step === 'pin' && pendingSessionId ? ( - + +

Установите PIN для завершения регистрации

(null); + const mapInstance = useRef(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 ( + +
+
+ +
+

{title}

+ {address && label ?

{address}

: null} +
+ +
+
+ ); +} diff --git a/apps/frontend/components/chat/chat-location-share-dialog.tsx b/apps/frontend/components/chat/chat-location-share-dialog.tsx new file mode 100644 index 0000000..ab60e89 --- /dev/null +++ b/apps/frontend/components/chat/chat-location-share-dialog.tsx @@ -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; + onLocateError?: (message: string) => void; +}) { + const [latitude, setLatitude] = useState(); + const [longitude, setLongitude] = useState(); + 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((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) { + 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 ( + + + +
+ +
+ Отправить геопозицию +

+ Выберите точку на карте или используйте текущее местоположение +

+
+ + void handleSubmit(event)} className="space-y-3"> +
+ void handlePick(lat, lng)} /> +
+ +
+ + {resolvingAddress ? ( + + + Определяем адрес... + + ) : null} +
+ + setLabel(event.target.value)} + placeholder="Подпись (необязательно)" + className="h-11 rounded-xl" + disabled={sending} + /> + + setAddress(event.target.value)} + placeholder="Адрес" + className="h-11 rounded-xl" + disabled={sending || resolvingAddress} + /> + + + +
+
+ ); +} diff --git a/apps/frontend/components/chat/chat-video-note-player.tsx b/apps/frontend/components/chat/chat-video-note-player.tsx index 9918865..2158f5b 100644 --- a/apps/frontend/components/chat/chat-video-note-player.tsx +++ b/apps/frontend/components/chat/chat-video-note-player.tsx @@ -1,9 +1,14 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Loader2, Pause, Play } from 'lucide-react'; 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) { if (!Number.isFinite(seconds) || seconds <= 0) return '0:00'; const whole = Math.floor(seconds); @@ -12,6 +17,16 @@ function formatDuration(seconds: number) { 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({ src, durationMs, @@ -21,11 +36,27 @@ export function ChatVideoNotePlayer({ durationMs?: number; variant?: 'mine' | 'theirs'; }) { + const rootRef = useRef(null); const videoRef = useRef(null); + const scrubbingRef = useRef(false); + const wasPlayingRef = useRef(false); + const [playing, setPlaying] = useState(false); const [loading, setLoading] = useState(true); + const [scrubbing, setScrubbing] = useState(false); + const [progress, setProgress] = useState(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(() => { const video = videoRef.current; if (!video) return; @@ -36,26 +67,64 @@ export function ChatVideoNotePlayer({ } setLoading(false); }; + const onTimeUpdate = () => { + if (scrubbingRef.current || !video.duration) return; + setProgress(video.currentTime / video.duration); + }; const onPlay = () => setPlaying(true); const onPause = () => setPlaying(false); - const onEnded = () => setPlaying(false); + const onEnded = () => { + setPlaying(false); + setProgress(0); + video.currentTime = 0; + }; video.addEventListener('loadedmetadata', onLoaded); + video.addEventListener('timeupdate', onTimeUpdate); video.addEventListener('play', onPlay); video.addEventListener('pause', onPause); video.addEventListener('ended', onEnded); return () => { video.removeEventListener('loadedmetadata', onLoaded); + video.removeEventListener('timeupdate', onTimeUpdate); video.removeEventListener('play', onPlay); video.removeEventListener('pause', onPause); video.removeEventListener('ended', onEnded); }; }, [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() { const video = videoRef.current; - if (!video) return; + if (!video || scrubbingRef.current) return; if (video.paused) { void video.play(); } else { @@ -63,35 +132,103 @@ export function ChatVideoNotePlayer({ } } + function handleScrubStart(event: React.PointerEvent) { + 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 ( - + > +
); } diff --git a/apps/frontend/components/family/family-group-view.tsx b/apps/frontend/components/family/family-group-view.tsx index b3bfae4..ea64f18 100644 --- a/apps/frontend/components/family/family-group-view.tsx +++ b/apps/frontend/components/family/family-group-view.tsx @@ -15,6 +15,7 @@ import { ImageIcon, Loader2, LogOut, + MapPin, Mic, Paperclip, Pin, @@ -60,6 +61,8 @@ 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 { 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 { ChatVideoNotePlayer } from '@/components/chat/chat-video-note-player'; import { ChatVideoPlayer } from '@/components/chat/chat-video-player'; @@ -145,6 +148,7 @@ import { detectChatMessageType, fileIconLabel, formatFileSize, + parseLocationMetadata, parseMessageMetadata, resolveChatContentType } from '@/lib/chat-media'; @@ -320,6 +324,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { const [lightboxIndex, setLightboxIndex] = useState(null); const [videoCaptureOpen, setVideoCaptureOpen] = useState(false); const [videoCaptureMode, setVideoCaptureMode] = useState('note'); + const [locationShareOpen, setLocationShareOpen] = useState(false); 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 }) { if (!token || !activeRoomId || !activeRoom) return; const contentType = resolveChatContentType(file); @@ -2051,6 +2107,22 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { ); })} + ) : message.type === 'LOCATION' ? ( + (() => { + const locationMeta = parseLocationMetadata(message.metadataJson, e2ePayload); + if (!locationMeta || locationMeta.latitude == null || locationMeta.longitude == null) { + return

Геопозиция недоступна

; + } + return ( + + ); + })() ) : message.storageKey ? ( (() => { if (isAlbumGroup) { @@ -2337,6 +2409,18 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { + {!activeRoomIsBot ? ( + + ) : null} {!activeRoomIsE2E ? (