import { memo } from 'preact/compat'; import { useEffect, useRef, useState } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; import { useDebouncedCallback } from 'use-debounce'; import { useSnapshot } from 'valtio'; import Icon from '../components/icon'; import Link from '../components/link'; 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 useScroll from '../utils/useScroll'; import useTitle from '../utils/useTitle'; const LIMIT = 20; function Home({ hidden }) { useTitle('Home', '/'); const snapStates = useSnapshot(states); const isHomeLocation = snapStates.currentLocation === '/'; const [uiState, setUIState] = useState('default'); const [showMore, setShowMore] = useState(false); console.debug('RENDER Home'); const homeIterator = useRef(); 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?.length) { // ENFORCE sort by datetime (Latest first) allStatuses.value.sort((a, b) => { const aDate = new Date(a.createdAt); const bDate = new Date(b.createdAt); return bDate - aDate; }); 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.homeLast = specialHome[0]; states.home = specialHome; } else { states.home.push(...specialHome); } } else { if (firstLoad) { states.homeLast = homeValues[0]; states.home = homeValues; } else { states.home.push(...homeValues); } } } states.homeLastFetchTime = Date.now(); return allStatuses; } const loadStatuses = useDebouncedCallback( (firstLoad) => { if (uiState === 'loading') return; setUIState('loading'); (async () => { try { const { done } = await fetchStatuses(firstLoad); setShowMore(!done); setUIState('default'); } catch (e) { console.warn(e); setUIState('error'); } finally { } })(); }, 1500, { leading: true, trailing: false, }, ); 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?.(); } } }, { enabled: isHomeLocation, }, ); useHotkeys( 'k, shift+k', (_, handler) => { // 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.findLast( (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?.(); } } }, { enabled: isHomeLocation, }, ); useHotkeys( ['enter', 'o'], () => { // open active status const activeStatus = document.activeElement.closest( '.status-link, .status-boost-link', ); if (activeStatus) { activeStatus.click(); } }, { enabled: isHomeLocation, }, ); const { scrollDirection, reachStart, nearReachStart, nearReachEnd, reachEnd, } = useScroll({ scrollableElement: scrollableRef.current, 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; } } })(); }, []); // const showUpdatesButton = snapStates.homeNew.length > 0 && reachStart; const [showUpdatesButton, setShowUpdatesButton] = useState(false); useEffect(() => { const isNewAndTop = snapStates.homeNew.length > 0 && reachStart; setShowUpdatesButton(isNewAndTop); }, [snapStates.homeNew.length]); return ( <> ); } function BoostsCarousel({ boosts }) { const carouselRef = useRef(); const { reachStart, reachEnd, init } = useScroll({ scrollableElement: carouselRef.current, direction: 'horizontal', }); useEffect(() => { init?.(); }, []); return ( ); } export default memo(Home);