import { Link } from 'preact-router/match'; import { memo } from 'preact/compat'; import { useEffect, useRef, useState } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; import { useSnapshot } from 'valtio'; import Icon from '../components/icon'; import Loader from '../components/loader'; import Status from '../components/status'; import db from '../utils/db'; import states, { saveStatus } from '../utils/states'; import { getCurrentAccountNS } from '../utils/store-utils'; import useDebouncedCallback from '../utils/useDebouncedCallback'; import useScroll from '../utils/useScroll'; const LIMIT = 20; function Home({ hidden }) { const snapStates = useSnapshot(states); const [uiState, setUIState] = useState('default'); const [showMore, setShowMore] = useState(false); console.debug('RENDER Home'); const homeIterator = useRef( masto.v1.timelines.listHome({ limit: LIMIT, }), ); async function fetchStatuses(firstLoad) { if (firstLoad) { // Reset iterator homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT, }); states.homeNew = []; } const allStatuses = await homeIterator.current.next(); if (allStatuses.value <= 0) { return { done: true }; } const homeValues = allStatuses.value.map((status) => { saveStatus(status); return { id: status.id, reblog: status.reblog?.id, reply: !!status.inReplyToAccountId, }; }); // BOOSTS CAROUSEL if (snapStates.settings.boostsCarousel) { let specialHome = []; let boostStash = []; let serialBoosts = 0; for (let i = 0; i < homeValues.length; i++) { const status = homeValues[i]; if (status.reblog) { boostStash.push(status); serialBoosts++; } else { specialHome.push(status); if (serialBoosts < 3) { serialBoosts = 0; } } } // if boostStash is more than quarter of homeValues // or if there are 3 or more boosts in a row if (boostStash.length > homeValues.length / 4 || serialBoosts >= 3) { // if boostStash is more than 3 quarter of homeValues const boostStashID = boostStash.map((status) => status.id); if (boostStash.length > (homeValues.length * 3) / 4) { // insert boost array at the end of specialHome list specialHome = [ ...specialHome, { id: boostStashID, boosts: boostStash }, ]; } else { // insert boosts array in the middle of specialHome list const half = Math.floor(specialHome.length / 2); specialHome = [ ...specialHome.slice(0, half), { id: boostStashID, boosts: boostStash, }, ...specialHome.slice(half), ]; } } else { // Untouched, this is fine specialHome = homeValues; } console.log({ specialHome, }); if (firstLoad) { states.home = specialHome; } else { states.home.push(...specialHome); } } else { if (firstLoad) { states.home = homeValues; } else { states.home.push(...homeValues); } } states.homeLastFetchTime = Date.now(); return { done: false, }; } const loadingStatuses = useRef(false); const loadStatuses = useDebouncedCallback((firstLoad) => { if (loadingStatuses.current) return; loadingStatuses.current = true; setUIState('loading'); (async () => { try { const { done } = await fetchStatuses(firstLoad); setShowMore(!done); setUIState('default'); } catch (e) { console.warn(e); setUIState('error'); } finally { loadingStatuses.current = false; } })(); }, 1000); useEffect(() => { loadStatuses(true); }, []); const scrollableRef = useRef(); useHotkeys('j, shift+j', (_, handler) => { // focus on next status after active status // Traverses .timeline li .status-link, focus on .status-link const activeStatus = document.activeElement.closest( '.status-link, .status-boost-link', ); const activeStatusRect = activeStatus?.getBoundingClientRect(); const allStatusLinks = Array.from( scrollableRef.current.querySelectorAll( '.status-link, .status-boost-link', ), ); if ( activeStatus && activeStatusRect.top < scrollableRef.current.clientHeight && activeStatusRect.bottom > 0 ) { const activeStatusIndex = allStatusLinks.indexOf(activeStatus); let nextStatus = allStatusLinks[activeStatusIndex + 1]; if (handler.shift) { // get next status that's not .status-boost-link nextStatus = allStatusLinks.find( (statusLink, index) => index > activeStatusIndex && !statusLink.classList.contains('status-boost-link'), ); } if (nextStatus) { nextStatus.focus(); nextStatus.scrollIntoViewIfNeeded?.(); } } else { // If active status is not in viewport, get the topmost status-link in viewport const topmostStatusLink = allStatusLinks.find((statusLink) => { const statusLinkRect = statusLink.getBoundingClientRect(); return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real }); if (topmostStatusLink) { topmostStatusLink.focus(); topmostStatusLink.scrollIntoViewIfNeeded?.(); } } }); useHotkeys('k. shift+k', () => { // focus on previous status after active status // Traverses .timeline li .status-link, focus on .status-link const activeStatus = document.activeElement.closest( '.status-link, .status-boost-link', ); const activeStatusRect = activeStatus?.getBoundingClientRect(); const allStatusLinks = Array.from( scrollableRef.current.querySelectorAll( '.status-link, .status-boost-link', ), ); if ( activeStatus && activeStatusRect.top < scrollableRef.current.clientHeight && activeStatusRect.bottom > 0 ) { const activeStatusIndex = allStatusLinks.indexOf(activeStatus); let prevStatus = allStatusLinks[activeStatusIndex - 1]; if (handler.shift) { // get prev status that's not .status-boost-link prevStatus = allStatusLinks.find( (statusLink, index) => index < activeStatusIndex && !statusLink.classList.contains('status-boost-link'), ); } if (prevStatus) { prevStatus.focus(); prevStatus.scrollIntoViewIfNeeded?.(); } } else { // If active status is not in viewport, get the topmost status-link in viewport const topmostStatusLink = allStatusLinks.find((statusLink) => { const statusLinkRect = statusLink.getBoundingClientRect(); return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real }); if (topmostStatusLink) { topmostStatusLink.focus(); topmostStatusLink.scrollIntoViewIfNeeded?.(); } } }); useHotkeys(['enter', 'o'], () => { // open active status const activeStatus = document.activeElement.closest( '.status-link, .status-boost-link', ); if (activeStatus) { activeStatus.click(); } }); const { scrollDirection, reachStart, nearReachStart, nearReachEnd, reachEnd, } = useScroll({ scrollableElement: scrollableRef.current, distanceFromStart: 1, distanceFromEnd: 3, scrollThresholdStart: 44, }); useEffect(() => { if (nearReachEnd || (reachEnd && showMore)) { loadStatuses(); } }, [nearReachEnd, reachEnd]); useEffect(() => { if (reachStart) { loadStatuses(true); } }, [reachStart]); useEffect(() => { (async () => { const keys = await db.drafts.keys(); if (keys.length) { const ns = getCurrentAccountNS(); const ownKeys = keys.filter((key) => key.startsWith(ns)); if (ownKeys.length) { states.showDrafts = true; } } })(); }, []); return ( <div id="home-page" class="deck-container" hidden={hidden} ref={scrollableRef} tabIndex="-1" > <button hidden={scrollDirection === 'end' && !nearReachStart} type="button" id="compose-button" onClick={(e) => { if (e.shiftKey) { const newWin = openCompose(); if (!newWin) { alert('Looks like your browser is blocking popups.'); states.showCompose = true; } } else { states.showCompose = true; } }} > <Icon icon="quill" size="xxl" alt="Compose" /> </button> <div class="timeline-deck deck"> <header hidden={scrollDirection === 'end' && !nearReachStart} onClick={() => { scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); }} onDblClick={() => { loadStatuses(true); }} > <div class="header-side"> <button type="button" class="plain" onClick={(e) => { e.preventDefault(); e.stopPropagation(); states.showSettings = true; }} > <Icon icon="gear" size="l" alt="Settings" /> </button> </div> <h1>Home</h1> <div class="header-side"> <Loader hidden={uiState !== 'loading'} />{' '} <a href="#/notifications" class={`button plain ${ snapStates.notificationsNew.length > 0 ? 'has-badge' : '' }`} onClick={(e) => { e.stopPropagation(); }} > <Icon icon="notification" size="l" alt="Notifications" /> </a> </div> </header> {snapStates.homeNew.length > 0 && scrollDirection === 'start' && !nearReachStart && !nearReachEnd && ( <button class="updates-button" type="button" onClick={() => { if (!snapStates.settings.boostsCarousel) { const uniqueHomeNew = snapStates.homeNew.filter( (status) => !states.home.some((s) => s.id === status.id), ); states.home.unshift(...uniqueHomeNew); } loadStatuses(true); states.homeNew = []; scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth', }); }} > <Icon icon="arrow-up" /> New posts </button> )} {snapStates.home.length ? ( <> <ul class="timeline"> {snapStates.home.map(({ id: statusID, reblog, boosts }) => { const actualStatusID = reblog || statusID; if (boosts) { return ( <li key={statusID}> <BoostsCarousel boosts={boosts} /> </li> ); } return ( <li key={statusID}> <Link activeClassName="active" class="status-link" href={`#/s/${actualStatusID}`} > <Status statusID={statusID} /> </Link> </li> ); })} {showMore && ( <> {/* <InView as="li" style={{ height: '20vh', }} onChange={(inView) => { if (inView) loadStatuses(); }} root={scrollableRef.current} rootMargin="100px 0px" > */} <li style={{ height: '20vh', }} > <Status skeleton /> </li> {/* </InView> */} <li style={{ height: '25vh', }} > <Status skeleton /> </li> </> )} </ul> </> ) : ( <> {uiState === 'loading' && ( <ul class="timeline"> {Array.from({ length: 5 }).map((_, i) => ( <li key={i}> <Status skeleton /> </li> ))} </ul> )} {uiState === 'error' && ( <p class="ui-state"> Unable to load statuses <br /> <br /> <button type="button" onClick={() => { loadStatuses(true); }} > Try again </button> </p> )} </> )} </div> </div> ); } function BoostsCarousel({ boosts }) { const carouselRef = useRef(); const { reachStart, reachEnd, init } = useScroll({ scrollableElement: carouselRef.current, direction: 'horizontal', }); useEffect(() => { init?.(); }, []); return ( <div class="boost-carousel"> <header> <h3>{boosts.length} Boosts</h3> <span> <button type="button" class="small plain2" disabled={reachStart} onClick={() => { carouselRef.current?.scrollBy({ left: -Math.min(320, carouselRef.current?.offsetWidth), behavior: 'smooth', }); }} > <Icon icon="chevron-left" /> </button>{' '} <button type="button" class="small plain2" disabled={reachEnd} onClick={() => { carouselRef.current?.scrollBy({ left: Math.min(320, carouselRef.current?.offsetWidth), behavior: 'smooth', }); }} > <Icon icon="chevron-right" /> </button> </span> </header> <ul ref={carouselRef}> {boosts.map((boost) => { const { id: statusID, reblog } = boost; const actualStatusID = reblog || statusID; return ( <li> <a class="status-boost-link" href={`#/s/${actualStatusID}`}> <Status statusID={statusID} size="s" /> </a> </li> ); })} </ul> </div> ); } export default memo(Home);