import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react'; import { NavLink, useNavigate, useLocation } from 'react-router-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import type { IconProp } from '@fortawesome/fontawesome-svg-core'; import { faHouse, faRightFromBracket, faGear, faPlus, faXmark, faChevronDown, faCircleCheck, faPenToSquare, faUser, faSearch, faBell, faBars, faBookmark, } from '@fortawesome/free-solid-svg-icons'; import { useAuth } from '../auth/useAuth'; import { usePostLabels } from '../settings/usePostLabels'; import { isDemoSession } from '../auth/accounts'; import { prefetchTimeline, prefetchModule } from '../prefetch'; import { atprotoClient } from '../api/client'; import type { ActorSearchResult } from '../api/types'; import { useNotificationCount } from './useNotificationCount'; import ComposeModal from './ComposeModal'; import PdsFavicon from './PdsFavicon'; import './Layout.css'; const SETTINGS_IMPORT = () => import('../settings/SettingsPage'); const NAV_ITEMS: Array<{ to: string; label: string; icon: IconProp; onPrefetch?: () => void; }> = [ { to: '/', label: 'Home', icon: faHouse, onPrefetch: () => prefetchTimeline(), }, { to: '/notifications', label: 'Notifications', icon: faBell, }, { to: '/saved', label: '__SAVED__', icon: faBookmark, }, { to: '/search', label: 'Search', icon: faSearch, }, { to: '/profile', label: 'Profile', icon: faUser, }, { to: '/settings', label: 'Settings', icon: faGear, onPrefetch: () => prefetchModule(SETTINGS_IMPORT, 'settings'), }, ]; const DRAWER_WIDTH = 280; const SNAP_RATIO = 0.4; const DIRECTION_THRESHOLD = 12; export default function Layout({ children }: { children: ReactNode }) { const { session, accounts, logout, switchAccount, removeAccount, } = useAuth(); const navigate = useNavigate(); const location = useLocation(); const { unreadCount } = useNotificationCount(); const { t: label } = usePostLabels(); const isDemo = session ? isDemoSession(session.did) : false; const [switcherOpen, setSwitcherOpen] = useState(false); const [switchingDid, setSwitchingDid] = useState(null); const [showCompose, setShowCompose] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); // null = not swiping, number = pixel offset (negative = shifted left) const [drawerOffset, setDrawerOffset] = useState(null); const switcherRef = useRef(null); const drawerElRef = useRef(null); const isSwipingRef = useRef(false); const swipeStartRef = useRef<{ x: number; y: number } | null>(null); const swipeDirRef = useRef<'open' | 'close' | null>(null); // Lock body scroll when drawer is open (but not mid-swipe — the swipe handler manages offset) useEffect(() => { if (drawerOpen && !isSwipingRef.current) { document.body.style.overflow = 'hidden'; } else if (!drawerOpen && !isSwipingRef.current) { document.body.style.overflow = ''; } return () => { document.body.style.overflow = ''; }; }, [drawerOpen]); // ── Swipe gesture handlers ── useEffect(() => { const isMobile = () => window.innerWidth <= 768; const handleTouchStart = (e: TouchEvent) => { if (!isMobile()) return; const touch = e.touches[0]; swipeStartRef.current = { x: touch.clientX, y: touch.clientY }; swipeDirRef.current = null; }; const handleTouchMove = (e: TouchEvent) => { const start = swipeStartRef.current; if (!start || !isMobile()) return; const touch = e.touches[0]; const dx = touch.clientX - start.x; const dy = touch.clientY - start.y; // Direction lock if (!swipeDirRef.current) { if (Math.abs(dx) < DIRECTION_THRESHOLD && Math.abs(dy) < DIRECTION_THRESHOLD) return; if (Math.abs(dy) > Math.abs(dx) * 1.2) { // vertical scroll — ignore this gesture swipeStartRef.current = null; return; } const dir = dx > 0 ? 'open' : 'close'; // Only start if the gesture makes sense: // - open when drawer is closed, or close when drawer is open if ((dir === 'open' && !drawerOpen) || (dir === 'close' && drawerOpen)) { swipeDirRef.current = dir; isSwipingRef.current = true; } else { swipeStartRef.current = null; return; } } // Compute offset if (swipeDirRef.current === 'open') { // Drawer starts fully hidden (-DRAWER_WIDTH), moves toward 0 const offset = Math.max(-DRAWER_WIDTH, Math.min(0, -DRAWER_WIDTH + dx)); setDrawerOffset(offset); } else { // Drawer starts at 0, moves toward -DRAWER_WIDTH const offset = Math.max(-DRAWER_WIDTH, Math.min(0, dx)); setDrawerOffset(offset); } }; const handleTouchEnd = () => { const start = swipeStartRef.current; if (!start || !isMobile()) return; swipeStartRef.current = null; if (!swipeDirRef.current || drawerOffset === null) { isSwipingRef.current = false; return; } const snapThreshold = DRAWER_WIDTH * SNAP_RATIO; if (swipeDirRef.current === 'open') { // If revealed past threshold, snap open if (drawerOffset > -snapThreshold) { setDrawerOffset(null); setDrawerOpen(true); } else { setDrawerOffset(null); setDrawerOpen(false); } } else { // If pushed past threshold, snap closed if (drawerOffset < -snapThreshold) { setDrawerOffset(null); setDrawerOpen(false); } else { setDrawerOffset(null); setDrawerOpen(true); } } isSwipingRef.current = false; }; document.addEventListener('touchstart', handleTouchStart, { passive: true }); document.addEventListener('touchmove', handleTouchMove, { passive: true }); document.addEventListener('touchend', handleTouchEnd, { passive: true }); return () => { document.removeEventListener('touchstart', handleTouchStart); document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchend', handleTouchEnd); }; }, [drawerOpen, drawerOffset]); const closeDrawer = useCallback(() => { setDrawerOpen(false); setDrawerOffset(null); }, []); const [searchQuery, setSearchQuery] = useState(''); const [searchAutocomplete, setSearchAutocomplete] = useState([]); const [showSearchAutocomplete, setShowSearchAutocomplete] = useState(false); const [selectedSearchIndex, setSelectedSearchIndex] = useState(-1); const searchInputRef = useRef(null); const searchWrapperRef = useRef(null); const searchDebounceRef = useRef | undefined>(undefined); useEffect(() => { if (!switcherOpen) return; const handleClickOutside = (e: MouseEvent) => { if (switcherRef.current && !switcherRef.current.contains(e.target as Node)) { setSwitcherOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [switcherOpen]); const handleLogout = () => { logout(); navigate('/login'); }; const handleSwitch = async (did: string) => { setSwitchingDid(did); try { await switchAccount(did); setSwitcherOpen(false); } catch { /* empty */ } finally { setSwitchingDid(null); } }; const handleRemoveAccount = async (did: string, e: React.MouseEvent) => { e.stopPropagation(); await removeAccount(did); }; const handleAddAccount = async () => { setSwitcherOpen(false); navigate('/login?add_account=1'); }; const fetchSearchAutocomplete = useCallback((q: string) => { clearTimeout(searchDebounceRef.current); searchDebounceRef.current = setTimeout(async () => { if (q.trim().length < 1) { setSearchAutocomplete([]); setShowSearchAutocomplete(false); return; } try { const resp = await atprotoClient.searchActorsTypeahead(q, 5); setSearchAutocomplete(resp.actors); setShowSearchAutocomplete(resp.actors.length > 0); setSelectedSearchIndex(-1); } catch { /* empty */ } }, 250); }, []); const handleSearchChange = (e: React.ChangeEvent) => { const val = e.target.value; setSearchQuery(val); fetchSearchAutocomplete(val); }; const handleSearchKeyDown = (e: React.KeyboardEvent) => { if (!showSearchAutocomplete || searchAutocomplete.length === 0) { if (e.key === 'Enter' && searchQuery.trim()) { e.preventDefault(); navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`); setShowSearchAutocomplete(false); setSearchQuery(''); searchInputRef.current?.blur(); } return; } switch (e.key) { case 'ArrowDown': e.preventDefault(); setSelectedSearchIndex((prev) => prev < searchAutocomplete.length - 1 ? prev + 1 : 0, ); break; case 'ArrowUp': e.preventDefault(); setSelectedSearchIndex((prev) => prev > 0 ? prev - 1 : searchAutocomplete.length - 1, ); break; case 'Enter': e.preventDefault(); if (selectedSearchIndex >= 0) { const actor = searchAutocomplete[selectedSearchIndex]; navigate(`/profile/${actor.handle}`); } else { navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`); } setShowSearchAutocomplete(false); setSearchQuery(''); searchInputRef.current?.blur(); break; case 'Escape': setShowSearchAutocomplete(false); searchInputRef.current?.blur(); break; } }; const handleSearchClear = () => { setSearchQuery(''); setSearchAutocomplete([]); setShowSearchAutocomplete(false); searchInputRef.current?.focus(); }; useEffect(() => { const handleClick = (e: MouseEvent) => { if ( searchWrapperRef.current && !searchWrapperRef.current.contains(e.target as Node) ) { setShowSearchAutocomplete(false); } }; document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); }, []); const otherAccounts = accounts.filter( (a) => a.did !== session?.did, ); const currentAccount = accounts.find((a) => a.did === session?.did); // ── Drawer positioning logic ── const isSwiping = drawerOffset !== null; // During swipe: use drawerOffset. Otherwise: 0 when open, -DRAWER_WIDTH when closed. const resolvedOffset = isSwiping ? drawerOffset : drawerOpen ? 0 : -DRAWER_WIDTH; const progress = 1 + resolvedOffset / DRAWER_WIDTH; // 0 = hidden, 1 = fully visible const showOverlay = progress > 0.01; return (
{/* ── Drawer overlay ── */} {showOverlay && (
)} {/* ── Drawer panel ── */}
Foxsky
{session && (
{currentAccount?.avatar ? ( ) : (
{(currentAccount?.displayName || session.handle || '?')[0].toUpperCase()}
)}
{currentAccount?.displayName || `@${session.handle}`} {currentAccount?.displayName && ( @{session.handle} )}
{otherAccounts.length > 0 && (
Switch account
{otherAccounts.map((account) => ( ))}
)}
)}
{children}
{showCompose && ( setShowCompose(false)} /> )}
); }