refactor(chat): разбиение Chat.tsx (2696→639) — механическая экстракция без смены поведения
Шаг 2.2 (часть 1) плана production-готовности: - 8 файлов в components/chat/: типы, утилиты, MessageBubble, диалоги, TypingIndicator, useChatController (логика перенесена дословно) - неизменность доказана посимвольной сверкой с оригиналом - check/lint/build чисто, vitest 104/104
This commit is contained in:
@@ -348,3 +348,13 @@
|
||||
- Как проверялось: docker compose config валиден для всех 4 файлов; check/lint/build чисто; vitest 104/104.
|
||||
- Влияние на поиск/UX: нет.
|
||||
- Подводные камни: пины синхронизированы в серверный бэкап .bak.serverfix (крит. правило №1) — только image-теги, креды бэкапа не тронуты; локальный .env.local требует MINIO_ACCESS_KEY (есть в .env.local.example).
|
||||
|
||||
---
|
||||
|
||||
## [2.2a] Разбиение Chat.tsx (2696 → 639 строк)
|
||||
|
||||
- Статус: ✅ done
|
||||
- Зачем: гигантские файлы нечитаемы и рискованны в правках (Фаза 2). БЕЗ смены поведения.
|
||||
- Что изменено: `client/src/pages/Chat.tsx` → 8 файлов в `client/src/components/chat/`: chat-types.ts, chat-utils.ts, MessageBubble.tsx (пузырь + кнопки ботов), NewConversationDialog.tsx, GroupMembersDialog.tsx, HighlightMatch.tsx, TypingIndicator.tsx, useChatController.ts (вся логика страницы дословно). В Chat.tsx — только деструктуризация хука + прежний JSX.
|
||||
- Как проверялось: ПРОГРАММНАЯ сверка с оригиналом (git show): тело логики == useChatController посимвольно, рендер == оригиналу (кроме замены inline-блока на <TypingIndicator>). Чеклист real-time фич дословно на месте (SSE typing, reportActiveChat в useLayoutEffect, polling fallback 10с, оптимистичная автоотметка прочтения с debounce, кнопки ботов с «нажато»). check/lint/build чисто; vitest 104/104.
|
||||
- Влияние на поиск/UX: нет (поведение идентично доказанно).
|
||||
|
||||
237
client/src/components/chat/GroupMembersDialog.tsx
Normal file
237
client/src/components/chat/GroupMembersDialog.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { apiRequest, queryClient } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarColor, getUserInitials } from '@/lib/avatar';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Trash2, UserPlus, UserMinus,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Conversation, ConvUser } from './chat-types';
|
||||
|
||||
// ── Group Members Dialog (unified: view + add) ─────────────────────────────────
|
||||
|
||||
export 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);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = 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-xs 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={() => setDeleteConfirmOpen(true)}
|
||||
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>
|
||||
|
||||
{/* Подтверждение удаления группы */}
|
||||
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить группу?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Группа «{conversation.displayName}» будет удалена навсегда. Это действие нельзя отменить.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deleteGroupMutation.mutate()}
|
||||
>
|
||||
Удалить группу
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
14
client/src/components/chat/HighlightMatch.tsx
Normal file
14
client/src/components/chat/HighlightMatch.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
// ── Highlight matching substring in search results ────────────────────────────
|
||||
|
||||
export 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-warning/30 text-inherit rounded-sm px-0">{text.slice(idx, idx + query.length)}</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
284
client/src/components/chat/MessageBubble.tsx
Normal file
284
client/src/components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarColor, getUserInitials } from '@/lib/avatar';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Reply, Edit, Check, MoreVertical, Trash2, Clock, Smile,
|
||||
} from 'lucide-react';
|
||||
import { AttachmentDisplay } 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 type { BotButton, ConvMessage } from './chat-types';
|
||||
import { groupButtonsByRow } from './chat-utils';
|
||||
|
||||
// ── Message bubble ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function MessageBubble({
|
||||
msg, isOwn, onReply, onEdit, onDelete, reactions, poll, onReactionsChange, onPollChange,
|
||||
onOpenUserChat, onOpenBotChat, onButtonClick, pressedButtons,
|
||||
}: {
|
||||
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;
|
||||
onButtonClick?: (m: ConvMessage, button: BotButton) => void;
|
||||
pressedButtons?: Set<string>;
|
||||
}) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const isEdited = msg.updatedAt && msg.updatedAt !== msg.createdAt;
|
||||
|
||||
// Мобильное меню действий (<md): кнопка «⋯» всегда видима и открывает DropdownMenu.
|
||||
// Отдельная кнопка (а не тап по пузырю) выбрана, чтобы не конфликтовать с выделением
|
||||
// текста и тапами по ссылкам/вложениям внутри сообщения. Desktop hover-тулбар ниже
|
||||
// сохранён без изменений.
|
||||
const mobileActionsMenu = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 mb-0.5 md:hidden"
|
||||
title="Действия с сообщением"
|
||||
aria-label="Действия с сообщением"
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={isOwn ? 'end' : 'start'} side="top" className="min-w-[10rem]">
|
||||
<DropdownMenuItem onSelect={() => onReply(msg)}>
|
||||
<Reply className="h-4 w-4 mr-2" />
|
||||
Ответить
|
||||
</DropdownMenuItem>
|
||||
{onReactionsChange && (
|
||||
<DropdownMenuItem onSelect={() => setPickerOpen(true)}>
|
||||
<Smile className="h-4 w-4 mr-2" />
|
||||
Реакция
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isOwn && (
|
||||
<DropdownMenuItem onSelect={() => onEdit(msg)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Редактировать
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isOwn && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={() => onDelete(msg)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
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 outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10" />
|
||||
: (
|
||||
<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-xs 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-warning/20', 'transition-colors', 'duration-500');
|
||||
setTimeout(() => {
|
||||
el.classList.remove('bg-warning/20', '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)} title="Ответить" aria-label="Ответить">
|
||||
<Reply className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => onReactionsChange && setPickerOpen(v => !v)}
|
||||
title="Реакция"
|
||||
aria-label="Реакция"
|
||||
>
|
||||
<Smile className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => onEdit(msg)} title="Редактировать" aria-label="Редактировать">
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 text-destructive" onClick={() => onDelete(msg)} title="Удалить" aria-label="Удалить">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{mobileActionsMenu}
|
||||
</>
|
||||
)}
|
||||
|
||||
<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 && (
|
||||
<>
|
||||
{mobileActionsMenu}
|
||||
<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)} title="Ответить" aria-label="Ответить">
|
||||
<Reply className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => onReactionsChange && setPickerOpen(v => !v)}
|
||||
title="Реакция"
|
||||
aria-label="Реакция"
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Кнопки бота */}
|
||||
{msg.botButtons && msg.botButtons.length > 0 && onButtonClick && !msg.isDeleted && (
|
||||
<div className={cn("flex flex-col gap-1.5 mt-1.5 max-w-full", isOwn ? "items-end" : "items-start")}>
|
||||
{groupButtonsByRow(msg.botButtons).map((row, rowIdx) => (
|
||||
<div key={rowIdx} className="flex flex-wrap gap-1.5">
|
||||
{row.map((btn, btnIdx) => {
|
||||
const btnKey = `${msg.id}:${btn.callbackData}`;
|
||||
const isPressed = pressedButtons?.has(btnKey) ?? false;
|
||||
return (
|
||||
<Button
|
||||
key={btnIdx}
|
||||
variant={isPressed ? 'default' : btn.style === 'primary' ? 'default' : btn.style === 'destructive' ? 'destructive' : btn.style === 'outline' ? 'outline' : 'secondary'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 text-xs px-3 py-1 rounded-full",
|
||||
isPressed && "bg-success text-success-foreground hover:bg-success/90"
|
||||
)}
|
||||
disabled={isPressed}
|
||||
onClick={() => onButtonClick(msg, btn)}
|
||||
>
|
||||
{isPressed && <Check className="w-3 h-3 mr-1" />}
|
||||
{btn.text}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="text-xs 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>
|
||||
);
|
||||
}
|
||||
414
client/src/components/chat/NewConversationDialog.tsx
Normal file
414
client/src/components/chat/NewConversationDialog.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { Link } from 'wouter';
|
||||
import { apiRequest, queryClient } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 {
|
||||
Users, Check, Link2, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Conversation, ConvUser, ExternalUser, OrgConnectionItem } from './chat-types';
|
||||
|
||||
// ── New Conversation Dialog ────────────────────────────────────────────────────
|
||||
|
||||
export 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-xs 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">
|
||||
Нет активных подключений. Настройте кросс-организационное подключение в разделе{' '}
|
||||
<Link href="/settings" className="text-primary underline underline-offset-2">Настройки → Подключения</Link>.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Выбор подключения */}
|
||||
{!selectedConnId ? (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs 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-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => { setSelectedConnId(null); setExtSelectedIds([]); }}
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
<span className="text-xs 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-xs 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>
|
||||
);
|
||||
}
|
||||
25
client/src/components/chat/TypingIndicator.tsx
Normal file
25
client/src/components/chat/TypingIndicator.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
|
||||
// ── Bot typing indicator ───────────────────────────────────────────────────────
|
||||
|
||||
export function TypingIndicator({ typingBots }: { typingBots: Set<string> }) {
|
||||
if (typingBots.size === 0) return null;
|
||||
return (
|
||||
<div className="flex items-start gap-2 mt-3">
|
||||
<Avatar className="w-6 h-6 shrink-0 mt-0.5">
|
||||
<AvatarFallback className="text-[10px]">🤖</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="bg-muted rounded-2xl rounded-bl-sm px-3 py-2 text-sm text-muted-foreground flex items-center gap-1">
|
||||
<span className="truncate max-w-[200px]">
|
||||
{Array.from(typingBots).join(', ')}
|
||||
</span>
|
||||
<span>печатает</span>
|
||||
<span className="inline-flex gap-0.5 ml-0.5">
|
||||
<span className="w-1 h-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-1 h-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-1 h-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
client/src/components/chat/chat-types.ts
Normal file
83
client/src/components/chat/chat-types.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { ChatAttachment } from '@/components/chat/ChatAttachments';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ConvUser {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
middleName?: string;
|
||||
email?: string | null;
|
||||
position?: string;
|
||||
lastReadAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ConvBot {
|
||||
id: number;
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface BotButton {
|
||||
text: string;
|
||||
callbackData: string;
|
||||
style?: 'primary' | 'secondary' | 'destructive' | 'outline';
|
||||
row?: number;
|
||||
}
|
||||
|
||||
export interface ConvMessage {
|
||||
id: number;
|
||||
conversationId: number;
|
||||
message: string;
|
||||
replyToId?: number | null;
|
||||
mentionedUserIds?: number[] | null;
|
||||
attachments?: ChatAttachment[] | null;
|
||||
botButtons?: BotButton[] | 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;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
// ── Types for connections ──────────────────────────────────────────────────────
|
||||
|
||||
export interface OrgConnectionItem {
|
||||
id: number;
|
||||
status: string;
|
||||
isInitiator: boolean;
|
||||
initiatorOrg: { id: number; displayName: string } | null;
|
||||
partnerOrg: { id: number; displayName: string } | null;
|
||||
}
|
||||
|
||||
export interface ExternalUser {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
middleName?: string;
|
||||
position?: string | null;
|
||||
organizationId: number;
|
||||
}
|
||||
37
client/src/components/chat/chat-utils.ts
Normal file
37
client/src/components/chat/chat-utils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { format, isToday, isYesterday } from 'date-fns';
|
||||
import type { BotButton, ConvMessage } from './chat-types';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function formatDateHeader(date: Date) {
|
||||
if (isToday(date)) return 'Сегодня';
|
||||
if (isYesterday(date)) return 'Вчера';
|
||||
return format(date, 'dd.MM.yyyy');
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function groupButtonsByRow(buttons: BotButton[]): BotButton[][] {
|
||||
const rows = new Map<number, BotButton[]>();
|
||||
for (const btn of buttons) {
|
||||
const rowIdx = btn.row ?? 0;
|
||||
if (!rows.has(rowIdx)) rows.set(rowIdx, []);
|
||||
rows.get(rowIdx)!.push(btn);
|
||||
}
|
||||
return Array.from(rows.entries()).sort((a, b) => a[0] - b[0]).map(([, btns]) => btns);
|
||||
}
|
||||
1175
client/src/components/chat/useChatController.ts
Normal file
1175
client/src/components/chat/useChatController.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user