diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6005936..de94723 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,7 +31,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) { } export default function App() { - const { setUser, setChats, addMessage, updateMessage, removeMessage, setOnline, setTyping, setConnected, markRead, markReadByOther } = useStore(); + const { setUser, setChats, setChatsLoading, addMessage, updateMessage, removeMessage, setOnline, setTyping, setConnected, markRead, markReadByOther } = useStore(); useEffect(() => { // Restore session @@ -45,7 +45,10 @@ export default function App() { } catch { localStorage.removeItem('jc_token'); localStorage.removeItem('jc_user'); + setChatsLoading(false); } + } else { + setChatsLoading(false); } }, []); diff --git a/frontend/src/components/ChatListItem.tsx b/frontend/src/components/ChatListItem.tsx index a007d43..bfefcd2 100644 --- a/frontend/src/components/ChatListItem.tsx +++ b/frontend/src/components/ChatListItem.tsx @@ -15,10 +15,16 @@ interface Props { 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 [isSwiping, setIsSwiping] = useState(false); + 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 }) @@ -26,54 +32,82 @@ export default function ChatListItem({ chat, active, online, onClick, onPin, onM 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 }; - setIsSwiping(false); + 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 (dy > 15 && Math.abs(dx) < dy) { touchStart.current = null; return; } - if (Math.abs(dx) > 8) setIsSwiping(true); - const clamped = Math.max(-120, Math.min(80, dx)); - setSwipeX(clamped); + 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 (swipeX > 60) { - onPin(chat.id, !chat.isPinned); - } - setSwipeX(0); - setIsSwiping(false); + 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 showLeftActions = swipeX < -40; + const leftOpen = snapped === 'left'; return (
- {/* Pin action (swipe right) */} -
+ {/* Right bg: pin hint while dragging */} +
{chat.isPinned ? 'Открепить' : 'Закрепить'}
- {/* Left actions: mute + leave (swipe left) */} -
+ {/* Left actions: always rendered, visible when snapped */} +
{chat.type !== 'private' && (