diff --git a/apps/api-gateway/src/controllers/chat.controller.ts b/apps/api-gateway/src/controllers/chat.controller.ts index 94018db..14fea7c 100644 --- a/apps/api-gateway/src/controllers/chat.controller.ts +++ b/apps/api-gateway/src/controllers/chat.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Query } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { firstValueFrom } from 'rxjs'; @@ -6,7 +6,10 @@ import { map } from 'rxjs/operators'; import { CoreGrpcService } from '../core-grpc.service'; import { getAuthorizedUserId } from '../document-access'; import { + AddChatRoomMemberDto, CreateChatRoomDto, + EditChatMessageDto, + MarkRoomReadDto, SendChatMessageDto, SetRoomNotificationsMutedDto, UpdateChatRoomDto, @@ -71,6 +74,28 @@ export class ChatController { ); } + @Post('rooms/:roomId/members') + @ApiOperation({ summary: 'Добавить участника в чат' }) + async addMember( + @Headers('authorization') authorization: string | undefined, + @Param('roomId') roomId: string, + @Body() dto: AddChatRoomMemberDto + ) { + const userId = await this.auth(authorization); + return firstValueFrom(this.core.chat.AddRoomMember({ userId, roomId, memberUserId: dto.memberUserId })); + } + + @Delete('rooms/:roomId/members/:memberUserId') + @ApiOperation({ summary: 'Удалить участника из чата' }) + async removeMember( + @Headers('authorization') authorization: string | undefined, + @Param('roomId') roomId: string, + @Param('memberUserId') memberUserId: string + ) { + const userId = await this.auth(authorization); + return firstValueFrom(this.core.chat.RemoveRoomMember({ userId, roomId, memberUserId })); + } + @Get('rooms/:roomId/messages') @ApiOperation({ summary: 'Сообщения чата' }) async listMessages( @@ -139,4 +164,35 @@ export class ChatController { const userId = await this.auth(authorization); return firstValueFrom(this.core.chat.SetRoomNotificationsMuted({ userId, roomId, muted: dto.muted })); } + + @Patch('messages/:messageId') + @ApiOperation({ summary: 'Редактировать сообщение' }) + async editMessage( + @Headers('authorization') authorization: string | undefined, + @Param('messageId') messageId: string, + @Body() dto: EditChatMessageDto + ) { + const userId = await this.auth(authorization); + return firstValueFrom(this.core.chat.EditMessage({ userId, messageId, content: dto.content })); + } + + @Delete('messages/:messageId') + @ApiOperation({ summary: 'Удалить сообщение' }) + async deleteMessage(@Headers('authorization') authorization: string | undefined, @Param('messageId') messageId: string) { + const userId = await this.auth(authorization); + return firstValueFrom(this.core.chat.DeleteMessage({ userId, messageId })); + } + + @Post('rooms/:roomId/read') + @ApiOperation({ summary: 'Отметить сообщения чата прочитанными' }) + async markRoomRead( + @Headers('authorization') authorization: string | undefined, + @Param('roomId') roomId: string, + @Body() dto: MarkRoomReadDto + ) { + const userId = await this.auth(authorization); + return firstValueFrom( + this.core.chat.MarkRoomRead({ userId, roomId, lastMessageId: dto.lastMessageId }) + ); + } } diff --git a/apps/api-gateway/src/controllers/family.controller.ts b/apps/api-gateway/src/controllers/family.controller.ts index cb5814b..760093e 100644 --- a/apps/api-gateway/src/controllers/family.controller.ts +++ b/apps/api-gateway/src/controllers/family.controller.ts @@ -144,5 +144,12 @@ export class FamilyController { const userId = await this.auth(authorization); return firstValueFrom(this.core.family.RespondFamilyInvite({ userId, inviteId, accept: dto.accept })); } + + @Get('groups/:groupId/presence') + @ApiOperation({ summary: 'Онлайн-статус участников семьи' }) + async getPresence(@Headers('authorization') authorization: string | undefined, @Param('groupId') groupId: string) { + const requesterId = await this.auth(authorization); + return firstValueFrom(this.core.family.GetFamilyPresence({ requesterId, groupId })); + } } diff --git a/apps/api-gateway/src/controllers/media.controller.ts b/apps/api-gateway/src/controllers/media.controller.ts index ce486c5..1a31d05 100644 --- a/apps/api-gateway/src/controllers/media.controller.ts +++ b/apps/api-gateway/src/controllers/media.controller.ts @@ -256,4 +256,37 @@ export class MediaController { this.core.media.GetChatMediaAccessUrl({ requesterId, roomId, storageKey, fileName }) ); } + + @Post('chat/:roomId/avatar/upload-url') + @ApiBearerAuth() + async createChatRoomAvatarUploadUrl( + @Headers('authorization') authorization: string | undefined, + @Param('roomId') roomId: string, + @Body() dto: AvatarUploadDto + ) { + const requesterId = await this.authUserId(authorization); + return firstValueFrom( + this.core.media.CreateChatRoomAvatarUploadUrl({ requesterId, roomId, contentType: dto.contentType }) + ); + } + + @Post('chat/:roomId/avatar/confirm') + @ApiBearerAuth() + async confirmChatRoomAvatar( + @Headers('authorization') authorization: string | undefined, + @Param('roomId') roomId: string, + @Body() dto: ConfirmAvatarDto + ) { + const requesterId = await this.authUserId(authorization); + return firstValueFrom( + this.core.media.ConfirmChatRoomAvatar({ requesterId, roomId, storageKey: dto.storageKey }) + ); + } + + @Get('chat/:roomId/avatar/url') + @ApiBearerAuth() + async getChatRoomAvatarUrl(@Headers('authorization') authorization: string | undefined, @Param('roomId') roomId: string) { + const requesterId = await this.authUserId(authorization); + return firstValueFrom(this.core.media.GetChatRoomAvatarAccessUrl({ requesterId, roomId })); + } } diff --git a/apps/api-gateway/src/dto/chat.dto.ts b/apps/api-gateway/src/dto/chat.dto.ts index 923483a..9ba4441 100644 --- a/apps/api-gateway/src/dto/chat.dto.ts +++ b/apps/api-gateway/src/dto/chat.dto.ts @@ -66,3 +66,20 @@ export class SetRoomNotificationsMutedDto { @IsBoolean() muted!: boolean; } + +export class AddChatRoomMemberDto { + @IsString() + memberUserId!: string; +} + +export class EditChatMessageDto { + @IsString() + @MinLength(1) + content!: string; +} + +export class MarkRoomReadDto { + @IsOptional() + @IsString() + lastMessageId?: string; +} diff --git a/apps/frontend/components/chat/voice-message-player.tsx b/apps/frontend/components/chat/voice-message-player.tsx new file mode 100644 index 0000000..20e4b25 --- /dev/null +++ b/apps/frontend/components/chat/voice-message-player.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Loader2, Pause, Play } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +function seedWaveform(seed: string, count = 36) { + let hash = 0; + for (let index = 0; index < seed.length; index += 1) { + hash = (hash * 31 + seed.charCodeAt(index)) | 0; + } + return Array.from({ length: count }, (_, index) => { + const wave = Math.abs(Math.sin((hash + 1) * (index + 1) * 0.17)); + return 0.25 + wave * 0.75; + }); +} + +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 VoiceMessagePlayer({ + src, + seed, + durationMs, + variant = 'theirs' +}: { + src?: string; + seed: string; + durationMs?: number; + variant?: 'mine' | 'theirs'; +}) { + const audioRef = useRef(null); + const [playing, setPlaying] = useState(false); + const [loading, setLoading] = useState(false); + const [progress, setProgress] = useState(0); + const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0); + const bars = useMemo(() => seedWaveform(seed), [seed]); + + useEffect(() => { + const audio = new Audio(); + audio.preload = 'metadata'; + audioRef.current = audio; + + const onLoaded = () => { + if (Number.isFinite(audio.duration) && audio.duration > 0) { + setDuration(audio.duration); + } + setLoading(false); + }; + const onTimeUpdate = () => { + if (!audio.duration) return; + setProgress(audio.currentTime / audio.duration); + }; + const onEnded = () => { + setPlaying(false); + setProgress(0); + audio.currentTime = 0; + }; + const onWaiting = () => setLoading(true); + const onCanPlay = () => setLoading(false); + const onError = () => { + setLoading(false); + setPlaying(false); + }; + + audio.addEventListener('loadedmetadata', onLoaded); + audio.addEventListener('timeupdate', onTimeUpdate); + audio.addEventListener('ended', onEnded); + audio.addEventListener('waiting', onWaiting); + audio.addEventListener('canplay', onCanPlay); + audio.addEventListener('error', onError); + + return () => { + audio.pause(); + audio.removeEventListener('loadedmetadata', onLoaded); + audio.removeEventListener('timeupdate', onTimeUpdate); + audio.removeEventListener('ended', onEnded); + audio.removeEventListener('waiting', onWaiting); + audio.removeEventListener('canplay', onCanPlay); + audio.removeEventListener('error', onError); + audioRef.current = null; + }; + }, []); + + useEffect(() => { + const audio = audioRef.current; + if (!audio || !src) return; + setLoading(true); + audio.src = src; + audio.load(); + }, [src]); + + async function togglePlayback() { + const audio = audioRef.current; + if (!audio || !src) return; + + if (playing) { + audio.pause(); + setPlaying(false); + return; + } + + try { + setLoading(true); + await audio.play(); + setPlaying(true); + } catch { + setPlaying(false); + } finally { + setLoading(false); + } + } + + function seekTo(ratio: number) { + const audio = audioRef.current; + if (!audio || !audio.duration) return; + const next = Math.min(Math.max(ratio, 0), 1); + audio.currentTime = next * audio.duration; + setProgress(next); + } + + const accent = variant === 'mine' ? 'bg-[#5bb85d]' : 'bg-[#3390ec]'; + const accentSoft = variant === 'mine' ? 'bg-[#5bb85d]/25' : 'bg-[#3390ec]/25'; + const remaining = duration * (1 - progress); + + return ( +
+ + + + + + {formatDuration(playing || progress > 0 ? remaining : duration)} + +
+ ); +} diff --git a/apps/frontend/components/family/family-group-view.tsx b/apps/frontend/components/family/family-group-view.tsx index ab574d8..b533405 100644 --- a/apps/frontend/components/family/family-group-view.tsx +++ b/apps/frontend/components/family/family-group-view.tsx @@ -6,36 +6,57 @@ import { ArrowLeft, BarChart3, Camera, + Check, + CheckCheck, FileText, Loader2, Mic, + MoreVertical, Paperclip, + Pencil, Plus, Send, Smile, + Trash2, UserPlus, + Users, Volume2, VolumeX } from 'lucide-react'; +import { VoiceMessagePlayer } from '@/components/chat/voice-message-player'; import { useAuth } from '@/components/id/auth-provider'; import { useToast } from '@/components/id/toast-provider'; import { useRealtime } from '@/components/notifications/realtime-provider'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { useRequireAuth } from '@/hooks/use-require-auth'; import { + addChatRoomMember, apiFetch, ChatMessage, ChatRoom, createChatRoom, + deleteChatMessage, + editChatMessage, FamilyGroup, FamilyInviteCandidate, + FamilyPresenceMember, fetchChatMessages, fetchChatRooms, fetchFamilyGroup, + fetchFamilyPresence, getApiErrorMessage, + markChatRoomRead, + removeChatRoomMember, + removeFamilyMember, searchFamilyInviteUsers, sendChatMessage, sendFamilyInvite, @@ -74,12 +95,54 @@ interface LoadedChatMedia { blobUrl: string; expiresAt: number; fileName?: string; + mimeType?: string; } function formatMessageTime(value: string) { return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value)); } +function formatLastSeen(value?: string) { + if (!value) return 'Не в сети'; + const date = new Date(value); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMinutes = Math.floor(diffMs / 60_000); + if (diffMinutes < 1) return 'был(а) только что'; + if (diffMinutes < 60) return `был(а) ${diffMinutes} мин. назад`; + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `был(а) ${diffHours} ч. назад`; + return new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(date); +} + +function formatPresenceStatus(member: FamilyPresenceMember) { + if (member.online) return 'В сети'; + return formatLastSeen(member.lastSeenAt); +} + +function messagePreview(message?: ChatMessage) { + if (!message) return ''; + if (message.isDeleted) return 'Сообщение удалено'; + if (message.type === 'POLL') return '📊 Опрос'; + if (message.type === 'VOICE') return '🎤 Голосовое'; + if (message.type === 'AUDIO') return '🎵 Аудио'; + if (message.type === 'IMAGE') return '📷 Фото'; + if (message.type === 'FILE') return '📄 Файл'; + return message.content ?? ''; +} + +function OnlineDot({ online, className }: { online?: boolean; className?: string }) { + if (!online) return null; + return ( + + ); +} + function FamilyAvatar({ groupId, name, hasAvatar, token }: { groupId: string; name: string; hasAvatar?: boolean; token: string | null }) { const [url, setUrl] = useState(null); useEffect(() => { @@ -96,12 +159,38 @@ function FamilyAvatar({ groupId, name, hasAvatar, token }: { groupId: string; na ); } +function ChatRoomAvatar({ roomId, name, hasAvatar, token }: { roomId: string; name: string; hasAvatar?: boolean; token: string | null }) { + const [url, setUrl] = useState(null); + useEffect(() => { + if (!hasAvatar || !token) return; + apiFetch<{ accessUrl: string }>(`/media/chat/${roomId}/avatar/url`, {}, token) + .then((response) => setUrl(response.accessUrl)) + .catch(() => setUrl(null)); + }, [hasAvatar, roomId, token]); + return ( + + {url ? : null} + {name.slice(0, 2).toUpperCase()} + + ); +} + +function memberRoleLabel(member: { familyRole?: string; isChatCreator?: boolean }) { + if (member.familyRole === 'owner') return 'Создатель семьи'; + if (member.isChatCreator) return 'Создатель чата'; + return 'Участник'; +} + +function familyMemberRoleLabel(role: string) { + return role === 'owner' ? 'Создатель семьи' : 'Участник'; +} + export function FamilyGroupView({ groupId }: { groupId: string }) { const router = useRouter(); const { user, token } = useAuth(); const { isReady, isPinLocked } = useRequireAuth(); const { showToast } = useToast(); - const { subscribe } = useRealtime(); + const { subscribe, connected } = useRealtime(); const [group, setGroup] = useState(null); const [rooms, setRooms] = useState([]); @@ -113,6 +202,9 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { const [inviteOpen, setInviteOpen] = useState(false); const [createRoomOpen, setCreateRoomOpen] = useState(false); const [pollOpen, setPollOpen] = useState(false); + const [membersOpen, setMembersOpen] = useState(false); + const [familyMembersOpen, setFamilyMembersOpen] = useState(false); + const [addMembersOpen, setAddMembersOpen] = useState(false); const [inviteQuery, setInviteQuery] = useState(''); const [inviteResults, setInviteResults] = useState([]); const [selectedInviteUser, setSelectedInviteUser] = useState(null); @@ -126,12 +218,17 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { const [recording, setRecording] = useState(false); const [mediaUrls, setMediaUrls] = useState>({}); const [downloadingKey, setDownloadingKey] = useState(null); + const [editingMessageId, setEditingMessageId] = useState(null); + const [editDraft, setEditDraft] = useState(''); + const [presenceMembers, setPresenceMembers] = useState([]); const messagesEndRef = useRef(null); + const readReceiptTimerRef = useRef | null>(null); const mediaRecorderRef = useRef(null); const recordingStartedAtRef = useRef(null); const mediaUrlsRef = useRef>({}); const avatarInputRef = useRef(null); + const roomAvatarInputRef = useRef(null); const fileInputRef = useRef(null); useEffect(() => { @@ -144,6 +241,20 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { const activeRoom = useMemo(() => rooms.find((room) => room.id === activeRoomId) ?? null, [activeRoomId, rooms]); const myMembership = activeRoom?.members.find((member) => member.userId === user?.id); + const isFamilyOwner = group?.ownerId === user?.id; + const isChatCreator = activeRoom?.createdById === user?.id; + const canManageChatMembers = Boolean(isFamilyOwner || isChatCreator); + const familyMembersNotInRoom = useMemo(() => { + if (!group?.members || !activeRoom) return []; + const inRoom = new Set(activeRoom.members.map((member) => member.userId)); + return group.members.filter((member) => !inRoom.has(member.userId)); + }, [activeRoom, group?.members]); + + const presenceByUserId = useMemo( + () => new Map(presenceMembers.map((member) => [member.userId, member])), + [presenceMembers] + ); + const onlineCount = useMemo(() => presenceMembers.filter((member) => member.online).length, [presenceMembers]); const loadGroup = useCallback(async () => { if (!token || isPinLocked) return; @@ -161,34 +272,85 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { setMessages(response.messages ?? []); }, [isPinLocked, token]); + const loadPresence = useCallback(async () => { + if (!token || isPinLocked) return; + try { + const response = await fetchFamilyPresence(groupId, token); + setPresenceMembers(response.members ?? []); + } catch { + // ignore background presence errors + } + }, [groupId, isPinLocked, token]); + useEffect(() => { if (!isReady || !token || isPinLocked) return; setLoading(true); loadGroup() .catch((error) => showToast(getApiErrorMessage(error, 'Не удалось загрузить семью') ?? 'Ошибка')) .finally(() => setLoading(false)); - }, [isPinLocked, isReady, loadGroup, showToast, token]); + void loadPresence(); + }, [isPinLocked, isReady, loadGroup, loadPresence, showToast, token]); + + useEffect(() => { + if (!token || isPinLocked) return; + void loadPresence(); + const timer = window.setInterval(() => void loadPresence(), 25_000); + return () => window.clearInterval(timer); + }, [connected, isPinLocked, loadPresence, token]); useEffect(() => { if (!activeRoomId) return; void loadMessages(activeRoomId); + setEditingMessageId(null); + setEditDraft(''); }, [activeRoomId, loadMessages]); + useEffect(() => { + if (!activeRoomId || !token || !messages.length) return; + const timer = window.setTimeout(() => { + const last = messages[messages.length - 1]; + void markChatRoomRead(activeRoomId, last.id, token).catch(() => {}); + }, 500); + return () => window.clearTimeout(timer); + }, [activeRoomId, messages, token]); + useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); useEffect(() => { return subscribe((event) => { - if (event.type !== 'chat_message') return; const payload = event.payload; - if (!payload?.roomId || payload.roomId !== activeRoomId) return; - if (payload.message) { - appendMessage(payload.message); - } else { - void loadMessages(activeRoomId!); + if (payload?.roomId && payload.roomId !== activeRoomId) { + if (event.type === 'chat_message') void loadGroup(); + return; + } + + if (event.type === 'chat_message') { + if (payload?.message) { + appendMessage(payload.message); + } else if (activeRoomId) { + void loadMessages(activeRoomId); + } + void loadGroup(); + return; + } + + if (event.type === 'chat_message_updated' || event.type === 'chat_message_deleted') { + const updated = payload?.message; + if (updated) { + setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item))); + } else if (activeRoomId) { + void loadMessages(activeRoomId); + } + void loadGroup(); + return; + } + + if (event.type === 'chat_read_receipt' && activeRoomId) { + if (readReceiptTimerRef.current) clearTimeout(readReceiptTimerRef.current); + readReceiptTimerRef.current = setTimeout(() => void loadMessages(activeRoomId), 400); } - void loadGroup(); }); }, [activeRoomId, appendMessage, loadGroup, loadMessages, subscribe]); @@ -201,6 +363,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { const meta = parseMessageMetadata(message.metadataJson); const fileName = meta.fileName || message.content || undefined; + const mimeType = message.mimeType || (message.type === 'VOICE' ? 'audio/webm' : message.type === 'AUDIO' ? 'audio/mpeg' : undefined); const query = new URLSearchParams({ storageKey }); if (fileName) query.set('fileName', fileName); @@ -210,7 +373,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { {}, token ); - const blob = await fetchAuthenticatedMediaBlob(access.accessUrl, token); + const blob = await fetchAuthenticatedMediaBlob(access.accessUrl, token, mimeType); const blobUrl = createBlobObjectUrl(blob); setMediaUrls((current) => { revokeBlobObjectUrl(current[storageKey]?.blobUrl); @@ -219,7 +382,8 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { [storageKey]: { blobUrl, expiresAt: mediaExpiresAtMs(access.expiresAt), - fileName + fileName, + mimeType } }; }); @@ -289,6 +453,70 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { } } + async function uploadRoomAvatar(file: File) { + if (!token || !activeRoomId) return; + try { + const presigned = await apiFetch( + `/media/chat/${activeRoomId}/avatar/upload-url`, + { method: 'POST', body: JSON.stringify({ contentType: file.type }) }, + token + ); + await uploadMediaObject(presigned, file, file.type); + await apiFetch(`/media/chat/${activeRoomId}/avatar/confirm`, { + method: 'POST', + body: JSON.stringify({ storageKey: presigned.storageKey }) + }, token); + await loadGroup(); + showToast('Фото чата обновлено'); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось загрузить фото чата') ?? 'Ошибка'); + } + } + + function applyRoomUpdate(room: ChatRoom) { + setRooms((current) => current.map((item) => (item.id === room.id ? room : item))); + } + + async function handleAddRoomMember(memberUserId: string) { + if (!token || !activeRoomId) return; + try { + const room = await addChatRoomMember(activeRoomId, memberUserId, token); + applyRoomUpdate(room); + setAddMembersOpen(false); + showToast('Участник добавлен в чат'); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось добавить участника') ?? 'Ошибка'); + } + } + + async function handleRemoveFromChat(memberUserId: string) { + if (!token || !activeRoomId) return; + try { + const room = await removeChatRoomMember(activeRoomId, memberUserId, token); + applyRoomUpdate(room); + if (memberUserId === user?.id) { + setActiveRoomId(null); + setMembersOpen(false); + } + showToast('Участник удалён из чата'); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось удалить участника из чата') ?? 'Ошибка'); + } + } + + async function handleRemoveFromFamily(memberId: string) { + if (!token) return; + try { + await removeFamilyMember(memberId, token); + await loadGroup(); + setMembersOpen(false); + setFamilyMembersOpen(false); + showToast('Участник удалён из семьи'); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось удалить участника из семьи') ?? 'Ошибка'); + } + } + async function saveRename() { if (!token || !renameValue.trim()) return; try { @@ -368,6 +596,51 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { } } + async function startEditMessage(message: ChatMessage) { + if (message.isDeleted) return; + setEditingMessageId(message.id); + setEditDraft(message.content ?? ''); + } + + async function submitEditMessage() { + if (!token || !editingMessageId) return; + const content = editDraft.trim(); + if (!content) { + showToast('Сообщение не может быть пустым'); + return; + } + setSending(true); + try { + const updated = await editChatMessage(editingMessageId, content, token); + setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item))); + setEditingMessageId(null); + setEditDraft(''); + void loadGroup(); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось изменить сообщение') ?? 'Ошибка'); + } finally { + setSending(false); + } + } + + async function handleDeleteMessage(messageId: string) { + if (!token) return; + setSending(true); + try { + const updated = await deleteChatMessage(messageId, token); + setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item))); + if (editingMessageId === messageId) { + setEditingMessageId(null); + setEditDraft(''); + } + void loadGroup(); + } catch (error) { + showToast(getApiErrorMessage(error, 'Не удалось удалить сообщение') ?? 'Ошибка'); + } finally { + setSending(false); + } + } + async function sendText(type: string = 'TEXT') { if (!token || !activeRoomId || sending) return; const content = draft.trim(); @@ -544,6 +817,22 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { +

Чаты

@@ -562,9 +851,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { activeRoomId === room.id && 'bg-[#3390ec]/10' )} > -
- {room.name.slice(0, 2).toUpperCase()} -
+

{room.name}

@@ -573,7 +860,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { ) : null}

- {room.lastMessage?.content ?? (room.type === 'GENERAL' ? 'Общий чат семьи' : 'Нет сообщений')} + {messagePreview(room.lastMessage) || (room.type === 'GENERAL' ? 'Общий чат семьи' : 'Нет сообщений')}

@@ -585,28 +872,105 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { {activeRoom ? ( <>
-
-

{activeRoom.name}

-

{activeRoom.members.length} участников

+ +
+ {activeRoom.type === 'GROUP' ? ( + + ) : null} + +
-
{messages.map((message) => { const mine = message.senderId === user?.id; + const isEditing = editingMessageId === message.id; + const canEdit = mine && !message.isDeleted && (message.type === 'TEXT' || message.type === 'EMOJI'); return ( -
+
+ {canEdit ? ( + + + + + + void startEditMessage(message)}> + + Изменить + + void handleDeleteMessage(message.id)} + > + + Удалить + + + + ) : null} {!mine ?

{message.senderName}

: null} - {message.type === 'POLL' && message.poll ? ( + {message.isDeleted ? ( +

Сообщение удалено

+ ) : isEditing ? ( +
+