update and fix messanger

This commit is contained in:
lendry
2026-06-26 00:16:17 +03:00
parent b0ea87e898
commit 4e78a81eb1
14 changed files with 898 additions and 125 deletions

View File

@@ -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 (

View File

@@ -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<ChatMessage | null>(null);
const [chatToolsOpen, setChatToolsOpen] = useState(false);
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
const messagesEndRef = useRef<HTMLDivElement>(null);
const readReceiptTimerRef = useRef<ReturnType<typeof setTimeout> | 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 }) {
</button>
</div>
<div className="flex items-center gap-2">
{!activeRoomIsBot ? (
<>
<Button
size="sm"
variant="secondary"
className="rounded-xl"
aria-label="Поиск по сообщениям"
onClick={() => {
setChatToolsTab('search');
setChatToolsOpen(true);
}}
>
<Search className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="secondary"
className="rounded-xl"
aria-label="Перейти к дате"
onClick={() => {
setChatToolsTab('calendar');
setChatToolsOpen(true);
}}
>
<CalendarDays className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="secondary"
className="rounded-xl"
aria-label="Медиа чата"
onClick={() => {
setChatToolsTab('media');
setChatToolsOpen(true);
}}
>
<ImageIcon className="h-4 w-4" />
</Button>
</>
) : null}
{canManageActiveBot ? (
<Button
size="sm"
@@ -1349,13 +1408,19 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
) : (
<>
<div className={cn('flex-1 space-y-2 overflow-y-auto px-4 py-4', isDragging && 'select-none')}>
{visibleMessages.map((message) => {
{visibleMessages.map((message, messageIndex) => {
const previousMessage = visibleMessages[messageIndex - 1];
const showDateSeparator = shouldShowChatDateSeparator(message.createdAt, previousMessage?.createdAt);
if (isSystemMessage(message)) {
return (
<div key={message.id} id={`msg-${message.id}`} className="flex justify-center px-2 py-1">
<p className="rounded-full bg-black/[0.06] px-3 py-1.5 text-xs text-[#667085]">
{getMessagePreviewText(message)}
</p>
<div key={message.id}>
{showDateSeparator ? <ChatDateSeparator date={message.createdAt} /> : null}
<div id={`msg-${message.id}`} className="flex justify-center px-2 py-1">
<p className="rounded-full bg-black/[0.06] px-3 py-1.5 text-xs text-[#667085]">
{getMessagePreviewText(message)}
</p>
</div>
</div>
);
}
@@ -1387,12 +1452,25 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
})
: '';
const isSelected = selectedMessageIds.includes(message.id);
const isLargeEmoji = isSingleEmojiMessage({
type: message.type,
content: emojiContent ?? message.content,
visibleText
});
const largeEmojiNative = isLargeEmoji
? getSingleEmojiNative({ type: message.type, content: emojiContent ?? message.content, visibleText })
: '';
const bubble = (
<div
className={cn(
'relative rounded-[18px] px-3 py-2 shadow-sm',
mine ? 'rounded-br-md bg-[#effdde] text-[#1f2430]' : 'rounded-bl-md bg-white text-[#1f2430]',
!selectionMode && 'max-w-[78%]'
'relative',
isLargeEmoji
? 'bg-transparent px-0 py-0 shadow-none'
: cn(
'rounded-[18px] px-3 py-2 shadow-sm',
mine ? 'rounded-br-md bg-[#effdde] text-[#1f2430]' : 'rounded-bl-md bg-white text-[#1f2430]',
!selectionMode && 'max-w-[78%]'
)
)}
>
{!mine ? <p className="mb-1 text-xs font-semibold text-[#3390ec]">{message.senderName}</p> : null}
@@ -1483,6 +1561,21 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
</button>
);
})()
) : isLargeEmoji ? (
<div className="relative inline-flex min-w-[72px] flex-col items-center">
<span className="text-[56px] leading-none">{largeEmojiNative}</span>
<div className="absolute bottom-0 right-0 flex items-center gap-0.5 rounded-full bg-black/45 px-1.5 py-0.5 text-[10px] text-white">
{message.editedAt ? <span>изм.</span> : null}
<span>{formatMessageTime(message.createdAt)}</span>
{mine ? (
message.readByAll ? (
<CheckCheck className="h-3 w-3" aria-label="Прочитано" />
) : (
<Check className="h-3 w-3" aria-label="Отправлено" />
)
) : null}
</div>
</div>
) : emojiContent ? (
<div className="py-1">
<ChatEmojiContent content={emojiContent} size={32} />
@@ -1492,32 +1585,37 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
<ChatEmojiContent content={visibleText} size={22} />
</div>
)}
<div className={cn('mt-1 flex items-center justify-end gap-1 text-[11px]', mine ? 'text-[#7ea06a]' : 'text-[#a8adbc]')}>
{message.editedAt ? <span className="mr-1">изменено</span> : null}
<span>{formatMessageTime(message.createdAt)}</span>
{mine ? (
message.readByAll ? (
<CheckCheck className="h-3.5 w-3.5 text-[#3390ec]" aria-label="Прочитано" />
) : (
<Check className="h-3.5 w-3.5" aria-label="Отправлено" />
)
) : null}
</div>
<ChatMessageReactions
reactions={message.reactions}
onToggle={(emoji) => void handleToggleReaction(message.id, emoji)}
/>
{!isLargeEmoji ? (
<div className={cn('mt-1 flex items-center justify-end gap-1 text-[11px]', mine ? 'text-[#7ea06a]' : 'text-[#a8adbc]')}>
{message.editedAt ? <span className="mr-1">изменено</span> : null}
<span>{formatMessageTime(message.createdAt)}</span>
{mine ? (
message.readByAll ? (
<CheckCheck className="h-3.5 w-3.5 text-[#3390ec]" aria-label="Прочитано" />
) : (
<Check className="h-3.5 w-3.5" aria-label="Отправлено" />
)
) : null}
</div>
) : null}
{!isLargeEmoji ? (
<ChatMessageReactions
reactions={message.reactions}
onToggle={(emoji) => void handleToggleReaction(message.id, emoji)}
/>
) : null}
</div>
);
return (
<div key={message.id}>
{showDateSeparator ? <ChatDateSeparator date={message.createdAt} /> : null}
<div
key={message.id}
id={`msg-${message.id}`}
className={cn('group flex gap-2', mine ? 'justify-end' : 'justify-start', isDragging && 'select-none')}
onMouseDown={(event) => {
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}
</ChatMessageContextMenu>
)}
{mine ? (
<UserAvatar
userId={user?.id ?? message.senderId}
displayName={user?.displayName ?? message.senderName}
hasAvatar={Boolean(user?.hasAvatar)}
token={token}
isVerified={user?.isVerified}
verificationIcon={user?.verificationIcon}
className="z-10 -ml-3 -mt-0.5 h-8 w-8 shrink-0 self-start ring-2 ring-[#eef1f6]"
badgeSize="xs"
/>
) : null}
</div>
</div>
);
})}
@@ -1705,6 +1822,28 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
onSelectRoom={(roomId) => void handleForwardToRoom(roomId)}
/>
<ChatToolsDialog
open={chatToolsOpen}
onOpenChange={setChatToolsOpen}
messages={visibleMessages}
defaultTab={chatToolsTab}
onJumpToMessage={scrollToChatMessage}
getMessagePreview={(message) =>
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)}
/>
<ChatDeleteMessageDialog
open={deleteMessagesDialogOpen}
count={pendingDeleteMessageIds.length}

