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

@@ -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>
);
}

View File

@@ -9,6 +9,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu';
import { suppressChatDragSelection } from '@/hooks/use-chat-drag-selection';
import type { ChatMessage } from '@/lib/api';
import { QUICK_REACTIONS, canDeleteMessage, canEditMessage, getMessageCopyText } from '@/lib/chat-message-utils';
import { cn } from '@/lib/utils';
@@ -47,8 +48,17 @@ export function ChatMessageContextMenu({
const copyText = getMessageCopyText(message, visibleText);
const disabled = message.isDeleted;
function runMenuAction(action?: () => void) {
suppressChatDragSelection();
action?.();
}
return (
<ContextMenu>
<ContextMenu
onOpenChange={(open) => {
if (open) suppressChatDragSelection(800);
}}
>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="min-w-[240px]">
{!disabled ? (
@@ -59,7 +69,7 @@ export function ChatMessageContextMenu({
key={emoji}
type="button"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-lg transition hover:bg-[#eef1f6]"
onClick={() => onToggleReaction?.(emoji)}
onClick={() => runMenuAction(() => onToggleReaction?.(emoji))}
>
{emoji}
</button>
@@ -69,22 +79,35 @@ export function ChatMessageContextMenu({
</>
) : null}
{onReply && !disabled ? (
<ContextMenuItem onClick={onReply}>
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onReply);
}}
>
<Reply className="h-4 w-4" />
Ответить
</ContextMenuItem>
) : null}
{editable ? (
<ContextMenuItem onClick={onEdit}>
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onEdit);
}}
>
<Pencil className="h-4 w-4" />
Изменить
</ContextMenuItem>
) : null}
{copyText ? (
<ContextMenuItem
onClick={() => {
void navigator.clipboard.writeText(copyText);
onCopy?.();
onSelect={(event) => {
event.preventDefault();
runMenuAction(() => {
void navigator.clipboard.writeText(copyText);
onCopy?.();
});
}}
>
<Copy className="h-4 w-4" />
@@ -92,13 +115,24 @@ export function ChatMessageContextMenu({
</ContextMenuItem>
) : null}
{onForward && !disabled && !message.isEncrypted ? (
<ContextMenuItem onClick={onForward}>
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onForward);
}}
>
<Forward className="h-4 w-4" />
Переслать
</ContextMenuItem>
) : null}
{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" />
Удалить
</ContextMenuItem>
@@ -106,7 +140,12 @@ export function ChatMessageContextMenu({
{onSelect && !disabled ? (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={onSelect}>
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onSelect);
}}
>
<CheckCircle2 className="h-4 w-4" />
Выделить
</ContextMenuItem>

View 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);
}