Something went wrong. Try again.
A Bsky-like frontend using the atprotocol natively.
Something went wrong. Try again.
28 kB · 809 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810import { 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<string | null>(null); const [showCompose, setShowCompose] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); // null = not swiping, number = pixel offset (negative = shifted left) const [drawerOffset, setDrawerOffset] = useState<number | null>(null); const switcherRef = useRef<HTMLDivElement>(null); const drawerElRef = useRef<HTMLDivElement>(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<ActorSearchResult[]>([]); const [showSearchAutocomplete, setShowSearchAutocomplete] = useState(false); const [selectedSearchIndex, setSelectedSearchIndex] = useState(-1); const searchInputRef = useRef<HTMLInputElement>(null); const searchWrapperRef = useRef<HTMLDivElement>(null); const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => { 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 ( <div className="layout"> <aside className="layout-sidebar"> <div className="layout-brand"> <img src="/favicon.svg" alt="" className="layout-brand-icon" /> Foxsky </div>
<button className="layout-burger-btn" onClick={() => setDrawerOpen(true)} type="button" aria-label="Open menu" > <FontAwesomeIcon icon={faBars} /> </button>
<nav className="layout-nav layout-nav--desktop"> {NAV_ITEMS.map((item) => ( <NavLink key={item.to} to={item.to} end={item.to === '/'} className={({ isActive, isPending }) => isActive ? 'active' : isPending ? 'pending' : '' } onClick={(e) => { // Demo account: Profile link opens bsky.app if (isDemo && item.to === '/profile') { e.preventDefault(); window.open('https://bsky.social', '_blank', 'noopener'); return; } const isHome = item.to === '/'; if (isHome && location.pathname === '/') { e.preventDefault(); window.scrollTo({ top: 0, behavior: 'smooth' }); window.dispatchEvent(new CustomEvent('foxsky:refresh-home')); } }} onMouseEnter={() => { const isCurrentPage = item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to); if (!isCurrentPage) { item.onPrefetch?.(); } }} onFocus={() => { const isCurrentPage = item.to === '/' ? location.pathname === '/' : location.pathname.startsWith(item.to); if (!isCurrentPage) { item.onPrefetch?.(); } }} > <FontAwesomeIcon icon={item.icon} /> {item.label === '__SAVED__' ? label('save', true, true) : item.label} {item.to === '/notifications' && unreadCount > 0 && ( <span className="layout-nav-badge">{unreadCount > 99 ? '99+' : unreadCount}</span> )} </NavLink> ))} </nav>
<button className="layout-compose-btn layout-compose-btn--desktop" onClick={() => setShowCompose(true)} type="button" > <FontAwesomeIcon icon={faPenToSquare} /> {label('post', false, true)} </button>
<div className="layout-spacer" />
{session && ( <div className="layout-profile-section layout-profile-section--desktop" ref={switcherRef}> <button className="layout-profile-trigger" onClick={() => setSwitcherOpen(!switcherOpen)} type="button" > {currentAccount?.avatar ? ( <img src={currentAccount.avatar} alt="" className="layout-profile-avatar" /> ) : ( <div className="layout-profile-avatar-placeholder"> {(currentAccount?.displayName || session.handle || '?')[0].toUpperCase()} </div> )} <div className="layout-profile-info"> <span className="layout-profile-name"> {currentAccount?.displayName || `@${session.handle}`} </span> {currentAccount?.displayName && ( <span className="layout-profile-handle"> @{session.handle} </span> )} </div> <FontAwesomeIcon icon={faChevronDown} className={`layout-profile-chevron ${switcherOpen ? 'open' : ''}`} /> </button>
{switcherOpen && ( <div className="account-switcher"> {otherAccounts.length > 0 && ( <div className="account-switcher-section"> <div className="account-switcher-label">Switch account</div> {otherAccounts.map((account) => ( <button key={account.did} className="account-switcher-item" onClick={() => handleSwitch(account.did)} disabled={switchingDid === account.did} type="button" > {account.avatar ? ( <img src={account.avatar} alt="" className="account-switcher-avatar" /> ) : ( <div className="account-switcher-avatar-placeholder"> {(account.displayName || account.handle)[0].toUpperCase()} </div> )} <div className="account-switcher-info"> {account.displayName && ( <span className="account-switcher-name"> {account.displayName} </span> )} <span className="account-switcher-handle"> @{account.handle} </span> </div> {switchingDid === account.did && ( <FontAwesomeIcon icon={faCircleCheck} className="account-switcher-spin" spin /> )} <button className="account-switcher-remove" onClick={(e) => handleRemoveAccount(account.did, e)} title="Remove account" type="button" > <FontAwesomeIcon icon={faXmark} /> </button> </button> ))} </div> )}
<div className="account-switcher-actions"> <button className="account-switcher-action" onClick={handleAddAccount} type="button" > <FontAwesomeIcon icon={faPlus} /> Add account </button> <button className="account-switcher-action logout" onClick={handleLogout} type="button" > <FontAwesomeIcon icon={faRightFromBracket} /> Sign out </button> </div> </div> )} </div> )} </aside>
{/* ── Drawer overlay ── */} {showOverlay && ( <div className="layout-drawer-overlay" style={{ opacity: 0.4 * progress, transition: isSwiping ? 'none' : 'opacity 0.25s ease-out', }} onClick={closeDrawer} /> )}
{/* ── Drawer panel ── */} <div ref={drawerElRef} className="layout-drawer" style={{ transform: `translateX(${resolvedOffset}px)`, transition: isSwiping ? 'none' : 'transform 0.25s ease-out', }} > <div className="layout-drawer-header"> <div className="layout-brand"> <img src="/favicon.svg" alt="" className="layout-brand-icon" /> Foxsky </div> <button className="layout-drawer-close" onClick={closeDrawer} type="button" aria-label="Close menu" > <FontAwesomeIcon icon={faXmark} /> </button> </div>
<nav className="layout-nav"> {NAV_ITEMS.map((item) => ( <NavLink key={item.to} to={item.to} end={item.to === '/'} className={({ isActive, isPending }) => isActive ? 'active' : isPending ? 'pending' : '' } onClick={(e) => { // Demo account: Profile link opens bsky.app if (isDemo && item.to === '/profile') { e.preventDefault(); window.open('https://bsky.social', '_blank', 'noopener'); return; } closeDrawer(); const isHome = item.to === '/'; if (isHome && location.pathname === '/') { e.preventDefault(); window.scrollTo({ top: 0, behavior: 'smooth' }); window.dispatchEvent(new CustomEvent('foxsky:refresh-home')); } }} > <FontAwesomeIcon icon={item.icon} /> {item.label === '__SAVED__' ? label('save', true, true) : item.label} {item.to === '/notifications' && unreadCount > 0 && ( <span className="layout-nav-badge">{unreadCount > 99 ? '99+' : unreadCount}</span> )} </NavLink> ))} </nav>
<button className="layout-compose-btn" onClick={() => { closeDrawer(); setShowCompose(true); }} type="button" > <FontAwesomeIcon icon={faPenToSquare} /> {label('post', false, true)} </button>
{session && ( <div className="layout-drawer-profile"> <div className="layout-profile-trigger" style={{ cursor: 'default' }}> {currentAccount?.avatar ? ( <img src={currentAccount.avatar} alt="" className="layout-profile-avatar" /> ) : ( <div className="layout-profile-avatar-placeholder"> {(currentAccount?.displayName || session.handle || '?')[0].toUpperCase()} </div> )} <div className="layout-profile-info"> <span className="layout-profile-name"> {currentAccount?.displayName || `@${session.handle}`} </span> {currentAccount?.displayName && ( <span className="layout-profile-handle"> @{session.handle} </span> )} </div> </div>
<div className="layout-drawer-actions"> <button className="account-switcher-action" onClick={() => { closeDrawer(); handleAddAccount(); }} type="button" > <FontAwesomeIcon icon={faPlus} /> Add account </button> <button className="account-switcher-action logout" onClick={() => { closeDrawer(); handleLogout(); }} type="button" > <FontAwesomeIcon icon={faRightFromBracket} /> Sign out </button> </div>
{otherAccounts.length > 0 && ( <div className="layout-drawer-accounts"> <div className="account-switcher-label">Switch account</div> {otherAccounts.map((account) => ( <button key={account.did} className="account-switcher-item" onClick={() => { closeDrawer(); handleSwitch(account.did); }} disabled={switchingDid === account.did} type="button" > {account.avatar ? ( <img src={account.avatar} alt="" className="account-switcher-avatar" /> ) : ( <div className="account-switcher-avatar-placeholder"> {(account.displayName || account.handle)[0].toUpperCase()} </div> )} <div className="account-switcher-info"> {account.displayName && ( <span className="account-switcher-name"> {account.displayName} </span> )} <span className="account-switcher-handle"> @{account.handle} </span> </div> {switchingDid === account.did && ( <FontAwesomeIcon icon={faCircleCheck} className="account-switcher-spin" spin /> )} </button> ))} </div> )} </div> )} </div>
<main className="layout-main">{children}</main>
<aside className="layout-right"> <div className="layout-search"> <div className="layout-search-input-wrapper" ref={searchWrapperRef}> <FontAwesomeIcon icon={faSearch} className="layout-search-icon" /> <input ref={searchInputRef} type="text" className="layout-search-input" placeholder="Search Bluesky" value={searchQuery} onChange={handleSearchChange} onKeyDown={handleSearchKeyDown} onFocus={() => { if (searchQuery.trim() && searchAutocomplete.length > 0) { setShowSearchAutocomplete(true); } }} autoComplete="off" spellCheck={false} /> {searchQuery && ( <button className="layout-search-clear" onClick={handleSearchClear} type="button" > <FontAwesomeIcon icon={faXmark} /> </button> )} {showSearchAutocomplete && searchAutocomplete.length > 0 && ( <div className="layout-search-dropdown"> {searchAutocomplete.map((actor, i) => ( <button key={actor.did} className={`layout-search-dropdown-item ${i === selectedSearchIndex ? 'selected' : ''}`} onClick={() => { navigate(`/profile/${actor.handle}`); setShowSearchAutocomplete(false); setSearchQuery(''); }} onMouseEnter={() => setSelectedSearchIndex(i)} type="button" > {actor.avatar ? ( <img src={actor.avatar} alt="" className="layout-search-avatar" /> ) : ( <div className="layout-search-avatar-placeholder"> {(actor.displayName || actor.handle)[0].toUpperCase()} </div> )} <div className="layout-search-info"> <span className="layout-search-name">{actor.displayName || actor.handle}</span> <PdsFavicon did={actor.did} /> <span className="layout-search-handle">@{actor.handle}</span> </div> </button> ))} <button className="layout-search-dropdown-item layout-search-posts-btn" onClick={() => { navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`); setShowSearchAutocomplete(false); setSearchQuery(''); searchInputRef.current?.blur(); }} onMouseEnter={() => setSelectedSearchIndex(-1)} type="button" > <FontAwesomeIcon icon={faSearch} className="layout-search-posts-icon" /> <span>Search {label('post', true)} for “{searchQuery}”</span> </button> </div> )} </div> </div> </aside>
{showCompose && ( <ComposeModal onClose={() => setShowCompose(false)} /> )} </div> );}