phanpy/src/components/timeline.jsx

586 lines
18 KiB
React
Raw Normal View History

import { useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
2023-02-03 16:08:08 +03:00
import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio';
import states, { statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek';
import { groupBoosts, groupContext } from '../utils/timeline-utils';
2023-02-08 14:11:33 +03:00
import useInterval from '../utils/useInterval';
2023-02-07 19:31:46 +03:00
import usePageVisibility from '../utils/usePageVisibility';
import useScroll from '../utils/useScroll';
import Icon from './icon';
import Link from './link';
import Loader from './loader';
import Menu from './menu';
import Status from './status';
2023-01-30 17:00:14 +03:00
function Timeline({
title,
2023-01-31 14:08:10 +03:00
titleComponent,
2023-01-30 17:00:14 +03:00
id,
instance,
2023-01-30 17:00:14 +03:00
emptyText,
errorText,
useItemID, // use statusID instead of status object, assuming it's already in states
2023-02-03 16:08:08 +03:00
boostsCarousel,
2023-01-30 17:00:14 +03:00
fetchItems = () => {},
2023-02-07 19:31:46 +03:00
checkForUpdates = () => {},
2023-02-08 14:11:33 +03:00
checkForUpdatesInterval = 60_000, // 1 minute
2023-02-09 17:27:49 +03:00
headerStart,
headerEnd,
timelineStart,
2023-03-21 19:09:36 +03:00
allowFilters,
2023-04-03 05:36:31 +03:00
refresh,
2023-01-30 17:00:14 +03:00
}) {
const [items, setItems] = useState([]);
const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false);
2023-02-07 19:31:46 +03:00
const [showNew, setShowNew] = useState(false);
2023-02-08 14:11:33 +03:00
const [visible, setVisible] = useState(true);
const scrollableRef = useRef();
2023-02-03 16:08:08 +03:00
const loadItems = useDebouncedCallback(
(firstLoad) => {
2023-02-07 19:31:46 +03:00
setShowNew(false);
2023-02-03 16:08:08 +03:00
if (uiState === 'loading') return;
setUIState('loading');
(async () => {
try {
let { done, value } = await fetchItems(firstLoad);
if (value?.length) {
if (boostsCarousel) {
value = groupBoosts(value);
}
value = groupContext(value);
2023-02-03 16:08:08 +03:00
console.log(value);
if (firstLoad) {
setItems(value);
} else {
2023-02-20 15:58:53 +03:00
setItems((items) => [...items, ...value]);
2023-02-03 16:08:08 +03:00
}
setShowMore(!done);
} else {
2023-02-03 16:08:08 +03:00
setShowMore(false);
}
2023-02-03 16:08:08 +03:00
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
} finally {
loadItems.cancel();
}
2023-02-03 16:08:08 +03:00
})();
},
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.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();
}
});
2023-02-07 19:31:46 +03:00
const {
scrollDirection,
nearReachStart,
nearReachEnd,
reachStart,
reachEnd,
} = useScroll({
2023-02-28 16:56:41 +03:00
scrollableRef,
2023-02-07 19:31:46 +03:00
distanceFromEnd: 2,
scrollThresholdStart: 44,
});
useEffect(() => {
scrollableRef.current?.scrollTo({ top: 0 });
loadItems(true);
}, []);
2023-04-03 05:36:31 +03:00
useEffect(() => {
loadItems(true);
}, [refresh]);
useEffect(() => {
if (reachStart) {
loadItems(true);
}
}, [reachStart]);
useEffect(() => {
2023-02-03 16:08:08 +03:00
if (nearReachEnd || (reachEnd && showMore)) {
loadItems();
}
}, [nearReachEnd, showMore]);
2023-02-07 19:31:46 +03:00
const lastHiddenTime = useRef();
2023-03-23 20:04:47 +03:00
usePageVisibility(
(visible) => {
if (visible) {
const timeDiff = Date.now() - lastHiddenTime.current;
if (!lastHiddenTime.current || timeDiff > 1000 * 60) {
(async () => {
console.log('✨ Check updates');
const hasUpdate = await checkForUpdates();
if (hasUpdate) {
console.log('✨ Has new updates');
setShowNew(true);
}
})();
}
} else {
lastHiddenTime.current = Date.now();
2023-02-07 19:31:46 +03:00
}
2023-03-23 20:04:47 +03:00
setVisible(visible);
},
[checkForUpdates],
);
2023-02-07 19:31:46 +03:00
2023-02-08 14:11:33 +03:00
// checkForUpdates interval
useInterval(
() => {
(async () => {
console.log('✨ Check updates');
const hasUpdate = await checkForUpdates();
if (hasUpdate) {
console.log('✨ Has new updates');
setShowNew(true);
}
})();
},
visible && !showNew ? checkForUpdatesInterval : null,
);
2023-02-07 19:31:46 +03:00
const hiddenUI = scrollDirection === 'end' && !nearReachStart;
return (
<div
id={`${id}-page`}
class="deck-container"
ref={(node) => {
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
oRef.current = node;
}}
tabIndex="-1"
>
<div class="timeline-deck deck">
<header
2023-02-07 19:31:46 +03:00
hidden={hiddenUI}
onClick={(e) => {
2023-02-09 17:27:49 +03:00
if (!e.target.closest('a, button')) {
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}
}}
2023-02-09 17:27:49 +03:00
onDblClick={(e) => {
if (!e.target.closest('a, button')) {
loadItems(true);
}
}}
>
2023-02-08 14:11:33 +03:00
<div class="header-grid">
<div class="header-side">
2023-02-11 03:37:54 +03:00
<Menu />
{headerStart !== null && headerStart !== undefined ? (
headerStart
) : (
2023-02-27 18:59:41 +03:00
<Link to="/" class="button plain home-button">
2023-02-09 17:27:49 +03:00
<Icon icon="home" size="l" />
</Link>
)}
2023-02-08 14:11:33 +03:00
</div>
{title && (titleComponent ? titleComponent : <h1>{title}</h1>)}
<div class="header-side">
<Loader hidden={uiState !== 'loading'} />
2023-02-09 17:27:49 +03:00
{!!headerEnd && headerEnd}
2023-02-08 14:11:33 +03:00
</div>
</div>
2023-02-07 19:31:46 +03:00
{items.length > 0 &&
uiState !== 'loading' &&
!hiddenUI &&
showNew && (
<button
2023-02-23 10:56:58 +03:00
class="updates-button shiny-pill"
2023-02-07 19:31:46 +03:00
type="button"
onClick={() => {
loadItems(true);
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-up" /> New posts
</button>
)}
</header>
{!!timelineStart && <div class="timeline-start">{timelineStart}</div>}
{!!items.length ? (
<>
<ul class="timeline">
{items.map((status) => {
const { id: statusID, reblog, items, type, _pinned } = status;
const actualStatusID = reblog?.id || statusID;
const url = instance
2023-02-06 14:54:18 +03:00
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
let title = '';
if (type === 'boosts') {
title = `${items.length} Boosts`;
} else if (type === 'pinned') {
title = 'Pinned posts';
}
const isCarousel = type === 'boosts' || type === 'pinned';
if (items) {
if (isCarousel) {
// Here, we don't hide filtered posts, but we sort them last
items.sort((a, b) => {
if (a._filtered && !b._filtered) {
return 1;
}
if (!a._filtered && b._filtered) {
return -1;
}
return 0;
});
return (
<li key={`timeline-${statusID}`}>
<StatusCarousel
title={title}
class={`${type}-carousel`}
>
{items.map((item) => {
const { id: statusID, reblog } = item;
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
return (
<li key={statusID}>
<Link
class="status-carousel-link timeline-item-alt"
to={url}
>
{useItemID ? (
<Status
statusID={statusID}
instance={instance}
size="s"
contentTextWeight
/>
) : (
<Status
status={item}
instance={instance}
size="s"
contentTextWeight
/>
)}
</Link>
</li>
);
})}
</StatusCarousel>
</li>
);
}
const manyItems = items.length > 3;
return items.map((item, i) => {
const { id: statusID } = item;
const url = instance
? `/${instance}/s/${statusID}`
: `/s/${statusID}`;
const isMiddle = i > 0 && i < items.length - 1;
return (
<li
key={`timeline-${statusID}`}
class={`timeline-item-container timeline-item-container-type-${type} timeline-item-container-${
i === 0
? 'start'
: i === items.length - 1
? 'end'
: 'middle'
}`}
>
<Link class="status-link timeline-item" to={url}>
{manyItems && isMiddle && type === 'thread' ? (
<TimelineStatusCompact
status={item}
instance={instance}
/>
) : useItemID ? (
<Status
statusID={statusID}
instance={instance}
allowFilters={allowFilters}
/>
) : (
<Status
status={item}
instance={instance}
allowFilters={allowFilters}
/>
)}
</Link>
</li>
);
2023-03-21 19:09:36 +03:00
});
2023-02-03 16:08:08 +03:00
}
return (
<li key={`timeline-${statusID + _pinned}`}>
<Link class="status-link timeline-item" to={url}>
{useItemID ? (
2023-03-21 19:09:36 +03:00
<Status
statusID={statusID}
instance={instance}
allowFilters={allowFilters}
2023-03-21 19:09:36 +03:00
/>
) : (
2023-03-21 19:09:36 +03:00
<Status
status={status}
instance={instance}
allowFilters={allowFilters}
2023-03-21 19:09:36 +03:00
/>
)}
</Link>
</li>
);
})}
2023-02-06 19:25:38 +03:00
{showMore && uiState === 'loading' && (
<>
<li
style={{
height: '20vh',
}}
>
<Status skeleton />
</li>
<li
style={{
height: '25vh',
}}
>
<Status skeleton />
</li>
</>
)}
</ul>
2023-02-03 16:08:08 +03:00
{uiState === 'default' &&
(showMore ? (
<InView
onChange={(inView) => {
if (inView) {
loadItems();
}
}}
2023-02-03 16:08:08 +03:00
>
<button
type="button"
class="plain block"
onClick={() => loadItems()}
style={{ marginBlockEnd: '6em' }}
>
Show more&hellip;
</button>
</InView>
2023-02-03 16:08:08 +03:00
) : (
<p class="ui-state insignificant">The end.</p>
))}
</>
) : uiState === 'loading' ? (
<ul class="timeline">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i}>
<Status skeleton />
</li>
))}
</ul>
) : (
2023-02-03 16:08:08 +03:00
uiState !== 'error' && <p class="ui-state">{emptyText}</p>
)}
2023-02-03 16:08:08 +03:00
{uiState === 'error' && (
<p class="ui-state">
{errorText}
<br />
<br />
<button
class="button plain"
onClick={() => loadItems(!items.length)}
>
Try again
</button>
</p>
)}
</div>
</div>
);
}
function StatusCarousel({ title, class: className, children }) {
2023-02-03 16:08:08 +03:00
const carouselRef = useRef();
const { reachStart, reachEnd, init } = useScroll({
2023-02-28 16:56:41 +03:00
scrollableRef: carouselRef,
2023-02-03 16:08:08 +03:00
direction: 'horizontal',
});
useEffect(() => {
init?.();
}, []);
return (
<div class={`status-carousel ${className}`}>
2023-02-03 16:08:08 +03:00
<header>
<h3>{title}</h3>
2023-02-03 16:08:08 +03:00
<span>
<button
type="button"
class="small plain2"
disabled={reachStart}
onClick={() => {
carouselRef.current?.scrollBy({
left: -Math.min(320, carouselRef.current?.offsetWidth),
behavior: 'smooth',
});
}}
>
<Icon icon="chevron-left" />
</button>{' '}
<button
type="button"
class="small plain2"
disabled={reachEnd}
onClick={() => {
carouselRef.current?.scrollBy({
left: Math.min(320, carouselRef.current?.offsetWidth),
behavior: 'smooth',
});
}}
>
<Icon icon="chevron-right" />
</button>
</span>
</header>
<ul ref={carouselRef}>{children}</ul>
2023-02-03 16:08:08 +03:00
</div>
);
}
function TimelineStatusCompact({ status, instance }) {
const snapStates = useSnapshot(states);
const { id } = status;
const statusPeekText = statusPeek(status);
const sKey = statusKey(id, instance);
return (
<article class="status compact-thread" tabindex="-1">
{!!snapStates.statusThreadNumber[sKey] ? (
<div class="status-thread-badge">
<Icon icon="thread" size="s" />
{snapStates.statusThreadNumber[sKey]
? ` ${snapStates.statusThreadNumber[sKey]}/X`
: ''}
</div>
) : (
<div class="status-thread-badge">
<Icon icon="thread" size="s" />
</div>
)}
2023-03-26 19:47:08 +03:00
<div class="content-compact" title={statusPeekText}>
{statusPeekText}
</div>
</article>
);
}
export default Timeline;