More ports to reusable Timeline component

- use status id instead of status, for "auto-update" feature
- hot keys!
This commit is contained in:
Lim Chee Aun 2023-02-06 23:50:00 +08:00
parent db428c04d1
commit 9992299716
2 changed files with 138 additions and 14 deletions

View file

@ -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({
<div
id={`${id}-page`}
class="deck-container"
ref={scrollableRef}
ref={(node) => {
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
oRef.current = node;
}}
tabIndex="-1"
>
<div class="timeline-deck deck">
@ -119,14 +215,22 @@ function Timeline({
if (boosts) {
return (
<li key={`timeline-${statusID}`}>
<BoostsCarousel boosts={boosts} instance={instance} />
<BoostsCarousel
boosts={boosts}
useItemID={useItemID}
instance={instance}
/>
</li>
);
}
return (
<li key={`timeline-${statusID}`}>
<Link class="status-link" to={url}>
<Link class="status-link timeline-item" to={url}>
{useItemID ? (
<Status statusID={statusID} instance={instance} />
) : (
<Status status={status} instance={instance} />
)}
</Link>
</li>
);
@ -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 (
<li key={statusID}>
<Link class="status-boost-link" to={url}>
<Link class="status-boost-link timeline-item-alt" to={url}>
{useItemID ? (
<Status statusID={statusID} instance={instance} size="s" />
) : (
<Status status={boost} instance={instance} size="s" />
)}
</Link>
</li>
);

View file

@ -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}
/>
);