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>
This commit is contained in:
@@ -31,7 +31,7 @@ function AuthGuard({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
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(() => {
|
useEffect(() => {
|
||||||
// Restore session
|
// Restore session
|
||||||
@@ -45,7 +45,10 @@ export default function App() {
|
|||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem('jc_token');
|
localStorage.removeItem('jc_token');
|
||||||
localStorage.removeItem('jc_user');
|
localStorage.removeItem('jc_user');
|
||||||
|
setChatsLoading(false);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setChatsLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,16 @@ interface Props {
|
|||||||
onLeave: (chatId: string) => 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) {
|
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 [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 touchStart = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const dragging = useRef(false);
|
||||||
|
|
||||||
const time = chat.lastMessageAt
|
const time = chat.lastMessageAt
|
||||||
? formatDistanceToNow(new Date(chat.lastMessageAt), { addSuffix: false, locale: ru })
|
? 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;
|
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) {
|
function handleTouchStart(e: React.TouchEvent) {
|
||||||
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||||
setIsSwiping(false);
|
dragging.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTouchMove(e: React.TouchEvent) {
|
function handleTouchMove(e: React.TouchEvent) {
|
||||||
if (!touchStart.current) return;
|
if (!touchStart.current) return;
|
||||||
const dx = e.touches[0].clientX - touchStart.current.x;
|
const dx = e.touches[0].clientX - touchStart.current.x;
|
||||||
const dy = Math.abs(e.touches[0].clientY - touchStart.current.y);
|
const dy = Math.abs(e.touches[0].clientY - touchStart.current.y);
|
||||||
if (dy > 15 && Math.abs(dx) < dy) { touchStart.current = null; return; }
|
if (!dragging.current && dy > 10 && Math.abs(dx) < dy) { touchStart.current = null; return; }
|
||||||
if (Math.abs(dx) > 8) setIsSwiping(true);
|
if (Math.abs(dx) > 6) dragging.current = true;
|
||||||
const clamped = Math.max(-120, Math.min(80, dx));
|
if (!dragging.current) return;
|
||||||
setSwipeX(clamped);
|
// 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() {
|
function handleTouchEnd() {
|
||||||
if (swipeX > 60) {
|
if (!dragging.current) { touchStart.current = null; return; }
|
||||||
onPin(chat.id, !chat.isPinned);
|
dragging.current = false;
|
||||||
}
|
|
||||||
setSwipeX(0);
|
|
||||||
setIsSwiping(false);
|
|
||||||
touchStart.current = null;
|
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 showPinHint = swipeX > 30;
|
||||||
const showLeftActions = swipeX < -40;
|
const leftOpen = snapped === 'left';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-xl">
|
<div className="relative overflow-hidden rounded-xl">
|
||||||
{/* Pin action (swipe right) */}
|
{/* Right bg: pin hint while dragging */}
|
||||||
<div className={`absolute inset-y-0 left-0 w-20 flex flex-col items-center justify-center bg-blue-500 rounded-l-xl transition-opacity ${showPinHint ? 'opacity-100' : 'opacity-0'}`}>
|
<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" />
|
<Pin className="w-5 h-5 text-white" />
|
||||||
<span className="text-white text-[10px] mt-0.5">{chat.isPinned ? 'Открепить' : 'Закрепить'}</span>
|
<span className="text-white text-[10px] mt-0.5">{chat.isPinned ? 'Открепить' : 'Закрепить'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Left actions: mute + leave (swipe left) */}
|
{/* Left actions: always rendered, visible when snapped */}
|
||||||
<div className={`absolute inset-y-0 right-0 flex rounded-r-xl overflow-hidden transition-opacity ${showLeftActions ? 'opacity-100' : 'opacity-0'}`}>
|
<div className={`absolute inset-y-0 right-0 flex rounded-r-xl overflow-hidden transition-opacity duration-150 ${leftOpen ? 'opacity-100' : 'opacity-0'}`}>
|
||||||
<button
|
<button
|
||||||
onTouchEnd={(e) => { e.stopPropagation(); onMute(chat.id, !chat.isMuted); setSwipeX(0); }}
|
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"
|
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" />}
|
{chat.isMuted ? <Bell className="w-5 h-5" /> : <BellOff className="w-5 h-5" />}
|
||||||
<span>{chat.isMuted ? 'Вкл. звук' : 'Без звука'}</span>
|
<span>{chat.isMuted ? 'Вкл. звук' : 'Без звука'}</span>
|
||||||
</button>
|
</button>
|
||||||
{chat.type !== 'private' && (
|
{chat.type !== 'private' && (
|
||||||
<button
|
<button
|
||||||
onTouchEnd={(e) => { e.stopPropagation(); onLeave(chat.id); setSwipeX(0); }}
|
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"
|
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" />
|
<LogOut className="w-5 h-5" />
|
||||||
<span>Выйти</span>
|
<span>Выйти</span>
|
||||||
@@ -83,11 +117,14 @@ export default function ChatListItem({ chat, active, online, onClick, onPin, onM
|
|||||||
|
|
||||||
{/* Main row */}
|
{/* Main row */}
|
||||||
<button
|
<button
|
||||||
onClick={isSwiping ? undefined : onClick}
|
onClick={handleMainClick}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
style={{ transform: `translateX(${swipeX}px)`, transition: swipeX === 0 ? 'transform 0.2s ease' : 'none' }}
|
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'}`}
|
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">
|
<div className="relative flex-shrink-0">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { usePushNotifications } from '../hooks/usePushNotifications';
|
|||||||
import { Chat } from '../types';
|
import { Chat } from '../types';
|
||||||
|
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
const { user, chats, activeChat, setActiveChat, connected, onlineUsers, updateChat, setChats } = useStore();
|
const { user, chats, chatsLoading, activeChat, setActiveChat, connected, onlineUsers, updateChat, setChats } = useStore();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [searchUsers, setSearchUsers] = useState<any[]>([]);
|
const [searchUsers, setSearchUsers] = useState<any[]>([]);
|
||||||
const [showNew, setShowNew] = useState(false);
|
const [showNew, setShowNew] = useState(false);
|
||||||
@@ -223,7 +223,20 @@ export default function MainLayout() {
|
|||||||
{search && filtered.length === 0 && searchUsers.length === 0 && (
|
{search && filtered.length === 0 && searchUsers.length === 0 && (
|
||||||
<div className="text-center text-gray-400 text-sm py-8">Ничего не найдено</div>
|
<div className="text-center text-gray-400 text-sm py-8">Ничего не найдено</div>
|
||||||
)}
|
)}
|
||||||
{!search && filtered.length === 0 && (
|
{!search && chatsLoading && (
|
||||||
|
<div className="space-y-1 px-1">
|
||||||
|
{[1,2,3,4].map(i => (
|
||||||
|
<div key={i} className="flex items-center gap-3 px-3 py-2.5 rounded-xl animate-pulse">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gray-200 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0 space-y-1.5">
|
||||||
|
<div className="h-3 bg-gray-200 rounded-full w-2/3" />
|
||||||
|
<div className="h-2.5 bg-gray-100 rounded-full w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!search && !chatsLoading && filtered.length === 0 && (
|
||||||
<div className="text-center text-gray-400 text-sm py-8">Нет чатов. Создайте новый!</div>
|
<div className="text-center text-gray-400 text-sm py-8">Нет чатов. Создайте новый!</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Chat, Message, User } from '../types';
|
|||||||
interface AppStore {
|
interface AppStore {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
chats: Chat[];
|
chats: Chat[];
|
||||||
|
chatsLoading: boolean;
|
||||||
activeChat: Chat | null;
|
activeChat: Chat | null;
|
||||||
messages: Record<string, Message[]>;
|
messages: Record<string, Message[]>;
|
||||||
onlineUsers: Set<string>;
|
onlineUsers: Set<string>;
|
||||||
@@ -13,6 +14,7 @@ interface AppStore {
|
|||||||
|
|
||||||
setUser: (user: User | null) => void;
|
setUser: (user: User | null) => void;
|
||||||
setChats: (chats: Chat[]) => void;
|
setChats: (chats: Chat[]) => void;
|
||||||
|
setChatsLoading: (v: boolean) => void;
|
||||||
updateChat: (chat: Partial<Chat> & { id: string }) => void;
|
updateChat: (chat: Partial<Chat> & { id: string }) => void;
|
||||||
setActiveChat: (chat: Chat | null) => void;
|
setActiveChat: (chat: Chat | null) => void;
|
||||||
setMessages: (chatId: string, messages: Message[]) => void;
|
setMessages: (chatId: string, messages: Message[]) => void;
|
||||||
@@ -38,6 +40,7 @@ function loadUser(): User | null {
|
|||||||
export const useStore = create<AppStore>((set, get) => ({
|
export const useStore = create<AppStore>((set, get) => ({
|
||||||
user: loadUser(),
|
user: loadUser(),
|
||||||
chats: [],
|
chats: [],
|
||||||
|
chatsLoading: true,
|
||||||
activeChat: null,
|
activeChat: null,
|
||||||
messages: {},
|
messages: {},
|
||||||
onlineUsers: new Set(),
|
onlineUsers: new Set(),
|
||||||
@@ -47,7 +50,8 @@ export const useStore = create<AppStore>((set, get) => ({
|
|||||||
|
|
||||||
setUser: (user) => set({ user }),
|
setUser: (user) => set({ user }),
|
||||||
|
|
||||||
setChats: (chats) => set({ chats }),
|
setChats: (chats) => set({ chats, chatsLoading: false }),
|
||||||
|
setChatsLoading: (v) => set({ chatsLoading: v }),
|
||||||
|
|
||||||
updateChat: (partial) => set(state => ({
|
updateChat: (partial) => set(state => ({
|
||||||
chats: state.chats.map(c => c.id === partial.id ? { ...c, ...partial } : c),
|
chats: state.chats.map(c => c.id === partial.id ? { ...c, ...partial } : c),
|
||||||
@@ -132,6 +136,6 @@ export const useStore = create<AppStore>((set, get) => ({
|
|||||||
logout: () => {
|
logout: () => {
|
||||||
localStorage.removeItem('jc_token');
|
localStorage.removeItem('jc_token');
|
||||||
localStorage.removeItem('jc_user');
|
localStorage.removeItem('jc_user');
|
||||||
set({ user: null, chats: [], activeChat: null, messages: {}, connected: false, chatReadAt: {} });
|
set({ user: null, chats: [], chatsLoading: false, activeChat: null, messages: {}, connected: false, chatReadAt: {} });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user