- 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>
168 lines
6.4 KiB
TypeScript
168 lines
6.4 KiB
TypeScript
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>
|
||
);
|
||
}
|