update and fix messanger
This commit is contained in:
13
apps/frontend/components/chat/chat-date-separator.tsx
Normal file
13
apps/frontend/components/chat/chat-date-separator.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { formatChatDateLabel } from '@/lib/chat-date-utils';
|
||||||
|
|
||||||
|
export function ChatDateSeparator({ date }: { date: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 py-2">
|
||||||
|
<div className="h-px flex-1 bg-black/10" />
|
||||||
|
<span className="shrink-0 text-xs font-medium text-[#667085]">{formatChatDateLabel(date)}</span>
|
||||||
|
<div className="h-px flex-1 bg-black/10" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger
|
ContextMenuTrigger
|
||||||
} from '@/components/ui/context-menu';
|
} from '@/components/ui/context-menu';
|
||||||
|
import { suppressChatDragSelection } from '@/hooks/use-chat-drag-selection';
|
||||||
import type { ChatMessage } from '@/lib/api';
|
import type { ChatMessage } from '@/lib/api';
|
||||||
import { QUICK_REACTIONS, canDeleteMessage, canEditMessage, getMessageCopyText } from '@/lib/chat-message-utils';
|
import { QUICK_REACTIONS, canDeleteMessage, canEditMessage, getMessageCopyText } from '@/lib/chat-message-utils';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -47,8 +48,17 @@ export function ChatMessageContextMenu({
|
|||||||
const copyText = getMessageCopyText(message, visibleText);
|
const copyText = getMessageCopyText(message, visibleText);
|
||||||
const disabled = message.isDeleted;
|
const disabled = message.isDeleted;
|
||||||
|
|
||||||
|
function runMenuAction(action?: () => void) {
|
||||||
|
suppressChatDragSelection();
|
||||||
|
action?.();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu>
|
<ContextMenu
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) suppressChatDragSelection(800);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||||
<ContextMenuContent className="min-w-[240px]">
|
<ContextMenuContent className="min-w-[240px]">
|
||||||
{!disabled ? (
|
{!disabled ? (
|
||||||
@@ -59,7 +69,7 @@ export function ChatMessageContextMenu({
|
|||||||
key={emoji}
|
key={emoji}
|
||||||
type="button"
|
type="button"
|
||||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-lg transition hover:bg-[#eef1f6]"
|
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}
|
{emoji}
|
||||||
</button>
|
</button>
|
||||||
@@ -69,22 +79,35 @@ export function ChatMessageContextMenu({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{onReply && !disabled ? (
|
{onReply && !disabled ? (
|
||||||
<ContextMenuItem onClick={onReply}>
|
<ContextMenuItem
|
||||||
|
onSelect={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
runMenuAction(onReply);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Reply className="h-4 w-4" />
|
<Reply className="h-4 w-4" />
|
||||||
Ответить
|
Ответить
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{editable ? (
|
{editable ? (
|
||||||
<ContextMenuItem onClick={onEdit}>
|
<ContextMenuItem
|
||||||
|
onSelect={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
runMenuAction(onEdit);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
Изменить
|
Изменить
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{copyText ? (
|
{copyText ? (
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => {
|
onSelect={(event) => {
|
||||||
void navigator.clipboard.writeText(copyText);
|
event.preventDefault();
|
||||||
onCopy?.();
|
runMenuAction(() => {
|
||||||
|
void navigator.clipboard.writeText(copyText);
|
||||||
|
onCopy?.();
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
@@ -92,13 +115,24 @@ export function ChatMessageContextMenu({
|
|||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{onForward && !disabled && !message.isEncrypted ? (
|
{onForward && !disabled && !message.isEncrypted ? (
|
||||||
<ContextMenuItem onClick={onForward}>
|
<ContextMenuItem
|
||||||
|
onSelect={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
runMenuAction(onForward);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Forward className="h-4 w-4" />
|
<Forward className="h-4 w-4" />
|
||||||
Переслать
|
Переслать
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
{deletable ? (
|
{deletable ? (
|
||||||
<ContextMenuItem className="text-red-600 data-[highlighted]:bg-red-50 data-[highlighted]:text-red-700" onClick={onDelete}>
|
<ContextMenuItem
|
||||||
|
className="text-red-600 data-[highlighted]:bg-red-50 data-[highlighted]:text-red-700"
|
||||||
|
onSelect={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
runMenuAction(onDelete);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
Удалить
|
Удалить
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
@@ -106,7 +140,12 @@ export function ChatMessageContextMenu({
|
|||||||
{onSelect && !disabled ? (
|
{onSelect && !disabled ? (
|
||||||
<>
|
<>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem onClick={onSelect}>
|
<ContextMenuItem
|
||||||
|
onSelect={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
runMenuAction(onSelect);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CheckCircle2 className="h-4 w-4" />
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
Выделить
|
Выделить
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
|||||||
220
apps/frontend/components/chat/chat-tools-dialog.tsx
Normal file
220
apps/frontend/components/chat/chat-tools-dialog.tsx
Normal file
@@ -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<ChatToolsTab>(defaultTab);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date | undefined>();
|
||||||
|
|
||||||
|
const messageDates = useMemo(() => {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="max-h-[85vh] overflow-hidden rounded-[28px] sm:max-w-[520px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Чат</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex gap-1 rounded-xl bg-[#f4f5f8] p-1">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ 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) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(item.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 items-center justify-center gap-1.5 rounded-lg px-2 py-2 text-xs font-medium transition',
|
||||||
|
tab === item.id ? 'bg-white text-[#1f2430] shadow-sm' : 'text-[#667085] hover:text-[#1f2430]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-3.5 w-3.5" />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[min(52vh,420px)] overflow-y-auto pt-1">
|
||||||
|
{tab === 'search' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#a8adbc]" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Поиск по сообщениям"
|
||||||
|
className="rounded-xl pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!query.trim() ? (
|
||||||
|
<p className="py-6 text-center text-sm text-[#667085]">Введите текст для поиска</p>
|
||||||
|
) : searchResults.length ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{searchResults.map((message) => (
|
||||||
|
<button
|
||||||
|
key={message.id}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full flex-col rounded-xl px-3 py-2 text-left transition hover:bg-[#f4f5f8]"
|
||||||
|
onClick={() => {
|
||||||
|
onJumpToMessage(message.id);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium text-[#3390ec]">{message.senderName}</span>
|
||||||
|
<span className="truncate text-sm text-[#1f2430]">{getMessagePreview(message)}</span>
|
||||||
|
<span className="text-[11px] text-[#a8adbc]">
|
||||||
|
{new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(new Date(message.createdAt))}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="py-6 text-center text-sm text-[#667085]">Ничего не найдено</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tab === 'calendar' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={selectedDate}
|
||||||
|
onSelect={(date) => {
|
||||||
|
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]'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-[#667085]">Перейти к дате</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!event.target.value) return;
|
||||||
|
jumpToDate(new Date(`${event.target.value}T12:00:00`));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#667085]">Точки под датами означают наличие сообщений в этот день</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tab === 'media' ? (
|
||||||
|
mediaMessages.length ? (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{mediaMessages.map((message) => {
|
||||||
|
const url = getMediaUrl?.(message);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={message.id}
|
||||||
|
type="button"
|
||||||
|
className="relative aspect-square overflow-hidden rounded-xl bg-[#eef1f6]"
|
||||||
|
onClick={() => {
|
||||||
|
onJumpToMessage(message.id);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{url ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={url} alt="" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-[#667085]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="py-8 text-center text-sm text-[#667085]">Медиафайлов пока нет</p>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMessagePreview(message: ChatMessage, visibleText?: string) {
|
||||||
|
return getMessagePreviewText(message, visibleText);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { UserAvatar } from '@/components/id/user-avatar';
|
import { UserAvatar } from '@/components/id/user-avatar';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { ChatRoom, apiFetch } from '@/lib/api';
|
import { ChatRoom, apiFetch } from '@/lib/api';
|
||||||
|
import { CHAT_ROOM_AVATAR_UPDATED_EVENT } from '@/lib/avatar-events';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface ChatRoomAvatarDisplayProps {
|
interface ChatRoomAvatarDisplayProps {
|
||||||
@@ -34,10 +35,30 @@ export function ChatRoomAvatarDisplay({ room, viewerUserId, token, className, si
|
|||||||
setRoomAvatarUrl(null);
|
setRoomAvatarUrl(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let cancelled = false;
|
||||||
apiFetch<{ accessUrl: string }>(`/media/chat/${room.id}/avatar/url`, {}, token)
|
apiFetch<{ accessUrl: string }>(`/media/chat/${room.id}/avatar/url`, {}, token)
|
||||||
.then((response) => setRoomAvatarUrl(response.accessUrl))
|
.then((response) => {
|
||||||
.catch(() => setRoomAvatarUrl(null));
|
if (!cancelled) setRoomAvatarUrl(response.accessUrl);
|
||||||
}, [room.hasAvatar, room.id, room.type, token]);
|
})
|
||||||
|
.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')) {
|
if (peerMember && (room.type === 'DIRECT' || room.type === 'E2E')) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,14 +5,17 @@ import { useRouter } from 'next/navigation';
|
|||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
CalendarDays,
|
||||||
Camera,
|
Camera,
|
||||||
Check,
|
Check,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
FileText,
|
FileText,
|
||||||
|
ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
Mic,
|
Mic,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Plus,
|
Plus,
|
||||||
|
Search,
|
||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
Smile,
|
Smile,
|
||||||
@@ -30,6 +33,8 @@ import { BotMiniAppSheet, FamilyBotChat } from '@/components/family/family-bot-c
|
|||||||
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
|
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
|
||||||
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
|
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
|
||||||
import { ChatForwardDialog } from '@/components/chat/chat-forward-dialog';
|
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 { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog';
|
||||||
import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner';
|
import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner';
|
||||||
import { ChatMessageContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
|
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 { useChatMessageSelection } from '@/hooks/use-chat-message-selection';
|
||||||
import { useChatDragSelection } from '@/hooks/use-chat-drag-selection';
|
import { useChatDragSelection } from '@/hooks/use-chat-drag-selection';
|
||||||
import { useChatTyping } from '@/hooks/use-chat-typing';
|
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 { findMemberRoom, readStoredActiveRoomId, roomDisplayLabel, storeActiveRoomId } from '@/lib/family-chat';
|
||||||
import {
|
import {
|
||||||
getChatListPreviewText,
|
getChatListPreviewText,
|
||||||
@@ -243,6 +251,8 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
const [forwarding, setForwarding] = useState(false);
|
const [forwarding, setForwarding] = useState(false);
|
||||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||||
const [replyToMessage, setReplyToMessage] = useState<ChatMessage | null>(null);
|
const [replyToMessage, setReplyToMessage] = useState<ChatMessage | null>(null);
|
||||||
|
const [chatToolsOpen, setChatToolsOpen] = useState(false);
|
||||||
|
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const readReceiptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const readReceiptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -279,7 +289,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
addSelectedMessage,
|
addSelectedMessage,
|
||||||
handleSelectionPointer
|
handleSelectionPointer
|
||||||
} = useChatMessageSelection(visibleMessages);
|
} = useChatMessageSelection(visibleMessages);
|
||||||
const { startDragSelection, handleDragEnterMessage, isDragging } = useChatDragSelection({
|
const { handleMessagePointerDown, handleDragEnterMessage, isDragging } = useChatDragSelection({
|
||||||
selectionMode,
|
selectionMode,
|
||||||
enterSelectionMode,
|
enterSelectionMode,
|
||||||
addSelectedMessage
|
addSelectedMessage
|
||||||
@@ -377,6 +387,14 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
void loadPresence();
|
void loadPresence();
|
||||||
}, [isPinLocked, isReady, loadGroup, loadPresence, showToast, token]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!token || isPinLocked) return;
|
if (!token || isPinLocked) return;
|
||||||
void loadPresence();
|
void loadPresence();
|
||||||
@@ -640,6 +658,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
body: JSON.stringify({ storageKey: presigned.storageKey })
|
body: JSON.stringify({ storageKey: presigned.storageKey })
|
||||||
}, token);
|
}, token);
|
||||||
await loadGroup();
|
await loadGroup();
|
||||||
|
dispatchChatRoomAvatarUpdated(activeRoomId);
|
||||||
showToast('Фото чата обновлено');
|
showToast('Фото чата обновлено');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(getApiErrorMessage(error, 'Не удалось загрузить фото чата') ?? 'Ошибка');
|
showToast(getApiErrorMessage(error, 'Не удалось загрузить фото чата') ?? 'Ошибка');
|
||||||
@@ -1300,6 +1319,46 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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 ? (
|
{canManageActiveBot ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
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')}>
|
<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)) {
|
if (isSystemMessage(message)) {
|
||||||
return (
|
return (
|
||||||
<div key={message.id} id={`msg-${message.id}`} className="flex justify-center px-2 py-1">
|
<div key={message.id}>
|
||||||
<p className="rounded-full bg-black/[0.06] px-3 py-1.5 text-xs text-[#667085]">
|
{showDateSeparator ? <ChatDateSeparator date={message.createdAt} /> : null}
|
||||||
{getMessagePreviewText(message)}
|
<div id={`msg-${message.id}`} className="flex justify-center px-2 py-1">
|
||||||
</p>
|
<p className="rounded-full bg-black/[0.06] px-3 py-1.5 text-xs text-[#667085]">
|
||||||
|
{getMessagePreviewText(message)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1387,12 +1452,25 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
})
|
})
|
||||||
: '';
|
: '';
|
||||||
const isSelected = selectedMessageIds.includes(message.id);
|
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 = (
|
const bubble = (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative rounded-[18px] px-3 py-2 shadow-sm',
|
'relative',
|
||||||
mine ? 'rounded-br-md bg-[#effdde] text-[#1f2430]' : 'rounded-bl-md bg-white text-[#1f2430]',
|
isLargeEmoji
|
||||||
!selectionMode && 'max-w-[78%]'
|
? '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}
|
{!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>
|
</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 ? (
|
) : emojiContent ? (
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
<ChatEmojiContent content={emojiContent} size={32} />
|
<ChatEmojiContent content={emojiContent} size={32} />
|
||||||
@@ -1492,32 +1585,37 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
<ChatEmojiContent content={visibleText} size={22} />
|
<ChatEmojiContent content={visibleText} size={22} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={cn('mt-1 flex items-center justify-end gap-1 text-[11px]', mine ? 'text-[#7ea06a]' : 'text-[#a8adbc]')}>
|
{!isLargeEmoji ? (
|
||||||
{message.editedAt ? <span className="mr-1">изменено</span> : null}
|
<div className={cn('mt-1 flex items-center justify-end gap-1 text-[11px]', mine ? 'text-[#7ea06a]' : 'text-[#a8adbc]')}>
|
||||||
<span>{formatMessageTime(message.createdAt)}</span>
|
{message.editedAt ? <span className="mr-1">изменено</span> : null}
|
||||||
{mine ? (
|
<span>{formatMessageTime(message.createdAt)}</span>
|
||||||
message.readByAll ? (
|
{mine ? (
|
||||||
<CheckCheck className="h-3.5 w-3.5 text-[#3390ec]" aria-label="Прочитано" />
|
message.readByAll ? (
|
||||||
) : (
|
<CheckCheck className="h-3.5 w-3.5 text-[#3390ec]" aria-label="Прочитано" />
|
||||||
<Check className="h-3.5 w-3.5" aria-label="Отправлено" />
|
) : (
|
||||||
)
|
<Check className="h-3.5 w-3.5" aria-label="Отправлено" />
|
||||||
) : null}
|
)
|
||||||
</div>
|
) : null}
|
||||||
<ChatMessageReactions
|
</div>
|
||||||
reactions={message.reactions}
|
) : null}
|
||||||
onToggle={(emoji) => void handleToggleReaction(message.id, emoji)}
|
{!isLargeEmoji ? (
|
||||||
/>
|
<ChatMessageReactions
|
||||||
|
reactions={message.reactions}
|
||||||
|
onToggle={(emoji) => void handleToggleReaction(message.id, emoji)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div key={message.id}>
|
||||||
|
{showDateSeparator ? <ChatDateSeparator date={message.createdAt} /> : null}
|
||||||
<div
|
<div
|
||||||
key={message.id}
|
|
||||||
id={`msg-${message.id}`}
|
id={`msg-${message.id}`}
|
||||||
className={cn('group flex gap-2', mine ? 'justify-end' : 'justify-start', isDragging && 'select-none')}
|
className={cn('group flex gap-2', mine ? 'justify-end' : 'justify-start', isDragging && 'select-none')}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
if (event.button !== 0 || isChatInteractiveTarget(event.target)) return;
|
if (isChatInteractiveTarget(event.target)) return;
|
||||||
startDragSelection(message.id);
|
handleMessagePointerDown(message.id, event);
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => handleDragEnterMessage(message.id)}
|
onMouseEnter={() => handleDragEnterMessage(message.id)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
@@ -1535,7 +1633,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
token={token}
|
token={token}
|
||||||
isVerified={message.senderIsVerified}
|
isVerified={message.senderIsVerified}
|
||||||
verificationIcon={message.senderVerificationIcon}
|
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"
|
badgeSize="xs"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1556,7 +1654,13 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
visibleText={visibleText}
|
visibleText={visibleText}
|
||||||
onEdit={() => void startEditMessage(message)}
|
onEdit={() => void startEditMessage(message)}
|
||||||
onDelete={() => requestDeleteMessages([message.id])}
|
onDelete={() => requestDeleteMessages([message.id])}
|
||||||
onReply={() => setReplyToMessage(message)}
|
onReply={() => {
|
||||||
|
exitSelectionMode();
|
||||||
|
setReplyToMessage(message);
|
||||||
|
setEditingMessageId(null);
|
||||||
|
setDraft('');
|
||||||
|
setEmojiOpen(false);
|
||||||
|
}}
|
||||||
onForward={() => {
|
onForward={() => {
|
||||||
setSelectedMessageIds([message.id]);
|
setSelectedMessageIds([message.id]);
|
||||||
setForwardDialogOpen(true);
|
setForwardDialogOpen(true);
|
||||||
@@ -1568,6 +1672,19 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
{bubble}
|
{bubble}
|
||||||
</ChatMessageContextMenu>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1705,6 +1822,28 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
onSelectRoom={(roomId) => void handleForwardToRoom(roomId)}
|
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
|
<ChatDeleteMessageDialog
|
||||||
open={deleteMessagesDialogOpen}
|
open={deleteMessagesDialogOpen}
|
||||||
count={pendingDeleteMessageIds.length}
|
count={pendingDeleteMessageIds.length}
|
||||||
|
|||||||
@@ -2,19 +2,23 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import Link from 'next/link';
|
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 { AvatarWithPresence } from '@/components/family/avatar-with-presence';
|
||||||
import { FamilyBotChat, BotMiniAppSheet } from '@/components/family/family-bot-chat';
|
import { FamilyBotChat, BotMiniAppSheet } from '@/components/family/family-bot-chat';
|
||||||
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
|
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 { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
|
||||||
import { ChatMessageContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
|
import { ChatMessageContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
|
||||||
import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog';
|
import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog';
|
||||||
import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner';
|
import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner';
|
||||||
import { ChatMessageReactions } from '@/components/chat/chat-message-reactions';
|
import { ChatMessageReactions } from '@/components/chat/chat-message-reactions';
|
||||||
import { ChatSelectionToolbar } from '@/components/chat/chat-selection-toolbar';
|
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 { EmojiPicker } from '@/components/chat/emoji-picker';
|
||||||
|
import { scrollToChatMessage } from '@/components/chat/replied-message-preview';
|
||||||
import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
|
import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
|
import { UserAvatar } from '@/components/id/user-avatar';
|
||||||
import { useToast } from '@/components/id/toast-provider';
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
import { useRealtime } from '@/components/notifications/realtime-provider';
|
import { useRealtime } from '@/components/notifications/realtime-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -52,6 +56,8 @@ import {
|
|||||||
isSystemMessage
|
isSystemMessage
|
||||||
} from '@/lib/chat-message-utils';
|
} from '@/lib/chat-message-utils';
|
||||||
import { detectChatMessageType, parseMessageMetadata, resolveChatContentType } from '@/lib/chat-media';
|
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 { encryptE2EBlob, e2eKindFromMessageType } from '@/lib/e2e-chat';
|
||||||
import { findMemberRoom, findOrCreateMemberChat, roomDisplayLabel } from '@/lib/family-chat';
|
import { findMemberRoom, findOrCreateMemberChat, roomDisplayLabel } from '@/lib/family-chat';
|
||||||
import { getE2EMessageText, useE2EChat } from '@/hooks/use-e2e-chat';
|
import { getE2EMessageText, useE2EChat } from '@/hooks/use-e2e-chat';
|
||||||
@@ -112,6 +118,8 @@ export function MiniFamilyChat() {
|
|||||||
const [pollOpen, setPollOpen] = useState(false);
|
const [pollOpen, setPollOpen] = useState(false);
|
||||||
const [pollQuestion, setPollQuestion] = useState('');
|
const [pollQuestion, setPollQuestion] = useState('');
|
||||||
const [pollOptions, setPollOptions] = useState(['', '']);
|
const [pollOptions, setPollOptions] = useState(['', '']);
|
||||||
|
const [chatToolsOpen, setChatToolsOpen] = useState(false);
|
||||||
|
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const previousGroupIdRef = useRef<string | null>(null);
|
const previousGroupIdRef = useRef<string | null>(null);
|
||||||
@@ -131,7 +139,7 @@ export function MiniFamilyChat() {
|
|||||||
handleSelectionPointer
|
handleSelectionPointer
|
||||||
} = useChatMessageSelection(visibleMessages);
|
} = useChatMessageSelection(visibleMessages);
|
||||||
|
|
||||||
const { startDragSelection, handleDragEnterMessage, isDragging } = useChatDragSelection({
|
const { handleMessagePointerDown, handleDragEnterMessage, isDragging } = useChatDragSelection({
|
||||||
selectionMode,
|
selectionMode,
|
||||||
enterSelectionMode,
|
enterSelectionMode,
|
||||||
addSelectedMessage
|
addSelectedMessage
|
||||||
@@ -684,6 +692,49 @@ export function MiniFamilyChat() {
|
|||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : 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' ? (
|
{view === 'chat' && activeRoom && activeRoom.type !== 'GENERAL' ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -840,13 +891,19 @@ export function MiniFamilyChat() {
|
|||||||
Загрузка...
|
Загрузка...
|
||||||
</div>
|
</div>
|
||||||
) : visibleMessages.length ? (
|
) : visibleMessages.length ? (
|
||||||
visibleMessages.map((message) => {
|
visibleMessages.map((message, messageIndex) => {
|
||||||
|
const previousMessage = visibleMessages[messageIndex - 1];
|
||||||
|
const showDateSeparator = shouldShowChatDateSeparator(message.createdAt, previousMessage?.createdAt);
|
||||||
|
|
||||||
if (isSystemMessage(message)) {
|
if (isSystemMessage(message)) {
|
||||||
return (
|
return (
|
||||||
<div key={message.id} id={`msg-${message.id}`} className="flex justify-center py-1">
|
<div key={message.id}>
|
||||||
<p className="rounded-full bg-black/[0.06] px-3 py-1 text-[11px] text-[#667085]">
|
{showDateSeparator ? <ChatDateSeparator date={message.createdAt} /> : null}
|
||||||
{getMessagePreviewText(message)}
|
<div id={`msg-${message.id}`} className="flex justify-center py-1">
|
||||||
</p>
|
<p className="rounded-full bg-black/[0.06] px-3 py-1 text-[11px] text-[#667085]">
|
||||||
|
{getMessagePreviewText(message)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -866,16 +923,31 @@ export function MiniFamilyChat() {
|
|||||||
? e2ePayload?.emojiId ?? message.content
|
? e2ePayload?.emojiId ?? message.content
|
||||||
: null;
|
: null;
|
||||||
const isSelected = selectedMessageIds.includes(message.id);
|
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 = (
|
const bubble = (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-w-[85%] rounded-[16px] px-3 py-2 text-sm shadow-sm',
|
'relative',
|
||||||
mine ? 'bg-[#effdde]' : 'bg-white',
|
isLargeEmoji
|
||||||
!selectionMode && 'max-w-[85%]'
|
? '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 ? (
|
{message.type === 'POLL' && message.poll ? (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="font-medium">{message.poll.question}</p>
|
<p className="font-medium">{message.poll.question}</p>
|
||||||
@@ -905,6 +977,13 @@ export function MiniFamilyChat() {
|
|||||||
</div>
|
</div>
|
||||||
) : message.storageKey ? (
|
) : message.storageKey ? (
|
||||||
<p className="text-sm">{getMessagePreviewText(message, visibleText)}</p>
|
<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 ? (
|
) : emojiContent ? (
|
||||||
<div className="py-0.5">
|
<div className="py-0.5">
|
||||||
<ChatEmojiContent content={emojiContent} size={28} />
|
<ChatEmojiContent content={emojiContent} size={28} />
|
||||||
@@ -914,69 +993,99 @@ export function MiniFamilyChat() {
|
|||||||
<ChatEmojiContent content={visibleText} size={18} />
|
<ChatEmojiContent content={visibleText} size={18} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="mt-1 text-[10px] text-[#a8adbc]">{formatMessageTime(message.createdAt)}</p>
|
{!isLargeEmoji ? (
|
||||||
<ChatMessageReactions
|
<p className="mt-1 text-[10px] text-[#a8adbc]">{formatMessageTime(message.createdAt)}</p>
|
||||||
reactions={message.reactions}
|
) : null}
|
||||||
onToggle={(emoji) => {
|
{!isLargeEmoji ? (
|
||||||
if (!token) return;
|
<ChatMessageReactions
|
||||||
void toggleChatMessageReaction(message.id, emoji, token).then((updated) => {
|
reactions={message.reactions}
|
||||||
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
|
onToggle={(emoji) => {
|
||||||
});
|
if (!token) return;
|
||||||
}}
|
void toggleChatMessageReaction(message.id, emoji, token).then((updated) => {
|
||||||
compact
|
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
|
||||||
/>
|
});
|
||||||
|
}}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={message.id}>
|
||||||
key={message.id}
|
{showDateSeparator ? <ChatDateSeparator date={message.createdAt} /> : null}
|
||||||
id={`msg-${message.id}`}
|
<div
|
||||||
className={cn('flex', mine ? 'justify-end' : 'justify-start', isDragging && 'select-none')}
|
id={`msg-${message.id}`}
|
||||||
onMouseDown={(event) => {
|
className={cn('group flex gap-1.5', mine ? 'justify-end' : 'justify-start', isDragging && 'select-none')}
|
||||||
if (event.button !== 0 || isChatInteractiveTarget(event.target)) return;
|
onMouseDown={(event) => {
|
||||||
startDragSelection(message.id);
|
if (isChatInteractiveTarget(event.target)) return;
|
||||||
}}
|
handleMessagePointerDown(message.id, event);
|
||||||
onMouseEnter={() => handleDragEnterMessage(message.id)}
|
}}
|
||||||
onClick={(event) => {
|
onMouseEnter={() => handleDragEnterMessage(message.id)}
|
||||||
if (selectionMode) return;
|
onClick={(event) => {
|
||||||
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
if (selectionMode) return;
|
||||||
handleSelectionPointer(message.id, event);
|
if (event.ctrlKey || event.metaKey || event.shiftKey) {
|
||||||
}
|
handleSelectionPointer(message.id, event);
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
{selectionMode ? (
|
>
|
||||||
<SelectableMessageWrapper
|
{!mine ? (
|
||||||
selected={isSelected}
|
<UserAvatar
|
||||||
selectionMode
|
userId={message.senderId}
|
||||||
align={mine ? 'right' : 'left'}
|
displayName={message.senderName}
|
||||||
onClick={() => toggleSelectedMessage(message.id)}
|
hasAvatar={message.senderHasAvatar}
|
||||||
>
|
token={token}
|
||||||
{bubble}
|
isVerified={message.senderIsVerified}
|
||||||
</SelectableMessageWrapper>
|
verificationIcon={message.senderVerificationIcon}
|
||||||
) : (
|
className="z-10 -mr-2 -mt-0.5 h-7 w-7 shrink-0 self-start ring-2 ring-[#eef1f6]"
|
||||||
<ChatMessageContextMenu
|
badgeSize="xs"
|
||||||
message={message}
|
/>
|
||||||
userId={user.id}
|
) : null}
|
||||||
isE2E={activeRoomIsE2E}
|
{selectionMode ? (
|
||||||
visibleText={visibleText}
|
<SelectableMessageWrapper
|
||||||
onEdit={() => {
|
selected={isSelected}
|
||||||
setEditingMessageId(message.id);
|
selectionMode
|
||||||
setDraft(message.content ?? '');
|
align={mine ? 'right' : 'left'}
|
||||||
}}
|
onClick={() => toggleSelectedMessage(message.id)}
|
||||||
onDelete={() => requestDeleteMessages([message.id])}
|
>
|
||||||
onForward={() => showToast('Пересылка доступна в полном виде семьи')}
|
{bubble}
|
||||||
onSelect={() => enterSelectionMode(message.id)}
|
</SelectableMessageWrapper>
|
||||||
onToggleReaction={async (emoji) => {
|
) : (
|
||||||
if (!token) return;
|
<ChatMessageContextMenu
|
||||||
const updated = await toggleChatMessageReaction(message.id, emoji, token);
|
message={message}
|
||||||
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
|
userId={user.id}
|
||||||
}}
|
isE2E={activeRoomIsE2E}
|
||||||
onCopy={() => showToast('Текст скопирован')}
|
visibleText={visibleText}
|
||||||
>
|
onEdit={() => {
|
||||||
{bubble}
|
setEditingMessageId(message.id);
|
||||||
</ChatMessageContextMenu>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -1072,6 +1181,26 @@ export function MiniFamilyChat() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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
|
<ChatDeleteMessageDialog
|
||||||
open={deleteMessagesDialogOpen}
|
open={deleteMessagesDialogOpen}
|
||||||
count={pendingDeleteMessageIds.length}
|
count={pendingDeleteMessageIds.length}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|||||||
import { useToast } from '@/components/id/toast-provider';
|
import { useToast } from '@/components/id/toast-provider';
|
||||||
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
||||||
import { apiFetch, getApiErrorMessage, uploadMediaObject } from '@/lib/api';
|
import { apiFetch, getApiErrorMessage, uploadMediaObject } from '@/lib/api';
|
||||||
|
import { dispatchAvatarUpdated } from '@/lib/avatar-events';
|
||||||
import { VerificationBadge } from '@/components/id/verification-badge';
|
import { VerificationBadge } from '@/components/id/verification-badge';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ export function AvatarUpload({
|
|||||||
|
|
||||||
await onUpdated();
|
await onUpdated();
|
||||||
await refreshAvatarUrl();
|
await refreshAvatarUrl();
|
||||||
|
dispatchAvatarUpdated(userId);
|
||||||
showToast('Аватар обновлён');
|
showToast('Аватар обновлён');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getApiErrorMessage(error, 'Не удалось загрузить аватар');
|
const message = getApiErrorMessage(error, 'Не удалось загрузить аватар');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { getWsUrl, type ChatMessage } from '@/lib/api';
|
import { getWsUrl, type ChatMessage } from '@/lib/api';
|
||||||
|
import { dispatchAvatarUpdated, dispatchChatRoomAvatarUpdated } from '@/lib/avatar-events';
|
||||||
|
|
||||||
export interface RealtimeEvent {
|
export interface RealtimeEvent {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
@@ -74,6 +75,22 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data as string) as RealtimeEvent;
|
const data = JSON.parse(event.data as string) as RealtimeEvent;
|
||||||
emit(data);
|
emit(data);
|
||||||
|
if (data.type === 'user_avatar_updated' && typeof data.payload?.userId === 'string') {
|
||||||
|
dispatchAvatarUpdated(data.payload.userId);
|
||||||
|
}
|
||||||
|
if (data.type === 'chat_message') {
|
||||||
|
const message = data.payload?.message;
|
||||||
|
if (message?.type === 'SYSTEM' && message.roomId && message.metadataJson) {
|
||||||
|
try {
|
||||||
|
const metadata = JSON.parse(message.metadataJson) as { systemKind?: string };
|
||||||
|
if (metadata.systemKind === 'avatar_changed') {
|
||||||
|
dispatchChatRoomAvatarUpdated(message.roomId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
data.type === 'family_invite' ||
|
data.type === 'family_invite' ||
|
||||||
data.type === 'role_assigned' ||
|
data.type === 'role_assigned' ||
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useAuth } from '@/components/id/auth-provider';
|
import { useAuth } from '@/components/id/auth-provider';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import { AVATAR_UPDATED_EVENT } from '@/lib/avatar-events';
|
||||||
|
|
||||||
interface AvatarAccessResponse {
|
interface AvatarAccessResponse {
|
||||||
accessUrl: string;
|
accessUrl: string;
|
||||||
@@ -35,5 +36,17 @@ export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | un
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [hasAvatar, refresh]);
|
}, [hasAvatar, refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId || !hasAvatar) return;
|
||||||
|
function handleAvatarUpdated(event: Event) {
|
||||||
|
const detail = (event as CustomEvent<{ userId?: string }>).detail;
|
||||||
|
if (detail?.userId === userId) {
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener(AVATAR_UPDATED_EVENT, handleAvatarUpdated);
|
||||||
|
return () => window.removeEventListener(AVATAR_UPDATED_EVENT, handleAvatarUpdated);
|
||||||
|
}, [hasAvatar, refresh, userId]);
|
||||||
|
|
||||||
return { avatarUrl, refreshAvatarUrl: refresh };
|
return { avatarUrl, refreshAvatarUrl: refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react';
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD_PX = 6;
|
||||||
|
|
||||||
interface UseChatDragSelectionOptions {
|
interface UseChatDragSelectionOptions {
|
||||||
selectionMode: boolean;
|
selectionMode: boolean;
|
||||||
@@ -8,6 +10,16 @@ interface UseChatDragSelectionOptions {
|
|||||||
addSelectedMessage: (messageId: string) => void;
|
addSelectedMessage: (messageId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dragSuppressedUntilRef = { current: 0 };
|
||||||
|
|
||||||
|
export function suppressChatDragSelection(durationMs = 400) {
|
||||||
|
dragSuppressedUntilRef.current = Date.now() + durationMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDragSuppressed() {
|
||||||
|
return Date.now() < dragSuppressedUntilRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
export function useChatDragSelection({
|
export function useChatDragSelection({
|
||||||
selectionMode,
|
selectionMode,
|
||||||
enterSelectionMode,
|
enterSelectionMode,
|
||||||
@@ -15,9 +27,11 @@ export function useChatDragSelection({
|
|||||||
}: UseChatDragSelectionOptions) {
|
}: UseChatDragSelectionOptions) {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const visitedRef = useRef<Set<string>>(new Set());
|
const visitedRef = useRef<Set<string>>(new Set());
|
||||||
|
const pendingDragRef = useRef<{ messageId: string; x: number; y: number } | null>(null);
|
||||||
|
|
||||||
const startDragSelection = useCallback(
|
const startDragSelection = useCallback(
|
||||||
(messageId: string) => {
|
(messageId: string) => {
|
||||||
|
if (isDragSuppressed()) return;
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
visitedRef.current = new Set([messageId]);
|
visitedRef.current = new Set([messageId]);
|
||||||
if (!selectionMode) {
|
if (!selectionMode) {
|
||||||
@@ -29,6 +43,14 @@ export function useChatDragSelection({
|
|||||||
[addSelectedMessage, enterSelectionMode, selectionMode]
|
[addSelectedMessage, enterSelectionMode, selectionMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleMessagePointerDown = useCallback(
|
||||||
|
(messageId: string, event: ReactMouseEvent) => {
|
||||||
|
if (event.button !== 0 || isDragSuppressed()) return;
|
||||||
|
pendingDragRef.current = { messageId, x: event.clientX, y: event.clientY };
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDragEnterMessage = useCallback(
|
const handleDragEnterMessage = useCallback(
|
||||||
(messageId: string) => {
|
(messageId: string) => {
|
||||||
if (!isDragging || visitedRef.current.has(messageId)) return;
|
if (!isDragging || visitedRef.current.has(messageId)) return;
|
||||||
@@ -39,18 +61,33 @@ export function useChatDragSelection({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const endDragSelection = useCallback(() => {
|
const endDragSelection = useCallback(() => {
|
||||||
|
pendingDragRef.current = null;
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
visitedRef.current.clear();
|
visitedRef.current.clear();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const onMouseMove = (event: MouseEvent) => {
|
||||||
|
const pending = pendingDragRef.current;
|
||||||
|
if (!pending || isDragSuppressed()) return;
|
||||||
|
const distance = Math.hypot(event.clientX - pending.x, event.clientY - pending.y);
|
||||||
|
if (distance < DRAG_THRESHOLD_PX) return;
|
||||||
|
const messageId = pending.messageId;
|
||||||
|
pendingDragRef.current = null;
|
||||||
|
startDragSelection(messageId);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', onMouseMove);
|
||||||
window.addEventListener('mouseup', endDragSelection);
|
window.addEventListener('mouseup', endDragSelection);
|
||||||
return () => window.removeEventListener('mouseup', endDragSelection);
|
return () => {
|
||||||
}, [endDragSelection]);
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
|
window.removeEventListener('mouseup', endDragSelection);
|
||||||
|
};
|
||||||
|
}, [endDragSelection, startDragSelection]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDragging,
|
isDragging,
|
||||||
startDragSelection,
|
handleMessagePointerDown,
|
||||||
handleDragEnterMessage,
|
handleDragEnterMessage,
|
||||||
endDragSelection
|
endDragSelection
|
||||||
};
|
};
|
||||||
|
|||||||
12
apps/frontend/lib/avatar-events.ts
Normal file
12
apps/frontend/lib/avatar-events.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export const AVATAR_UPDATED_EVENT = 'id:avatar-updated';
|
||||||
|
export const CHAT_ROOM_AVATAR_UPDATED_EVENT = 'id:chat-room-avatar-updated';
|
||||||
|
|
||||||
|
export function dispatchAvatarUpdated(userId: string) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.dispatchEvent(new CustomEvent(AVATAR_UPDATED_EVENT, { detail: { userId } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchChatRoomAvatarUpdated(roomId: string) {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.dispatchEvent(new CustomEvent(CHAT_ROOM_AVATAR_UPDATED_EVENT, { detail: { roomId } }));
|
||||||
|
}
|
||||||
33
apps/frontend/lib/chat-date-utils.ts
Normal file
33
apps/frontend/lib/chat-date-utils.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export function getChatDateKey(value: string | Date) {
|
||||||
|
const date = typeof value === 'string' ? new Date(value) : value;
|
||||||
|
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatChatDateLabel(value: string, now = new Date()) {
|
||||||
|
const date = new Date(value);
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const target = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||||
|
const diffDays = Math.round((today.getTime() - target.getTime()) / 86_400_000);
|
||||||
|
|
||||||
|
if (diffDays === 0) return 'Сегодня';
|
||||||
|
if (diffDays === 1) return 'Вчера';
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldShowChatDateSeparator(currentCreatedAt: string, previousCreatedAt?: string) {
|
||||||
|
if (!previousCreatedAt) return true;
|
||||||
|
return getChatDateKey(currentCreatedAt) !== getChatDateKey(previousCreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDateInputValue(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
43
apps/frontend/lib/chat-emoji-display.ts
Normal file
43
apps/frontend/lib/chat-emoji-display.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { isEmojiId } from '@/lib/emoji-catalog';
|
||||||
|
import { EMOJI_NATIVE_MAP, emojiIdToNative, resolveNativeEmojiContent } from '@/lib/emoji-native-map';
|
||||||
|
|
||||||
|
function countGraphemes(text: string) {
|
||||||
|
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
|
||||||
|
return [...new Intl.Segmenter('ru', { granularity: 'grapheme' }).segment(text)].length;
|
||||||
|
}
|
||||||
|
return [...text].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEmojiDisplayText(content?: string | null, visibleText?: string | null) {
|
||||||
|
const raw = (visibleText ?? content ?? '').trim();
|
||||||
|
if (!raw) return '';
|
||||||
|
if (isEmojiId(raw)) return emojiIdToNative(raw) ?? raw;
|
||||||
|
if (EMOJI_NATIVE_MAP[raw]) return EMOJI_NATIVE_MAP[raw]!;
|
||||||
|
return resolveNativeEmojiContent(raw).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSingleEmojiMessage(options: {
|
||||||
|
type?: string;
|
||||||
|
content?: string | null;
|
||||||
|
visibleText?: string | null;
|
||||||
|
}) {
|
||||||
|
if (options.type === 'POLL' || options.type === 'IMAGE' || options.type === 'VOICE' || options.type === 'AUDIO' || options.type === 'FILE') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = resolveEmojiDisplayText(options.content, options.visibleText);
|
||||||
|
if (!text) return false;
|
||||||
|
if (countGraphemes(text) !== 1) return false;
|
||||||
|
return /\p{Extended_Pictographic}/u.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSingleEmojiNative(options: {
|
||||||
|
type?: string;
|
||||||
|
content?: string | null;
|
||||||
|
visibleText?: string | null;
|
||||||
|
}) {
|
||||||
|
if (options.type === 'EMOJI') {
|
||||||
|
return resolveEmojiDisplayText(options.content, options.visibleText);
|
||||||
|
}
|
||||||
|
return resolveEmojiDisplayText(options.content, options.visibleText);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { GetObjectCommand } from '@aws-sdk/client-s3';
|
|||||||
import { PrismaService } from '../infra/prisma.service';
|
import { PrismaService } from '../infra/prisma.service';
|
||||||
import { AccessService } from './access.service';
|
import { AccessService } from './access.service';
|
||||||
import { ChatService } from './chat.service';
|
import { ChatService } from './chat.service';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
import { MinioService } from '../infra/minio.service';
|
import { MinioService } from '../infra/minio.service';
|
||||||
|
|
||||||
const AVATAR_ACCESS_TTL_SECONDS = 900;
|
const AVATAR_ACCESS_TTL_SECONDS = 900;
|
||||||
@@ -34,7 +35,8 @@ export class MediaService {
|
|||||||
private readonly jwt: JwtService,
|
private readonly jwt: JwtService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly access: AccessService,
|
private readonly access: AccessService,
|
||||||
private readonly chat: ChatService
|
private readonly chat: ChatService,
|
||||||
|
private readonly notifications: NotificationsService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createAvatarUploadUrl(userId: string, contentType: string) {
|
async createAvatarUploadUrl(userId: string, contentType: string) {
|
||||||
@@ -61,6 +63,8 @@ export class MediaService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.publishUserAvatarUpdated(userId);
|
||||||
|
|
||||||
return { userId, hasAvatar: true };
|
return { userId, hasAvatar: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,12 +74,7 @@ export class MediaService {
|
|||||||
throw new NotFoundException('Аватар не найден');
|
throw new NotFoundException('Аватар не найден');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requesterId !== targetUserId) {
|
await this.assertCanViewUserAvatar(requesterId, targetUserId);
|
||||||
const requester = await this.prisma.user.findUnique({ where: { id: requesterId } });
|
|
||||||
if (!requester?.isSuperAdmin) {
|
|
||||||
throw new ForbiddenException('Нет доступа к аватару пользователя');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = await this.jwt.signAsync(
|
const token = await this.jwt.signAsync(
|
||||||
{
|
{
|
||||||
@@ -130,11 +129,11 @@ export class MediaService {
|
|||||||
throw new ForbiddenException('Ссылка недействительна');
|
throw new ForbiddenException('Ссылка недействительна');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.objectKey.startsWith('chat/') || payload.objectKey.startsWith('chat-rooms/')) {
|
if (payload.objectKey.startsWith('chat/')) {
|
||||||
if (!requesterId) {
|
if (!requesterId) {
|
||||||
throw new ForbiddenException('Для доступа к файлу чата требуется авторизация');
|
throw new ForbiddenException('Для доступа к файлу чата требуется авторизация');
|
||||||
}
|
}
|
||||||
const roomId = payload.roomId ?? this.extractChatRoomId(payload.objectKey) ?? this.extractChatRoomAvatarRoomId(payload.objectKey);
|
const roomId = payload.roomId ?? this.extractChatRoomId(payload.objectKey);
|
||||||
if (!roomId) {
|
if (!roomId) {
|
||||||
throw new ForbiddenException('Некорректный ключ файла чата');
|
throw new ForbiddenException('Некорректный ключ файла чата');
|
||||||
}
|
}
|
||||||
@@ -360,4 +359,60 @@ export class MediaService {
|
|||||||
throw new ForbiddenException('Нет доступа к чату');
|
throw new ForbiddenException('Нет доступа к чату');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async publishUserAvatarUpdated(userId: string) {
|
||||||
|
const memberships = await this.prisma.familyMember.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { groupId: true }
|
||||||
|
});
|
||||||
|
const groupIds = memberships.map((member) => member.groupId);
|
||||||
|
if (!groupIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coMembers = await this.prisma.familyMember.findMany({
|
||||||
|
where: { groupId: { in: groupIds }, userId: { not: userId } },
|
||||||
|
select: { userId: true }
|
||||||
|
});
|
||||||
|
const recipientIds = [...new Set(coMembers.map((member) => member.userId))];
|
||||||
|
await Promise.all(
|
||||||
|
recipientIds.map((memberId) =>
|
||||||
|
this.notifications.publishRealtime(memberId, 'user_avatar_updated', '', '', { userId })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanViewUserAvatar(requesterId: string, targetUserId: string) {
|
||||||
|
if (requesterId === targetUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requester = await this.prisma.user.findUnique({ where: { id: requesterId } });
|
||||||
|
if (requester?.isSuperAdmin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [requesterFamilies, targetFamilies] = await Promise.all([
|
||||||
|
this.prisma.familyMember.findMany({ where: { userId: requesterId }, select: { groupId: true } }),
|
||||||
|
this.prisma.familyMember.findMany({ where: { userId: targetUserId }, select: { groupId: true } })
|
||||||
|
]);
|
||||||
|
const targetGroupIds = new Set(targetFamilies.map((member) => member.groupId));
|
||||||
|
if (requesterFamilies.some((member) => targetGroupIds.has(member.groupId))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedChat = await this.prisma.chatRoomMember.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: requesterId,
|
||||||
|
room: {
|
||||||
|
members: {
|
||||||
|
some: { userId: targetUserId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!sharedChat) {
|
||||||
|
throw new ForbiddenException('Нет доступа к аватару пользователя');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user