import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio';
import FilterContext from '../utils/filter-context';
import { filteredItems, isFiltered } from '../utils/filters';
import states, { statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek';
import { groupBoosts, groupContext } from '../utils/timeline-utils';
import useInterval from '../utils/useInterval';
import usePageVisibility from '../utils/usePageVisibility';
import useScroll from '../utils/useScroll';
import useScrollFn from '../utils/useScrollFn';
import Icon from './icon';
import Link from './link';
import MediaPost from './media-post';
import NavMenu from './nav-menu';
import Status from './status';
const scrollIntoViewOptions = {
block: 'nearest',
inline: 'center',
behavior: 'smooth',
};
function Timeline({
title,
titleComponent,
id,
instance,
emptyText,
errorText,
useItemID, // use statusID instead of status object, assuming it's already in states
boostsCarousel,
fetchItems = () => {},
checkForUpdates = () => {},
checkForUpdatesInterval = 15_000, // 15 seconds
headerStart,
headerEnd,
timelineStart,
// allowFilters,
refresh,
view,
filterContext,
showFollowedTags,
}) {
const snapStates = useSnapshot(states);
const [items, setItems] = useState([]);
const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false);
const [showNew, setShowNew] = useState(false);
const [visible, setVisible] = useState(true);
const scrollableRef = useRef();
console.debug('RENDER Timeline', id, refresh);
const allowGrouping = view !== 'media';
const loadItems = useDebouncedCallback(
(firstLoad) => {
setShowNew(false);
if (uiState === 'loading') return;
setUIState('loading');
(async () => {
try {
let { done, value } = await fetchItems(firstLoad);
if (Array.isArray(value)) {
// Avoid grouping for pinned posts
const [pinnedPosts, otherPosts] = value.reduce(
(acc, item) => {
if (item._pinned) {
acc[0].push(item);
} else {
acc[1].push(item);
}
return acc;
},
[[], []],
);
value = otherPosts;
if (allowGrouping) {
if (boostsCarousel) {
value = groupBoosts(value);
}
value = groupContext(value);
}
if (pinnedPosts.length) {
value = pinnedPosts.concat(value);
}
console.log(value);
if (firstLoad) {
setItems(value);
} else {
setItems((items) => [...items, ...value]);
}
if (!value.length) done = true;
setShowMore(!done);
} else {
setShowMore(false);
}
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
} finally {
loadItems.cancel();
}
})();
},
1500,
{
leading: true,
trailing: false,
},
);
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.scrollIntoView(scrollIntoViewOptions);
}
} 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.scrollIntoView(scrollIntoViewOptions);
}
}
});
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.scrollIntoView(scrollIntoViewOptions);
}
} 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.scrollIntoView(scrollIntoViewOptions);
}
}
});
const oRef = useHotkeys(['enter', 'o'], () => {
// open active status
const activeItem = document.activeElement.closest(itemsSelector);
if (activeItem) {
activeItem.click();
}
});
// const {
// scrollDirection,
// nearReachStart,
// nearReachEnd,
// reachStart,
// reachEnd,
// } = useScroll({
// scrollableRef,
// distanceFromEnd: 2,
// scrollThresholdStart: 44,
// });
const headerRef = useRef();
// const [hiddenUI, setHiddenUI] = useState(false);
const [nearReachStart, setNearReachStart] = useState(false);
useScrollFn(
{
scrollableRef,
distanceFromEnd: 2,
scrollThresholdStart: 44,
},
({
scrollDirection,
nearReachStart,
nearReachEnd,
reachStart,
reachEnd,
}) => {
// setHiddenUI(scrollDirection === 'end' && !nearReachEnd);
if (headerRef.current) {
const hiddenUI = scrollDirection === 'end' && !nearReachStart;
headerRef.current.hidden = hiddenUI;
}
setNearReachStart(nearReachStart);
if (reachStart) {
loadItems(true);
} else if (nearReachEnd || (reachEnd && showMore)) {
loadItems();
}
},
[],
);
useEffect(() => {
scrollableRef.current?.scrollTo({ top: 0 });
loadItems(true);
}, []);
useEffect(() => {
loadItems(true);
}, [refresh]);
// useEffect(() => {
// if (reachStart) {
// loadItems(true);
// }
// }, [reachStart]);
// useEffect(() => {
// if (nearReachEnd || (reachEnd && showMore)) {
// loadItems();
// }
// }, [nearReachEnd, showMore]);
const prevView = useRef(view);
useEffect(() => {
if (prevView.current !== view) {
prevView.current = view;
setItems([]);
}
}, [view]);
const loadOrCheckUpdates = useCallback(
async ({ disableIdleCheck = false } = {}) => {
const noPointers = scrollableRef.current
? getComputedStyle(scrollableRef.current).pointerEvents === 'none'
: false;
console.log('✨ Load or check updates', id, {
autoRefresh: snapStates.settings.autoRefresh,
scrollTop: scrollableRef.current.scrollTop,
disableIdleCheck,
idle: window.__IDLE__,
inBackground: inBackground(),
noPointers,
});
if (
snapStates.settings.autoRefresh &&
scrollableRef.current.scrollTop < 16 &&
(disableIdleCheck || window.__IDLE__) &&
!inBackground() &&
!noPointers
) {
console.log('✨ Load updates', id, snapStates.settings.autoRefresh);
loadItems(true);
} else {
console.log('✨ Check updates', id, snapStates.settings.autoRefresh);
const hasUpdate = await checkForUpdates();
if (hasUpdate) {
console.log('✨ Has new updates', id);
setShowNew(true);
}
}
},
[id, loadItems, checkForUpdates, snapStates.settings.autoRefresh],
);
const lastHiddenTime = useRef();
usePageVisibility(
(visible) => {
if (visible) {
const timeDiff = Date.now() - lastHiddenTime.current;
if (!lastHiddenTime.current || timeDiff > 1000 * 3) {
// 3 seconds
loadOrCheckUpdates({
disableIdleCheck: true,
});
}
} else {
lastHiddenTime.current = Date.now();
}
setVisible(visible);
},
[checkForUpdates, loadOrCheckUpdates, snapStates.settings.autoRefresh],
);
// checkForUpdates interval
useInterval(
loadOrCheckUpdates,
visible && !showNew
? checkForUpdatesInterval * (nearReachStart ? 1 : 2)
: null,
);
// const hiddenUI = scrollDirection === 'end' && !nearReachStart;
return (
The end. {emptyText}
{errorText}
{title}
)}
{items.map((status) => (
{uiState === 'default' &&
(showMore ? (
{Array.from({ length: 5 }).map((_, i) =>
view === 'media' ? (
) : (
) : (
uiState !== 'error' &&