- Добавлены active_chat_* поля в user_presence и endpoint /api/devices/active-chat - Сервер не шлёт Web Push/Pushy, если получатель смотрит в тот же чат на видимой вкладке - Клиент сообщает серверу и Service Worker об активном чате из Chat.tsx и TaskChat.tsx - TaskChat: входящие сообщения автоматически отмечаются прочитанными при видимой вкладке - SW: подавляет foreground/system push, если активный чат совпадает
2449 lines
104 KiB
TypeScript
2449 lines
104 KiB
TypeScript
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||
import { useLocation, useParams } from 'wouter';
|
||
import { format, isToday, isYesterday } from 'date-fns';
|
||
import { apiRequest, queryClient } from '@/lib/queryClient';
|
||
import {
|
||
enqueueAttachment,
|
||
getAllAttachments,
|
||
registerAttachmentBackgroundSync,
|
||
dispatchAttachmentQueueUpdated,
|
||
} from '@/lib/attachmentQueue';
|
||
import { getAll as getAllQueued } from '@/lib/offlineQueue';
|
||
import { reportActiveChat, clearActiveChat, setActiveChatInSw, clearActiveChatInSw } from '@/lib/pwa';
|
||
import { useAuth } from '@/hooks/useAuth';
|
||
import { useEvents } from '@/hooks/useEvents';
|
||
import { useToast } from '@/hooks/use-toast';
|
||
import { Sidebar } from '@/components/Sidebar';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { AutoResizeTextarea } from '@/components/ui/auto-resize-textarea';
|
||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||
import { getAvatarColor, getUserInitials } from '@/lib/avatar';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import {
|
||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||
} from '@/components/ui/dialog';
|
||
import {
|
||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||
} from '@/components/ui/dropdown-menu';
|
||
import {
|
||
MessageSquare, Plus, Send, Reply, X, Edit, Check, MoreVertical,
|
||
Users, Trash2, UserPlus, AtSign, UserMinus, BarChart2, Link2, ExternalLink, Paperclip, Loader2,
|
||
Clock,
|
||
ChevronLeft, Bell, BellOff, Smile,
|
||
} from 'lucide-react';
|
||
import { isPWAInstalled } from '@/lib/pwa';
|
||
import { PendingAttachmentList, AttachmentDisplay } from '@/components/chat/ChatAttachments';
|
||
import type { ChatAttachment } from '@/components/chat/ChatAttachments';
|
||
import { cn } from '@/lib/utils';
|
||
import { ReactionBar, type ReactionGroup } from '@/components/chat/ReactionBar';
|
||
import { PollCard, type PollData } from '@/components/chat/PollCard';
|
||
import { PollCreateDialog } from '@/components/chat/PollCreateDialog';
|
||
|
||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||
|
||
interface ConvUser {
|
||
id: number;
|
||
firstName: string;
|
||
lastName: string;
|
||
middleName?: string;
|
||
email?: string | null;
|
||
position?: string;
|
||
lastReadAt?: string | null;
|
||
}
|
||
|
||
interface ConvBot {
|
||
id: number;
|
||
name: string;
|
||
avatarUrl?: string | null;
|
||
}
|
||
|
||
interface ConvMessage {
|
||
id: number;
|
||
conversationId: number;
|
||
message: string;
|
||
replyToId?: number | null;
|
||
mentionedUserIds?: number[] | null;
|
||
attachments?: ChatAttachment[] | null;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
isDeleted: boolean;
|
||
author: { id: number; firstName: string; lastName: string; middleName?: string } | null;
|
||
bot?: ConvBot | null;
|
||
replyTo?: { id: number; message: string; author: { id: number; firstName: string; lastName: string; middleName?: string } | null } | null;
|
||
pending?: boolean;
|
||
queueItemId?: string;
|
||
attachmentQueueItemId?: string;
|
||
}
|
||
|
||
interface Conversation {
|
||
id: number;
|
||
type: 'direct' | 'group' | 'bot_direct';
|
||
name?: string | null;
|
||
displayName: string;
|
||
organizationId: number;
|
||
createdBy: number;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
members: ConvUser[];
|
||
unreadCount: number;
|
||
lastMessage?: ConvMessage | null;
|
||
isExternal?: boolean;
|
||
isDefault?: boolean;
|
||
isMuted?: boolean;
|
||
bot?: ConvBot | null;
|
||
botId?: number | null;
|
||
}
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
|
||
function formatDateHeader(date: Date) {
|
||
if (isToday(date)) return 'Сегодня';
|
||
if (isYesterday(date)) return 'Вчера';
|
||
return format(date, 'dd.MM.yyyy');
|
||
}
|
||
|
||
function groupByDate(messages: ConvMessage[]) {
|
||
const groups: { dateKey: string; date: Date; messages: ConvMessage[] }[] = [];
|
||
for (const msg of messages) {
|
||
if (!msg.createdAt) continue;
|
||
const d = new Date(msg.createdAt);
|
||
if (isNaN(d.getTime())) continue;
|
||
const key = format(d, 'yyyy-MM-dd');
|
||
const last = groups[groups.length - 1];
|
||
if (last && last.dateKey === key) {
|
||
last.messages.push(msg);
|
||
} else {
|
||
groups.push({ dateKey: key, date: d, messages: [msg] });
|
||
}
|
||
}
|
||
return groups;
|
||
}
|
||
|
||
// ── Message bubble ─────────────────────────────────────────────────────────────
|
||
|
||
function MessageBubble({
|
||
msg, isOwn, onReply, onEdit, onDelete, reactions, poll, onReactionsChange, onPollChange,
|
||
onOpenUserChat, onOpenBotChat,
|
||
}: {
|
||
msg: ConvMessage;
|
||
isOwn: boolean;
|
||
onReply: (m: ConvMessage) => void;
|
||
onEdit: (m: ConvMessage) => void;
|
||
onDelete: (m: ConvMessage) => void;
|
||
reactions?: ReactionGroup[];
|
||
poll?: PollData;
|
||
onReactionsChange?: (r: ReactionGroup[]) => void;
|
||
onPollChange?: (p: PollData) => void;
|
||
onOpenUserChat?: (userId: number) => void;
|
||
onOpenBotChat?: (botId: number) => void;
|
||
}) {
|
||
const [pickerOpen, setPickerOpen] = useState(false);
|
||
const isEdited = msg.updatedAt && msg.updatedAt !== msg.createdAt;
|
||
|
||
if (msg.isDeleted) {
|
||
return (
|
||
<div className={cn("flex", isOwn ? "justify-end" : "justify-start")} data-msg-id={msg.id}>
|
||
<span className="text-xs text-muted-foreground italic px-3 py-1">Сообщение удалено</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={cn("flex group", isOwn ? "justify-end" : "justify-start")} data-msg-id={msg.id}>
|
||
{!isOwn && (
|
||
<Avatar className="w-6 h-6 mr-2 mt-1 shrink-0">
|
||
{msg.bot?.avatarUrl
|
||
? <img src={msg.bot.avatarUrl} alt={msg.bot.name} className="w-full h-full object-cover rounded-full" />
|
||
: (
|
||
<AvatarFallback className={cn("text-[9px]", msg.author ? `${getAvatarColor(msg.author.id)} text-white` : "")}>
|
||
{msg.bot
|
||
? <span className="text-[10px]">🤖</span>
|
||
: msg.author ? getUserInitials(msg.author.firstName, msg.author.lastName, msg.author.middleName) : '?'
|
||
}
|
||
</AvatarFallback>
|
||
)
|
||
}
|
||
</Avatar>
|
||
)}
|
||
<div className={cn("max-w-[85%] md:max-w-[70%] flex flex-col", isOwn ? "items-end" : "items-start")}>
|
||
{!isOwn && (
|
||
<span className="text-[10px] text-muted-foreground mb-0.5 px-1">
|
||
{msg.bot ? (
|
||
<button
|
||
className="hover:underline cursor-pointer text-left"
|
||
onClick={(e) => { e.stopPropagation(); onOpenBotChat?.(msg.bot!.id); }}
|
||
>
|
||
{msg.bot.name}
|
||
</button>
|
||
) : (
|
||
msg.author ? (
|
||
<button
|
||
className="hover:underline cursor-pointer text-left"
|
||
onClick={(e) => { e.stopPropagation(); onOpenUserChat?.(msg.author!.id); }}
|
||
>
|
||
{msg.author.firstName} {msg.author.middleName || ''} {msg.author.lastName}
|
||
</button>
|
||
) : ''
|
||
)}
|
||
</span>
|
||
)}
|
||
|
||
{msg.replyTo && (
|
||
<button
|
||
type="button"
|
||
className="mb-1 px-2 py-1 rounded border-l-2 border-primary bg-muted/50 text-xs text-muted-foreground max-w-full text-left w-full cursor-pointer hover:bg-muted transition-colors"
|
||
onClick={() => {
|
||
const el = document.querySelector(`[data-msg-id="${msg.replyTo!.id}"]`);
|
||
if (el) {
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
el.classList.add('bg-yellow-100', 'dark:bg-yellow-900/30', 'transition-colors', 'duration-500');
|
||
setTimeout(() => {
|
||
el.classList.remove('bg-yellow-100', 'dark:bg-yellow-900/30', 'transition-colors', 'duration-500');
|
||
}, 2000);
|
||
}
|
||
}}
|
||
>
|
||
<span className="font-medium">{msg.replyTo.author?.firstName} {msg.replyTo.author?.middleName || ''} {msg.replyTo.author?.lastName}:</span>{' '}
|
||
<span className="line-clamp-1">{msg.replyTo.message}</span>
|
||
</button>
|
||
)}
|
||
|
||
<div className="flex items-end gap-1 min-w-0">
|
||
{isOwn && (
|
||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-0.5 mb-0.5 hidden md:flex">
|
||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => onReply(msg)}>
|
||
<Reply className="h-3 w-3" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-5 w-5"
|
||
onClick={() => onReactionsChange && setPickerOpen(v => !v)}
|
||
title="Реакция"
|
||
>
|
||
<Smile className="h-3 w-3" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => onEdit(msg)}>
|
||
<Edit className="h-3 w-3" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" className="h-5 w-5 text-destructive" onClick={() => onDelete(msg)}>
|
||
<Trash2 className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
className={cn(
|
||
"rounded-2xl px-3 py-2 text-sm break-words whitespace-pre-wrap select-text max-w-full min-w-0 [overflow-wrap:anywhere]",
|
||
isOwn
|
||
? "bg-primary text-primary-foreground rounded-br-sm"
|
||
: "bg-muted rounded-bl-sm",
|
||
pickerOpen && "ring-2 ring-primary/40"
|
||
)}
|
||
>
|
||
{msg.message}
|
||
</div>
|
||
|
||
{!isOwn && (
|
||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-0.5 mb-0.5 hidden md:flex">
|
||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => onReply(msg)}>
|
||
<Reply className="h-3 w-3" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-5 w-5"
|
||
onClick={() => onReactionsChange && setPickerOpen(v => !v)}
|
||
title="Реакция"
|
||
>
|
||
<Smile className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{msg.attachments && msg.attachments.length > 0 && (
|
||
<AttachmentDisplay attachments={msg.attachments} isOwn={isOwn} />
|
||
)}
|
||
|
||
{/* Опрос */}
|
||
{poll && onPollChange && (
|
||
<PollCard poll={poll} onPollChange={onPollChange} isOwn={isOwn} />
|
||
)}
|
||
|
||
{/* Реакции — пикер открывается по клику на пузырёк */}
|
||
{onReactionsChange && (
|
||
<ReactionBar
|
||
messageId={msg.id}
|
||
messageType="conv"
|
||
reactions={reactions ?? []}
|
||
onReactionsChange={onReactionsChange}
|
||
className={isOwn ? "justify-end" : ""}
|
||
pickerOpen={pickerOpen}
|
||
onPickerOpenChange={setPickerOpen}
|
||
/>
|
||
)}
|
||
|
||
<span className="text-[10px] text-muted-foreground mt-0.5 px-1 flex items-center gap-1">
|
||
{msg.pending && (
|
||
<>
|
||
<Clock className="w-3 h-3" />
|
||
<span>отправка</span>
|
||
</>
|
||
)}
|
||
{format(new Date(msg.createdAt), 'HH:mm')}
|
||
{isEdited && ' · изменено'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Types for connections ──────────────────────────────────────────────────────
|
||
|
||
interface OrgConnectionItem {
|
||
id: number;
|
||
status: string;
|
||
isInitiator: boolean;
|
||
initiatorOrg: { id: number; displayName: string } | null;
|
||
partnerOrg: { id: number; displayName: string } | null;
|
||
}
|
||
|
||
interface ExternalUser {
|
||
id: number;
|
||
firstName: string;
|
||
lastName: string;
|
||
middleName?: string;
|
||
position?: string | null;
|
||
organizationId: number;
|
||
}
|
||
|
||
// ── New Conversation Dialog ────────────────────────────────────────────────────
|
||
|
||
function NewConversationDialog({
|
||
open,
|
||
onOpenChange,
|
||
onCreated,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (v: boolean) => void;
|
||
onCreated: (conv: Conversation) => void;
|
||
}) {
|
||
const [tab, setTab] = useState<'internal' | 'external'>('internal');
|
||
const [mode, setMode] = useState<'direct' | 'group'>('direct');
|
||
const [groupName, setGroupName] = useState('');
|
||
const [search, setSearch] = useState('');
|
||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||
|
||
// External state
|
||
const [selectedConnId, setSelectedConnId] = useState<number | null>(null);
|
||
const [extSearch, setExtSearch] = useState('');
|
||
const [extSelectedIds, setExtSelectedIds] = useState<number[]>([]);
|
||
const [extGroupName, setExtGroupName] = useState('');
|
||
|
||
const { toast } = useToast();
|
||
|
||
// Internal users
|
||
const { data: usersData } = useQuery<{ success: boolean; users: ConvUser[] }>({
|
||
queryKey: ['/api/users/list'],
|
||
enabled: open && tab === 'internal',
|
||
});
|
||
const allUsers = usersData?.users ?? [];
|
||
const filtered = useMemo(() => {
|
||
const list = allUsers.filter(u =>
|
||
!search || `${u.firstName} ${u.middleName || ''} ${u.lastName} ${u.email}`.toLowerCase().includes(search.toLowerCase())
|
||
);
|
||
list.sort((a, b) => {
|
||
const aSel = selectedIds.includes(a.id);
|
||
const bSel = selectedIds.includes(b.id);
|
||
if (aSel === bSel) return 0;
|
||
return aSel ? -1 : 1;
|
||
});
|
||
return list;
|
||
}, [allUsers, search, selectedIds]);
|
||
|
||
// Connections list — uses non-admin endpoint (active only, no tokens)
|
||
const { data: connData } = useQuery<{ success: boolean; connections: OrgConnectionItem[] }>({
|
||
queryKey: ['/api/org-connections/active'],
|
||
enabled: open && tab === 'external',
|
||
});
|
||
const activeConns = (connData?.connections ?? []).filter(c => c.status === 'active');
|
||
|
||
// External users for selected connection
|
||
const { data: extUsersData, isLoading: extUsersLoading } = useQuery<{ success: boolean; users: ExternalUser[]; partnerOrgId: number }>({
|
||
queryKey: ['/api/org-connections', selectedConnId, 'users'],
|
||
enabled: open && tab === 'external' && !!selectedConnId,
|
||
queryFn: async () => {
|
||
const res = await apiRequest('GET', `/api/org-connections/${selectedConnId}/users`);
|
||
return res.json();
|
||
},
|
||
});
|
||
const extUsers = useMemo(() => {
|
||
const list = (extUsersData?.users ?? []).filter(u =>
|
||
!extSearch || `${u.firstName} ${u.middleName || ''} ${u.lastName}`.toLowerCase().includes(extSearch.toLowerCase())
|
||
);
|
||
list.sort((a, b) => {
|
||
const aSel = extSelectedIds.includes(a.id);
|
||
const bSel = extSelectedIds.includes(b.id);
|
||
if (aSel === bSel) return 0;
|
||
return aSel ? -1 : 1;
|
||
});
|
||
return list;
|
||
}, [extUsersData, extSearch, extSelectedIds]);
|
||
|
||
const selectedConn = activeConns.find(c => c.id === selectedConnId);
|
||
|
||
const reset = () => {
|
||
setSelectedIds([]);
|
||
setGroupName('');
|
||
setSearch('');
|
||
setExtSelectedIds([]);
|
||
setExtGroupName('');
|
||
setExtSearch('');
|
||
setSelectedConnId(null);
|
||
};
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: async () => {
|
||
if (tab === 'internal') {
|
||
if (mode === 'direct') {
|
||
if (selectedIds.length !== 1) throw new Error('Выберите одного пользователя');
|
||
const res = await apiRequest('POST', '/api/messenger/conversations', {
|
||
type: 'direct',
|
||
targetUserId: selectedIds[0],
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error);
|
||
return data.conversation as Conversation;
|
||
} else {
|
||
if (!groupName.trim()) throw new Error('Введите название чата');
|
||
if (selectedIds.length === 0) throw new Error('Добавьте хотя бы одного участника');
|
||
const res = await apiRequest('POST', '/api/messenger/conversations', {
|
||
type: 'group',
|
||
name: groupName.trim(),
|
||
memberIds: selectedIds,
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error);
|
||
return data.conversation as Conversation;
|
||
}
|
||
} else {
|
||
// External
|
||
if (!selectedConnId) throw new Error('Выберите подключение');
|
||
if (mode === 'direct') {
|
||
if (extSelectedIds.length !== 1) throw new Error('Выберите одного пользователя');
|
||
const res = await apiRequest('POST', '/api/messenger/conversations', {
|
||
type: 'external_direct',
|
||
connectionId: selectedConnId,
|
||
targetUserId: extSelectedIds[0],
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error);
|
||
return data.conversation as Conversation;
|
||
} else {
|
||
if (!extGroupName.trim()) throw new Error('Введите название группы');
|
||
if (extSelectedIds.length === 0) throw new Error('Добавьте хотя бы одного участника');
|
||
const res = await apiRequest('POST', '/api/messenger/conversations', {
|
||
type: 'external_group',
|
||
connectionId: selectedConnId,
|
||
name: extGroupName.trim(),
|
||
memberIds: extSelectedIds,
|
||
});
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error);
|
||
return data.conversation as Conversation;
|
||
}
|
||
}
|
||
},
|
||
onSuccess: (conv) => {
|
||
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
|
||
onCreated(conv);
|
||
onOpenChange(false);
|
||
reset();
|
||
},
|
||
onError: (err: any) => {
|
||
toast({ variant: 'destructive', title: err.message });
|
||
},
|
||
});
|
||
|
||
const toggleUser = useCallback((id: number) => {
|
||
if (mode === 'direct') {
|
||
// Автопереключение в групповой режим при выборе второго пользователя
|
||
if (selectedIds.length === 1 && selectedIds[0] !== id) {
|
||
setMode('group');
|
||
setSelectedIds([selectedIds[0], id]);
|
||
} else {
|
||
setSelectedIds([id]);
|
||
}
|
||
} else {
|
||
setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]);
|
||
}
|
||
}, [mode, selectedIds]);
|
||
|
||
const toggleExtUser = useCallback((id: number) => {
|
||
if (mode === 'direct') {
|
||
// Автопереключение в групповой режим при выборе второго пользователя
|
||
if (extSelectedIds.length === 1 && extSelectedIds[0] !== id) {
|
||
setMode('group');
|
||
setExtSelectedIds([extSelectedIds[0], id]);
|
||
} else {
|
||
setExtSelectedIds([id]);
|
||
}
|
||
} else {
|
||
setExtSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]);
|
||
}
|
||
}, [mode, extSelectedIds]);
|
||
|
||
const getConnName = (conn: OrgConnectionItem) => {
|
||
const partner = conn.isInitiator ? conn.partnerOrg : conn.initiatorOrg;
|
||
return partner?.displayName ?? 'Партнёр';
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) reset(); }}>
|
||
<DialogContent className="max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle>Новый чат</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
{/* Tab: внутренний / внешний */}
|
||
<div className="flex gap-1 mb-3 border border-border rounded p-0.5 bg-muted/30">
|
||
<button
|
||
className={cn(
|
||
"flex-1 flex items-center justify-center gap-1.5 text-xs py-1 rounded transition-colors",
|
||
tab === 'internal' ? "bg-background shadow-sm font-medium" : "text-muted-foreground hover:text-foreground"
|
||
)}
|
||
onClick={() => { setTab('internal'); reset(); }}
|
||
>
|
||
<Users className="w-3.5 h-3.5" />
|
||
Моя организация
|
||
</button>
|
||
<button
|
||
className={cn(
|
||
"flex-1 flex items-center justify-center gap-1.5 text-xs py-1 rounded transition-colors",
|
||
tab === 'external' ? "bg-background shadow-sm font-medium" : "text-muted-foreground hover:text-foreground"
|
||
)}
|
||
onClick={() => { setTab('external'); reset(); }}
|
||
>
|
||
<Link2 className="w-3.5 h-3.5" />
|
||
Внешние контакты
|
||
</button>
|
||
</div>
|
||
|
||
{/* Тип чата */}
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<div className="flex gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant={mode === 'direct' ? 'default' : 'outline'}
|
||
className="h-7 px-3 text-xs"
|
||
onClick={() => { setMode('direct'); setSelectedIds([]); setExtSelectedIds([]); }}
|
||
>
|
||
Личный
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant={mode === 'group' ? 'default' : 'outline'}
|
||
className="h-7 px-3 text-xs"
|
||
onClick={() => { setMode('group'); setSelectedIds([]); setExtSelectedIds([]); }}
|
||
>
|
||
<Users className="w-3.5 h-3.5 mr-1.5" />
|
||
Группа
|
||
</Button>
|
||
</div>
|
||
<Badge variant={mode === 'group' ? 'default' : 'secondary'} className="text-[10px] h-5 px-1.5">
|
||
{mode === 'group' ? 'Групповой чат' : 'Личный чат'}
|
||
</Badge>
|
||
</div>
|
||
|
||
{/* ── Internal ── */}
|
||
{tab === 'internal' && (
|
||
<>
|
||
{mode === 'group' && (
|
||
<Input
|
||
placeholder="Название группы"
|
||
value={groupName}
|
||
onChange={e => setGroupName(e.target.value)}
|
||
className="mb-2 h-8 text-sm"
|
||
/>
|
||
)}
|
||
<Input
|
||
placeholder="Поиск пользователей..."
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
className="mb-2 h-8 text-sm"
|
||
/>
|
||
<ScrollArea className="h-52 border rounded-md">
|
||
<div className="p-1 space-y-0.5">
|
||
{filtered.map(u => (
|
||
<div
|
||
key={u.id}
|
||
className={cn(
|
||
"flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer hover:bg-muted transition-colors",
|
||
selectedIds.includes(u.id) && "bg-primary/10"
|
||
)}
|
||
onClick={() => toggleUser(u.id)}
|
||
>
|
||
<Avatar className="w-6 h-6 shrink-0">
|
||
<AvatarFallback className={cn(getAvatarColor(u.id), "text-white text-[8px]")}>
|
||
{getUserInitials(u.firstName, u.lastName, u.middleName)}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-xs font-medium truncate">{u.firstName} {u.middleName || ''} {u.lastName}</div>
|
||
{u.position && <div className="text-[10px] text-muted-foreground truncate">{u.position}</div>}
|
||
</div>
|
||
{selectedIds.includes(u.id) && <Check className="w-3.5 h-3.5 text-primary shrink-0" />}
|
||
</div>
|
||
))}
|
||
{filtered.length === 0 && (
|
||
<div className="text-xs text-muted-foreground text-center py-4">Пользователи не найдены</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</>
|
||
)}
|
||
|
||
{/* ── External ── */}
|
||
{tab === 'external' && (
|
||
<>
|
||
{activeConns.length === 0 ? (
|
||
<div className="border border-border rounded p-4 text-center space-y-2">
|
||
<Link2 className="w-6 h-6 mx-auto text-muted-foreground opacity-50" />
|
||
<p className="text-xs text-muted-foreground">
|
||
Нет активных подключений. Настройте кросс-организационное подключение в разделе <strong>Настройки → Подключения</strong>.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Выбор подключения */}
|
||
{!selectedConnId ? (
|
||
<div className="space-y-1.5">
|
||
<p className="text-[11px] text-muted-foreground">Выберите партнёрскую организацию:</p>
|
||
{activeConns.map(conn => (
|
||
<div
|
||
key={conn.id}
|
||
className="flex items-center gap-2 px-3 py-2 rounded border border-border cursor-pointer hover:bg-muted transition-colors"
|
||
onClick={() => setSelectedConnId(conn.id)}
|
||
>
|
||
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||
<span className="text-sm font-medium">{getConnName(conn)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Заголовок партнёра + кнопка назад */}
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<button
|
||
className="text-[11px] text-muted-foreground hover:text-foreground"
|
||
onClick={() => { setSelectedConnId(null); setExtSelectedIds([]); }}
|
||
>
|
||
← Назад
|
||
</button>
|
||
<span className="text-[11px] font-medium">{selectedConn ? getConnName(selectedConn) : ''}</span>
|
||
</div>
|
||
|
||
{mode === 'group' && (
|
||
<Input
|
||
placeholder="Название группы"
|
||
value={extGroupName}
|
||
onChange={e => setExtGroupName(e.target.value)}
|
||
className="mb-2 h-8 text-sm"
|
||
/>
|
||
)}
|
||
|
||
<Input
|
||
placeholder="Поиск пользователей..."
|
||
value={extSearch}
|
||
onChange={e => setExtSearch(e.target.value)}
|
||
className="mb-2 h-8 text-sm"
|
||
/>
|
||
|
||
<ScrollArea className="h-44 border rounded-md">
|
||
<div className="p-1 space-y-0.5">
|
||
{extUsersLoading && (
|
||
<div className="text-xs text-muted-foreground text-center py-4">Загрузка...</div>
|
||
)}
|
||
{!extUsersLoading && extUsers.map(u => (
|
||
<div
|
||
key={u.id}
|
||
className={cn(
|
||
"flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer hover:bg-muted transition-colors",
|
||
extSelectedIds.includes(u.id) && "bg-primary/10"
|
||
)}
|
||
onClick={() => toggleExtUser(u.id)}
|
||
>
|
||
<Avatar className="w-6 h-6 shrink-0">
|
||
<AvatarFallback className="text-[9px]">
|
||
{getUserInitials(u.firstName, u.lastName, u.middleName)}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-xs font-medium truncate">{u.firstName} {u.middleName || ''} {u.lastName}</div>
|
||
{u.position && <div className="text-[10px] text-muted-foreground truncate">{u.position}</div>}
|
||
</div>
|
||
{extSelectedIds.includes(u.id) && <Check className="w-3.5 h-3.5 text-primary shrink-0" />}
|
||
</div>
|
||
))}
|
||
{!extUsersLoading && extUsers.length === 0 && (
|
||
<div className="text-xs text-muted-foreground text-center py-4">Нет пользователей</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
<DialogFooter>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => createMutation.mutate()}
|
||
disabled={createMutation.isPending}
|
||
>
|
||
{createMutation.isPending ? 'Создание...' : 'Создать'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// ── Group Members Dialog (unified: view + add) ─────────────────────────────────
|
||
|
||
function GroupMembersDialog({
|
||
open,
|
||
onOpenChange,
|
||
conversation,
|
||
currentUserId,
|
||
currentUserRole,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (v: boolean) => void;
|
||
conversation: Conversation;
|
||
currentUserId: number;
|
||
currentUserRole: string;
|
||
}) {
|
||
const { toast } = useToast();
|
||
const [addSearch, setAddSearch] = useState('');
|
||
const [showAdd, setShowAdd] = useState(false);
|
||
|
||
// Определяем, является ли текущий пользователь "администратором группы"
|
||
// Для «Наша Фирма» (isDefault) — только org admin; для остальных групп — создатель или org admin
|
||
const isGroupAdmin = conversation.isDefault
|
||
? currentUserRole === 'admin'
|
||
: (conversation.createdBy === currentUserId || currentUserRole === 'admin');
|
||
|
||
const canLeave = !conversation.isDefault && conversation.createdBy !== currentUserId;
|
||
const canDeleteGroup = isGroupAdmin && !conversation.isDefault;
|
||
|
||
// Список пользователей для добавления
|
||
const { data: usersData } = useQuery<{ success: boolean; users: ConvUser[] }>({
|
||
queryKey: ['/api/users/list'],
|
||
enabled: open && isGroupAdmin && showAdd,
|
||
});
|
||
const existingIds = new Set(conversation.members.map(m => m.id));
|
||
const availableToAdd = (usersData?.users ?? [])
|
||
.filter(u => !existingIds.has(u.id))
|
||
.filter(u => !addSearch || `${u.firstName} ${u.middleName || ''} ${u.lastName}`.toLowerCase().includes(addSearch.toLowerCase()));
|
||
|
||
const addMutation = useMutation({
|
||
mutationFn: async (targetUserId: number) => {
|
||
const res = await apiRequest('POST', `/api/messenger/conversations/${conversation.id}/members`, { targetUserId });
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error);
|
||
return data;
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
|
||
setShowAdd(false);
|
||
setAddSearch('');
|
||
},
|
||
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
|
||
});
|
||
|
||
const removeMutation = useMutation({
|
||
mutationFn: async (memberId: number) => {
|
||
const res = await apiRequest('DELETE', `/api/messenger/conversations/${conversation.id}/members/${memberId}`);
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error);
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
|
||
if (removeMutation.variables === currentUserId) onOpenChange(false);
|
||
},
|
||
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
|
||
});
|
||
|
||
const deleteGroupMutation = useMutation({
|
||
mutationFn: async () => {
|
||
const res = await apiRequest('DELETE', `/api/messenger/conversations/${conversation.id}`);
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error);
|
||
},
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
|
||
onOpenChange(false);
|
||
toast({ title: 'Группа удалена' });
|
||
},
|
||
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
|
||
});
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) { setShowAdd(false); setAddSearch(''); } }}>
|
||
<DialogContent className="max-w-sm">
|
||
<DialogHeader>
|
||
<DialogTitle>{conversation.displayName} · участники</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
{/* Список участников */}
|
||
<ScrollArea className="h-56 border rounded-md">
|
||
<div className="p-1 space-y-0.5">
|
||
{conversation.members.map(m => {
|
||
const isMe = m.id === currentUserId;
|
||
const isCreatorTag = conversation.createdBy === m.id && !conversation.isDefault;
|
||
const canRemove = isGroupAdmin && m.id !== currentUserId;
|
||
return (
|
||
<div key={m.id} className="flex items-center gap-2 px-2 py-1.5 rounded">
|
||
<Avatar className="w-6 h-6 shrink-0">
|
||
<AvatarFallback className={cn(getAvatarColor(m.id), "text-white text-[8px]")}>{getUserInitials(m.firstName, m.lastName, m.middleName)}</AvatarFallback>
|
||
</Avatar>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-xs font-medium truncate">
|
||
{m.firstName} {m.middleName || ''} {m.lastName}
|
||
{isMe && <span className="text-muted-foreground font-normal"> (я)</span>}
|
||
{isCreatorTag && <span className="text-muted-foreground font-normal"> · создатель</span>}
|
||
</div>
|
||
{m.position && <div className="text-[10px] text-muted-foreground truncate">{m.position}</div>}
|
||
</div>
|
||
{canRemove && (
|
||
<Button
|
||
size="icon" variant="ghost"
|
||
className="h-5 w-5 text-destructive shrink-0"
|
||
onClick={() => removeMutation.mutate(m.id)}
|
||
disabled={removeMutation.isPending}
|
||
>
|
||
<UserMinus className="h-3 w-3" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</ScrollArea>
|
||
|
||
{/* Добавить участника (только для группового администратора) */}
|
||
{isGroupAdmin && (
|
||
<>
|
||
{!showAdd ? (
|
||
<Button variant="outline" size="sm" className="w-full" onClick={() => setShowAdd(true)}>
|
||
<UserPlus className="w-3.5 h-3.5 mr-1.5" />
|
||
Добавить участника
|
||
</Button>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<Input
|
||
autoFocus
|
||
placeholder="Поиск пользователя..."
|
||
value={addSearch}
|
||
onChange={e => setAddSearch(e.target.value)}
|
||
className="h-8 text-xs"
|
||
/>
|
||
<ScrollArea className="h-36 border rounded-md">
|
||
<div className="p-1 space-y-0.5">
|
||
{availableToAdd.map(u => (
|
||
<div
|
||
key={u.id}
|
||
className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer hover:bg-muted"
|
||
onClick={() => addMutation.mutate(u.id)}
|
||
>
|
||
<Avatar className="w-6 h-6 shrink-0">
|
||
<AvatarFallback className={cn(getAvatarColor(u.id), "text-white text-[8px]")}>{getUserInitials(u.firstName, u.lastName, u.middleName)}</AvatarFallback>
|
||
</Avatar>
|
||
<span className="text-xs">{u.firstName} {u.middleName || ''} {u.lastName}</span>
|
||
</div>
|
||
))}
|
||
{availableToAdd.length === 0 && (
|
||
<div className="text-xs text-muted-foreground text-center py-4">Нет доступных пользователей</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
<Button variant="ghost" size="sm" className="w-full text-xs" onClick={() => { setShowAdd(false); setAddSearch(''); }}>
|
||
Отмена
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Удалить группу (только для создателя или админа) */}
|
||
{canDeleteGroup && (
|
||
<Button
|
||
variant="destructive" size="sm" className="w-full"
|
||
onClick={() => {
|
||
if (confirm('Удалить группу навсегда? Это действие нельзя отменить.')) {
|
||
deleteGroupMutation.mutate();
|
||
}
|
||
}}
|
||
disabled={deleteGroupMutation.isPending}
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5 mr-1.5" />
|
||
Удалить группу
|
||
</Button>
|
||
)}
|
||
|
||
{/* Покинуть группу (только для не-создателей, не «Наша Фирма») */}
|
||
{canLeave && (
|
||
<Button
|
||
variant="destructive" size="sm" className="w-full"
|
||
onClick={() => removeMutation.mutate(currentUserId)}
|
||
disabled={removeMutation.isPending}
|
||
>
|
||
Покинуть группу
|
||
</Button>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// ── Highlight matching substring in search results ────────────────────────────
|
||
|
||
function HighlightMatch({ text, query }: { text: string; query: string }) {
|
||
if (!query.trim()) return <>{text}</>;
|
||
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
||
if (idx === -1) return <>{text}</>;
|
||
return (
|
||
<>
|
||
{text.slice(0, idx)}
|
||
<mark className="bg-yellow-200 dark:bg-yellow-700 text-inherit rounded-sm px-0">{text.slice(idx, idx + query.length)}</mark>
|
||
{text.slice(idx + query.length)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ── Main Chat Page ────────────────────────────────────────────────────────────
|
||
|
||
export default function ChatPage() {
|
||
const { user } = useAuth();
|
||
const { toast } = useToast();
|
||
const [location, setLocation] = useLocation();
|
||
const params = useParams<{ conversationId?: string }>();
|
||
|
||
const [activeConvId, setActiveConvId] = useState<number | null>(null);
|
||
const [newConvOpen, setNewConvOpen] = useState(false);
|
||
const [groupMembersOpen, setGroupMembersOpen] = useState(false);
|
||
const [replyTo, setReplyTo] = useState<ConvMessage | null>(null);
|
||
const [editingMsg, setEditingMsg] = useState<ConvMessage | null>(null);
|
||
const [messageText, setMessageText] = useState('');
|
||
const [editText, setEditText] = useState('');
|
||
const [mentionedUserIds, setMentionedUserIds] = useState<number[]>([]);
|
||
const [mentionSearch, setMentionSearch] = useState<string | null>(null);
|
||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
// Reactions & polls
|
||
const [reactionsMap, setReactionsMap] = useState<Record<number, ReactionGroup[]>>({});
|
||
const [pollsMap, setPollsMap] = useState<Record<number, PollData>>({});
|
||
const [pollDialogOpen, setPollDialogOpen] = useState(false);
|
||
// Search
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||
// File attachments
|
||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const [isDragOver, setIsDragOver] = useState(false);
|
||
// Tracks IndexedDB queue item IDs that are still pending. Used by the select
|
||
// function to keep optimistic messages after server refetches.
|
||
const activeQueueIdsRef = useRef<Set<string>>(new Set());
|
||
const activeAttachmentIdsRef = useRef<Set<string>>(new Set());
|
||
|
||
// Pagination for loading older messages
|
||
const [olderMessages, setOlderMessages] = useState<ConvMessage[]>([]);
|
||
const [hasMore, setHasMore] = useState(false);
|
||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||
|
||
// Мобильный / PWA режим
|
||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768 || isPWAInstalled());
|
||
const [mobileView, setMobileView] = useState<'list' | 'chat'>('list');
|
||
|
||
useEffect(() => {
|
||
const mq = window.matchMedia('(max-width: 767px)');
|
||
const update = () => setIsMobile(mq.matches || isPWAInstalled());
|
||
mq.addEventListener('change', update);
|
||
return () => mq.removeEventListener('change', update);
|
||
}, []);
|
||
|
||
// Debounce поиска
|
||
useEffect(() => {
|
||
const t = setTimeout(() => setDebouncedSearch(searchQuery), 200);
|
||
return () => clearTimeout(t);
|
||
}, [searchQuery]);
|
||
|
||
// ── Queries ──────────────────────────────────────────────────────────────────
|
||
|
||
const { data: searchData } = useQuery<{
|
||
success: boolean;
|
||
people: { id: number; firstName: string; lastName: string; middleName?: string; position?: string | null }[];
|
||
groups: { id: number; name: string }[];
|
||
external: { id: number; firstName: string; lastName: string; middleName?: string; position?: string | null; organizationId: number; connectionId: number }[];
|
||
bots: { id: number; name: string; login: string; avatarUrl?: string | null }[];
|
||
}>({
|
||
queryKey: ['/api/messenger/search', debouncedSearch],
|
||
queryFn: async () => {
|
||
if (!debouncedSearch.trim()) return { success: true, people: [], groups: [], external: [], bots: [] };
|
||
const res = await apiRequest('GET', `/api/messenger/search?q=${encodeURIComponent(debouncedSearch)}`);
|
||
return res.json();
|
||
},
|
||
enabled: debouncedSearch.trim().length > 0,
|
||
staleTime: 10000,
|
||
});
|
||
const searchResults = (searchQuery.trim() && debouncedSearch.trim()) ? searchData : null;
|
||
|
||
const { data: convsData, isLoading: convsLoading } = useQuery<{ success: boolean; conversations: Conversation[] }>({
|
||
queryKey: ['/api/messenger/conversations'],
|
||
refetchInterval: false,
|
||
});
|
||
const conversations = convsData?.conversations ?? [];
|
||
|
||
// Автооткрытие чата при переходе по push deep-link (/chat/:conversationId)
|
||
useEffect(() => {
|
||
if (!params?.conversationId) return;
|
||
const targetId = parseInt(params.conversationId, 10);
|
||
if (isNaN(targetId)) return;
|
||
setActiveConvId(targetId);
|
||
if (isMobile) setMobileView('chat');
|
||
}, [params?.conversationId, conversations.length]);
|
||
|
||
// Auto-mark read when a conversation is opened (including from URL deep-link)
|
||
useEffect(() => {
|
||
if (!activeConvId) return;
|
||
markReadMutation.mutate(activeConvId);
|
||
}, [activeConvId]);
|
||
|
||
// Auto-mark read when user returns to the tab/window with an open conversation
|
||
useEffect(() => {
|
||
if (!activeConvId) return;
|
||
const handleVisibility = () => {
|
||
if (document.visibilityState === 'visible') {
|
||
markReadMutation.mutate(activeConvId);
|
||
}
|
||
};
|
||
const handleFocus = () => {
|
||
markReadMutation.mutate(activeConvId);
|
||
};
|
||
document.addEventListener('visibilitychange', handleVisibility);
|
||
window.addEventListener('focus', handleFocus);
|
||
return () => {
|
||
document.removeEventListener('visibilitychange', handleVisibility);
|
||
window.removeEventListener('focus', handleFocus);
|
||
};
|
||
}, [activeConvId]);
|
||
|
||
// Report active chat to server and SW so push is suppressed while this chat is open.
|
||
useEffect(() => {
|
||
if (!activeConvId) return;
|
||
|
||
const report = (visible: boolean) => {
|
||
reportActiveChat('messenger', activeConvId, visible);
|
||
setActiveChatInSw('messenger', activeConvId, visible);
|
||
};
|
||
|
||
report(document.visibilityState === 'visible');
|
||
|
||
const handleVisibility = () => {
|
||
report(document.visibilityState === 'visible');
|
||
};
|
||
|
||
document.addEventListener('visibilitychange', handleVisibility);
|
||
return () => {
|
||
document.removeEventListener('visibilitychange', handleVisibility);
|
||
clearActiveChat();
|
||
clearActiveChatInSw();
|
||
};
|
||
}, [activeConvId]);
|
||
|
||
const activeConv = conversations.find(c => c.id === activeConvId) ?? null;
|
||
|
||
const { data: msgsData, isLoading: msgsLoading } = useQuery<{ success: boolean; messages: ConvMessage[] }>({
|
||
queryKey: ['/api/messenger/conversations', activeConvId, 'messages'],
|
||
enabled: !!activeConvId,
|
||
refetchInterval: false,
|
||
// Reconciliation: merge optimistic pending messages back after server refetches
|
||
select: (data) => {
|
||
if (!data) return data;
|
||
const currentData = queryClient.getQueryData(['/api/messenger/conversations', activeConvId, 'messages']) as any;
|
||
if (!currentData?.messages) return data;
|
||
const optimistic = currentData.messages.filter((msg: ConvMessage) => msg.id < 0);
|
||
if (optimistic.length === 0) return data;
|
||
const serverIds = new Set(data.messages.map((m: ConvMessage) => m.id));
|
||
const activeQueueIds = activeQueueIdsRef.current;
|
||
const activeAttachmentIds = activeAttachmentIdsRef.current;
|
||
const uniqueOptimistic = optimistic.filter((msg: ConvMessage) => {
|
||
if (serverIds.has(msg.id)) return false;
|
||
if (msg.queueItemId) return activeQueueIds.has(msg.queueItemId);
|
||
if (msg.attachmentQueueItemId) return activeAttachmentIds.has(msg.attachmentQueueItemId);
|
||
return true;
|
||
});
|
||
if (uniqueOptimistic.length === 0) return data;
|
||
return { ...data, messages: [...data.messages, ...uniqueOptimistic] };
|
||
},
|
||
});
|
||
const initialMessages = msgsData?.messages ?? [];
|
||
|
||
// Reset pagination state on conversation switch
|
||
useEffect(() => {
|
||
setOlderMessages([]);
|
||
setHasMore(false);
|
||
}, [activeConvId]);
|
||
|
||
// Track whether there are more messages to load (50 is the default page size).
|
||
// Only update from the initial batch when no older pages have been loaded yet —
|
||
// once the user starts paginating, hasMore is exclusively controlled by loadMoreMessages.
|
||
useEffect(() => {
|
||
if (!msgsLoading && msgsData?.messages && olderMessages.length === 0) {
|
||
setHasMore(msgsData.messages.length >= 50);
|
||
}
|
||
}, [msgsData, msgsLoading]); // intentionally omit olderMessages to avoid loop
|
||
|
||
// Combined messages: older (paginated) prepended before the initial batch
|
||
const messages = [...olderMessages, ...initialMessages];
|
||
|
||
// Keep active queue ID refs in sync with IndexedDB. This lets the select
|
||
// function below preserve optimistic messages after server refetches.
|
||
useEffect(() => {
|
||
const refreshActiveIds = async () => {
|
||
try {
|
||
const [queued, attachments] = await Promise.all([
|
||
getAllQueued(),
|
||
getAllAttachments(),
|
||
]);
|
||
activeQueueIdsRef.current = new Set(queued.map(i => i.id));
|
||
activeAttachmentIdsRef.current = new Set(attachments.map(i => i.id));
|
||
|
||
// Proactively remove stale pending messages from this conversation's cache
|
||
queryClient.setQueryData(
|
||
['/api/messenger/conversations', activeConvId, 'messages'],
|
||
(old: any) => {
|
||
if (!old?.messages) return old;
|
||
const cleaned = old.messages.filter((msg: ConvMessage) => {
|
||
if (msg.id >= 0) return true;
|
||
if (msg.queueItemId) return activeQueueIdsRef.current.has(msg.queueItemId);
|
||
if (msg.attachmentQueueItemId) return activeAttachmentIdsRef.current.has(msg.attachmentQueueItemId);
|
||
return true;
|
||
});
|
||
if (cleaned.length === old.messages.length) return old;
|
||
return { ...old, messages: cleaned };
|
||
}
|
||
);
|
||
} catch {
|
||
// IndexedDB not available
|
||
}
|
||
};
|
||
|
||
refreshActiveIds();
|
||
|
||
window.addEventListener('offline-queue-updated', refreshActiveIds);
|
||
window.addEventListener('attachment-queue-updated', refreshActiveIds);
|
||
return () => {
|
||
window.removeEventListener('offline-queue-updated', refreshActiveIds);
|
||
window.removeEventListener('attachment-queue-updated', refreshActiveIds);
|
||
};
|
||
}, [activeConvId]);
|
||
|
||
// Hydrate pending attachment messages from IndexedDB on mount
|
||
useEffect(() => {
|
||
if (!user || !activeConvId) return;
|
||
|
||
const hydrateFromAttachmentQueue = async () => {
|
||
try {
|
||
const items = await getAllAttachments();
|
||
const chatItems = items.filter(
|
||
item => item.type === 'messenger' && item.targetId === activeConvId
|
||
);
|
||
|
||
if (chatItems.length === 0) return;
|
||
|
||
queryClient.setQueryData(
|
||
['/api/messenger/conversations', activeConvId, 'messages'],
|
||
(old: any) => {
|
||
const currentMessages: ConvMessage[] = old?.messages ?? [];
|
||
const existingIds = new Set(
|
||
currentMessages.filter(m => m.attachmentQueueItemId).map(m => m.attachmentQueueItemId!)
|
||
);
|
||
|
||
const pendingMessages: ConvMessage[] = [];
|
||
chatItems.forEach((item, index) => {
|
||
if (existingIds.has(item.id)) return;
|
||
|
||
const stableId = -((item.enqueuedAt % 900000) + (index + 1) * 1000 + 1);
|
||
|
||
const pendingMsg: ConvMessage = {
|
||
id: stableId,
|
||
conversationId: activeConvId,
|
||
message: item.message,
|
||
replyToId: item.replyToId || item.replyToMessageId || null,
|
||
mentionedUserIds: item.mentionedUserIds || null,
|
||
attachments: item.files.map(f => ({
|
||
name: f.name,
|
||
size: f.blob.size,
|
||
mimeType: f.type || undefined,
|
||
url: '',
|
||
})),
|
||
createdAt: new Date(item.enqueuedAt).toISOString(),
|
||
updatedAt: new Date(item.enqueuedAt).toISOString(),
|
||
isDeleted: false,
|
||
author: { id: user.id, firstName: user.firstName, lastName: user.lastName, middleName: (user as any).middleName },
|
||
replyTo: replyTo ? { id: replyTo.id, message: replyTo.message, author: replyTo.author } : null,
|
||
pending: true,
|
||
attachmentQueueItemId: item.id,
|
||
};
|
||
pendingMessages.push(pendingMsg);
|
||
});
|
||
|
||
if (pendingMessages.length === 0) return old;
|
||
|
||
return {
|
||
...(old ?? {}),
|
||
messages: [...currentMessages, ...pendingMessages],
|
||
};
|
||
}
|
||
);
|
||
} catch {
|
||
// IndexedDB not available
|
||
}
|
||
};
|
||
|
||
hydrateFromAttachmentQueue();
|
||
}, [activeConvId, user]);
|
||
|
||
// Remove completed attachment messages from optimistic cache
|
||
useEffect(() => {
|
||
if (!user || !activeConvId) return;
|
||
|
||
const refreshAttachmentIds = async () => {
|
||
try {
|
||
const items = await getAllAttachments();
|
||
const activeIds = new Set(items.map(i => i.id));
|
||
|
||
queryClient.setQueryData(
|
||
['/api/messenger/conversations', activeConvId, 'messages'],
|
||
(old: any) => {
|
||
if (!old?.messages) return old;
|
||
const cleaned = old.messages.filter((msg: ConvMessage) => {
|
||
if (msg.id >= 0) return true;
|
||
if (!msg.attachmentQueueItemId) return true;
|
||
return activeIds.has(msg.attachmentQueueItemId);
|
||
});
|
||
if (cleaned.length === old.messages.length) return old;
|
||
return { ...old, messages: cleaned };
|
||
}
|
||
);
|
||
} catch {
|
||
// IndexedDB not available
|
||
}
|
||
};
|
||
|
||
refreshAttachmentIds();
|
||
window.addEventListener('attachment-queue-updated', refreshAttachmentIds);
|
||
return () => window.removeEventListener('attachment-queue-updated', refreshAttachmentIds);
|
||
}, [activeConvId, user]);
|
||
|
||
const loadMoreMessages = async () => {
|
||
if (!activeConvId || isLoadingMore || !hasMore) return;
|
||
const oldestId = olderMessages.length > 0 ? olderMessages[0].id : (initialMessages[0]?.id ?? null);
|
||
if (!oldestId) return;
|
||
|
||
// Capture scroll position before prepending to preserve viewport after DOM update
|
||
const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]');
|
||
const prevScrollHeight = viewport?.scrollHeight ?? 0;
|
||
const prevScrollTop = viewport?.scrollTop ?? 0;
|
||
|
||
setIsLoadingMore(true);
|
||
try {
|
||
const res = await apiRequest('GET', `/api/messenger/conversations/${activeConvId}/messages?beforeId=${oldestId}&limit=50`);
|
||
const data = await res.json();
|
||
if (data.success && Array.isArray(data.messages)) {
|
||
setOlderMessages(prev => [...data.messages, ...prev]);
|
||
setHasMore(data.messages.length >= 50);
|
||
// Restore scroll position so the user stays at the same spot after prepend
|
||
requestAnimationFrame(() => {
|
||
const vp = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]');
|
||
if (vp) {
|
||
vp.scrollTop = prevScrollTop + (vp.scrollHeight - prevScrollHeight);
|
||
}
|
||
});
|
||
}
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
setIsLoadingMore(false);
|
||
}
|
||
};
|
||
|
||
// ── Reactions & polls queries ─────────────────────────────────────────────────
|
||
|
||
useQuery({
|
||
queryKey: ['/api/messenger/conversations', activeConvId, 'reactions'],
|
||
enabled: !!activeConvId && messages.length > 0,
|
||
staleTime: 30000,
|
||
refetchInterval: false,
|
||
queryFn: async () => {
|
||
const res = await apiRequest('GET', `/api/messenger/conversations/${activeConvId}/reactions`);
|
||
const data = await res.json();
|
||
if (data.success) setReactionsMap(data.reactions ?? {});
|
||
return data;
|
||
},
|
||
});
|
||
|
||
useQuery({
|
||
queryKey: ['/api/messenger/conversations', activeConvId, 'polls'],
|
||
enabled: !!activeConvId && messages.length > 0,
|
||
staleTime: 30000,
|
||
refetchInterval: false,
|
||
queryFn: async () => {
|
||
const res = await apiRequest('GET', `/api/messenger/conversations/${activeConvId}/polls`);
|
||
const data = await res.json();
|
||
if (data.success) setPollsMap(data.polls ?? {});
|
||
return data;
|
||
},
|
||
});
|
||
|
||
// ── SSE ───────────────────────────────────────────────────────────────────────
|
||
|
||
useEvents({
|
||
onConvMessageCreated: useCallback((data: { conversationId: number; message: ConvMessage }) => {
|
||
const { conversationId, message } = data;
|
||
const isActiveConv = conversationId === activeConvId;
|
||
const isMine = message.author?.id === user?.id;
|
||
const isVisible = typeof document !== 'undefined' && document.visibilityState === 'visible';
|
||
|
||
// 1. Сначала добавляем сообщение в кэш активного чата, чтобы оно появилось
|
||
// одновременно со значком, а не после него.
|
||
queryClient.setQueryData(
|
||
['/api/messenger/conversations', conversationId, 'messages'],
|
||
(old: any) => {
|
||
if (!old) return { success: true, messages: [message] };
|
||
const exists = old.messages.some((m: ConvMessage) => m.id === message.id);
|
||
if (exists) return old;
|
||
return { ...old, messages: [...old.messages, message] };
|
||
}
|
||
);
|
||
|
||
// 2. Обновляем список чатов: bump наверх, lastMessage, unreadCount.
|
||
// Для активного и видимого чата сразу сбрасываем unreadCount в 0.
|
||
queryClient.setQueryData(
|
||
['/api/messenger/conversations'],
|
||
(old: any) => {
|
||
if (!old) {
|
||
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
|
||
return old;
|
||
}
|
||
const exists = old.conversations.some((c: Conversation) => c.id === conversationId);
|
||
if (!exists) {
|
||
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
|
||
return old;
|
||
}
|
||
const updated = old.conversations.map((c: Conversation) => {
|
||
if (c.id !== conversationId) return c;
|
||
return {
|
||
...c,
|
||
lastMessage: message,
|
||
updatedAt: message.createdAt,
|
||
unreadCount: (isActiveConv && isVisible) || isMine ? 0 : c.unreadCount + 1,
|
||
};
|
||
});
|
||
// Bump updated conversation to top (sort by updatedAt desc)
|
||
const sorted = [...updated].sort((a: Conversation, b: Conversation) =>
|
||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||
);
|
||
return { ...old, conversations: sorted };
|
||
}
|
||
);
|
||
|
||
// 3. Автоотметка прочтения только для активного, видимого чата и чужих сообщений.
|
||
// Используем mutation, чтобы onSuccess обновил кэш и сбросил бейджи.
|
||
if (isActiveConv && !isMine && isVisible) {
|
||
markReadMutation.mutate(conversationId);
|
||
}
|
||
}, [activeConvId, user]),
|
||
|
||
onConvMessageUpdated: useCallback((data: { conversationId: number; message: ConvMessage }) => {
|
||
queryClient.setQueryData(
|
||
['/api/messenger/conversations', data.conversationId, 'messages'],
|
||
(old: any) => {
|
||
if (!old) return old;
|
||
return {
|
||
...old,
|
||
messages: old.messages.map((m: ConvMessage) =>
|
||
m.id === data.message.id ? data.message : m
|
||
),
|
||
};
|
||
}
|
||
);
|
||
}, []),
|
||
|
||
onConvMessageDeleted: useCallback((data: { conversationId: number; messageId: number }) => {
|
||
queryClient.setQueryData(
|
||
['/api/messenger/conversations', data.conversationId, 'messages'],
|
||
(old: any) => {
|
||
if (!old) return old;
|
||
return {
|
||
...old,
|
||
messages: old.messages.map((m: ConvMessage) =>
|
||
m.id === data.messageId ? { ...m, isDeleted: true } : m
|
||
),
|
||
};
|
||
}
|
||
);
|
||
}, []),
|
||
|
||
onConvRead: useCallback((data: { conversationId: number }) => {
|
||
queryClient.setQueryData(
|
||
['/api/messenger/conversations'],
|
||
(old: any) => {
|
||
if (!old) return old;
|
||
return {
|
||
...old,
|
||
conversations: old.conversations.map((c: Conversation) =>
|
||
c.id === data.conversationId ? { ...c, unreadCount: 0 } : c
|
||
),
|
||
};
|
||
}
|
||
);
|
||
}, []),
|
||
|
||
onConvCreated: useCallback((conv: Conversation) => {
|
||
queryClient.setQueryData(
|
||
['/api/messenger/conversations'],
|
||
(old: any) => {
|
||
if (!old) return { success: true, conversations: [conv] };
|
||
const exists = old.conversations.some((c: Conversation) => c.id === conv.id);
|
||
if (exists) return old;
|
||
return { ...old, conversations: [conv, ...old.conversations] };
|
||
}
|
||
);
|
||
}, []),
|
||
|
||
onReactionUpdated: useCallback((data: { taskMessageId?: number; convMessageId?: number; conversationId?: number; reactions: ReactionGroup[] }) => {
|
||
if (data.convMessageId != null) {
|
||
setReactionsMap(prev => ({ ...prev, [data.convMessageId!]: data.reactions }));
|
||
}
|
||
}, []),
|
||
|
||
onPollUpdated: useCallback((data: { taskMessageId?: number; convMessageId?: number; conversationId?: number; poll: PollData }) => {
|
||
if (data.convMessageId != null) {
|
||
setPollsMap(prev => ({ ...prev, [data.convMessageId!]: data.poll }));
|
||
}
|
||
}, []),
|
||
});
|
||
|
||
// Lightweight polling fallback: если SSE не доставил событие, раз в 10 секунд
|
||
// запрашиваем только новые сообщения после последнего известного id.
|
||
useEffect(() => {
|
||
if (!activeConvId || !user) return;
|
||
const interval = setInterval(async () => {
|
||
if (document.hidden) return;
|
||
const currentData = queryClient.getQueryData(['/api/messenger/conversations', activeConvId, 'messages']) as any;
|
||
const currentMessages: ConvMessage[] = currentData?.messages || [];
|
||
const lastServerMessage = currentMessages.filter((m) => m.id > 0).pop();
|
||
if (!lastServerMessage) return;
|
||
try {
|
||
const res = await apiRequest('GET', `/api/messenger/conversations/${activeConvId}/messages?afterId=${lastServerMessage.id}&limit=50`);
|
||
const data = await res.json();
|
||
if (!data.success || !Array.isArray(data.messages) || data.messages.length === 0) return;
|
||
queryClient.setQueryData(['/api/messenger/conversations', activeConvId, 'messages'], (old: any) => {
|
||
if (!old) return { success: true, messages: data.messages };
|
||
const existingIds = new Set(old.messages.map((m: ConvMessage) => m.id));
|
||
const merged = [...old.messages, ...data.messages.filter((m: ConvMessage) => !existingIds.has(m.id))];
|
||
return { ...old, messages: merged };
|
||
});
|
||
} catch {
|
||
// ignore polling errors
|
||
}
|
||
}, 10000);
|
||
return () => clearInterval(interval);
|
||
}, [activeConvId, user]);
|
||
|
||
// ── Auto-scroll ───────────────────────────────────────────────────────────────
|
||
|
||
// Scroll to bottom whenever the active conversation changes (open from cache, new chat, etc.)
|
||
useEffect(() => {
|
||
if (!activeConvId) return;
|
||
requestAnimationFrame(() => {
|
||
setTimeout(() => {
|
||
const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]');
|
||
if (viewport) viewport.scrollTop = viewport.scrollHeight;
|
||
}, 120);
|
||
});
|
||
}, [activeConvId]);
|
||
|
||
// Scroll to bottom when new messages arrive
|
||
// (tracked via initialMessages.length), NOT when old messages are prepended via load-more
|
||
useEffect(() => {
|
||
if (!msgsLoading && initialMessages.length > 0) {
|
||
requestAnimationFrame(() => {
|
||
setTimeout(() => {
|
||
const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]');
|
||
if (viewport) viewport.scrollTop = viewport.scrollHeight;
|
||
}, 80);
|
||
});
|
||
}
|
||
}, [initialMessages.length, msgsLoading]);
|
||
|
||
// ── Mark as read when switching conversations ─────────────────────────────────
|
||
|
||
const markReadMutation = useMutation({
|
||
mutationFn: async (convId: number) => {
|
||
await apiRequest('POST', `/api/messenger/conversations/${convId}/read`);
|
||
},
|
||
onSuccess: (_, convId) => {
|
||
queryClient.setQueryData(['/api/messenger/conversations'], (old: any) => {
|
||
if (!old) return old;
|
||
return {
|
||
...old,
|
||
conversations: old.conversations.map((c: Conversation) =>
|
||
c.id === convId ? { ...c, unreadCount: 0 } : c
|
||
),
|
||
};
|
||
});
|
||
queryClient.invalidateQueries({ queryKey: ['/api/messenger/unread-count'] });
|
||
},
|
||
});
|
||
|
||
const handleSelectConv = (id: number) => {
|
||
setActiveConvId(id);
|
||
setReplyTo(null);
|
||
setEditingMsg(null);
|
||
setMessageText('');
|
||
setSearchQuery('');
|
||
markReadMutation.mutate(id);
|
||
if (isMobile) setMobileView('chat');
|
||
setLocation(`/chat/${id}`);
|
||
};
|
||
|
||
// ── Открытие/создание диалога из поиска ─────────────────────────────────────
|
||
|
||
const openDirectMutation = useMutation({
|
||
mutationFn: async (targetUserId: number) => {
|
||
const res = await apiRequest('POST', '/api/messenger/conversations', { type: 'direct', targetUserId });
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error);
|
||
return data.conversation as Conversation;
|
||
},
|
||
onSuccess: (conv) => {
|
||
queryClient.setQueryData(['/api/messenger/conversations'], (old: any) => {
|
||
if (!old) return { success: true, conversations: [conv] };
|
||
const exists = old.conversations.some((c: Conversation) => c.id === conv.id);
|
||
if (exists) return old;
|
||
return { ...old, conversations: [conv, ...old.conversations] };
|
||
});
|
||
handleSelectConv(conv.id);
|
||
},
|
||
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
|
||
});
|
||
|
||
const openExternalDirectMutation = useMutation({
|
||
mutationFn: async ({ connectionId, targetUserId }: { connectionId: number; targetUserId: number }) => {
|
||
const res = await apiRequest('POST', '/api/messenger/conversations', { type: 'external_direct', connectionId, targetUserId });
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error);
|
||
return data.conversation as Conversation;
|
||
},
|
||
onSuccess: (conv) => {
|
||
queryClient.setQueryData(['/api/messenger/conversations'], (old: any) => {
|
||
if (!old) return { success: true, conversations: [conv] };
|
||
const exists = old.conversations.some((c: Conversation) => c.id === conv.id);
|
||
if (exists) return old;
|
||
return { ...old, conversations: [conv, ...old.conversations] };
|
||
});
|
||
handleSelectConv(conv.id);
|
||
},
|
||
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
|
||
});
|
||
|
||
const muteMutation = useMutation({
|
||
mutationFn: async ({ convId, muted }: { convId: number; muted: boolean }) => {
|
||
const res = await apiRequest('PATCH', `/api/messenger/conversations/${convId}/mute`, { muted });
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error);
|
||
return data.conversation as Conversation;
|
||
},
|
||
onSuccess: (updatedConv) => {
|
||
queryClient.setQueryData(['/api/messenger/conversations'], (old: any) => {
|
||
if (!old) return old;
|
||
return {
|
||
...old,
|
||
conversations: old.conversations.map((c: Conversation) =>
|
||
c.id === updatedConv.id ? { ...c, isMuted: updatedConv.isMuted } : c
|
||
),
|
||
};
|
||
});
|
||
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations', updatedConv.id] });
|
||
},
|
||
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
|
||
});
|
||
|
||
const handleSearchSelectPerson = (personId: number) => {
|
||
setSearchQuery('');
|
||
const existing = conversations.find(c =>
|
||
c.type === 'direct' && !c.isExternal && c.members.some(m => m.id === personId)
|
||
);
|
||
if (existing) { handleSelectConv(existing.id); return; }
|
||
openDirectMutation.mutate(personId);
|
||
};
|
||
|
||
const handleSearchSelectExternal = (u: { id: number; connectionId: number }) => {
|
||
setSearchQuery('');
|
||
openExternalDirectMutation.mutate({ connectionId: u.connectionId, targetUserId: u.id });
|
||
};
|
||
|
||
const handleSearchSelectGroup = (convId: number) => {
|
||
setSearchQuery('');
|
||
handleSelectConv(convId);
|
||
};
|
||
|
||
const openBotDirectMutation = useMutation({
|
||
mutationFn: async (botId: number) => {
|
||
const res = await apiRequest('POST', '/api/messenger/conversations', { type: 'bot_direct', botId });
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error ?? 'Ошибка');
|
||
return data.conversation as Conversation;
|
||
},
|
||
onSuccess: (conv) => {
|
||
queryClient.setQueryData<{ success: boolean; conversations: Conversation[] }>(
|
||
['/api/messenger/conversations'],
|
||
(old) => {
|
||
if (!old) return { success: true, conversations: [conv] };
|
||
const exists = old.conversations.some(c => c.id === conv.id);
|
||
return { ...old, conversations: exists ? old.conversations : [conv, ...old.conversations] };
|
||
}
|
||
);
|
||
handleSelectConv(conv.id);
|
||
},
|
||
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
|
||
});
|
||
|
||
const handleSearchSelectBot = (botId: number) => {
|
||
setSearchQuery('');
|
||
const existing = conversations.find(c => c.type === 'bot_direct' && c.botId === botId);
|
||
if (existing) { handleSelectConv(existing.id); return; }
|
||
openBotDirectMutation.mutate(botId);
|
||
};
|
||
|
||
// ── Send message ──────────────────────────────────────────────────────────────
|
||
|
||
const handleFileSelect = (files: FileList | null) => {
|
||
if (!files) return;
|
||
setPendingFiles(prev => [...prev, ...Array.from(files)].slice(0, 10));
|
||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||
};
|
||
|
||
const handleDrop = (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
setIsDragOver(false);
|
||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||
handleFileSelect(e.dataTransfer.files);
|
||
}
|
||
};
|
||
|
||
const handleDragOver = (e: React.DragEvent) => {
|
||
// Подсвечиваем зону дропа только для файлов, чтобы не конфликтовать с выделением текста
|
||
if (!Array.from(e.dataTransfer.types).includes('Files')) return;
|
||
e.preventDefault();
|
||
setIsDragOver(true);
|
||
};
|
||
|
||
const handleDragLeave = (e: React.DragEvent) => {
|
||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||
setIsDragOver(false);
|
||
}
|
||
};
|
||
|
||
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||
const files = e.clipboardData.files;
|
||
if (files && files.length > 0) {
|
||
e.preventDefault();
|
||
handleFileSelect(files);
|
||
}
|
||
};
|
||
|
||
const sendMutation = useMutation({
|
||
mutationFn: async (payload: { message: string; replyToId?: number; mentionedUserIds?: number[]; attachments?: ChatAttachment[] }) => {
|
||
const res = await apiRequest('POST', `/api/messenger/conversations/${activeConvId}/messages`, payload);
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error);
|
||
return data.message as ConvMessage;
|
||
},
|
||
onMutate: async (payload) => {
|
||
await queryClient.cancelQueries({ queryKey: ['/api/messenger/conversations', activeConvId, 'messages'] });
|
||
const previous = queryClient.getQueryData(['/api/messenger/conversations', activeConvId, 'messages']);
|
||
const tempId = -Math.floor(Math.random() * 1_000_000);
|
||
const optimistic: ConvMessage = {
|
||
id: tempId,
|
||
conversationId: activeConvId!,
|
||
message: payload.message,
|
||
replyToId: payload.replyToId ?? null,
|
||
mentionedUserIds: payload.mentionedUserIds ?? null,
|
||
attachments: payload.attachments ?? null,
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
isDeleted: false,
|
||
author: { id: user!.id, firstName: user!.firstName, lastName: user!.lastName, middleName: (user as any).middleName },
|
||
replyTo: replyTo ? { id: replyTo.id, message: replyTo.message, author: replyTo.author } : null,
|
||
};
|
||
queryClient.setQueryData(
|
||
['/api/messenger/conversations', activeConvId, 'messages'],
|
||
(old: any) => {
|
||
if (!old) return { success: true, messages: [optimistic] };
|
||
return { ...old, messages: [...old.messages, optimistic] };
|
||
}
|
||
);
|
||
return { previous, tempId };
|
||
},
|
||
onSuccess: (msg, _, context) => {
|
||
queryClient.setQueryData(
|
||
['/api/messenger/conversations', activeConvId, 'messages'],
|
||
(old: any) => {
|
||
if (!old) return { success: true, messages: [msg] };
|
||
const cleaned = (old.messages as ConvMessage[]).filter(
|
||
(m) => m.id !== context?.tempId && m.id !== msg.id
|
||
);
|
||
return { ...old, messages: [...cleaned, msg] };
|
||
}
|
||
);
|
||
queryClient.setQueryData(['/api/messenger/conversations'], (old: any) => {
|
||
if (!old) return old;
|
||
return {
|
||
...old,
|
||
conversations: old.conversations.map((c: Conversation) =>
|
||
c.id === activeConvId ? { ...c, lastMessage: msg, updatedAt: msg.createdAt } : c
|
||
),
|
||
};
|
||
});
|
||
setMessageText('');
|
||
setReplyTo(null);
|
||
},
|
||
onError: (err: any, _, context) => {
|
||
if (context?.previous) {
|
||
queryClient.setQueryData(
|
||
['/api/messenger/conversations', activeConvId, 'messages'],
|
||
context.previous
|
||
);
|
||
}
|
||
toast({ variant: 'destructive', title: err.message });
|
||
},
|
||
});
|
||
|
||
const editMutation = useMutation({
|
||
mutationFn: async ({ msgId, message }: { msgId: number; message: string }) => {
|
||
const res = await apiRequest('PATCH', `/api/messenger/messages/${msgId}`, { message });
|
||
const data = await res.json();
|
||
if (!data.success) throw new Error(data.error);
|
||
return data.message as ConvMessage;
|
||
},
|
||
onSuccess: (msg) => {
|
||
queryClient.setQueryData(
|
||
['/api/messenger/conversations', activeConvId, 'messages'],
|
||
(old: any) => {
|
||
if (!old) return old;
|
||
return { ...old, messages: old.messages.map((m: ConvMessage) => m.id === msg.id ? msg : m) };
|
||
}
|
||
);
|
||
setEditingMsg(null);
|
||
setEditText('');
|
||
},
|
||
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: async (msgId: number) => {
|
||
await apiRequest('DELETE', `/api/messenger/messages/${msgId}`);
|
||
},
|
||
onSuccess: (_, msgId) => {
|
||
queryClient.setQueryData(
|
||
['/api/messenger/conversations', activeConvId, 'messages'],
|
||
(old: any) => {
|
||
if (!old) return old;
|
||
return {
|
||
...old,
|
||
messages: old.messages.map((m: ConvMessage) => m.id === msgId ? { ...m, isDeleted: true } : m),
|
||
};
|
||
}
|
||
);
|
||
},
|
||
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
|
||
});
|
||
|
||
// ── @mentions ─────────────────────────────────────────────────────────────────
|
||
|
||
const members = activeConv?.members ?? [];
|
||
|
||
const handleMessageChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||
const val = e.target.value;
|
||
setMessageText(val);
|
||
// Detect @word at end of text
|
||
const match = val.match(/@(\w*)$/);
|
||
if (match) {
|
||
setMentionSearch(match[1]);
|
||
} else {
|
||
setMentionSearch(null);
|
||
}
|
||
};
|
||
|
||
const mentionCandidates = mentionSearch !== null
|
||
? members.filter(m =>
|
||
m.id !== user?.id &&
|
||
`${m.firstName} ${m.middleName || ''} ${m.lastName}`.toLowerCase().includes(mentionSearch.toLowerCase())
|
||
)
|
||
: [];
|
||
|
||
const handleSelectMention = (m: ConvUser) => {
|
||
const newText = messageText.replace(/@(\w*)$/, `@${m.firstName} ${m.middleName || ''} `);
|
||
setMessageText(newText);
|
||
setMentionSearch(null);
|
||
setMentionedUserIds(prev => prev.includes(m.id) ? prev : [...prev, m.id]);
|
||
inputRef.current?.focus();
|
||
};
|
||
|
||
function getOrgIdFromToken(token: string | null): string | null {
|
||
if (!token) return null;
|
||
try {
|
||
const [, payload] = token.split('.');
|
||
const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
||
return decoded.organizationId ? String(decoded.organizationId) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
const handleSend = async () => {
|
||
if (!messageText.trim() && pendingFiles.length === 0) return;
|
||
if (!activeConvId) return;
|
||
|
||
if (pendingFiles.length > 0) {
|
||
setIsUploading(true);
|
||
enqueueAttachment({
|
||
type: 'messenger',
|
||
targetId: activeConvId,
|
||
message: messageText.trim(),
|
||
replyToId: replyTo?.id,
|
||
mentionedUserIds: mentionedUserIds.length > 0 ? mentionedUserIds : undefined,
|
||
files: pendingFiles,
|
||
token: null,
|
||
orgId: null,
|
||
})
|
||
.then(async (attachmentId) => {
|
||
const tempId = -Math.floor(Math.random() * 1_000_000);
|
||
const optimistic: ConvMessage = {
|
||
id: tempId,
|
||
conversationId: activeConvId,
|
||
message: messageText.trim(),
|
||
replyToId: replyTo?.id ?? null,
|
||
mentionedUserIds: mentionedUserIds.length > 0 ? mentionedUserIds : null,
|
||
attachments: pendingFiles.map(f => ({
|
||
name: f.name,
|
||
size: f.size,
|
||
mimeType: f.type || undefined,
|
||
url: '',
|
||
})),
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
isDeleted: false,
|
||
author: { id: user!.id, firstName: user!.firstName, lastName: user!.lastName, middleName: (user as any).middleName },
|
||
replyTo: replyTo ? { id: replyTo.id, message: replyTo.message, author: replyTo.author } : null,
|
||
pending: true,
|
||
attachmentQueueItemId: attachmentId,
|
||
};
|
||
|
||
queryClient.setQueryData(
|
||
['/api/messenger/conversations', activeConvId, 'messages'],
|
||
(old: any) => {
|
||
if (!old) return { success: true, messages: [optimistic] };
|
||
return { ...old, messages: [...old.messages, optimistic] };
|
||
}
|
||
);
|
||
|
||
setPendingFiles([]);
|
||
setMessageText('');
|
||
setReplyTo(null);
|
||
setMentionedUserIds([]);
|
||
setMentionSearch(null);
|
||
|
||
await registerAttachmentBackgroundSync();
|
||
dispatchAttachmentQueueUpdated();
|
||
})
|
||
.catch((err: any) => {
|
||
toast({ variant: 'destructive', title: err.message || 'Не удалось поставить сообщение в очередь' });
|
||
})
|
||
.finally(() => setIsUploading(false));
|
||
return;
|
||
}
|
||
|
||
sendMutation.mutate({
|
||
message: messageText.trim(),
|
||
replyToId: replyTo?.id,
|
||
mentionedUserIds: mentionedUserIds.length > 0 ? mentionedUserIds : undefined,
|
||
attachments: undefined,
|
||
});
|
||
setMentionedUserIds([]);
|
||
setMentionSearch(null);
|
||
};
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (e.key === 'Escape' && mentionSearch !== null) {
|
||
e.preventDefault();
|
||
setMentionSearch(null);
|
||
return;
|
||
}
|
||
if (e.key === 'Enter' && !e.shiftKey && mentionSearch === null) {
|
||
// В PWA/standalone-режиме Enter вставляет новую строку, отправка — только кнопкой
|
||
if (isPWAInstalled()) return;
|
||
e.preventDefault();
|
||
handleSend();
|
||
}
|
||
};
|
||
|
||
const handleEditKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (e.key === 'Enter' && !e.shiftKey && editingMsg) {
|
||
// В PWA/standalone-режиме Enter вставляет новую строку, сохранение — только кнопкой
|
||
if (isPWAInstalled()) return;
|
||
e.preventDefault();
|
||
if (editText.trim()) editMutation.mutate({ msgId: editingMsg.id, message: editText.trim() });
|
||
}
|
||
if (e.key === 'Escape') {
|
||
setEditingMsg(null);
|
||
setEditText('');
|
||
}
|
||
};
|
||
|
||
const groupedMessages = groupByDate(messages);
|
||
|
||
// ── Render ────────────────────────────────────────────────────────────────────
|
||
|
||
return (
|
||
<Sidebar>
|
||
<div className="flex h-full">
|
||
{/* Left panel — conversation list */}
|
||
<div className={cn(
|
||
"shrink-0 border-r border-border flex flex-col h-full relative",
|
||
isMobile ? "w-full" : "w-64",
|
||
isMobile && mobileView !== 'list' && "hidden",
|
||
)}>
|
||
<div className="flex items-center gap-1.5 px-2 py-2 border-b border-border">
|
||
<div className="relative flex-1">
|
||
<Input
|
||
ref={searchInputRef}
|
||
value={searchQuery}
|
||
onChange={e => setSearchQuery(e.target.value)}
|
||
placeholder="Поиск..."
|
||
className="h-7 text-xs pr-6"
|
||
/>
|
||
{searchQuery && (
|
||
<button
|
||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||
onClick={() => setSearchQuery('')}
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<Button size="icon" variant="ghost" className="h-7 w-7 shrink-0" onClick={() => setNewConvOpen(true)}>
|
||
<Plus className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Результаты поиска — абсолютный оверлей поверх списка бесед */}
|
||
{searchResults && (
|
||
<div className="absolute top-[calc(2.25rem+1px)] left-0 right-0 z-50 border border-border rounded-b-md bg-background shadow-lg max-h-72 overflow-y-auto">
|
||
{searchResults.people.length === 0 && searchResults.groups.length === 0 && searchResults.external.length === 0 && (searchResults.bots ?? []).length === 0 && (
|
||
<p className="text-xs text-muted-foreground text-center py-4">Ничего не найдено</p>
|
||
)}
|
||
{searchResults.people.length > 0 && (
|
||
<>
|
||
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide px-3 pt-2 pb-1">Люди</p>
|
||
{searchResults.people.map(p => (
|
||
<button
|
||
key={p.id}
|
||
type="button"
|
||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/60 transition-colors text-left"
|
||
onClick={() => handleSearchSelectPerson(p.id)}
|
||
>
|
||
<Avatar className="w-6 h-6 shrink-0">
|
||
<AvatarFallback className={cn(getAvatarColor(p.id), "text-white text-[8px]")}>{getUserInitials(p.firstName, p.lastName, p.middleName)}</AvatarFallback>
|
||
</Avatar>
|
||
<div className="min-w-0">
|
||
<p className="text-xs font-medium truncate"><HighlightMatch text={`${p.firstName} ${p.middleName || ''} ${p.lastName}`} query={debouncedSearch} /></p>
|
||
{p.position && <p className="text-[10px] text-muted-foreground truncate">{p.position}</p>}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</>
|
||
)}
|
||
{searchResults.groups.length > 0 && (
|
||
<>
|
||
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide px-3 pt-2 pb-1">Группы</p>
|
||
{searchResults.groups.map(g => (
|
||
<button
|
||
key={g.id}
|
||
type="button"
|
||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/60 transition-colors text-left"
|
||
onClick={() => handleSearchSelectGroup(g.id)}
|
||
>
|
||
<Avatar className="w-6 h-6 shrink-0">
|
||
<AvatarFallback className="text-[9px]"><Users className="w-3 h-3" /></AvatarFallback>
|
||
</Avatar>
|
||
<p className="text-xs font-medium truncate"><HighlightMatch text={g.name} query={debouncedSearch} /></p>
|
||
</button>
|
||
))}
|
||
</>
|
||
)}
|
||
{searchResults.external.length > 0 && (
|
||
<>
|
||
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide px-3 pt-2 pb-1">Внешние</p>
|
||
{searchResults.external.map(u => (
|
||
<button
|
||
key={u.id}
|
||
type="button"
|
||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/60 transition-colors text-left"
|
||
onClick={() => handleSearchSelectExternal(u)}
|
||
>
|
||
<Avatar className="w-6 h-6 shrink-0">
|
||
<AvatarFallback className="text-[9px]">{getUserInitials(u.firstName, u.lastName, u.middleName)}</AvatarFallback>
|
||
</Avatar>
|
||
<div className="min-w-0">
|
||
<p className="text-xs font-medium truncate"><HighlightMatch text={`${u.firstName} ${u.middleName || ''} ${u.lastName}`} query={debouncedSearch} /></p>
|
||
<div className="flex items-center gap-0.5">
|
||
<Link2 className="w-2.5 h-2.5 text-blue-500 shrink-0" />
|
||
{u.position && <p className="text-[10px] text-muted-foreground truncate">{u.position}</p>}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</>
|
||
)}
|
||
{(searchResults.bots ?? []).length > 0 && (
|
||
<>
|
||
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide px-3 pt-2 pb-1">AI-боты</p>
|
||
{(searchResults.bots ?? []).map(bot => (
|
||
<button
|
||
key={bot.id}
|
||
type="button"
|
||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/60 transition-colors text-left"
|
||
onClick={() => handleSearchSelectBot(bot.id)}
|
||
>
|
||
<Avatar className="w-6 h-6 shrink-0">
|
||
{bot.avatarUrl
|
||
? <img src={bot.avatarUrl} alt={bot.name} className="w-full h-full object-cover rounded-full" />
|
||
: <AvatarFallback className="text-[10px]">🤖</AvatarFallback>
|
||
}
|
||
</Avatar>
|
||
<div className="min-w-0">
|
||
<p className="text-xs font-medium truncate"><HighlightMatch text={bot.name} query={debouncedSearch} /></p>
|
||
<p className="text-[10px] text-muted-foreground truncate">@{bot.login}</p>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<ScrollArea className="flex-1">
|
||
{convsLoading && (
|
||
<div className="text-xs text-muted-foreground text-center py-6">Загрузка...</div>
|
||
)}
|
||
{!convsLoading && conversations.length === 0 && (
|
||
<div className="text-xs text-muted-foreground text-center py-8 px-4">
|
||
Нет диалогов. Нажмите + чтобы начать общение.
|
||
</div>
|
||
)}
|
||
<div className="p-1 space-y-0.5">
|
||
{conversations.map(conv => {
|
||
const isActive = conv.id === activeConvId;
|
||
const lastMsgText = conv.lastMessage?.isDeleted
|
||
? 'Сообщение удалено'
|
||
: (conv.lastMessage?.message?.trim() || (conv.lastMessage?.attachments?.length ? '📎 Вложение' : ''));
|
||
|
||
return (
|
||
<div
|
||
key={conv.id}
|
||
onClick={() => handleSelectConv(conv.id)}
|
||
className={cn(
|
||
"flex items-center gap-2 px-2 py-2 rounded cursor-pointer transition-colors",
|
||
isActive ? "bg-muted" : "hover:bg-muted/50"
|
||
)}
|
||
>
|
||
<div className="relative shrink-0">
|
||
<Avatar className="w-8 h-8">
|
||
{conv.type === 'bot_direct' && conv.bot?.avatarUrl
|
||
? <img src={conv.bot.avatarUrl} alt={conv.bot.name} className="w-full h-full object-cover rounded-full" />
|
||
: (
|
||
<AvatarFallback className="text-xs">
|
||
{conv.type === 'group'
|
||
? <Users className="w-4 h-4" />
|
||
: conv.type === 'bot_direct'
|
||
? <span className="text-base">🤖</span>
|
||
: (() => {
|
||
const other = conv.members.find(m => m.id !== user?.id);
|
||
return other ? getUserInitials(other.firstName, other.lastName, other.middleName) : '?';
|
||
})()
|
||
}
|
||
</AvatarFallback>
|
||
)
|
||
}
|
||
</Avatar>
|
||
{conv.isExternal && (
|
||
<span className="absolute -bottom-0.5 -right-0.5 bg-blue-500 rounded-full p-0.5">
|
||
<Link2 className="w-2 h-2 text-white" />
|
||
</span>
|
||
)}
|
||
{conv.unreadCount > 0 && (
|
||
<span className="absolute -top-1 -right-1 bg-primary text-primary-foreground text-[9px] font-bold rounded-full min-w-[14px] h-[14px] flex items-center justify-center px-0.5">
|
||
{conv.unreadCount > 99 ? '99+' : conv.unreadCount}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex justify-between items-center">
|
||
<span className={cn("text-xs font-medium truncate", conv.unreadCount > 0 && "font-semibold")}>
|
||
{conv.displayName}
|
||
</span>
|
||
<div className="flex items-center gap-1 shrink-0 ml-1">
|
||
{conv.lastMessage?.createdAt && (
|
||
<span className="text-[10px] text-muted-foreground">
|
||
{format(new Date(conv.lastMessage.createdAt), 'HH:mm')}
|
||
</span>
|
||
)}
|
||
<button
|
||
title={conv.isMuted ? 'Включить уведомления' : 'Отключить уведомления'}
|
||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
muteMutation.mutate({ convId: conv.id, muted: !conv.isMuted });
|
||
}}
|
||
>
|
||
{conv.isMuted
|
||
? <BellOff className="w-3 h-3" />
|
||
: <Bell className="w-3 h-3" />
|
||
}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{lastMsgText && (
|
||
<p className="text-[10px] text-muted-foreground truncate">{lastMsgText}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
|
||
{/* Right panel — chat window */}
|
||
{(!isMobile || mobileView === 'chat') && (!activeConvId ? (
|
||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||
<div className="text-center">
|
||
<MessageSquare className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||
<p className="text-sm">Выберите диалог</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex-1 flex flex-col min-w-0 relative">
|
||
{/* Chat header */}
|
||
<div className="flex items-center justify-between px-4 py-2 border-b border-border shrink-0">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
{isMobile && (
|
||
<button
|
||
onClick={() => setMobileView('list')}
|
||
aria-label="Назад"
|
||
className="flex items-center gap-0.5 text-muted-foreground hover:text-foreground shrink-0 mr-1"
|
||
>
|
||
<ChevronLeft className="w-5 h-5" />
|
||
<span className="text-xs">Назад</span>
|
||
</button>
|
||
)}
|
||
<Avatar
|
||
className={cn("w-7 h-7", activeConv?.type === 'group' && "cursor-pointer hover:opacity-80 transition-opacity")}
|
||
onClick={() => activeConv?.type === 'group' && setGroupMembersOpen(true)}
|
||
>
|
||
{activeConv?.type === 'bot_direct' && activeConv.bot?.avatarUrl
|
||
? <img src={activeConv.bot.avatarUrl} alt={activeConv.bot.name} className="w-full h-full object-cover rounded-full" />
|
||
: (
|
||
<AvatarFallback className="text-xs">
|
||
{activeConv?.type === 'group'
|
||
? <Users className="w-3.5 h-3.5" />
|
||
: activeConv?.type === 'bot_direct'
|
||
? <span className="text-base">🤖</span>
|
||
: (() => {
|
||
const other = activeConv?.members.find(m => m.id !== user?.id);
|
||
return other ? getUserInitials(other.firstName, other.lastName, other.middleName) : '?';
|
||
})()
|
||
}
|
||
</AvatarFallback>
|
||
)
|
||
}
|
||
</Avatar>
|
||
<div
|
||
className={cn("min-w-0", activeConv?.type === 'group' && "cursor-pointer hover:opacity-80 transition-opacity")}
|
||
onClick={() => activeConv?.type === 'group' && setGroupMembersOpen(true)}
|
||
title={activeConv?.type === 'group' ? 'Участники группы' : undefined}
|
||
>
|
||
<p className="text-sm font-medium truncate">{activeConv?.displayName}</p>
|
||
{activeConv?.type === 'group' && (
|
||
<p className="text-[10px] text-muted-foreground">
|
||
{activeConv.members.length} участников · нажмите чтобы открыть
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{activeConv && (
|
||
<button
|
||
title={activeConv.isMuted ? 'Включить уведомления' : 'Отключить уведомления'}
|
||
className="text-muted-foreground hover:text-foreground transition-colors p-1 rounded"
|
||
onClick={() => muteMutation.mutate({ convId: activeConv.id, muted: !activeConv.isMuted })}
|
||
>
|
||
{activeConv.isMuted
|
||
? <BellOff className="w-4 h-4" />
|
||
: <Bell className="w-4 h-4" />
|
||
}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Messages */}
|
||
<ScrollArea ref={scrollRef} className="flex-1 px-4 py-3 chat-scroll-area">
|
||
{msgsLoading && (
|
||
<div className="text-xs text-muted-foreground text-center py-6">Загрузка...</div>
|
||
)}
|
||
{!msgsLoading && messages.length === 0 && (
|
||
<div className="text-xs text-muted-foreground text-center py-8">Начните общение</div>
|
||
)}
|
||
|
||
{!msgsLoading && hasMore && (
|
||
<div className="flex justify-center mb-3">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-7 text-xs"
|
||
onClick={loadMoreMessages}
|
||
disabled={isLoadingMore}
|
||
>
|
||
{isLoadingMore
|
||
? <><Loader2 className="w-3 h-3 mr-1.5 animate-spin" />Загрузка...</>
|
||
: 'Загрузить ещё'
|
||
}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-4">
|
||
{groupedMessages.map(group => (
|
||
<div key={group.dateKey}>
|
||
<div className="flex items-center gap-2 my-2">
|
||
<div className="flex-1 border-t border-border" />
|
||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||
{formatDateHeader(group.date)}
|
||
</span>
|
||
<div className="flex-1 border-t border-border" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
{group.messages.map(msg => (
|
||
<MessageBubble
|
||
key={msg.id}
|
||
msg={msg}
|
||
isOwn={!!msg.author && msg.author.id === user?.id}
|
||
onReply={m => { setReplyTo(m); inputRef.current?.focus(); }}
|
||
onEdit={m => { setEditingMsg(m); setEditText(m.message); }}
|
||
onDelete={m => deleteMutation.mutate(m.id)}
|
||
reactions={reactionsMap[msg.id]}
|
||
poll={pollsMap[msg.id]}
|
||
onReactionsChange={(r) => setReactionsMap(prev => ({ ...prev, [msg.id]: r }))}
|
||
onPollChange={(p) => setPollsMap(prev => ({ ...prev, [msg.id]: p }))}
|
||
onOpenUserChat={(userId) => {
|
||
const existing = conversations.find(c =>
|
||
c.type === 'direct' && !c.isExternal && c.members.some(m => m.id === userId)
|
||
);
|
||
if (existing) { handleSelectConv(existing.id); return; }
|
||
openDirectMutation.mutate(userId);
|
||
}}
|
||
onOpenBotChat={(botId) => {
|
||
const existing = conversations.find(c => c.type === 'bot_direct' && c.botId === botId);
|
||
if (existing) { handleSelectConv(existing.id); return; }
|
||
openBotDirectMutation.mutate(botId);
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
|
||
{/* Input area */}
|
||
<div className="shrink-0 border-t border-border p-3 space-y-2">
|
||
{replyTo && (
|
||
<div className="flex items-center gap-2 px-2 py-1 rounded bg-muted text-xs text-muted-foreground">
|
||
<Reply className="w-3 h-3 shrink-0" />
|
||
<span className="flex-1 truncate">
|
||
<span className="font-medium">{replyTo.author?.firstName} {replyTo.author?.middleName || ''} {replyTo.author?.lastName}:</span> {replyTo.message}
|
||
</span>
|
||
<button onClick={() => setReplyTo(null)} className="hover:text-foreground">
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* @mention dropdown */}
|
||
{mentionSearch !== null && mentionCandidates.length > 0 && (
|
||
<div className="border border-border rounded-md bg-background shadow-md overflow-hidden">
|
||
{mentionCandidates.map(m => (
|
||
<div
|
||
key={m.id}
|
||
className="flex items-center gap-2 px-2 py-1.5 hover:bg-muted cursor-pointer"
|
||
onMouseDown={(e) => { e.preventDefault(); handleSelectMention(m); }}
|
||
>
|
||
<Avatar className="w-5 h-5 shrink-0">
|
||
<AvatarFallback className={cn(getAvatarColor(m.id), "text-white text-[7px]")}>{getUserInitials(m.firstName, m.lastName, m.middleName)}</AvatarFallback>
|
||
</Avatar>
|
||
<span className="text-xs">{m.firstName} {m.middleName || ''} {m.lastName}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{editingMsg ? (
|
||
<div className="flex gap-2 items-end">
|
||
<AutoResizeTextarea
|
||
ref={inputRef}
|
||
value={editText}
|
||
onChange={e => setEditText(e.target.value)}
|
||
onKeyDown={handleEditKeyDown}
|
||
placeholder="Редактировать..."
|
||
className="flex-1 text-sm resize-none min-h-[32px]"
|
||
minRows={1}
|
||
maxRows={5}
|
||
autoFocus
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
className="h-8"
|
||
onClick={() => editingMsg && editMutation.mutate({ msgId: editingMsg.id, message: editText.trim() })}
|
||
disabled={!editText.trim() || editMutation.isPending}
|
||
>
|
||
<Check className="w-3.5 h-3.5" />
|
||
</Button>
|
||
<Button size="sm" variant="ghost" className="h-8" onClick={() => { setEditingMsg(null); setEditText(''); }}>
|
||
<X className="w-3.5 h-3.5" />
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<PendingAttachmentList
|
||
files={pendingFiles}
|
||
onRemove={(i) => setPendingFiles(prev => prev.filter((_, idx) => idx !== i))}
|
||
/>
|
||
<div
|
||
className={cn("flex gap-2 relative rounded-lg transition-colors", isDragOver && "ring-2 ring-primary/40 bg-primary/5")}
|
||
onDrop={handleDrop}
|
||
onDragOver={handleDragOver}
|
||
onDragLeave={handleDragLeave}
|
||
title="Перетащите файл в поле сообщения"
|
||
>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||
className="hidden"
|
||
onChange={e => handleFileSelect(e.target.files)}
|
||
/>
|
||
<AutoResizeTextarea
|
||
ref={inputRef}
|
||
value={messageText}
|
||
onChange={handleMessageChange}
|
||
onKeyDown={handleKeyDown}
|
||
onPaste={handlePaste}
|
||
placeholder="Написать сообщение..."
|
||
className="flex-1 text-sm resize-none min-h-[32px]"
|
||
minRows={1}
|
||
maxRows={5}
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-8 px-2 shrink-0"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
title="Прикрепить файл"
|
||
>
|
||
<Paperclip className="w-3.5 h-3.5" />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-8 px-2 shrink-0"
|
||
onClick={() => setPollDialogOpen(true)}
|
||
title="Создать опрос"
|
||
>
|
||
<BarChart2 className="w-3.5 h-3.5" />
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
className="h-8"
|
||
onClick={handleSend}
|
||
disabled={(!messageText.trim() && pendingFiles.length === 0) || sendMutation.isPending || isUploading}
|
||
>
|
||
{(sendMutation.isPending || isUploading)
|
||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||
: <Send className="w-3.5 h-3.5" />}
|
||
</Button>
|
||
</div>
|
||
</>
|
||
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<PollCreateDialog
|
||
open={pollDialogOpen}
|
||
onOpenChange={setPollDialogOpen}
|
||
convId={activeConvId ?? undefined}
|
||
onCreated={(poll, messageId) => {
|
||
setPollsMap(prev => ({ ...prev, [messageId]: poll }));
|
||
setPollDialogOpen(false);
|
||
}}
|
||
/>
|
||
|
||
<NewConversationDialog
|
||
open={newConvOpen}
|
||
onOpenChange={setNewConvOpen}
|
||
onCreated={(conv) => {
|
||
handleSelectConv(conv.id);
|
||
}}
|
||
/>
|
||
|
||
{activeConv && activeConv.type === 'group' && user && (
|
||
<GroupMembersDialog
|
||
open={groupMembersOpen}
|
||
onOpenChange={setGroupMembersOpen}
|
||
conversation={activeConv}
|
||
currentUserId={user.id}
|
||
currentUserRole={user.appRole}
|
||
/>
|
||
)}
|
||
</Sidebar>
|
||
);
|
||
}
|