2023-01-28 13:52:18 +03:00
|
|
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
2023-02-06 18:50:00 +03:00
|
|
|
import { useHotkeys } from 'react-hotkeys-hook';
|
2023-02-03 16:08:08 +03:00
|
|
|
import { useDebouncedCallback } from 'use-debounce';
|
2023-01-28 13:52:18 +03:00
|
|
|
|
|
|
|
import useScroll from '../utils/useScroll';
|
|
|
|
|
|
|
|
import Icon from './icon';
|
|
|
|
import Link from './link';
|
|
|
|
import Loader from './loader';
|
|
|
|
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,
|
2023-02-05 19:17:19 +03:00
|
|
|
instance,
|
2023-01-30 17:00:14 +03:00
|
|
|
emptyText,
|
|
|
|
errorText,
|
2023-02-06 18:50:00 +03:00
|
|
|
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-01-28 13:52:18 +03:00
|
|
|
const [items, setItems] = useState([]);
|
|
|
|
const [uiState, setUIState] = useState('default');
|
|
|
|
const [showMore, setShowMore] = useState(false);
|
2023-02-06 18:50:00 +03:00
|
|
|
const scrollableRef = useRef();
|
2023-01-28 13:52:18 +03:00
|
|
|
|
2023-02-03 16:08:08 +03:00
|
|
|
const loadItems = useDebouncedCallback(
|
|
|
|
(firstLoad) => {
|
|
|
|
if (uiState === 'loading') return;
|
|
|
|
setUIState('loading');
|
|
|
|
(async () => {
|
|
|
|
try {
|
|
|
|
let { done, value } = await fetchItems(firstLoad);
|
|
|
|
if (value?.length) {
|
|
|
|
if (boostsCarousel) {
|
|
|
|
value = groupBoosts(value);
|
|
|
|
}
|
|
|
|
console.log(value);
|
|
|
|
if (firstLoad) {
|
|
|
|
setItems(value);
|
|
|
|
} else {
|
|
|
|
setItems([...items, ...value]);
|
|
|
|
}
|
|
|
|
setShowMore(!done);
|
2023-01-28 13:52:18 +03:00
|
|
|
} else {
|
2023-02-03 16:08:08 +03:00
|
|
|
setShowMore(false);
|
2023-01-28 13:52:18 +03:00
|
|
|
}
|
2023-02-03 16:08:08 +03:00
|
|
|
setUIState('default');
|
|
|
|
} catch (e) {
|
|
|
|
console.error(e);
|
|
|
|
setUIState('error');
|
2023-01-28 13:52:18 +03:00
|
|
|
}
|
2023-02-03 16:08:08 +03:00
|
|
|
})();
|
|
|
|
},
|
|
|
|
1500,
|
|
|
|
{
|
|
|
|
leading: true,
|
|
|
|
trailing: false,
|
|
|
|
},
|
|
|
|
);
|
2023-01-28 13:52:18 +03:00
|
|
|
|
2023-02-06 18:50:00 +03:00
|
|
|
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,
|
|
|
|
});
|
|
|
|
|
2023-01-28 13:52:18 +03:00
|
|
|
useEffect(() => {
|
|
|
|
scrollableRef.current?.scrollTo({ top: 0 });
|
|
|
|
loadItems(true);
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (reachStart) {
|
|
|
|
loadItems(true);
|
|
|
|
}
|
|
|
|
}, [reachStart]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
2023-02-03 16:08:08 +03:00
|
|
|
if (nearReachEnd || (reachEnd && showMore)) {
|
2023-01-28 13:52:18 +03:00
|
|
|
loadItems();
|
|
|
|
}
|
|
|
|
}, [nearReachEnd, showMore]);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div
|
|
|
|
id={`${id}-page`}
|
|
|
|
class="deck-container"
|
2023-02-06 18:50:00 +03:00
|
|
|
ref={(node) => {
|
|
|
|
scrollableRef.current = node;
|
|
|
|
jRef.current = node;
|
|
|
|
kRef.current = node;
|
|
|
|
oRef.current = node;
|
|
|
|
}}
|
2023-01-28 13:52:18 +03:00
|
|
|
tabIndex="-1"
|
|
|
|
>
|
|
|
|
<div class="timeline-deck deck">
|
|
|
|
<header
|
|
|
|
onClick={(e) => {
|
|
|
|
if (e.target === e.currentTarget) {
|
|
|
|
scrollableRef.current?.scrollTo({
|
|
|
|
top: 0,
|
|
|
|
behavior: 'smooth',
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<div class="header-side">
|
|
|
|
<Link to="/" class="button plain">
|
|
|
|
<Icon icon="home" size="l" />
|
|
|
|
</Link>
|
|
|
|
</div>
|
2023-01-31 14:31:25 +03:00
|
|
|
{title && (titleComponent ? titleComponent : <h1>{title}</h1>)}
|
2023-01-28 13:52:18 +03:00
|
|
|
<div class="header-side">
|
|
|
|
<Loader hidden={uiState !== 'loading'} />
|
|
|
|
</div>
|
|
|
|
</header>
|
|
|
|
{!!items.length ? (
|
|
|
|
<>
|
|
|
|
<ul class="timeline">
|
2023-01-29 18:34:51 +03:00
|
|
|
{items.map((status) => {
|
2023-02-03 16:08:08 +03:00
|
|
|
const { id: statusID, reblog, boosts } = status;
|
2023-01-29 18:34:51 +03:00
|
|
|
const actualStatusID = reblog?.id || statusID;
|
2023-02-05 19:17:19 +03:00
|
|
|
const url = instance
|
2023-02-06 14:54:18 +03:00
|
|
|
? `/${instance}/s/${actualStatusID}`
|
2023-02-05 19:17:19 +03:00
|
|
|
: `/s/${actualStatusID}`;
|
2023-02-03 16:08:08 +03:00
|
|
|
if (boosts) {
|
|
|
|
return (
|
|
|
|
<li key={`timeline-${statusID}`}>
|
2023-02-06 18:50:00 +03:00
|
|
|
<BoostsCarousel
|
|
|
|
boosts={boosts}
|
|
|
|
useItemID={useItemID}
|
|
|
|
instance={instance}
|
|
|
|
/>
|
2023-02-03 16:08:08 +03:00
|
|
|
</li>
|
|
|
|
);
|
|
|
|
}
|
2023-01-29 18:34:51 +03:00
|
|
|
return (
|
|
|
|
<li key={`timeline-${statusID}`}>
|
2023-02-06 18:50:00 +03:00
|
|
|
<Link class="status-link timeline-item" to={url}>
|
|
|
|
{useItemID ? (
|
|
|
|
<Status statusID={statusID} instance={instance} />
|
|
|
|
) : (
|
|
|
|
<Status status={status} instance={instance} />
|
|
|
|
)}
|
2023-01-29 18:34:51 +03:00
|
|
|
</Link>
|
|
|
|
</li>
|
|
|
|
);
|
|
|
|
})}
|
2023-01-28 13:52:18 +03:00
|
|
|
</ul>
|
2023-02-03 16:08:08 +03:00
|
|
|
{uiState === 'default' &&
|
|
|
|
(showMore ? (
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
class="plain block"
|
|
|
|
onClick={() => loadItems()}
|
|
|
|
style={{ marginBlockEnd: '6em' }}
|
|
|
|
>
|
|
|
|
Show more…
|
|
|
|
</button>
|
|
|
|
) : (
|
|
|
|
<p class="ui-state insignificant">The end.</p>
|
|
|
|
))}
|
2023-01-28 13:52:18 +03:00
|
|
|
</>
|
|
|
|
) : 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-01-28 13:52:18 +03:00
|
|
|
)}
|
2023-02-03 16:08:08 +03:00
|
|
|
{uiState === 'error' && (
|
2023-01-28 13:52:18 +03:00
|
|
|
<p class="ui-state">
|
|
|
|
{errorText}
|
|
|
|
<br />
|
|
|
|
<br />
|
|
|
|
<button
|
|
|
|
class="button plain"
|
|
|
|
onClick={() => loadItems(!items.length)}
|
|
|
|
>
|
|
|
|
Try again
|
|
|
|
</button>
|
|
|
|
</p>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-02-03 16:08:08 +03:00
|
|
|
function groupBoosts(values) {
|
|
|
|
let newValues = [];
|
|
|
|
let boostStash = [];
|
|
|
|
let serialBoosts = 0;
|
|
|
|
for (let i = 0; i < values.length; i++) {
|
|
|
|
const item = values[i];
|
|
|
|
if (item.reblog) {
|
|
|
|
boostStash.push(item);
|
|
|
|
serialBoosts++;
|
|
|
|
} else {
|
|
|
|
newValues.push(item);
|
|
|
|
if (serialBoosts < 3) {
|
|
|
|
serialBoosts = 0;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// if boostStash is more than quarter of values
|
|
|
|
// or if there are 3 or more boosts in a row
|
|
|
|
if (boostStash.length > values.length / 4 || serialBoosts >= 3) {
|
|
|
|
// if boostStash is more than 3 quarter of values
|
|
|
|
const boostStashID = boostStash.map((status) => status.id);
|
|
|
|
if (boostStash.length > (values.length * 3) / 4) {
|
|
|
|
// insert boost array at the end of specialHome list
|
|
|
|
newValues = [...newValues, { id: boostStashID, boosts: boostStash }];
|
|
|
|
} else {
|
|
|
|
// insert boosts array in the middle of specialHome list
|
|
|
|
const half = Math.floor(newValues.length / 2);
|
|
|
|
newValues = [
|
|
|
|
...newValues.slice(0, half),
|
|
|
|
{
|
|
|
|
id: boostStashID,
|
|
|
|
boosts: boostStash,
|
|
|
|
},
|
|
|
|
...newValues.slice(half),
|
|
|
|
];
|
|
|
|
}
|
|
|
|
return newValues;
|
|
|
|
} else {
|
|
|
|
return values;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-02-06 18:50:00 +03:00
|
|
|
function BoostsCarousel({ boosts, useItemID, instance }) {
|
2023-02-03 16:08:08 +03:00
|
|
|
const carouselRef = useRef();
|
|
|
|
const { reachStart, reachEnd, init } = useScroll({
|
|
|
|
scrollableElement: carouselRef.current,
|
|
|
|
direction: 'horizontal',
|
|
|
|
});
|
|
|
|
useEffect(() => {
|
|
|
|
init?.();
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div class="boost-carousel">
|
|
|
|
<header>
|
|
|
|
<h3>{boosts.length} Boosts</h3>
|
|
|
|
<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}>
|
|
|
|
{boosts.map((boost) => {
|
|
|
|
const { id: statusID, reblog } = boost;
|
|
|
|
const actualStatusID = reblog?.id || statusID;
|
2023-02-05 19:17:19 +03:00
|
|
|
const url = instance
|
2023-02-06 14:54:18 +03:00
|
|
|
? `/${instance}/s/${actualStatusID}`
|
2023-02-05 19:17:19 +03:00
|
|
|
: `/s/${actualStatusID}`;
|
2023-02-03 16:08:08 +03:00
|
|
|
return (
|
|
|
|
<li key={statusID}>
|
2023-02-06 18:50:00 +03:00
|
|
|
<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" />
|
|
|
|
)}
|
2023-02-03 16:08:08 +03:00
|
|
|
</Link>
|
|
|
|
</li>
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
</ul>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-01-28 13:52:18 +03:00
|
|
|
export default Timeline;
|