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({