diff --git a/apps/frontend/components/chat/chat-date-separator.tsx b/apps/frontend/components/chat/chat-date-separator.tsx new file mode 100644 index 0000000..7a098ad --- /dev/null +++ b/apps/frontend/components/chat/chat-date-separator.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { formatChatDateLabel } from '@/lib/chat-date-utils'; + +export function ChatDateSeparator({ date }: { date: string }) { + return ( +
+
+ {formatChatDateLabel(date)} +
+
+ ); +} diff --git a/apps/frontend/components/chat/chat-message-context-menu.tsx b/apps/frontend/components/chat/chat-message-context-menu.tsx index ee1a00c..ebc4cb9 100644 --- a/apps/frontend/components/chat/chat-message-context-menu.tsx +++ b/apps/frontend/components/chat/chat-message-context-menu.tsx @@ -9,6 +9,7 @@ import { ContextMenuSeparator, ContextMenuTrigger } from '@/components/ui/context-menu'; +import { suppressChatDragSelection } from '@/hooks/use-chat-drag-selection'; import type { ChatMessage } from '@/lib/api'; import { QUICK_REACTIONS, canDeleteMessage, canEditMessage, getMessageCopyText } from '@/lib/chat-message-utils'; import { cn } from '@/lib/utils'; @@ -47,8 +48,17 @@ export function ChatMessageContextMenu({ const copyText = getMessageCopyText(message, visibleText); const disabled = message.isDeleted; + function runMenuAction(action?: () => void) { + suppressChatDragSelection(); + action?.(); + } + return ( - + { + if (open) suppressChatDragSelection(800); + }} + > {children} {!disabled ? ( @@ -59,7 +69,7 @@ export function ChatMessageContextMenu({ key={emoji} type="button" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-lg transition hover:bg-[#eef1f6]" - onClick={() => onToggleReaction?.(emoji)} + onClick={() => runMenuAction(() => onToggleReaction?.(emoji))} > {emoji} @@ -69,22 +79,35 @@ export function ChatMessageContextMenu({ ) : null} {onReply && !disabled ? ( - + { + event.preventDefault(); + runMenuAction(onReply); + }} + > Ответить ) : null} {editable ? ( - + { + event.preventDefault(); + runMenuAction(onEdit); + }} + > Изменить ) : null} {copyText ? ( { - void navigator.clipboard.writeText(copyText); - onCopy?.(); + onSelect={(event) => { + event.preventDefault(); + runMenuAction(() => { + void navigator.clipboard.writeText(copyText); + onCopy?.(); + }); }} > @@ -92,13 +115,24 @@ export function ChatMessageContextMenu({ ) : null} {onForward && !disabled && !message.isEncrypted ? ( - + { + event.preventDefault(); + runMenuAction(onForward); + }} + > Переслать ) : null} {deletable ? ( - + { + event.preventDefault(); + runMenuAction(onDelete); + }} + > Удалить @@ -106,7 +140,12 @@ export function ChatMessageContextMenu({ {onSelect && !disabled ? ( <> - + { + event.preventDefault(); + runMenuAction(onSelect); + }} + > Выделить diff --git a/apps/frontend/components/chat/chat-tools-dialog.tsx b/apps/frontend/components/chat/chat-tools-dialog.tsx new file mode 100644 index 0000000..100cf6f --- /dev/null +++ b/apps/frontend/components/chat/chat-tools-dialog.tsx @@ -0,0 +1,220 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { CalendarDays, ImageIcon, Loader2, Search } from 'lucide-react'; +import { Calendar } from '@/components/ui/calendar'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import type { ChatMessage } from '@/lib/api'; +import { getChatDateKey } from '@/lib/chat-date-utils'; +import { getMessagePreviewText } from '@/lib/chat-message-utils'; +import { cn } from '@/lib/utils'; + +export type ChatToolsTab = 'search' | 'calendar' | 'media'; + +interface ChatToolsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + messages: ChatMessage[]; + defaultTab?: ChatToolsTab; + getMessagePreview: (message: ChatMessage) => string; + getMediaUrl?: (message: ChatMessage) => string | null; + onJumpToMessage: (messageId: string) => void; +} + +export function ChatToolsDialog({ + open, + onOpenChange, + messages, + defaultTab = 'search', + getMessagePreview, + getMediaUrl, + onJumpToMessage +}: ChatToolsDialogProps) { + const [tab, setTab] = useState(defaultTab); + const [query, setQuery] = useState(''); + const [selectedDate, setSelectedDate] = useState(); + + const messageDates = useMemo(() => { + const keys = new Set(); + for (const message of messages) { + keys.add(getChatDateKey(message.createdAt)); + } + return keys; + }, [messages]); + + const searchResults = useMemo(() => { + const normalized = query.trim().toLowerCase(); + if (!normalized) return []; + return messages.filter((message) => getMessagePreview(message).toLowerCase().includes(normalized)); + }, [getMessagePreview, messages, query]); + + const mediaMessages = useMemo( + () => messages.filter((message) => message.type === 'IMAGE' && message.storageKey), + [messages] + ); + + const calendarModifiers = useMemo( + () => ({ + hasMessages: (date: Date) => messageDates.has(getChatDateKey(date)) + }), + [messageDates] + ); + + function jumpToDate(date: Date) { + const key = getChatDateKey(date); + const target = messages.find((message) => getChatDateKey(message.createdAt) === key); + if (!target) return; + onJumpToMessage(target.id); + onOpenChange(false); + } + + function handleOpenChange(next: boolean) { + onOpenChange(next); + if (next) setTab(defaultTab); + } + + return ( + + + + Чат + + +
+ {( + [ + { id: 'search' as const, label: 'Поиск', icon: Search }, + { id: 'calendar' as const, label: 'Дата', icon: CalendarDays }, + { id: 'media' as const, label: 'Медиа', icon: ImageIcon } + ] as const + ).map((item) => ( + + ))} +
+ +
+ {tab === 'search' ? ( +
+
+ + setQuery(event.target.value)} + placeholder="Поиск по сообщениям" + className="rounded-xl pl-9" + /> +
+ {!query.trim() ? ( +

Введите текст для поиска

+ ) : searchResults.length ? ( +
+ {searchResults.map((message) => ( + + ))} +
+ ) : ( +

Ничего не найдено

+ )} +
+ ) : null} + + {tab === 'calendar' ? ( +
+ { + setSelectedDate(date); + if (date) jumpToDate(date); + }} + modifiers={calendarModifiers} + modifiersClassNames={{ + hasMessages: 'relative after:absolute after:bottom-1 after:left-1/2 after:h-1 after:w-1 after:-translate-x-1/2 after:rounded-full after:bg-[#3390ec]' + }} + /> +
+ + { + if (!event.target.value) return; + jumpToDate(new Date(`${event.target.value}T12:00:00`)); + }} + /> +
+

Точки под датами означают наличие сообщений в этот день

+
+ ) : null} + + {tab === 'media' ? ( + mediaMessages.length ? ( +
+ {mediaMessages.map((message) => { + const url = getMediaUrl?.(message); + return ( + + ); + })} +
+ ) : ( +

Медиафайлов пока нет

+ ) + ) : null} +
+
+
+ ); +} + +export function buildMessagePreview(message: ChatMessage, visibleText?: string) { + return getMessagePreviewText(message, visibleText); +} diff --git a/apps/frontend/components/family/chat-room-avatar-display.tsx b/apps/frontend/components/family/chat-room-avatar-display.tsx index 6374984..2b3d70d 100644 --- a/apps/frontend/components/family/chat-room-avatar-display.tsx +++ b/apps/frontend/components/family/chat-room-avatar-display.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react'; import { UserAvatar } from '@/components/id/user-avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { ChatRoom, apiFetch } from '@/lib/api'; +import { CHAT_ROOM_AVATAR_UPDATED_EVENT } from '@/lib/avatar-events'; import { cn } from '@/lib/utils'; interface ChatRoomAvatarDisplayProps { @@ -34,10 +35,30 @@ export function ChatRoomAvatarDisplay({ room, viewerUserId, token, className, si setRoomAvatarUrl(null); return; } + let cancelled = false; apiFetch<{ accessUrl: string }>(`/media/chat/${room.id}/avatar/url`, {}, token) - .then((response) => setRoomAvatarUrl(response.accessUrl)) - .catch(() => setRoomAvatarUrl(null)); - }, [room.hasAvatar, room.id, room.type, token]); + .then((response) => { + if (!cancelled) setRoomAvatarUrl(response.accessUrl); + }) + .catch(() => { + if (!cancelled) setRoomAvatarUrl(null); + }); + return () => { + cancelled = true; + }; + }, [room.hasAvatar, room.id, room.type, room.updatedAt, token]); + + useEffect(() => { + function handleRoomAvatarUpdated(event: Event) { + const detail = (event as CustomEvent<{ roomId?: string }>).detail; + if (detail?.roomId !== room.id || !room.hasAvatar || !token) return; + apiFetch<{ accessUrl: string }>(`/media/chat/${room.id}/avatar/url`, {}, token) + .then((response) => setRoomAvatarUrl(response.accessUrl)) + .catch(() => setRoomAvatarUrl(null)); + } + window.addEventListener(CHAT_ROOM_AVATAR_UPDATED_EVENT, handleRoomAvatarUpdated); + return () => window.removeEventListener(CHAT_ROOM_AVATAR_UPDATED_EVENT, handleRoomAvatarUpdated); + }, [room.hasAvatar, room.id, token]); if (peerMember && (room.type === 'DIRECT' || room.type === 'E2E')) { return ( diff --git a/apps/frontend/components/family/family-group-view.tsx b/apps/frontend/components/family/family-group-view.tsx index 711e389..0703a89 100644 --- a/apps/frontend/components/family/family-group-view.tsx +++ b/apps/frontend/components/family/family-group-view.tsx @@ -5,14 +5,17 @@ import { useRouter } from 'next/navigation'; import { ArrowLeft, BarChart3, + CalendarDays, Camera, Check, CheckCheck, FileText, + ImageIcon, Loader2, Mic, Paperclip, Plus, + Search, Send, Settings, Smile, @@ -30,6 +33,8 @@ import { BotMiniAppSheet, FamilyBotChat } from '@/components/family/family-bot-c import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display'; import { ChatEmojiContent } from '@/components/chat/chat-emoji-content'; import { ChatForwardDialog } from '@/components/chat/chat-forward-dialog'; +import { ChatDateSeparator } from '@/components/chat/chat-date-separator'; +import { ChatToolsDialog, type ChatToolsTab } from '@/components/chat/chat-tools-dialog'; import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog'; import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner'; import { ChatMessageContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu'; @@ -89,6 +94,9 @@ import { getE2EMessageText, useE2EChat } from '@/hooks/use-e2e-chat'; import { useChatMessageSelection } from '@/hooks/use-chat-message-selection'; import { useChatDragSelection } from '@/hooks/use-chat-drag-selection'; import { useChatTyping } from '@/hooks/use-chat-typing'; +import { shouldShowChatDateSeparator } from '@/lib/chat-date-utils'; +import { AVATAR_UPDATED_EVENT, dispatchChatRoomAvatarUpdated } from '@/lib/avatar-events'; +import { getSingleEmojiNative, isSingleEmojiMessage } from '@/lib/chat-emoji-display'; import { findMemberRoom, readStoredActiveRoomId, roomDisplayLabel, storeActiveRoomId } from '@/lib/family-chat'; import { getChatListPreviewText, @@ -243,6 +251,8 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { const [forwarding, setForwarding] = useState(false); const [bulkDeleting, setBulkDeleting] = useState(false); const [replyToMessage, setReplyToMessage] = useState(null); + const [chatToolsOpen, setChatToolsOpen] = useState(false); + const [chatToolsTab, setChatToolsTab] = useState('search'); const messagesEndRef = useRef(null); const readReceiptTimerRef = useRef | null>(null); @@ -279,7 +289,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { addSelectedMessage, handleSelectionPointer } = useChatMessageSelection(visibleMessages); - const { startDragSelection, handleDragEnterMessage, isDragging } = useChatDragSelection({ + const { handleMessagePointerDown, handleDragEnterMessage, isDragging } = useChatDragSelection({ selectionMode, enterSelectionMode, addSelectedMessage @@ -377,6 +387,14 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { void loadPresence(); }, [isPinLocked, isReady, loadGroup, loadPresence, showToast, token]); + useEffect(() => { + function handleAvatarUpdated() { + void loadGroup(); + } + window.addEventListener(AVATAR_UPDATED_EVENT, handleAvatarUpdated); + return () => window.removeEventListener(AVATAR_UPDATED_EVENT, handleAvatarUpdated); + }, [loadGroup]); + useEffect(() => { if (!token || isPinLocked) return; void loadPresence(); @@ -640,6 +658,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { body: JSON.stringify({ storageKey: presigned.storageKey }) }, token); await loadGroup(); + dispatchChatRoomAvatarUpdated(activeRoomId); showToast('Фото чата обновлено'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось загрузить фото чата') ?? 'Ошибка'); @@ -1300,6 +1319,46 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
+ {!activeRoomIsBot ? ( + <> + + + + + ) : null} {canManageActiveBot ? ( ); })() + ) : isLargeEmoji ? ( +
+ {largeEmojiNative} +
+ {message.editedAt ? изм. : null} + {formatMessageTime(message.createdAt)} + {mine ? ( + message.readByAll ? ( + + ) : ( + + ) + ) : null} +
+
) : emojiContent ? (
@@ -1492,32 +1585,37 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
)} -
- {message.editedAt ? изменено : null} - {formatMessageTime(message.createdAt)} - {mine ? ( - message.readByAll ? ( - - ) : ( - - ) - ) : null} -
- void handleToggleReaction(message.id, emoji)} - /> + {!isLargeEmoji ? ( +
+ {message.editedAt ? изменено : null} + {formatMessageTime(message.createdAt)} + {mine ? ( + message.readByAll ? ( + + ) : ( + + ) + ) : null} +
+ ) : null} + {!isLargeEmoji ? ( + void handleToggleReaction(message.id, emoji)} + /> + ) : null}
); return ( +
+ {showDateSeparator ? : null}
{ - if (event.button !== 0 || isChatInteractiveTarget(event.target)) return; - startDragSelection(message.id); + if (isChatInteractiveTarget(event.target)) return; + handleMessagePointerDown(message.id, event); }} onMouseEnter={() => handleDragEnterMessage(message.id)} onClick={(event) => { @@ -1535,7 +1633,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { token={token} isVerified={message.senderIsVerified} verificationIcon={message.senderVerificationIcon} - className="h-8 w-8 self-end" + className="z-10 -mr-3 -mt-0.5 h-8 w-8 shrink-0 self-start ring-2 ring-[#eef1f6]" badgeSize="xs" /> ) : null} @@ -1556,7 +1654,13 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { visibleText={visibleText} onEdit={() => void startEditMessage(message)} onDelete={() => requestDeleteMessages([message.id])} - onReply={() => setReplyToMessage(message)} + onReply={() => { + exitSelectionMode(); + setReplyToMessage(message); + setEditingMessageId(null); + setDraft(''); + setEmojiOpen(false); + }} onForward={() => { setSelectedMessageIds([message.id]); setForwardDialogOpen(true); @@ -1568,6 +1672,19 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { {bubble} )} + {mine ? ( + + ) : null} +
); })} @@ -1705,6 +1822,28 @@ export function FamilyGroupView({ groupId }: { groupId: string }) { onSelectRoom={(roomId) => void handleForwardToRoom(roomId)} /> + + getMessagePreviewText( + message, + getE2EMessageText({ + message, + isE2E: activeRoomIsE2E, + peerE2eKey, + peerKeyLoading, + e2ePayload: message.isEncrypted ? e2ePayloads[message.id] : null, + e2eError: e2eErrors[message.id] + }) + ) + } + getMediaUrl={(message) => (message.storageKey ? mediaUrls[message.storageKey]?.blobUrl ?? null : null)} + /> + ('search'); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); const previousGroupIdRef = useRef(null); @@ -131,7 +139,7 @@ export function MiniFamilyChat() { handleSelectionPointer } = useChatMessageSelection(visibleMessages); - const { startDragSelection, handleDragEnterMessage, isDragging } = useChatDragSelection({ + const { handleMessagePointerDown, handleDragEnterMessage, isDragging } = useChatDragSelection({ selectionMode, enterSelectionMode, addSelectedMessage @@ -684,6 +692,49 @@ export function MiniFamilyChat() { ) : null} + {view === 'chat' && activeRoom ? ( + <> + + + + + ) : null} {view === 'chat' && activeRoom && activeRoom.type !== 'GENERAL' ? (