View File

@@ -2,19 +2,23 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import { ArrowLeft, BarChart3, Loader2, MessageCircle, Paperclip, Send, Settings, Smile, Trash2, X } from 'lucide-react';
import { ArrowLeft, BarChart3, CalendarDays, ImageIcon, Loader2, MessageCircle, Paperclip, Search, Send, Settings, Smile, Trash2, X } from 'lucide-react';
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
import { FamilyBotChat, BotMiniAppSheet } from '@/components/family/family-bot-chat';
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
import { ChatDateSeparator } from '@/components/chat/chat-date-separator';
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
import { ChatMessageContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog';
import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner';
import { ChatMessageReactions } from '@/components/chat/chat-message-reactions';
import { ChatSelectionToolbar } from '@/components/chat/chat-selection-toolbar';
import { ChatToolsDialog, type ChatToolsTab } from '@/components/chat/chat-tools-dialog';
import { EmojiPicker } from '@/components/chat/emoji-picker';
import { scrollToChatMessage } from '@/components/chat/replied-message-preview';
import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
import { useAuth } from '@/components/id/auth-provider';
import { UserAvatar } from '@/components/id/user-avatar';
import { useToast } from '@/components/id/toast-provider';
import { useRealtime } from '@/components/notifications/realtime-provider';
import { Button } from '@/components/ui/button';
@@ -52,6 +56,8 @@ import {
isSystemMessage
} from '@/lib/chat-message-utils';
import { detectChatMessageType, parseMessageMetadata, resolveChatContentType } from '@/lib/chat-media';
import { getSingleEmojiNative, isSingleEmojiMessage } from '@/lib/chat-emoji-display';
import { shouldShowChatDateSeparator } from '@/lib/chat-date-utils';
import { encryptE2EBlob, e2eKindFromMessageType } from '@/lib/e2e-chat';
import { findMemberRoom, findOrCreateMemberChat, roomDisplayLabel } from '@/lib/family-chat';
import { getE2EMessageText, useE2EChat } from '@/hooks/use-e2e-chat';
@@ -112,6 +118,8 @@ export function MiniFamilyChat() {
const [pollOpen, setPollOpen] = useState(false);
const [pollQuestion, setPollQuestion] = useState('');
const [pollOptions, setPollOptions] = useState(['', '']);
const [chatToolsOpen, setChatToolsOpen] = useState(false);
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const previousGroupIdRef = useRef<string | null>(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() {
<Settings className="h-4 w-4" />
</Button>
) : null}
{view === 'chat' && activeRoom ? (
<>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label="Поиск по сообщениям"
onClick={() => {
setChatToolsTab('search');
setChatToolsOpen(true);
}}
>
<Search className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label="Перейти к дате"
onClick={() => {
setChatToolsTab('calendar');
setChatToolsOpen(true);
}}
>
<CalendarDays className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label="Медиа чата"
onClick={() => {
setChatToolsTab('media');
setChatToolsOpen(true);
}}
>
<ImageIcon className="h-4 w-4" />
</Button>
</>
) : null}
{view === 'chat' && activeRoom && activeRoom.type !== 'GENERAL' ? (
<Button
type="button"
@@ -840,13 +891,19 @@ export function MiniFamilyChat() {
Загрузка...
</div>
) : visibleMessages.length ? (
visibleMessages.map((message) => {
visibleMessages.map((message, messageIndex) => {
const previousMessage = visibleMessages[messageIndex - 1];
const showDateSeparator = shouldShowChatDateSeparator(message.createdAt, previousMessage?.createdAt);
if (isSystemMessage(message)) {
return (
<div key={message.id} id={`msg-${message.id}`} className="flex justify-center py-1">
<p className="rounded-full bg-black/[0.06] px-3 py-1 text-[11px] text-[#667085]">
{getMessagePreviewText(message)}
</p>
<div key={message.id}>
{showDateSeparator ? <ChatDateSeparator date={message.createdAt} /> : null}
<div id={`msg-${message.id}`} className="flex justify-center py-1">
<p className="rounded-full bg-black/[0.06] px-3 py-1 text-[11px] text-[#667085]">
{getMessagePreviewText(message)}
</p>
</div>
</div>
);
}
@@ -866,16 +923,31 @@ export function MiniFamilyChat() {
? e2ePayload?.emojiId ?? message.content
: null;
const isSelected = selectedMessageIds.includes(message.id);
const isLargeEmoji = isSingleEmojiMessage({
type: message.type,
content: emojiContent ?? message.content,
visibleText
});
const largeEmojiNative = isLargeEmoji
? getSingleEmojiNative({ type: message.type, content: emojiContent ?? message.content, visibleText })
: '';
const bubble = (
<div
className={cn(
'max-w-[85%] rounded-[16px] px-3 py-2 text-sm shadow-sm',
mine ? 'bg-[#effdde]' : 'bg-white',
!selectionMode && 'max-w-[85%]'
'relative',
isLargeEmoji
? 'bg-transparent px-0 py-0 shadow-none'
: cn(
'max-w-[85%] rounded-[16px] px-3 py-2 text-sm shadow-sm',
mine ? 'bg-[#effdde]' : 'bg-white',
!selectionMode && 'max-w-[85%]'
)
)}
>
{!mine ? <p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{message.senderName}</p> : null}
{!mine && !isLargeEmoji ? (
<p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{message.senderName}</p>
) : null}
{message.type === 'POLL' && message.poll ? (
<div className="space-y-1.5">
<p className="font-medium">{message.poll.question}</p>
@@ -905,6 +977,13 @@ export function MiniFamilyChat() {
</div>
) : message.storageKey ? (
<p className="text-sm">{getMessagePreviewText(message, visibleText)}</p>
) : isLargeEmoji ? (
<div className="relative inline-flex min-w-[64px] flex-col items-center">
<span className="text-[48px] leading-none">{largeEmojiNative}</span>
<div className="absolute bottom-0 right-0 rounded-full bg-black/45 px-1.5 py-0.5 text-[10px] text-white">
{formatMessageTime(message.createdAt)}
</div>
</div>
) : emojiContent ? (
<div className="py-0.5">
<ChatEmojiContent content={emojiContent} size={28} />
@@ -914,69 +993,99 @@ export function MiniFamilyChat() {
<ChatEmojiContent content={visibleText} size={18} />
</div>
)}
<p className="mt-1 text-[10px] text-[#a8adbc]">{formatMessageTime(message.createdAt)}</p>
<ChatMessageReactions
reactions={message.reactions}
onToggle={(emoji) => {
if (!token) return;
void toggleChatMessageReaction(message.id, emoji, token).then((updated) => {
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
});
}}
compact
/>
{!isLargeEmoji ? (
<p className="mt-1 text-[10px] text-[#a8adbc]">{formatMessageTime(message.createdAt)}</p>
) : null}
{!isLargeEmoji ? (
<ChatMessageReactions
reactions={message.reactions}
onToggle={(emoji) => {
if (!token) return;
void toggleChatMessageReaction(message.id, emoji, token).then((updated) => {
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
});
}}
compact
/>
) : null}
</div>
);
return (
<div
key={message.id}
id={`msg-${message.id}`}
className={cn('flex', mine ? 'justify-end' : 'justify-start', isDragging && 'select-none')}
onMouseDown={(event) => {
if (event.button !== 0 || isChatInteractiveTarget(event.target)) return;
startDragSelection(message.id);
}}
onMouseEnter={() => handleDragEnterMessage(message.id)}
onClick={(event) => {
if (selectionMode) return;
if (event.ctrlKey || event.metaKey || event.shiftKey) {
handleSelectionPointer(message.id, event);
}
}}
>
{selectionMode ? (
<SelectableMessageWrapper
selected={isSelected}
selectionMode
align={mine ? 'right' : 'left'}
onClick={() => toggleSelectedMessage(message.id)}
>
{bubble}
</SelectableMessageWrapper>
) : (
<ChatMessageContextMenu
message={message}
userId={user.id}
isE2E={activeRoomIsE2E}
visibleText={visibleText}
onEdit={() => {
setEditingMessageId(message.id);
setDraft(message.content ?? '');
}}
onDelete={() => requestDeleteMessages([message.id])}
onForward={() => showToast('Пересылка доступна в полном виде семьи')}
onSelect={() => enterSelectionMode(message.id)}
onToggleReaction={async (emoji) => {
if (!token) return;
const updated = await toggleChatMessageReaction(message.id, emoji, token);
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
}}
onCopy={() => showToast('Текст скопирован')}
>
{bubble}
</ChatMessageContextMenu>
)}
<div key={message.id}>
{showDateSeparator ? <ChatDateSeparator date={message.createdAt} /> : null}
<div
id={`msg-${message.id}`}
className={cn('group flex gap-1.5', mine ? 'justify-end' : 'justify-start', isDragging && 'select-none')}
onMouseDown={(event) => {
if (isChatInteractiveTarget(event.target)) return;
handleMessagePointerDown(message.id, event);
}}
onMouseEnter={() => handleDragEnterMessage(message.id)}
onClick={(event) => {
if (selectionMode) return;
if (event.ctrlKey || event.metaKey || event.shiftKey) {
handleSelectionPointer(message.id, event);
}
}}
>
{!mine ? (
<UserAvatar
userId={message.senderId}
displayName={message.senderName}
hasAvatar={message.senderHasAvatar}
token={token}
isVerified={message.senderIsVerified}
verificationIcon={message.senderVerificationIcon}
className="z-10 -mr-2 -mt-0.5 h-7 w-7 shrink-0 self-start ring-2 ring-[#eef1f6]"
badgeSize="xs"
/>
) : null}
{selectionMode ? (
<SelectableMessageWrapper
selected={isSelected}
selectionMode
align={mine ? 'right' : 'left'}
onClick={() => toggleSelectedMessage(message.id)}
>
{bubble}
</SelectableMessageWrapper>
) : (
<ChatMessageContextMenu
message={message}
userId={user.id}
isE2E={activeRoomIsE2E}
visibleText={visibleText}
onEdit={() => {
setEditingMessageId(message.id);
setDraft(message.content ?? '');
}}
onDelete={() => requestDeleteMessages([message.id])}
onForward={() => showToast('Пересылка доступна в полном виде семьи')}
onSelect={() => enterSelectionMode(message.id)}
onToggleReaction={async (emoji) => {
if (!token) return;
const updated = await toggleChatMessageReaction(message.id, emoji, token);
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
}}
onCopy={() => showToast('Текст скопирован')}
>
{bubble}
</ChatMessageContextMenu>
)}
{mine ? (
<UserAvatar
userId={user.id}
displayName={user.displayName}
hasAvatar={Boolean(user.hasAvatar)}
token={token}
isVerified={user.isVerified}
verificationIcon={user.verificationIcon}
className="z-10 -ml-2 -mt-0.5 h-7 w-7 shrink-0 self-start ring-2 ring-[#eef1f6]"
badgeSize="xs"
/>
) : null}
</div>
</div>
);
})
@@ -1072,6 +1181,26 @@ export function MiniFamilyChat() {
)}
</div>
) : null}
<ChatToolsDialog
open={chatToolsOpen}
onOpenChange={setChatToolsOpen}
messages={visibleMessages}
defaultTab={chatToolsTab}
onJumpToMessage={scrollToChatMessage}
getMessagePreview={(message) =>
getMessagePreviewText(
message,
getE2EMessageText({
message,
isE2E: activeRoomIsE2E,
peerE2eKey,
peerKeyLoading,
e2ePayload: message.isEncrypted ? e2ePayloads[message.id] : null,
e2eError: e2eErrors[message.id]
})
)
}
/>
<ChatDeleteMessageDialog
open={deleteMessagesDialogOpen}
count={pendingDeleteMessageIds.length}