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' && (