2024-02-14 12:17:15 +03:00
|
|
|
import { memo } from 'preact/compat';
|
2024-04-11 12:18:17 +03:00
|
|
|
import {
|
|
|
|
useCallback,
|
|
|
|
useEffect,
|
|
|
|
useMemo,
|
|
|
|
useRef,
|
|
|
|
useState,
|
|
|
|
} from 'preact/hooks';
|
2024-04-15 19:09:53 +03:00
|
|
|
import { useHotkeys } from 'react-hotkeys-hook';
|
2023-02-23 20:27:46 +03:00
|
|
|
import { InView } from 'react-intersection-observer';
|
2023-02-03 16:08:08 +03:00
|
|
|
import { useDebouncedCallback } from 'use-debounce';
|
2023-03-26 18:18:36 +03:00
|
|
|
import { useSnapshot } from 'valtio';
|
2023-01-28 13:52:18 +03:00
|
|
|
|
2023-11-03 16:45:31 +03:00
|
|
|
import FilterContext from '../utils/filter-context';
|
2023-12-21 08:32:32 +03:00
|
|
|
import { filteredItems, isFiltered } from '../utils/filters';
|
2023-03-26 18:18:36 +03:00
|
|
|
import states, { statusKey } from '../utils/states';
|
|
|
|
import statusPeek from '../utils/status-peek';
|
2024-04-11 12:18:17 +03:00
|
|
|
import { isMediaFirstInstance } from '../utils/store-utils';
|
2023-03-26 18:18:36 +03:00
|
|
|
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';
|
2023-01-28 13:52:18 +03:00
|
|
|
import useScroll from '../utils/useScroll';
|
2023-12-29 13:29:08 +03:00
|
|
|
import useScrollFn from '../utils/useScrollFn';
|
2023-01-28 13:52:18 +03:00
|
|
|
|
|
|
|
import Icon from './icon';
|
|
|
|
import Link from './link';
|
2023-10-29 16:41:03 +03:00
|
|
|
import MediaPost from './media-post';
|
2023-04-26 08:09:44 +03:00
|
|
|
import NavMenu from './nav-menu';
|
2023-01-28 13:52:18 +03:00
|
|
|
import Status from './status';
|
|
|
|
|
2023-10-04 16:24:48 +03:00
|
|
|
const scrollIntoViewOptions = {
|
|
|
|
block: 'nearest',
|
|
|
|
inline: 'center',
|
|
|
|
behavior: 'smooth',
|
|
|
|
};
|
|
|
|
|
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-02-07 19:31:46 +03:00
|
|
|
checkForUpdates = () => {},
|
2023-11-06 04:44:46 +03:00
|
|
|
checkForUpdatesInterval = 15_000, // 15 seconds
|
2023-02-09 17:27:49 +03:00
|
|
|
headerStart,
|
|
|
|
headerEnd,
|
2023-03-11 09:05:56 +03:00
|
|
|
timelineStart,
|
2023-11-03 16:45:31 +03:00
|
|
|
// allowFilters,
|
2023-04-03 05:36:31 +03:00
|
|
|
refresh,
|
2023-10-29 16:41:03 +03:00
|
|
|
view,
|
2023-11-03 16:45:31 +03:00
|
|
|
filterContext,
|
2023-12-14 20:58:29 +03:00
|
|
|
showFollowedTags,
|
2024-01-30 09:34:54 +03:00
|
|
|
showReplyParent,
|
2023-01-30 17:00:14 +03:00
|
|
|
}) {
|
2023-05-05 12:53:16 +03:00
|
|
|
const snapStates = useSnapshot(states);
|
2023-01-28 13:52:18 +03:00
|
|
|
const [items, setItems] = useState([]);
|
2024-04-02 12:42:51 +03:00
|
|
|
const [uiState, setUIState] = useState('start');
|
2023-01-28 13:52:18 +03:00
|
|
|
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);
|
2023-02-06 18:50:00 +03:00
|
|
|
const scrollableRef = useRef();
|
2023-01-28 13:52:18 +03:00
|
|
|
|
2023-04-14 10:30:04 +03:00
|
|
|
console.debug('RENDER Timeline', id, refresh);
|
|
|
|
|
2024-04-11 12:18:17 +03:00
|
|
|
const mediaFirst = useMemo(() => isMediaFirstInstance(), []);
|
|
|
|
|
2023-10-29 16:41:03 +03:00
|
|
|
const allowGrouping = view !== 'media';
|
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);
|
2023-07-05 11:54:33 +03:00
|
|
|
if (Array.isArray(value)) {
|
2023-11-13 11:57:15 +03:00
|
|
|
// 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;
|
2023-10-29 16:41:03 +03:00
|
|
|
if (allowGrouping) {
|
|
|
|
if (boostsCarousel) {
|
|
|
|
value = groupBoosts(value);
|
|
|
|
}
|
2024-01-30 09:34:54 +03:00
|
|
|
value = groupContext(value, instance);
|
2023-02-03 16:08:08 +03:00
|
|
|
}
|
2023-11-13 11:57:15 +03:00
|
|
|
if (pinnedPosts.length) {
|
|
|
|
value = pinnedPosts.concat(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
|
|
|
}
|
2023-08-07 06:39:42 +03:00
|
|
|
if (!value.length) done = true;
|
2023-02-03 16:08:08 +03:00
|
|
|
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-02-23 20:28:15 +03:00
|
|
|
} finally {
|
|
|
|
loadItems.cancel();
|
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();
|
2023-10-04 16:24:48 +03:00
|
|
|
nextItem.scrollIntoView(scrollIntoViewOptions);
|
2023-02-06 18:50:00 +03:00
|
|
|
}
|
|
|
|
} 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();
|
2023-10-04 16:24:48 +03:00
|
|
|
topmostItem.scrollIntoView(scrollIntoViewOptions);
|
2023-02-06 18:50:00 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
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();
|
2023-10-04 16:24:48 +03:00
|
|
|
prevItem.scrollIntoView(scrollIntoViewOptions);
|
2023-02-06 18:50:00 +03:00
|
|
|
}
|
|
|
|
} 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();
|
2023-10-04 16:24:48 +03:00
|
|
|
topmostItem.scrollIntoView(scrollIntoViewOptions);
|
2023-02-06 18:50:00 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
const oRef = useHotkeys(['enter', 'o'], () => {
|
|
|
|
// open active status
|
2024-04-26 14:23:53 +03:00
|
|
|
const activeItem = document.activeElement;
|
|
|
|
if (activeItem?.matches(itemsSelector)) {
|
2023-02-06 18:50:00 +03:00
|
|
|
activeItem.click();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2024-01-07 09:47:17 +03:00
|
|
|
const showNewPostsIndicator =
|
|
|
|
items.length > 0 && uiState !== 'loading' && showNew;
|
|
|
|
const handleLoadNewPosts = useCallback(() => {
|
2024-03-23 19:21:41 +03:00
|
|
|
if (showNewPostsIndicator) loadItems(true);
|
2024-01-07 09:47:17 +03:00
|
|
|
scrollableRef.current?.scrollTo({
|
|
|
|
top: 0,
|
|
|
|
behavior: 'smooth',
|
|
|
|
});
|
2024-03-23 19:21:41 +03:00
|
|
|
}, [loadItems, showNewPostsIndicator]);
|
|
|
|
const dotRef = useHotkeys('.', handleLoadNewPosts);
|
2024-01-07 09:47:17 +03:00
|
|
|
|
2023-12-29 13:29:08 +03:00
|
|
|
// 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,
|
2024-02-14 12:17:15 +03:00
|
|
|
// nearReachEnd,
|
2023-12-29 13:29:08 +03:00
|
|
|
reachStart,
|
2024-02-14 12:17:15 +03:00
|
|
|
// reachEnd,
|
2023-12-29 13:29:08 +03:00
|
|
|
}) => {
|
|
|
|
// setHiddenUI(scrollDirection === 'end' && !nearReachEnd);
|
|
|
|
if (headerRef.current) {
|
|
|
|
const hiddenUI = scrollDirection === 'end' && !nearReachStart;
|
|
|
|
headerRef.current.hidden = hiddenUI;
|
|
|
|
}
|
|
|
|
setNearReachStart(nearReachStart);
|
|
|
|
if (reachStart) {
|
|
|
|
loadItems(true);
|
|
|
|
}
|
2024-01-02 07:24:03 +03:00
|
|
|
// else if (nearReachEnd || (reachEnd && showMore)) {
|
|
|
|
// loadItems();
|
|
|
|
// }
|
2023-12-29 13:29:08 +03:00
|
|
|
},
|
|
|
|
[],
|
|
|
|
);
|
2023-02-06 18:50:00 +03:00
|
|
|
|
2023-01-28 13:52:18 +03:00
|
|
|
useEffect(() => {
|
|
|
|
scrollableRef.current?.scrollTo({ top: 0 });
|
|
|
|
loadItems(true);
|
|
|
|
}, []);
|
2023-04-03 05:36:31 +03:00
|
|
|
useEffect(() => {
|
|
|
|
loadItems(true);
|
|
|
|
}, [refresh]);
|
2023-01-28 13:52:18 +03:00
|
|
|
|
2023-12-29 13:29:08 +03:00
|
|
|
// useEffect(() => {
|
|
|
|
// if (reachStart) {
|
|
|
|
// loadItems(true);
|
|
|
|
// }
|
|
|
|
// }, [reachStart]);
|
2023-01-28 13:52:18 +03:00
|
|
|
|
2023-12-29 13:29:08 +03:00
|
|
|
// useEffect(() => {
|
|
|
|
// if (nearReachEnd || (reachEnd && showMore)) {
|
|
|
|
// loadItems();
|
|
|
|
// }
|
|
|
|
// }, [nearReachEnd, showMore]);
|
2023-01-28 13:52:18 +03:00
|
|
|
|
2023-10-29 16:41:03 +03:00
|
|
|
const prevView = useRef(view);
|
|
|
|
useEffect(() => {
|
|
|
|
if (prevView.current !== view) {
|
|
|
|
prevView.current = view;
|
|
|
|
setItems([]);
|
|
|
|
}
|
|
|
|
}, [view]);
|
|
|
|
|
2023-05-05 12:53:16 +03:00
|
|
|
const loadOrCheckUpdates = useCallback(
|
2023-10-21 07:26:28 +03:00
|
|
|
async ({ disableIdleCheck = false } = {}) => {
|
2023-11-01 13:12:22 +03:00
|
|
|
const noPointers = scrollableRef.current
|
|
|
|
? getComputedStyle(scrollableRef.current).pointerEvents === 'none'
|
|
|
|
: false;
|
|
|
|
console.log('✨ Load or check updates', id, {
|
2023-07-12 12:32:05 +03:00
|
|
|
autoRefresh: snapStates.settings.autoRefresh,
|
|
|
|
scrollTop: scrollableRef.current.scrollTop,
|
2023-10-21 07:26:28 +03:00
|
|
|
disableIdleCheck,
|
|
|
|
idle: window.__IDLE__,
|
2023-07-12 12:32:05 +03:00
|
|
|
inBackground: inBackground(),
|
2023-11-01 13:12:22 +03:00
|
|
|
noPointers,
|
2023-07-12 12:32:05 +03:00
|
|
|
});
|
2023-05-05 12:53:16 +03:00
|
|
|
if (
|
|
|
|
snapStates.settings.autoRefresh &&
|
2023-10-30 19:42:24 +03:00
|
|
|
scrollableRef.current.scrollTop < 16 &&
|
2023-10-21 07:26:28 +03:00
|
|
|
(disableIdleCheck || window.__IDLE__) &&
|
2023-11-01 13:12:22 +03:00
|
|
|
!inBackground() &&
|
|
|
|
!noPointers
|
2023-05-05 12:53:16 +03:00
|
|
|
) {
|
2023-11-01 13:12:22 +03:00
|
|
|
console.log('✨ Load updates', id, snapStates.settings.autoRefresh);
|
2023-05-05 12:53:16 +03:00
|
|
|
loadItems(true);
|
|
|
|
} else {
|
2023-11-01 13:12:22 +03:00
|
|
|
console.log('✨ Check updates', id, snapStates.settings.autoRefresh);
|
2023-05-05 12:53:16 +03:00
|
|
|
const hasUpdate = await checkForUpdates();
|
|
|
|
if (hasUpdate) {
|
|
|
|
console.log('✨ Has new updates', id);
|
|
|
|
setShowNew(true);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
2023-10-21 07:26:28 +03:00
|
|
|
[id, loadItems, checkForUpdates, snapStates.settings.autoRefresh],
|
2023-05-05 12:53:16 +03:00
|
|
|
);
|
|
|
|
|
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;
|
2023-11-07 06:19:49 +03:00
|
|
|
if (!lastHiddenTime.current || timeDiff > 1000 * 3) {
|
|
|
|
// 3 seconds
|
2023-10-30 15:45:30 +03:00
|
|
|
loadOrCheckUpdates({
|
2023-10-21 07:26:28 +03:00
|
|
|
disableIdleCheck: true,
|
2023-05-05 12:53:16 +03:00
|
|
|
});
|
2023-03-23 20:04:47 +03:00
|
|
|
}
|
|
|
|
} else {
|
|
|
|
lastHiddenTime.current = Date.now();
|
2023-02-07 19:31:46 +03:00
|
|
|
}
|
2023-03-23 20:04:47 +03:00
|
|
|
setVisible(visible);
|
|
|
|
},
|
2023-05-05 12:53:16 +03:00
|
|
|
[checkForUpdates, loadOrCheckUpdates, snapStates.settings.autoRefresh],
|
2023-03-23 20:04:47 +03:00
|
|
|
);
|
2023-02-07 19:31:46 +03:00
|
|
|
|
2023-02-08 14:11:33 +03:00
|
|
|
// checkForUpdates interval
|
|
|
|
useInterval(
|
2023-05-05 12:53:16 +03:00
|
|
|
loadOrCheckUpdates,
|
2023-11-08 19:16:16 +03:00
|
|
|
visible && !showNew
|
|
|
|
? checkForUpdatesInterval * (nearReachStart ? 1 : 2)
|
|
|
|
: null,
|
2023-02-08 14:11:33 +03:00
|
|
|
);
|
|
|
|
|
2023-12-29 13:29:08 +03:00
|
|
|
// const hiddenUI = scrollDirection === 'end' && !nearReachStart;
|
2023-02-07 19:31:46 +03:00
|
|
|
|
2023-01-28 13:52:18 +03:00
|
|
|
return (
|
2023-11-03 16:45:31 +03:00
|
|
|
<FilterContext.Provider value={filterContext}>
|
|
|
|
<div
|
|
|
|
id={`${id}-page`}
|
2024-04-11 12:18:17 +03:00
|
|
|
class={`deck-container ${
|
|
|
|
mediaFirst ? 'deck-container-media-first' : ''
|
|
|
|
}`}
|
2023-11-03 16:45:31 +03:00
|
|
|
ref={(node) => {
|
|
|
|
scrollableRef.current = node;
|
|
|
|
jRef.current = node;
|
|
|
|
kRef.current = node;
|
|
|
|
oRef.current = node;
|
2024-03-23 19:21:41 +03:00
|
|
|
dotRef.current = node;
|
2023-11-03 16:45:31 +03:00
|
|
|
}}
|
|
|
|
tabIndex="-1"
|
|
|
|
>
|
|
|
|
<div class="timeline-deck deck">
|
|
|
|
<header
|
2023-12-29 13:29:08 +03:00
|
|
|
ref={headerRef}
|
|
|
|
// hidden={hiddenUI}
|
2023-11-03 16:45:31 +03:00
|
|
|
onClick={(e) => {
|
|
|
|
if (!e.target.closest('a, button')) {
|
|
|
|
scrollableRef.current?.scrollTo({
|
|
|
|
top: 0,
|
|
|
|
behavior: 'smooth',
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
onDblClick={(e) => {
|
|
|
|
if (!e.target.closest('a, button')) {
|
|
|
|
loadItems(true);
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
class={uiState === 'loading' ? 'loading' : ''}
|
|
|
|
>
|
|
|
|
<div class="header-grid">
|
|
|
|
<div class="header-side">
|
|
|
|
<NavMenu />
|
|
|
|
{headerStart !== null && headerStart !== undefined ? (
|
|
|
|
headerStart
|
|
|
|
) : (
|
|
|
|
<Link to="/" class="button plain home-button">
|
|
|
|
<Icon icon="home" size="l" />
|
|
|
|
</Link>
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
{title && (titleComponent ? titleComponent : <h1>{title}</h1>)}
|
|
|
|
<div class="header-side">
|
|
|
|
{/* <Loader hidden={uiState !== 'loading'} /> */}
|
|
|
|
{!!headerEnd && headerEnd}
|
|
|
|
</div>
|
2023-02-08 14:11:33 +03:00
|
|
|
</div>
|
2024-01-07 09:47:17 +03:00
|
|
|
{showNewPostsIndicator && (
|
|
|
|
<button
|
|
|
|
class="updates-button shiny-pill"
|
|
|
|
type="button"
|
|
|
|
onClick={handleLoadNewPosts}
|
|
|
|
>
|
|
|
|
<Icon icon="arrow-up" /> New posts
|
|
|
|
</button>
|
|
|
|
)}
|
2023-11-03 16:45:31 +03:00
|
|
|
</header>
|
|
|
|
{!!timelineStart && (
|
|
|
|
<div
|
|
|
|
class={`timeline-start ${uiState === 'loading' ? 'loading' : ''}`}
|
|
|
|
>
|
|
|
|
{timelineStart}
|
2023-02-08 14:11:33 +03:00
|
|
|
</div>
|
2023-11-03 16:45:31 +03:00
|
|
|
)}
|
|
|
|
{!!items.length ? (
|
|
|
|
<>
|
|
|
|
<ul class={`timeline ${view ? `timeline-${view}` : ''}`}>
|
|
|
|
{items.map((status) => (
|
|
|
|
<TimelineItem
|
|
|
|
status={status}
|
|
|
|
instance={instance}
|
|
|
|
useItemID={useItemID}
|
|
|
|
// allowFilters={allowFilters}
|
|
|
|
filterContext={filterContext}
|
2023-11-06 17:48:20 +03:00
|
|
|
key={status.id + status?._pinned + view}
|
2023-11-03 16:45:31 +03:00
|
|
|
view={view}
|
2023-12-14 20:58:29 +03:00
|
|
|
showFollowedTags={showFollowedTags}
|
2024-01-30 12:43:02 +03:00
|
|
|
showReplyParent={showReplyParent}
|
2024-04-11 12:18:17 +03:00
|
|
|
mediaFirst={mediaFirst}
|
2023-11-03 16:45:31 +03:00
|
|
|
/>
|
|
|
|
))}
|
|
|
|
{showMore &&
|
|
|
|
uiState === 'loading' &&
|
|
|
|
(view === 'media' ? null : (
|
|
|
|
<>
|
|
|
|
<li
|
|
|
|
style={{
|
|
|
|
height: '20vh',
|
|
|
|
}}
|
|
|
|
>
|
2024-04-11 12:18:17 +03:00
|
|
|
<Status skeleton mediaFirst={mediaFirst} />
|
2023-11-03 16:45:31 +03:00
|
|
|
</li>
|
|
|
|
<li
|
|
|
|
style={{
|
|
|
|
height: '25vh',
|
|
|
|
}}
|
|
|
|
>
|
2024-04-11 12:18:17 +03:00
|
|
|
<Status skeleton mediaFirst={mediaFirst} />
|
2023-11-03 16:45:31 +03:00
|
|
|
</li>
|
|
|
|
</>
|
|
|
|
))}
|
|
|
|
</ul>
|
|
|
|
{uiState === 'default' &&
|
|
|
|
(showMore ? (
|
|
|
|
<InView
|
2024-01-02 07:24:03 +03:00
|
|
|
root={scrollableRef.current}
|
2024-01-03 05:54:55 +03:00
|
|
|
rootMargin={`0px 0px ${screen.height * 1.5}px 0px`}
|
2023-11-03 16:45:31 +03:00
|
|
|
onChange={(inView) => {
|
|
|
|
if (inView) {
|
|
|
|
loadItems();
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
class="plain block"
|
|
|
|
onClick={() => loadItems()}
|
|
|
|
style={{ marginBlockEnd: '6em' }}
|
2023-10-29 16:41:03 +03:00
|
|
|
>
|
2023-11-03 16:45:31 +03:00
|
|
|
Show more…
|
|
|
|
</button>
|
|
|
|
</InView>
|
|
|
|
) : (
|
|
|
|
<p class="ui-state insignificant">The end.</p>
|
2023-10-29 16:41:03 +03:00
|
|
|
))}
|
2023-11-03 16:45:31 +03:00
|
|
|
</>
|
|
|
|
) : uiState === 'loading' ? (
|
|
|
|
<ul class="timeline">
|
|
|
|
{Array.from({ length: 5 }).map((_, i) =>
|
|
|
|
view === 'media' ? (
|
|
|
|
<div
|
|
|
|
style={{
|
|
|
|
height: '50vh',
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<li key={i}>
|
2024-04-11 12:18:17 +03:00
|
|
|
<Status skeleton mediaFirst={mediaFirst} />
|
2023-11-03 16:45:31 +03:00
|
|
|
</li>
|
|
|
|
),
|
|
|
|
)}
|
2023-01-28 13:52:18 +03:00
|
|
|
</ul>
|
2023-11-03 16:45:31 +03:00
|
|
|
) : (
|
2024-04-02 12:42:51 +03:00
|
|
|
uiState !== 'error' &&
|
|
|
|
uiState !== 'start' && <p class="ui-state">{emptyText}</p>
|
2023-11-03 16:45:31 +03:00
|
|
|
)}
|
|
|
|
{uiState === 'error' && (
|
|
|
|
<p class="ui-state">
|
|
|
|
{errorText}
|
|
|
|
<br />
|
|
|
|
<br />
|
2023-11-04 04:56:06 +03:00
|
|
|
<button type="button" onClick={() => loadItems(!items.length)}>
|
2023-11-03 16:45:31 +03:00
|
|
|
Try again
|
|
|
|
</button>
|
|
|
|
</p>
|
|
|
|
)}
|
|
|
|
</div>
|
2023-01-28 13:52:18 +03:00
|
|
|
</div>
|
2023-11-03 16:45:31 +03:00
|
|
|
</FilterContext.Provider>
|
2023-01-28 13:52:18 +03:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2024-02-14 12:17:15 +03:00
|
|
|
const TimelineItem = memo(
|
|
|
|
({
|
|
|
|
status,
|
|
|
|
instance,
|
|
|
|
useItemID,
|
|
|
|
// allowFilters,
|
|
|
|
filterContext,
|
|
|
|
view,
|
|
|
|
showFollowedTags,
|
|
|
|
showReplyParent,
|
2024-04-11 12:18:17 +03:00
|
|
|
mediaFirst,
|
2024-02-14 12:17:15 +03:00
|
|
|
}) => {
|
|
|
|
console.debug('RENDER TimelineItem', status.id);
|
|
|
|
const { id: statusID, reblog, items, type, _pinned } = status;
|
|
|
|
if (_pinned) useItemID = false;
|
|
|
|
const actualStatusID = reblog?.id || statusID;
|
|
|
|
const url = instance
|
|
|
|
? `/${instance}/s/${actualStatusID}`
|
|
|
|
: `/s/${actualStatusID}`;
|
2024-04-11 12:18:17 +03:00
|
|
|
|
2024-02-14 12:17:15 +03:00
|
|
|
if (items) {
|
|
|
|
const fItems = filteredItems(items, filterContext);
|
2024-03-16 08:36:23 +03:00
|
|
|
let title = '';
|
|
|
|
if (type === 'boosts') {
|
|
|
|
title = `${fItems.length} Boosts`;
|
|
|
|
} else if (type === 'pinned') {
|
|
|
|
title = 'Pinned posts';
|
|
|
|
}
|
|
|
|
const isCarousel = type === 'boosts' || type === 'pinned';
|
2024-02-14 12:17:15 +03:00
|
|
|
if (isCarousel) {
|
|
|
|
// Here, we don't hide filtered posts, but we sort them last
|
|
|
|
fItems.sort((a, b) => {
|
|
|
|
// if (a._filtered && !b._filtered) {
|
|
|
|
// return 1;
|
|
|
|
// }
|
|
|
|
// if (!a._filtered && b._filtered) {
|
|
|
|
// return -1;
|
|
|
|
// }
|
|
|
|
const aFiltered = isFiltered(a.filtered, filterContext);
|
|
|
|
const bFiltered = isFiltered(b.filtered, filterContext);
|
|
|
|
if (aFiltered && !bFiltered) {
|
|
|
|
return 1;
|
|
|
|
}
|
|
|
|
if (!aFiltered && bFiltered) {
|
|
|
|
return -1;
|
|
|
|
}
|
|
|
|
return 0;
|
|
|
|
});
|
|
|
|
return (
|
|
|
|
<li key={`timeline-${statusID}`} class="timeline-item-carousel">
|
|
|
|
<StatusCarousel title={title} class={`${type}-carousel`}>
|
|
|
|
{fItems.map((item) => {
|
|
|
|
const { id: statusID, reblog, _pinned } = item;
|
|
|
|
const actualStatusID = reblog?.id || statusID;
|
|
|
|
const url = instance
|
|
|
|
? `/${instance}/s/${actualStatusID}`
|
|
|
|
: `/s/${actualStatusID}`;
|
|
|
|
if (_pinned) useItemID = false;
|
|
|
|
return (
|
|
|
|
<li key={statusID}>
|
|
|
|
<Link
|
|
|
|
class="status-carousel-link timeline-item-alt"
|
|
|
|
to={url}
|
|
|
|
>
|
|
|
|
{useItemID ? (
|
|
|
|
<Status
|
|
|
|
statusID={statusID}
|
|
|
|
instance={instance}
|
|
|
|
size="s"
|
|
|
|
contentTextWeight
|
|
|
|
enableCommentHint
|
|
|
|
// allowFilters={allowFilters}
|
2024-04-11 12:18:17 +03:00
|
|
|
mediaFirst={mediaFirst}
|
2024-02-14 12:17:15 +03:00
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<Status
|
|
|
|
status={item}
|
|
|
|
instance={instance}
|
|
|
|
size="s"
|
|
|
|
contentTextWeight
|
|
|
|
enableCommentHint
|
|
|
|
// allowFilters={allowFilters}
|
2024-04-11 12:18:17 +03:00
|
|
|
mediaFirst={mediaFirst}
|
2024-02-14 12:17:15 +03:00
|
|
|
/>
|
|
|
|
)}
|
|
|
|
</Link>
|
|
|
|
</li>
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
</StatusCarousel>
|
|
|
|
</li>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
const manyItems = fItems.length > 3;
|
|
|
|
return fItems.map((item, i) => {
|
|
|
|
const { id: statusID, _differentAuthor } = item;
|
|
|
|
const url = instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`;
|
|
|
|
const isMiddle = i > 0 && i < fItems.length - 1;
|
|
|
|
const isSpoiler = item.sensitive && !!item.spoilerText;
|
|
|
|
const showCompact =
|
|
|
|
(!_differentAuthor && isSpoiler && i > 0) ||
|
|
|
|
(manyItems &&
|
|
|
|
isMiddle &&
|
|
|
|
(type === 'thread' ||
|
|
|
|
(type === 'conversation' &&
|
|
|
|
!_differentAuthor &&
|
|
|
|
!fItems[i - 1]._differentAuthor &&
|
|
|
|
!fItems[i + 1]._differentAuthor)));
|
|
|
|
const isStart = i === 0;
|
|
|
|
const isEnd = i === fItems.length - 1;
|
|
|
|
return (
|
|
|
|
<li
|
|
|
|
key={`timeline-${statusID}`}
|
|
|
|
class={`timeline-item-container timeline-item-container-type-${type} timeline-item-container-${
|
|
|
|
isStart ? 'start' : isEnd ? 'end' : 'middle'
|
|
|
|
} ${_differentAuthor ? 'timeline-item-diff-author' : ''}`}
|
|
|
|
>
|
|
|
|
<Link class="status-link timeline-item" to={url}>
|
|
|
|
{showCompact ? (
|
2024-05-02 18:29:01 +03:00
|
|
|
<TimelineStatusCompact
|
|
|
|
status={item}
|
|
|
|
instance={instance}
|
|
|
|
filterContext={filterContext}
|
|
|
|
/>
|
2024-02-14 12:17:15 +03:00
|
|
|
) : useItemID ? (
|
|
|
|
<Status
|
|
|
|
statusID={statusID}
|
|
|
|
instance={instance}
|
|
|
|
enableCommentHint={isEnd}
|
|
|
|
showFollowedTags={showFollowedTags}
|
|
|
|
// allowFilters={allowFilters}
|
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<Status
|
|
|
|
status={item}
|
|
|
|
instance={instance}
|
|
|
|
enableCommentHint={isEnd}
|
|
|
|
showFollowedTags={showFollowedTags}
|
|
|
|
// allowFilters={allowFilters}
|
|
|
|
/>
|
|
|
|
)}
|
|
|
|
</Link>
|
|
|
|
</li>
|
|
|
|
);
|
2023-10-27 09:15:03 +03:00
|
|
|
});
|
|
|
|
}
|
2023-10-29 16:41:03 +03:00
|
|
|
|
2024-02-14 12:17:15 +03:00
|
|
|
const itemKey = `timeline-${statusID + _pinned}`;
|
2023-10-29 16:41:03 +03:00
|
|
|
|
2024-02-14 12:17:15 +03:00
|
|
|
if (view === 'media') {
|
|
|
|
return useItemID ? (
|
|
|
|
<MediaPost
|
|
|
|
class="timeline-item"
|
|
|
|
parent="li"
|
|
|
|
key={itemKey}
|
|
|
|
statusID={statusID}
|
|
|
|
instance={instance}
|
|
|
|
// allowFilters={allowFilters}
|
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<MediaPost
|
|
|
|
class="timeline-item"
|
|
|
|
parent="li"
|
|
|
|
key={itemKey}
|
|
|
|
status={status}
|
|
|
|
instance={instance}
|
|
|
|
// allowFilters={allowFilters}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
2023-10-29 16:41:03 +03:00
|
|
|
|
2024-02-14 12:17:15 +03:00
|
|
|
return (
|
|
|
|
<li key={itemKey}>
|
|
|
|
<Link class="status-link timeline-item" to={url}>
|
|
|
|
{useItemID ? (
|
|
|
|
<Status
|
|
|
|
statusID={statusID}
|
|
|
|
instance={instance}
|
|
|
|
enableCommentHint
|
|
|
|
showFollowedTags={showFollowedTags}
|
|
|
|
showReplyParent={showReplyParent}
|
|
|
|
// allowFilters={allowFilters}
|
2024-04-11 12:18:17 +03:00
|
|
|
mediaFirst={mediaFirst}
|
2024-02-14 12:17:15 +03:00
|
|
|
/>
|
|
|
|
) : (
|
|
|
|
<Status
|
|
|
|
status={status}
|
|
|
|
instance={instance}
|
|
|
|
enableCommentHint
|
|
|
|
showFollowedTags={showFollowedTags}
|
|
|
|
showReplyParent={showReplyParent}
|
|
|
|
// allowFilters={allowFilters}
|
2024-04-11 12:18:17 +03:00
|
|
|
mediaFirst={mediaFirst}
|
2024-02-14 12:17:15 +03:00
|
|
|
/>
|
|
|
|
)}
|
|
|
|
</Link>
|
|
|
|
</li>
|
|
|
|
);
|
|
|
|
},
|
|
|
|
(oldProps, newProps) => {
|
|
|
|
const oldID = (oldProps.status?.id || '').toString();
|
|
|
|
const newID = (newProps.status?.id || '').toString();
|
|
|
|
return (
|
|
|
|
oldID === newID &&
|
|
|
|
oldProps.instance === newProps.instance &&
|
|
|
|
oldProps.view === newProps.view
|
|
|
|
);
|
|
|
|
},
|
|
|
|
);
|
2023-10-27 09:15:03 +03:00
|
|
|
|
2023-02-17 05:55:16 +03:00
|
|
|
function StatusCarousel({ title, class: className, children }) {
|
2023-02-03 16:08:08 +03:00
|
|
|
const carouselRef = useRef();
|
2023-12-29 13:29:08 +03:00
|
|
|
// const { reachStart, reachEnd, init } = useScroll({
|
|
|
|
// scrollableRef: carouselRef,
|
|
|
|
// direction: 'horizontal',
|
|
|
|
// });
|
|
|
|
const startButtonRef = useRef();
|
|
|
|
const endButtonRef = useRef();
|
2024-01-02 14:26:05 +03:00
|
|
|
// useScrollFn(
|
|
|
|
// {
|
|
|
|
// scrollableRef: carouselRef,
|
|
|
|
// direction: 'horizontal',
|
|
|
|
// init: true,
|
|
|
|
// },
|
|
|
|
// ({ reachStart, reachEnd }) => {
|
|
|
|
// if (startButtonRef.current) startButtonRef.current.disabled = reachStart;
|
|
|
|
// if (endButtonRef.current) endButtonRef.current.disabled = reachEnd;
|
|
|
|
// },
|
|
|
|
// [],
|
|
|
|
// );
|
2023-12-29 13:29:08 +03:00
|
|
|
// useEffect(() => {
|
|
|
|
// init?.();
|
|
|
|
// }, []);
|
2023-02-03 16:08:08 +03:00
|
|
|
|
2024-01-02 14:56:54 +03:00
|
|
|
const [render, setRender] = useState(false);
|
|
|
|
useEffect(() => {
|
|
|
|
setTimeout(() => {
|
|
|
|
setRender(true);
|
|
|
|
}, 1);
|
|
|
|
}, []);
|
|
|
|
|
2023-02-03 16:08:08 +03:00
|
|
|
return (
|
2023-02-17 05:55:16 +03:00
|
|
|
<div class={`status-carousel ${className}`}>
|
2023-02-03 16:08:08 +03:00
|
|
|
<header>
|
2023-02-17 05:55:16 +03:00
|
|
|
<h3>{title}</h3>
|
2023-02-03 16:08:08 +03:00
|
|
|
<span>
|
|
|
|
<button
|
2023-12-29 13:29:08 +03:00
|
|
|
ref={startButtonRef}
|
2023-02-03 16:08:08 +03:00
|
|
|
type="button"
|
|
|
|
class="small plain2"
|
2023-12-29 13:29:08 +03:00
|
|
|
// disabled={reachStart}
|
2023-02-03 16:08:08 +03:00
|
|
|
onClick={() => {
|
|
|
|
carouselRef.current?.scrollBy({
|
|
|
|
left: -Math.min(320, carouselRef.current?.offsetWidth),
|
|
|
|
behavior: 'smooth',
|
|
|
|
});
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<Icon icon="chevron-left" />
|
|
|
|
</button>{' '}
|
|
|
|
<button
|
2023-12-29 13:29:08 +03:00
|
|
|
ref={endButtonRef}
|
2023-02-03 16:08:08 +03:00
|
|
|
type="button"
|
|
|
|
class="small plain2"
|
2023-12-29 13:29:08 +03:00
|
|
|
// disabled={reachEnd}
|
2023-02-03 16:08:08 +03:00
|
|
|
onClick={() => {
|
|
|
|
carouselRef.current?.scrollBy({
|
|
|
|
left: Math.min(320, carouselRef.current?.offsetWidth),
|
|
|
|
behavior: 'smooth',
|
|
|
|
});
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<Icon icon="chevron-right" />
|
|
|
|
</button>
|
|
|
|
</span>
|
|
|
|
</header>
|
2024-01-02 14:26:05 +03:00
|
|
|
<ul ref={carouselRef}>
|
|
|
|
<InView
|
|
|
|
class="status-carousel-beacon"
|
|
|
|
onChange={(inView) => {
|
|
|
|
if (startButtonRef.current)
|
|
|
|
startButtonRef.current.disabled = inView;
|
|
|
|
}}
|
|
|
|
/>
|
2024-01-02 14:56:54 +03:00
|
|
|
{children[0]}
|
|
|
|
{render && children.slice(1)}
|
2024-01-02 14:26:05 +03:00
|
|
|
<InView
|
|
|
|
class="status-carousel-beacon"
|
|
|
|
onChange={(inView) => {
|
|
|
|
if (endButtonRef.current) endButtonRef.current.disabled = inView;
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
</ul>
|
2023-02-03 16:08:08 +03:00
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2024-05-02 18:29:01 +03:00
|
|
|
function TimelineStatusCompact({ status, instance, filterContext }) {
|
2023-03-26 18:18:36 +03:00
|
|
|
const snapStates = useSnapshot(states);
|
2024-01-30 12:43:02 +03:00
|
|
|
const { id, visibility, language } = status;
|
2023-03-26 18:18:36 +03:00
|
|
|
const statusPeekText = statusPeek(status);
|
|
|
|
const sKey = statusKey(id, instance);
|
2024-05-02 18:29:01 +03:00
|
|
|
const filterInfo = isFiltered(status.filtered, filterContext);
|
2023-03-26 18:18:36 +03:00
|
|
|
return (
|
2023-10-21 18:05:32 +03:00
|
|
|
<article
|
|
|
|
class={`status compact-thread ${
|
|
|
|
visibility === 'direct' ? 'visibility-direct' : ''
|
|
|
|
}`}
|
|
|
|
tabindex="-1"
|
|
|
|
>
|
2023-04-02 11:04:37 +03:00
|
|
|
{!!snapStates.statusThreadNumber[sKey] ? (
|
2023-03-26 18:18:36 +03:00
|
|
|
<div class="status-thread-badge">
|
|
|
|
<Icon icon="thread" size="s" />
|
|
|
|
{snapStates.statusThreadNumber[sKey]
|
|
|
|
? ` ${snapStates.statusThreadNumber[sKey]}/X`
|
|
|
|
: ''}
|
|
|
|
</div>
|
2023-04-02 11:04:37 +03:00
|
|
|
) : (
|
|
|
|
<div class="status-thread-badge">
|
|
|
|
<Icon icon="thread" size="s" />
|
|
|
|
</div>
|
2023-03-26 18:18:36 +03:00
|
|
|
)}
|
2024-01-30 12:43:02 +03:00
|
|
|
<div
|
|
|
|
class="content-compact"
|
|
|
|
title={statusPeekText}
|
|
|
|
lang={language}
|
|
|
|
dir="auto"
|
|
|
|
>
|
2024-05-02 18:29:01 +03:00
|
|
|
{!!filterInfo ? (
|
|
|
|
<b
|
|
|
|
class="status-filtered-badge badge-meta horizontal"
|
|
|
|
title={filterInfo?.titlesStr || ''}
|
|
|
|
>
|
|
|
|
<span>Filtered</span>: <span>{filterInfo?.titlesStr || ''}</span>
|
|
|
|
</b>
|
|
|
|
) : (
|
2023-04-17 14:09:46 +03:00
|
|
|
<>
|
2024-05-02 18:29:01 +03:00
|
|
|
{statusPeekText}
|
|
|
|
{status.sensitive && status.spoilerText && (
|
|
|
|
<>
|
|
|
|
{' '}
|
|
|
|
<span class="spoiler-badge">
|
|
|
|
<Icon icon="eye-close" size="s" />
|
|
|
|
</span>
|
|
|
|
</>
|
|
|
|
)}
|
2023-04-17 14:09:46 +03:00
|
|
|
</>
|
|
|
|
)}
|
2023-03-26 19:47:08 +03:00
|
|
|
</div>
|
2023-03-26 18:18:36 +03:00
|
|
|
</article>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-05-05 12:53:16 +03:00
|
|
|
function inBackground() {
|
|
|
|
return !!document.querySelector('.deck-backdrop, #modal-container > *');
|
|
|
|
}
|
|
|
|
|
2023-01-28 13:52:18 +03:00
|
|
|
export default Timeline;
|