From 9992299716c1caee7c8751721ab3cced0b433886 Mon Sep 17 00:00:00 2001 From: Lim Chee Aun Date: Mon, 6 Feb 2023 23:50:00 +0800 Subject: [PATCH] More ports to reusable Timeline component - use status id instead of status, for "auto-update" feature - hot keys! --- src/components/timeline.jsx | 132 ++++++++++++++++++++++++++++++++---- src/pages/following.jsx | 20 +++++- 2 files changed, 138 insertions(+), 14 deletions(-) diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx index 53cadc4d..1d0472ef 100644 --- a/src/components/timeline.jsx +++ b/src/components/timeline.jsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'preact/hooks'; +import { useHotkeys } from 'react-hotkeys-hook'; import { useDebouncedCallback } from 'use-debounce'; import useScroll from '../utils/useScroll'; @@ -15,17 +16,14 @@ function Timeline({ instance, emptyText, errorText, + useItemID, // use statusID instead of status object, assuming it's already in states boostsCarousel, fetchItems = () => {}, }) { const [items, setItems] = useState([]); const [uiState, setUIState] = useState('default'); const [showMore, setShowMore] = useState(false); - const scrollableRef = useRef(null); - const { nearReachEnd, reachStart, reachEnd } = useScroll({ - scrollableElement: scrollableRef.current, - distanceFromEnd: 1, - }); + const scrollableRef = useRef(); const loadItems = useDebouncedCallback( (firstLoad) => { @@ -62,6 +60,99 @@ function Timeline({ }, ); + const itemsSelector = '.timeline-item, .timeline-item-alt'; + + const jRef = useHotkeys('j, shift+j', (_, handler) => { + // focus on next status after active item + const activeItem = document.activeElement.closest(itemsSelector); + const activeItemRect = activeItem?.getBoundingClientRect(); + const allItems = Array.from( + scrollableRef.current.querySelectorAll(itemsSelector), + ); + if ( + activeItem && + activeItemRect.top < scrollableRef.current.clientHeight && + activeItemRect.bottom > 0 + ) { + const activeItemIndex = allItems.indexOf(activeItem); + let nextItem = allItems[activeItemIndex + 1]; + if (handler.shift) { + // get next status that's not .timeline-item-alt + nextItem = allItems.find( + (item, index) => + index > activeItemIndex && + !item.classList.contains('timeline-item-alt'), + ); + } + if (nextItem) { + nextItem.focus(); + nextItem.scrollIntoViewIfNeeded?.(); + } + } else { + // If active status is not in viewport, get the topmost status-link in viewport + const topmostItem = allItems.find((item) => { + const itemRect = item.getBoundingClientRect(); + return itemRect.top >= 44 && itemRect.left >= 0; // 44 is the magic number for header height, not real + }); + if (topmostItem) { + topmostItem.focus(); + topmostItem.scrollIntoViewIfNeeded?.(); + } + } + }); + + const kRef = useHotkeys('k, shift+k', (_, handler) => { + // focus on previous status after active item + const activeItem = document.activeElement.closest(itemsSelector); + const activeItemRect = activeItem?.getBoundingClientRect(); + const allItems = Array.from( + scrollableRef.current.querySelectorAll(itemsSelector), + ); + if ( + activeItem && + activeItemRect.top < scrollableRef.current.clientHeight && + activeItemRect.bottom > 0 + ) { + const activeItemIndex = allItems.indexOf(activeItem); + let prevItem = allItems[activeItemIndex - 1]; + if (handler.shift) { + // get prev status that's not .timeline-item-alt + prevItem = allItems.findLast( + (item, index) => + index < activeItemIndex && + !item.classList.contains('timeline-item-alt'), + ); + } + if (prevItem) { + prevItem.focus(); + prevItem.scrollIntoViewIfNeeded?.(); + } + } else { + // If active status is not in viewport, get the topmost status-link in viewport + const topmostItem = allItems.find((item) => { + const itemRect = item.getBoundingClientRect(); + return itemRect.top >= 44 && itemRect.left >= 0; // 44 is the magic number for header height, not real + }); + if (topmostItem) { + topmostItem.focus(); + topmostItem.scrollIntoViewIfNeeded?.(); + } + } + }); + + const oRef = useHotkeys(['enter', 'o'], () => { + // open active status + const activeItem = document.activeElement.closest(itemsSelector); + if (activeItem) { + activeItem.click(); + } + }); + + const { nearReachEnd, reachStart, reachEnd } = useScroll({ + scrollableElement: scrollableRef.current, + distanceFromEnd: 1, + }); + useEffect(() => { scrollableRef.current?.scrollTo({ top: 0 }); loadItems(true); @@ -83,7 +174,12 @@ function Timeline({
{ + scrollableRef.current = node; + jRef.current = node; + kRef.current = node; + oRef.current = node; + }} tabIndex="-1" >
@@ -119,14 +215,22 @@ function Timeline({ if (boosts) { return (
  • - +
  • ); } return (
  • - - + + {useItemID ? ( + + ) : ( + + )}
  • ); @@ -217,7 +321,7 @@ function groupBoosts(values) { } } -function BoostsCarousel({ boosts, instance }) { +function BoostsCarousel({ boosts, useItemID, instance }) { const carouselRef = useRef(); const { reachStart, reachEnd, init } = useScroll({ scrollableElement: carouselRef.current, @@ -269,8 +373,12 @@ function BoostsCarousel({ boosts, instance }) { : `/s/${actualStatusID}`; return (
  • - - + + {useItemID ? ( + + ) : ( + + )}
  • ); diff --git a/src/pages/following.jsx b/src/pages/following.jsx index 039513af..dfb4f7e5 100644 --- a/src/pages/following.jsx +++ b/src/pages/following.jsx @@ -4,20 +4,35 @@ import { useSnapshot } from 'valtio'; import Timeline from '../components/timeline'; import { api } from '../utils/api'; import states from '../utils/states'; +import { saveStatus } from '../utils/states'; import useTitle from '../utils/useTitle'; const LIMIT = 20; function Following() { useTitle('Following', '/l/f'); - const { masto } = api(); + const { masto, instance } = api(); const snapStates = useSnapshot(states); const homeIterator = useRef(); async function fetchHome(firstLoad) { if (firstLoad || !homeIterator.current) { homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT }); } - return await homeIterator.current.next(); + const results = await homeIterator.current.next(); + const { value } = results; + if (value?.length) { + value.forEach((item) => { + saveStatus(item, instance); + }); + + // ENFORCE sort by datetime (Latest first) + value.sort((a, b) => { + const aDate = new Date(a.createdAt); + const bDate = new Date(b.createdAt); + return bDate - aDate; + }); + } + return results; } return ( @@ -27,6 +42,7 @@ function Following() { emptyText="Nothing to see here." errorText="Unable to load posts." fetchItems={fetchHome} + useItemID boostsCarousel={snapStates.settings.boostsCarousel} /> );