Files
janichat/frontend/src/components/ChatListItem.tsx
Ai 3a8c5be1ae fix: swipe stays open for button tap, loading skeleton for chats
- Swipe left now snaps open and action buttons are tappable (onClick)
- Swipe right still executes pin immediately on release
- chatsLoading state in store: shows skeleton animation while loading
- "Нет чатов" only shown after chats have finished loading

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:02:23 +03:00

168 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useState } from 'react';
import { formatDistanceToNow } from 'date-fns';
import { ru } from 'date-fns/locale';
import { Pin, BellOff, Bell, LogOut } from 'lucide-react';
import Avatar from './Avatar';
import { Chat } from '../types';
interface Props {
chat: Chat;
active: boolean;
online?: boolean;
onClick: () => void;
onPin: (chatId: string, pinned: boolean) => void;
onMute: (chatId: string, muted: boolean) => void;
onLeave: (chatId: string) => void;
}
// How far buttons extend when snapped open
const SNAP_LEFT = -120;
const SNAP_RIGHT = 72;
export default function ChatListItem({ chat, active, online, onClick, onPin, onMute, onLeave }: Props) {
// swipeX: live drag offset; snapped: 'left' | 'right' | null = locked open
const [swipeX, setSwipeX] = useState(0);
const [snapped, setSnapped] = useState<'left' | 'right' | null>(null);
const touchStart = useRef<{ x: number; y: number } | null>(null);
const dragging = useRef(false);
const time = chat.lastMessageAt
? formatDistanceToNow(new Date(chat.lastMessageAt), { addSuffix: false, locale: ru })
: '';
const icon = chat.type === 'channel' ? '📢' : chat.type === 'group' ? '👥' : null;
// Current visual offset = live drag + snap position
const baseX = snapped === 'left' ? SNAP_LEFT : snapped === 'right' ? SNAP_RIGHT : 0;
const visualX = snapped ? baseX : swipeX;
function handleTouchStart(e: React.TouchEvent) {
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
dragging.current = false;
}
function handleTouchMove(e: React.TouchEvent) {
if (!touchStart.current) return;
const dx = e.touches[0].clientX - touchStart.current.x;
const dy = Math.abs(e.touches[0].clientY - touchStart.current.y);
if (!dragging.current && dy > 10 && Math.abs(dx) < dy) { touchStart.current = null; return; }
if (Math.abs(dx) > 6) dragging.current = true;
if (!dragging.current) return;
// If already snapped, allow dragging back from snap position
const raw = snapped ? baseX + dx : dx;
setSwipeX(Math.max(SNAP_LEFT, Math.min(SNAP_RIGHT, raw)));
}
function handleTouchEnd() {
if (!dragging.current) { touchStart.current = null; return; }
dragging.current = false;
touchStart.current = null;
if (swipeX > 50) {
// Snapped right → pin immediately and close
onPin(chat.id, !chat.isPinned);
setSwipeX(0);
setSnapped(null);
} else if (swipeX < -60) {
// Snap left open → user can tap action buttons
setSwipeX(0);
setSnapped('left');
} else {
// Close
setSwipeX(0);
setSnapped(null);
}
}
function close() {
setSwipeX(0);
setSnapped(null);
}
function handleMainClick() {
if (snapped) { close(); return; }
onClick();
}
const showPinHint = swipeX > 30;
const leftOpen = snapped === 'left';
return (
<div className="relative overflow-hidden rounded-xl">
{/* Right bg: pin hint while dragging */}
<div className={`absolute inset-y-0 left-0 w-[72px] flex flex-col items-center justify-center bg-blue-500 rounded-l-xl transition-opacity duration-150 ${showPinHint ? 'opacity-100' : 'opacity-0'}`}>
<Pin className="w-5 h-5 text-white" />
<span className="text-white text-[10px] mt-0.5">{chat.isPinned ? 'Открепить' : 'Закрепить'}</span>
</div>
{/* Left actions: always rendered, visible when snapped */}
<div className={`absolute inset-y-0 right-0 flex rounded-r-xl overflow-hidden transition-opacity duration-150 ${leftOpen ? 'opacity-100' : 'opacity-0'}`}>
<button
onClick={() => { onMute(chat.id, !chat.isMuted); close(); }}
className="w-16 h-full flex flex-col items-center justify-center bg-gray-500 text-white text-[10px] gap-0.5 active:brightness-90"
>
{chat.isMuted ? <Bell className="w-5 h-5" /> : <BellOff className="w-5 h-5" />}
<span>{chat.isMuted ? 'Вкл. звук' : 'Без звука'}</span>
</button>
{chat.type !== 'private' && (
<button
onClick={() => { close(); onLeave(chat.id); }}
className="w-16 h-full flex flex-col items-center justify-center bg-red-500 text-white text-[10px] gap-0.5 active:brightness-90"
>
<LogOut className="w-5 h-5" />
<span>Выйти</span>
</button>
)}
</div>
{/* Main row */}
<button
onClick={handleMainClick}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{
transform: `translateX(${visualX}px)`,
transition: dragging.current ? 'none' : 'transform 0.2s ease',
}}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left ${active ? 'bg-blue-50' : 'bg-white hover:bg-gray-50'}`}
>
<div className="relative flex-shrink-0">
<Avatar
name={chat.title}
color={chat.avatarColor}
online={chat.type === 'private' ? online : undefined}
/>
{chat.isPinned && (
<span className="absolute -bottom-0.5 -right-0.5 bg-blue-500 rounded-full p-0.5">
<Pin className="w-2 h-2 text-white" />
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900 text-sm truncate">
{icon && <span className="mr-1 text-xs">{icon}</span>}
{chat.title}
</span>
{time && <span className="text-xs text-gray-400 flex-shrink-0 ml-1">{time}</span>}
</div>
<div className="flex items-center justify-between mt-0.5">
<p className="text-xs text-gray-500 truncate">
{chat.lastMessage || (chat.type === 'channel' ? 'Канал' : 'Нет сообщений')}
</p>
<div className="flex items-center gap-1 flex-shrink-0 ml-1">
{chat.isMuted && <BellOff className="w-3 h-3 text-gray-400" />}
{chat.unreadCount > 0 && (
<span className={`${chat.isMuted ? 'bg-gray-400' : 'bg-blue-500'} text-white text-xs rounded-full min-w-[18px] h-[18px] flex items-center justify-center px-1`}>
{chat.unreadCount > 99 ? '99+' : chat.unreadCount}
</span>
)}
</div>
</div>
</div>
</button>
</div>
);
}