phanpy/src/pages/catchup.jsx

1921 wiersze
62 KiB
React

2024-02-26 06:02:58 +00:00
import '../components/links-bar.css';
import './catchup.css';
import autoAnimate from '@formkit/auto-animate';
import { getBlurHashAverageColor } from 'fast-blurhash';
import { Fragment } from 'preact';
import { memo } from 'preact/compat';
2024-03-02 13:25:54 +00:00
import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'preact/hooks';
import punycode from 'punycode';
import { useHotkeys } from 'react-hotkeys-hook';
2024-02-26 06:02:58 +00:00
import { useSearchParams } from 'react-router-dom';
import { uid } from 'uid/single';
2024-03-02 02:00:45 +00:00
import catchupUrl from '../assets/features/catch-up.png';
2024-02-26 06:02:58 +00:00
import Avatar from '../components/avatar';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
2024-03-04 06:36:47 +00:00
import Modal from '../components/modal';
2024-02-26 06:02:58 +00:00
import NameText from '../components/name-text';
import NavMenu from '../components/nav-menu';
import RelativeTime from '../components/relative-time';
import { api } from '../utils/api';
import { oklab2rgb, rgb2oklab } from '../utils/color-utils';
import db from '../utils/db';
import emojifyText from '../utils/emojify-text';
import { isFiltered } from '../utils/filters';
2024-03-01 08:03:45 +00:00
import htmlContentLength from '../utils/html-content-length';
2024-02-26 06:02:58 +00:00
import niceDateTime from '../utils/nice-date-time';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
2024-02-27 10:02:00 +00:00
import states, { statusKey } from '../utils/states';
2024-03-07 04:34:38 +00:00
import statusPeek from '../utils/status-peek';
2024-02-26 06:02:58 +00:00
import store from '../utils/store';
import { getCurrentAccountID, getCurrentAccountNS } from '../utils/store-utils';
import supports from '../utils/supports';
2024-02-27 10:02:00 +00:00
import { assignFollowedTags } from '../utils/timeline-utils';
2024-02-26 06:02:58 +00:00
import useTitle from '../utils/useTitle';
const FILTER_CONTEXT = 'home';
2024-03-02 13:25:54 +00:00
const RANGES = [
{ label: 'last 1 hour', value: 1 },
{ label: 'last 2 hours', value: 2 },
{ label: 'last 3 hours', value: 3 },
{ label: 'last 4 hours', value: 4 },
{ label: 'last 5 hours', value: 5 },
{ label: 'last 6 hours', value: 6 },
{ label: 'last 7 hours', value: 7 },
{ label: 'last 8 hours', value: 8 },
{ label: 'last 9 hours', value: 9 },
{ label: 'last 10 hours', value: 10 },
{ label: 'last 11 hours', value: 11 },
{ label: 'last 12 hours', value: 12 },
{ label: 'beyond 12 hours', value: 13 },
];
2024-03-05 15:30:12 +00:00
const FILTER_LABELS = [
'Original',
'Replies',
'Boosts',
'Followed tags',
'Groups',
'Filtered',
];
const FILTER_SORTS = [
'createdAt',
'repliesCount',
'favouritesCount',
'reblogsCount',
'density',
];
const FILTER_GROUPS = [null, 'account'];
2024-03-02 13:25:54 +00:00
const FILTER_VALUES = {
Filtered: 'filtered',
Groups: 'group',
Boosts: 'boost',
Replies: 'reply',
'Followed tags': 'followedTags',
Original: 'original',
};
const FILTER_CATEGORY_TEXT = {
Filtered: 'filtered posts',
Groups: 'group posts',
Boosts: 'boosts',
Replies: 'replies',
'Followed tags': 'followed-tag posts',
Original: 'original posts',
};
const SORT_BY_TEXT = {
// asc, desc
createdAt: ['oldest', 'latest'],
repliesCount: ['fewest replies', 'most replies'],
favouritesCount: ['fewest likes', 'most likes'],
reblogsCount: ['fewest boosts', 'most boosts'],
density: ['least dense', 'most dense'],
};
2024-02-26 06:02:58 +00:00
function Catchup() {
useTitle('Catch-up', '/catchup');
const { masto, instance } = api();
const [searchParams, setSearchParams] = useSearchParams();
const id = searchParams.get('id');
const [uiState, setUIState] = useState('start');
const [showTopLinks, setShowTopLinks] = useState(false);
const currentAccount = useMemo(() => {
return getCurrentAccountID();
2024-02-26 06:02:58 +00:00
}, []);
const isSelf = (accountID) => accountID === currentAccount;
const supportsPixelfed = supports('@pixelfed/home-include-reblogs');
2024-02-26 06:02:58 +00:00
async function fetchHome({ maxCreatedAt }) {
const maxCreatedAtDate = maxCreatedAt ? new Date(maxCreatedAt) : null;
console.debug('fetchHome', maxCreatedAtDate);
const allResults = [];
const homeIterator = masto.v1.timelines.home.list({ limit: 40 });
mainloop: while (true) {
try {
if (supportsPixelfed && homeIterator.nextParams) {
if (typeof homeIterator.nextParams === 'string') {
homeIterator.nextParams += '&include_reblogs=true';
} else {
homeIterator.nextParams.include_reblogs = true;
}
}
2024-02-26 06:02:58 +00:00
const results = await homeIterator.next();
const { value } = results;
if (value?.length) {
// This ignores maxCreatedAt filter, but it's ok for now
await assignFollowedTags(value, instance);
let addedResults = false;
for (let i = 0; i < value.length; i++) {
const item = value[i];
const createdAtDate = new Date(item.createdAt);
if (!maxCreatedAtDate || createdAtDate >= maxCreatedAtDate) {
// Filtered
const selfPost = isSelf(
item.reblog?.account?.id || item.account.id,
);
const filterInfo =
!selfPost &&
isFiltered(
item.reblog?.filtered || item.filtered,
FILTER_CONTEXT,
);
if (filterInfo?.action === 'hide') continue;
item._filtered = filterInfo;
// Followed tags
const sKey = statusKey(item.id, instance);
item._followedTags = states.statusFollowedTags[sKey]
? [...states.statusFollowedTags[sKey]]
: [];
allResults.push(item);
addedResults = true;
} else {
// Don't immediately stop, still add the other items that might still be within range
// break mainloop;
}
// Only stop when ALL items are outside of range
if (!addedResults) {
break mainloop;
}
}
} else {
break mainloop;
}
// Pause 1s
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (e) {
console.error(e);
break mainloop;
}
}
// Post-process all results
// 1. Threadify - tag 1st-post in a thread
allResults.forEach((status) => {
if (status?.inReplyToId) {
const replyToStatus = allResults.find(
(s) => s.id === status.inReplyToId,
);
if (replyToStatus && !replyToStatus.inReplyToId) {
replyToStatus._thread = true;
}
}
});
return allResults;
}
const [posts, setPosts] = useState([]);
const catchupRangeRef = useRef();
const catchupLastRef = useRef();
2024-03-02 13:25:54 +00:00
const NS = useMemo(() => getCurrentAccountNS(), []);
const handleCatchupClick = useCallback(async ({ duration } = {}) => {
2024-02-26 06:02:58 +00:00
const now = Date.now();
const maxCreatedAt = duration ? now - duration : null;
setUIState('loading');
const results = await fetchHome({ maxCreatedAt });
// Namespaced by account ID
// Possible conflict if ID matches between different accounts from different instances
2024-03-02 13:25:54 +00:00
const catchupID = `${NS}-${uid()}`;
2024-02-26 06:02:58 +00:00
try {
await db.catchup.set(catchupID, {
id: catchupID,
posts: results,
count: results.length,
startAt: maxCreatedAt,
endAt: now,
});
setSearchParams({ id: catchupID });
} catch (e) {
console.error(e, results);
}
2024-03-02 13:25:54 +00:00
}, []);
2024-02-26 06:02:58 +00:00
useEffect(() => {
if (id) {
(async () => {
const catchup = await db.catchup.get(id);
if (catchup) {
2024-03-02 13:25:54 +00:00
catchup.posts.sort((a, b) => (a.createdAt > b.createdAt ? 1 : -1));
2024-02-26 06:02:58 +00:00
setPosts(catchup.posts);
setUIState('results');
}
})();
} else if (uiState === 'results') {
setPosts([]);
setUIState('start');
}
}, [id]);
const [reloadCatchupsCount, reloadCatchups] = useReducer((c) => c + 1, 0);
const [lastCatchupEndAt, setLastCatchupEndAt] = useState(null);
const [prevCatchups, setPrevCatchups] = useState([]);
useEffect(() => {
(async () => {
try {
const catchups = await db.catchup.keys();
if (catchups.length) {
const ns = getCurrentAccountNS();
const ownKeys = catchups.filter((key) => key.startsWith(`${ns}-`));
if (ownKeys.length) {
let ownCatchups = await db.catchup.getMany(ownKeys);
ownCatchups.sort((a, b) => b.endAt - a.endAt);
// Split to 1st 3 last catchups, and the rest
let lastCatchups = ownCatchups.slice(0, 3);
let restCatchups = ownCatchups.slice(3);
const trimmedCatchups = lastCatchups.map((c) => {
const { id, count, startAt, endAt } = c;
return {
id,
count,
startAt,
endAt,
};
});
setPrevCatchups(trimmedCatchups);
setLastCatchupEndAt(lastCatchups[0].endAt);
// GC time
ownCatchups = null;
lastCatchups = null;
queueMicrotask(() => {
if (restCatchups.length) {
// delete them
db.catchup
.delMany(restCatchups.map((c) => c.id))
.then(() => {
// GC time
restCatchups = null;
})
.catch((e) => {
console.error(e);
});
}
});
return;
}
}
} catch (e) {
console.error(e);
}
setPrevCatchups([]);
})();
}, [reloadCatchupsCount]);
useEffect(() => {
if (uiState === 'start') {
reloadCatchups();
}
}, [uiState === 'start']);
const [filterCounts, links] = useMemo(() => {
let filtereds = 0,
groups = 0,
boosts = 0,
replies = 0,
followedTags = 0,
originals = 0;
const links = {};
for (const post of posts) {
if (post._filtered) {
filtereds++;
post.__FILTER = 'filtered';
} else if (post.group) {
groups++;
post.__FILTER = 'group';
} else if (post.reblog) {
boosts++;
post.__FILTER = 'boost';
} else if (post._followedTags?.length) {
followedTags++;
post.__FILTER = 'followedTags';
} else if (
post.inReplyToId &&
post.inReplyToAccountId !== post.account?.id
) {
replies++;
post.__FILTER = 'reply';
} else {
originals++;
post.__FILTER = 'original';
}
const thePost = post.reblog || post;
if (
post.__FILTER !== 'filtered' &&
2024-02-26 06:02:58 +00:00
thePost.card?.url &&
thePost.card?.image &&
thePost.card?.type === 'link'
) {
const { card, favouritesCount, reblogsCount } = thePost;
let { url } = card;
url = url.replace(/\/$/, '');
if (!links[url]) {
links[url] = {
postID: thePost.id,
card,
shared: 1,
sharers: [post.account],
likes: favouritesCount,
boosts: reblogsCount,
};
} else {
if (links[url].sharers.find((a) => a.id === post.account.id)) {
continue;
}
links[url].shared++;
links[url].sharers.push(post.account);
if (links[url].postID !== thePost.id) {
links[url].likes += favouritesCount;
links[url].boosts += reblogsCount;
}
}
}
}
let topLinks = [];
for (const link in links) {
topLinks.push({
url: link,
...links[link],
});
}
topLinks.sort((a, b) => {
if (a.shared > b.shared) return -1;
if (a.shared < b.shared) return 1;
if (a.boosts > b.boosts) return -1;
if (a.boosts < b.boosts) return 1;
if (a.likes > b.likes) return -1;
if (a.likes < b.likes) return 1;
return 0;
});
// Slice links to shared > 1 but min 10 links
if (topLinks.length > 10) {
linksLoop: for (let i = 10; i < topLinks.length; i++) {
const { shared } = topLinks[i];
if (shared <= 1) {
topLinks = topLinks.slice(0, i);
break linksLoop;
}
}
}
return [
{
Filtered: filtereds,
Groups: groups,
Boosts: boosts,
Replies: replies,
'Followed tags': followedTags,
Original: originals,
},
topLinks,
];
}, [posts]);
const [selectedFilterCategory, setSelectedFilterCategory] = useState('All');
const [selectedAuthor, setSelectedAuthor] = useState(null);
const [range, setRange] = useState(1);
const [sortBy, setSortBy] = useState('createdAt');
const [sortOrder, setSortOrder] = useState('asc');
const [groupBy, setGroupBy] = useState(null);
const [filteredPosts, authors, authorCounts] = useMemo(() => {
2024-03-02 13:25:54 +00:00
const authorsHash = {};
const authorCountsMap = new Map();
2024-02-26 06:02:58 +00:00
let filteredPosts = posts.filter((post) => {
2024-03-02 13:25:54 +00:00
const postFilterMatches =
2024-02-26 06:02:58 +00:00
selectedFilterCategory === 'All' ||
2024-03-02 13:25:54 +00:00
post.__FILTER === FILTER_VALUES[selectedFilterCategory];
2024-02-26 06:02:58 +00:00
2024-03-02 13:25:54 +00:00
if (postFilterMatches) {
authorsHash[post.account.id] = post.account;
authorCountsMap.set(
post.account.id,
(authorCountsMap.get(post.account.id) || 0) + 1,
);
2024-02-26 06:02:58 +00:00
}
2024-03-02 13:25:54 +00:00
return postFilterMatches;
2024-02-26 06:02:58 +00:00
});
2024-03-10 15:24:17 +00:00
// Deduplicate boosts
const boostedPosts = {};
filteredPosts.forEach((post) => {
2024-03-10 15:24:17 +00:00
if (post.reblog) {
if (boostedPosts[post.reblog.id]) {
if (boostedPosts[post.reblog.id].__BOOSTERS) {
boostedPosts[post.reblog.id].__BOOSTERS.add(post.account);
} else {
boostedPosts[post.reblog.id].__BOOSTERS = new Set([post.account]);
}
post.__HIDDEN = true;
2024-03-10 15:24:17 +00:00
} else {
boostedPosts[post.reblog.id] = post;
}
}
});
2024-03-02 13:25:54 +00:00
if (selectedAuthor && authorCountsMap.has(selectedAuthor)) {
2024-02-26 06:02:58 +00:00
filteredPosts = filteredPosts.filter(
2024-03-10 15:24:17 +00:00
(post) =>
post.account.id === selectedAuthor ||
[...(post.__BOOSTERS || [])].find((a) => a.id === selectedAuthor),
2024-02-26 06:02:58 +00:00
);
}
2024-03-02 13:25:54 +00:00
return [filteredPosts, authorsHash, Object.fromEntries(authorCountsMap)];
2024-02-26 06:02:58 +00:00
}, [selectedFilterCategory, selectedAuthor, posts]);
2024-03-02 13:25:54 +00:00
const filteredPostsMap = useMemo(() => {
const map = {};
filteredPosts.forEach((post) => {
map[post.id] = post;
});
return map;
}, [filteredPosts]);
2024-02-26 06:02:58 +00:00
const authorCountsList = useMemo(
() =>
Object.keys(authorCounts).sort(
(a, b) => authorCounts[b] - authorCounts[a],
),
[authorCounts],
);
const sortedFilteredPosts = useMemo(() => {
const authorIndices = {};
authorCountsList.forEach((authorID, index) => {
authorIndices[authorID] = index;
});
return filteredPosts
.filter((post) => !post.__HIDDEN)
.sort((a, b) => {
if (groupBy === 'account') {
const aAccountID = a.account.id;
const bAccountID = b.account.id;
const aIndex = authorIndices[aAccountID];
const bIndex = authorIndices[bAccountID];
const order = aIndex - bIndex;
if (order !== 0) {
return order;
}
2024-02-26 06:02:58 +00:00
}
if (sortBy !== 'createdAt') {
a = a.reblog || a;
b = b.reblog || b;
if (sortBy !== 'density' && a[sortBy] === b[sortBy]) {
return a.createdAt > b.createdAt ? 1 : -1;
}
}
if (sortBy === 'density') {
const aDensity = postDensity(a);
const bDensity = postDensity(b);
if (sortOrder === 'asc') {
return aDensity > bDensity ? 1 : -1;
} else {
return bDensity > aDensity ? 1 : -1;
}
2024-02-26 06:02:58 +00:00
}
2024-03-01 08:03:45 +00:00
if (sortOrder === 'asc') {
return a[sortBy] > b[sortBy] ? 1 : -1;
2024-03-01 08:03:45 +00:00
} else {
return b[sortBy] > a[sortBy] ? 1 : -1;
2024-03-01 08:03:45 +00:00
}
});
2024-02-26 06:02:58 +00:00
}, [filteredPosts, sortBy, sortOrder, groupBy, authorCountsList]);
const prevGroup = useRef(null);
const authorsListParent = useRef(null);
2024-03-02 13:25:54 +00:00
const autoAnimated = useRef(false);
2024-02-26 06:02:58 +00:00
useEffect(() => {
2024-03-02 13:25:54 +00:00
if (posts.length > 100 || autoAnimated.current) return;
if (authorsListParent.current) {
2024-02-26 06:02:58 +00:00
autoAnimate(authorsListParent.current, {
duration: 200,
});
2024-03-02 13:25:54 +00:00
autoAnimated.current = true;
2024-02-26 06:02:58 +00:00
}
2024-03-02 13:25:54 +00:00
}, [posts, authorsListParent]);
const postsBarType = posts.length > 160 ? '3d' : '2d';
2024-02-26 06:02:58 +00:00
const postsBar = useMemo(() => {
2024-03-02 13:25:54 +00:00
if (postsBarType !== '2d') return null;
2024-02-26 06:02:58 +00:00
return posts.map((post) => {
// If part of filteredPosts
2024-03-02 13:25:54 +00:00
const isFiltered = filteredPostsMap[post.id];
2024-02-26 06:02:58 +00:00
return (
<span
key={post.id}
class={`post-dot ${isFiltered ? 'post-dot-highlight' : ''}`}
/>
);
});
2024-03-02 13:25:54 +00:00
}, [filteredPostsMap]);
const postsBins = useMemo(() => {
if (postsBarType !== '3d') return null;
if (!posts?.length) return null;
const bins = binByTime(posts, 'createdAt', 320);
return bins.map((posts, i) => {
return (
<div class="posts-bin" key={i}>
{posts.map((post) => {
const isFiltered = filteredPostsMap[post.id];
return (
<span
key={post.id}
class={`post-dot ${isFiltered ? 'post-dot-highlight' : ''}`}
/>
);
})}
</div>
);
});
}, [filteredPostsMap]);
2024-02-26 06:02:58 +00:00
const scrollableRef = useRef(null);
// if range value exceeded lastCatchupEndAt, show error
const lastCatchupRange = useMemo(() => {
// return hour, not ms
if (!lastCatchupEndAt) return null;
return (Date.now() - lastCatchupEndAt) / 1000 / 60 / 60;
}, [lastCatchupEndAt, range]);
useEffect(() => {
if (uiState !== 'results') return;
const authorUsername =
selectedAuthor && authors[selectedAuthor]
? authors[selectedAuthor].username
: '';
const sortOrderIndex = sortOrder === 'asc' ? 0 : 1;
const groupByText = {
account: 'authors',
};
let toast = showToast({
duration: 5_000, // 5 seconds
text: `Showing ${
2024-03-02 13:25:54 +00:00
FILTER_CATEGORY_TEXT[selectedFilterCategory] || 'all posts'
}${authorUsername ? ` by @${authorUsername}` : ''}, ${
2024-03-02 13:25:54 +00:00
SORT_BY_TEXT[sortBy][sortOrderIndex]
} first${
!!groupBy
? `, grouped by ${groupBy === 'account' ? groupByText[groupBy] : ''}`
: ''
}`,
});
return () => {
toast?.hideToast?.();
};
}, [
uiState,
selectedFilterCategory,
selectedAuthor,
sortBy,
sortOrder,
groupBy,
authors,
]);
2024-02-29 13:01:31 +00:00
useEffect(() => {
if (selectedAuthor) {
if (authors[selectedAuthor]) {
// Check if author is visible and within the scrollable area viewport
const authorElement = authorsListParent.current.querySelector(
`[data-author="${selectedAuthor}"]`,
);
const scrollableRect =
authorsListParent.current?.getBoundingClientRect();
const authorRect = authorElement?.getBoundingClientRect();
console.log({
sLeft: scrollableRect.left,
sRight: scrollableRect.right,
aLeft: authorRect.left,
aRight: authorRect.right,
});
if (
authorRect.left < scrollableRect.left ||
authorRect.right > scrollableRect.right
) {
authorElement.scrollIntoView({
block: 'nearest',
inline: 'center',
behavior: 'smooth',
});
2024-03-11 04:21:15 +00:00
} else if (authorRect.top < 0) {
authorElement.scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: 'smooth',
});
2024-02-29 13:01:31 +00:00
}
}
}
}, [selectedAuthor, authors]);
2024-03-04 06:36:47 +00:00
const [showHelp, setShowHelp] = useState(false);
const itemsSelector = '.catchup-list > li > a';
const jRef = useHotkeys(
'j',
() => {
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);
const nextItem = allItems[activeItemIndex + 1];
if (nextItem) {
nextItem.focus();
nextItem.scrollIntoView({
block: 'center',
inline: 'center',
behavior: 'smooth',
});
}
} else {
const topmostItem = allItems.find((item) => {
const itemRect = item.getBoundingClientRect();
return itemRect.top >= 0;
});
if (topmostItem) {
topmostItem.focus();
topmostItem.scrollIntoView({
block: 'nearest',
inline: 'center',
behavior: 'smooth',
});
}
}
},
{
preventDefault: true,
ignoreModifiers: true,
},
);
const kRef = useHotkeys(
'k',
() => {
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 (prevItem) {
prevItem.focus();
prevItem.scrollIntoView({
block: 'center',
inline: 'center',
behavior: 'smooth',
});
}
} else {
const topmostItem = allItems.find((item) => {
const itemRect = item.getBoundingClientRect();
return itemRect.top >= 44 && itemRect.left >= 0;
});
if (topmostItem) {
topmostItem.focus();
topmostItem.scrollIntoView({
block: 'nearest',
inline: 'center',
behavior: 'smooth',
});
}
}
},
{
preventDefault: true,
ignoreModifiers: true,
},
);
const hlRef = useHotkeys(
'h, l',
(_, handler) => {
// Go next/prev selectedAuthor in authorCountsList list
2024-03-15 10:06:52 +00:00
const key = handler.keys[0];
if (selectedAuthor) {
const index = authorCountsList.indexOf(selectedAuthor);
if (key === 'h') {
if (index > 0 && index < authorCountsList.length) {
setSelectedAuthor(authorCountsList[index - 1]);
2024-03-15 10:06:52 +00:00
scrollableRef.current?.focus();
}
} else if (key === 'l') {
if (index < authorCountsList.length - 1 && index >= 0) {
setSelectedAuthor(authorCountsList[index + 1]);
2024-03-15 10:06:52 +00:00
scrollableRef.current?.focus();
}
}
2024-03-15 10:06:52 +00:00
} else if (key === 'l') {
setSelectedAuthor(authorCountsList[0]);
scrollableRef.current?.focus();
}
},
{
preventDefault: true,
ignoreModifiers: true,
enableOnFormTags: ['input'],
},
);
2024-03-15 10:06:52 +00:00
const escRef = useHotkeys(
'esc',
() => {
setSelectedAuthor(null);
scrollableRef.current?.focus();
},
{
preventDefault: true,
ignoreModifiers: true,
enableOnFormTags: ['input'],
},
);
const dotRef = useHotkeys(
'.',
() => {
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
},
{
preventDefault: true,
ignoreModifiers: true,
enableOnFormTags: ['input'],
},
);
2024-02-26 06:02:58 +00:00
return (
<div
ref={(node) => {
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
hlRef.current = node;
2024-03-15 10:06:52 +00:00
escRef.current = node;
}}
2024-02-26 06:02:58 +00:00
id="catchup-page"
class="deck-container"
tabIndex="-1"
>
<div class="timeline-deck deck wide">
<header
class={`${uiState === 'loading' ? 'loading' : ''}`}
onClick={(e) => {
if (!e.target.closest('a, button')) {
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}
}}
>
<div class="header-grid">
<div class="header-side">
<NavMenu />
2024-03-04 06:37:03 +00:00
{uiState === 'results' && (
<Link to="/catchup" class="button plain">
2024-03-05 08:23:16 +00:00
<Icon icon="history2" size="l" />
2024-03-04 06:37:03 +00:00
</Link>
)}
{uiState === 'start' && (
<Link to="/" class="button plain">
<Icon icon="home" size="l" />
</Link>
)}
2024-02-26 06:02:58 +00:00
</div>
<h1>
{uiState !== 'start' && (
<>
Catch-up <sup>beta</sup>
</>
)}
</h1>
<div class="header-side">
{uiState !== 'start' && uiState !== 'loading' && (
<button
type="button"
class="plain"
onClick={() => {
2024-03-04 06:36:47 +00:00
setShowHelp(true);
2024-02-26 06:02:58 +00:00
}}
>
2024-03-04 06:36:47 +00:00
Help
2024-02-26 06:02:58 +00:00
</button>
)}
</div>
</div>
</header>
<main>
{uiState === 'start' && (
<div class="catchup-start">
<h1>
Catch-up <sup>beta</sup>
</h1>
2024-03-02 02:00:45 +00:00
<details>
<summary>What is this?</summary>
<p>
Catch-up is a separate timeline for your followings, offering
a high-level view at a glance, with a simple, email-inspired
interface to effortlessly sort and filter through posts.
</p>
<img
src={catchupUrl}
width="1200"
height="900"
alt="Preview of Catch-up UI"
/>
<p>
<button
type="button"
onClick={(e) => {
e.target.closest('details').open = false;
}}
>
Let's catch up
</button>
</p>
</details>
2024-02-26 06:02:58 +00:00
<p>Let's catch up on the posts from your followings.</p>
<p>
<b>Show me all posts from</b>
</p>
<div class="catchup-form">
<input
ref={catchupRangeRef}
type="range"
value={range}
2024-03-02 13:25:54 +00:00
min={RANGES[0].value}
max={RANGES[RANGES.length - 1].value}
2024-02-26 06:02:58 +00:00
step="1"
list="catchup-ranges"
onChange={(e) => setRange(+e.target.value)}
/>{' '}
<span
style={{
width: '8em',
}}
>
2024-03-02 13:25:54 +00:00
{RANGES[range - 1].label}
2024-02-26 06:02:58 +00:00
<br />
<small class="insignificant">
2024-03-02 13:25:54 +00:00
{range == RANGES[RANGES.length - 1].value
2024-02-26 06:02:58 +00:00
? 'until the max'
: niceDateTime(
new Date(Date.now() - range * 60 * 60 * 1000),
)}
</small>
</span>
<datalist id="catchup-ranges">
2024-03-02 13:25:54 +00:00
{RANGES.map(({ label, value }) => (
2024-02-26 06:02:58 +00:00
<option value={value} label={label} />
))}
</datalist>{' '}
<button
type="button"
onClick={() => {
2024-03-02 13:25:54 +00:00
if (range < RANGES[RANGES.length - 1].value) {
let duration;
if (
range === RANGES[RANGES.length - 1].value &&
catchupLastRef.current?.checked
) {
duration = Date.now() - lastCatchupEndAt;
} else {
duration = range * 60 * 60 * 1000;
}
2024-02-26 06:02:58 +00:00
handleCatchupClick({ duration });
} else {
handleCatchupClick();
}
}}
>
Catch up
</button>
</div>
{lastCatchupRange && range > lastCatchupRange ? (
2024-02-26 06:02:58 +00:00
<p class="catchup-info">
<Icon icon="info" /> Overlaps with your last catch-up
</p>
) : range === RANGES[RANGES.length - 1].value &&
lastCatchupEndAt ? (
<p class="catchup-info">
<label>
<input
type="checkbox"
switch
checked
ref={catchupLastRef}
/>{' '}
Until the last catch-up (
{dtf.format(new Date(lastCatchupEndAt))})
</label>
</p>
) : null}
2024-02-26 06:02:58 +00:00
<p class="insignificant">
<small>
Note: your instance might only show a maximum of 800 posts in
the Home timeline regardless of the time range. Could be less
or more.
</small>
</p>
{!!prevCatchups?.length && (
<div class="catchup-prev">
<p>Previously</p>
<ul>
{prevCatchups.map((pc) => (
<li key={pc.id}>
<Link to={`/catchup?id=${pc.id}`}>
2024-03-05 08:23:16 +00:00
<Icon icon="history2" />{' '}
2024-02-26 06:02:58 +00:00
<span>
2024-03-22 01:33:32 +00:00
{pc.startAt
? dtf.formatRange(
new Date(pc.startAt),
new Date(pc.endAt),
)
: `… – ${dtf.format(new Date(pc.endAt))}`}
2024-02-26 06:02:58 +00:00
</span>
</Link>{' '}
2024-03-02 02:01:22 +00:00
<span>
<small class="ib insignificant">
{pc.count} posts
</small>{' '}
<button
type="button"
class="light danger small"
onClick={async () => {
const yes = confirm('Remove this catch-up?');
if (yes) {
let t = showToast(`Removing Catch-up ${pc.id}`);
await db.catchup.del(pc.id);
t?.hideToast?.();
showToast(`Catch-up ${pc.id} removed`);
reloadCatchups();
}
}}
>
<Icon icon="x" />
</button>
</span>
2024-02-26 06:02:58 +00:00
</li>
))}
</ul>
{prevCatchups.length >= 3 && (
<p>
<small>
Note: Only max 3 will be stored. The rest will be
automatically removed.
</small>
</p>
)}
</div>
)}
</div>
)}
{uiState === 'loading' && (
<div class="ui-state catchup-start">
<Loader abrupt />
<p class="insignificant">Fetching posts</p>
<p class="insignificant">This might take a while.</p>
</div>
)}
{uiState === 'results' && (
<>
<div class="catchup-header">
{posts.length > 0 && (
<p>
<b class="ib">
2024-03-22 01:33:32 +00:00
{dtf.formatRange(
2024-02-26 06:02:58 +00:00
new Date(posts[0].createdAt),
2024-03-02 13:53:03 +00:00
new Date(posts[posts.length - 1].createdAt),
2024-02-26 06:02:58 +00:00
)}
</b>
</p>
)}
<aside>
<button
hidden={
selectedFilterCategory === 'All' &&
!selectedAuthor &&
sortBy === 'createdAt' &&
sortOrder === 'asc'
}
type="button"
class="plain4 small"
onClick={() => {
setSelectedFilterCategory('All');
setSelectedAuthor(null);
setSortBy('createdAt');
setGroupBy(null);
setSortOrder('asc');
}}
>
Reset filters
</button>
{links?.length > 0 && (
<button
type="button"
class="plain small"
onClick={() => setShowTopLinks(!showTopLinks)}
>
Top links{' '}
<Icon
icon="chevron-down"
style={{
transform: showTopLinks
? 'rotate(180deg)'
: 'rotate(0deg)',
}}
/>
</button>
)}
</aside>
</div>
<div class="shazam-container no-animation" hidden={!showTopLinks}>
<div class="shazam-container-inner">
<div class="catchup-top-links links-bar">
{links.map((link) => {
const { card, shared, sharers, likes, boosts } = link;
const {
blurhash,
title,
description,
url,
image,
imageDescription,
language,
width,
height,
publishedAt,
} = card;
const domain = punycode.toUnicode(
new URL(url).hostname
.replace(/^www\./, '')
.replace(/\/$/, ''),
);
2024-02-26 06:02:58 +00:00
let accentColor;
if (blurhash) {
const averageColor = getBlurHashAverageColor(blurhash);
const labAverageColor = rgb2oklab(averageColor);
accentColor = oklab2rgb([
0.6,
labAverageColor[1],
labAverageColor[2],
]);
}
return (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
style={
accentColor
? {
'--accent-color': `rgb(${accentColor.join(
',',
)})`,
'--accent-alpha-color': `rgba(${accentColor.join(
',',
)}, 0.4)`,
}
: {}
}
>
<article>
<figure>
<img
src={image}
alt={imageDescription}
width={width}
height={height}
loading="lazy"
/>
</figure>
<div class="article-body">
<header>
<div class="article-meta">
<span class="domain">{domain}</span>{' '}
{!!publishedAt && <>&middot; </>}
{!!publishedAt && (
<>
<RelativeTime
datetime={publishedAt}
format="micro"
/>
</>
)}
</div>
{!!title && (
<h1
class="title"
lang={language}
dir="auto"
title={title}
>
2024-02-26 06:02:58 +00:00
{title}
</h1>
)}
</header>
{!!description && (
<p
class="description"
lang={language}
dir="auto"
title={description}
2024-02-26 06:02:58 +00:00
>
{description}
</p>
)}
<hr />
<p
style={{
whiteSpace: 'nowrap',
}}
>
Shared by{' '}
{sharers.map((s) => {
const { avatarStatic, displayName } = s;
return (
<Avatar
url={avatarStatic}
size="s"
alt={displayName}
/>
);
})}
</p>
</div>
</article>
</a>
);
})}
</div>
</div>
</div>
2024-03-02 13:25:54 +00:00
{posts.length >= 5 &&
(postsBarType === '3d' ? (
<div class="catchup-posts-viz-time-bar">{postsBins}</div>
) : (
<div class="catchup-posts-viz-bar">{postsBar}</div>
))}
2024-02-26 06:02:58 +00:00
{posts.length >= 2 && (
<div class="catchup-filters">
<label class="filter-cat">
<input
type="radio"
name="filter-cat"
checked={selectedFilterCategory.toLowerCase() === 'all'}
onChange={() => {
setSelectedFilterCategory('All');
}}
/>
All <span class="count">{posts.length}</span>
</label>
2024-03-05 15:30:12 +00:00
{FILTER_LABELS.map(
2024-02-26 06:02:58 +00:00
(label) =>
!!filterCounts[label] && (
2024-03-05 12:56:37 +00:00
<label
class="filter-cat"
key={label}
title={
(
(filterCounts[label] / posts.length) *
100
).toFixed(2) + '%'
}
>
2024-02-26 06:02:58 +00:00
<input
type="radio"
name="filter-cat"
checked={
selectedFilterCategory.toLowerCase() ===
label.toLowerCase()
}
onChange={() => {
setSelectedFilterCategory(label);
// setSelectedAuthor(null);
}}
/>
{label}{' '}
<span class="count">{filterCounts[label]}</span>
</label>
),
)}
</div>
)}
{posts.length >= 2 && !!authorCounts && (
<div
class="catchup-filters authors-filters"
ref={authorsListParent}
>
{authorCountsList.map((author) => (
<label
class="filter-author"
2024-02-29 13:01:31 +00:00
data-author={author}
2024-02-26 06:02:58 +00:00
key={`${author}-${authorCounts[author]}`}
// Preact messed up the order sometimes, need additional key besides just `author`
// https://github.com/preactjs/preact/issues/2849
>
<input
type="radio"
name="filter-author"
checked={selectedAuthor === author}
onChange={() => {
setSelectedAuthor(author);
// setGroupBy(null);
}}
onClick={() => {
if (selectedAuthor === author) {
setSelectedAuthor(null);
}
}}
/>
<Avatar
url={
authors[author].avatarStatic || authors[author].avatar
}
size="xxl"
2024-03-27 02:18:12 +00:00
alt={`${authors[author].displayName} (@${authors[author].acct})`}
2024-02-26 06:02:58 +00:00
/>{' '}
<span class="count">{authorCounts[author]}</span>
<span class="username">{authors[author].username}</span>
</label>
))}
{authorCountsList.length > 5 && (
<small
key="authors-count"
style={{
whiteSpace: 'nowrap',
paddingInline: '1em',
opacity: 0.33,
}}
>
{authorCountsList.length} authors
</small>
)}
</div>
)}
{posts.length >= 2 && (
<div class="catchup-filters">
<span class="filter-label">Sort</span>{' '}
<fieldset class="radio-field-group">
2024-03-05 15:30:12 +00:00
{FILTER_SORTS.map((key) => (
2024-03-05 11:11:28 +00:00
<label
class="filter-sort"
key={key}
onClick={(e) => {
if (sortBy === key) {
e.preventDefault();
e.stopPropagation();
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
}
}}
>
2024-02-26 06:02:58 +00:00
<input
type="radio"
name="filter-sort-cat"
checked={sortBy === key}
onChange={() => {
setSortBy(key);
2024-03-03 01:48:53 +00:00
const order = /(replies|favourites|reblogs)/.test(
key,
)
? 'desc'
: 'asc';
2024-02-26 06:02:58 +00:00
setSortOrder(order);
}}
/>
{
{
createdAt: 'Date',
repliesCount: 'Replies',
favouritesCount: 'Likes',
reblogsCount: 'Boosts',
2024-03-01 08:03:45 +00:00
density: 'Density',
2024-02-26 06:02:58 +00:00
}[key]
}
2024-03-05 11:11:28 +00:00
{sortBy === key && (sortOrder === 'asc' ? ' ↑' : ' ↓')}
2024-02-26 06:02:58 +00:00
</label>
))}
</fieldset>
2024-03-05 11:11:28 +00:00
{/* <fieldset class="radio-field-group">
2024-02-26 06:02:58 +00:00
{['asc', 'desc'].map((key) => (
<label class="filter-sort" key={key}>
<input
type="radio"
name="filter-sort-dir"
checked={sortOrder === key}
onChange={() => {
setSortOrder(key);
}}
/>
{key === 'asc' ? '↑' : '↓'}
</label>
))}
2024-03-05 11:11:28 +00:00
</fieldset> */}
2024-02-26 06:02:58 +00:00
<span class="filter-label">Group</span>{' '}
<fieldset class="radio-field-group">
2024-03-05 15:30:12 +00:00
{FILTER_GROUPS.map((key) => (
2024-02-26 06:02:58 +00:00
<label class="filter-group" key={key || 'none'}>
<input
type="radio"
name="filter-group"
checked={groupBy === key}
onChange={() => {
setGroupBy(key);
}}
disabled={key === 'account' && selectedAuthor}
/>
{{
account: 'Authors',
}[key] || 'None'}
</label>
))}
</fieldset>
{
selectedAuthor && authorCountsList.length > 1 ? (
<button
type="button"
class="plain6 small"
2024-02-26 06:02:58 +00:00
onClick={() => {
setSelectedAuthor(null);
}}
style={{
whiteSpace: 'nowrap',
}}
>
Show all authors
</button>
) : null
// <button
// type="button"
// class="plain4 small"
// onClick={() => {}}
// >
// Group by authors
// </button>
}
</div>
)}
2024-03-05 05:32:40 +00:00
<ul
class={`catchup-list catchup-filter-${
FILTER_VALUES[selectedFilterCategory] || ''
} ${sortBy ? `catchup-sort-${sortBy}` : ''} ${
selectedAuthor && authors[selectedAuthor]
? `catchup-selected-author`
: ''
} ${groupBy ? `catchup-group-${groupBy}` : ''}`}
>
2024-02-26 06:02:58 +00:00
{sortedFilteredPosts.map((post, i) => {
const id = post.reblog?.id || post.id;
let showSeparator = false;
if (groupBy === 'account') {
if (
prevGroup.current &&
post.account.id !== prevGroup.current &&
i > 0
) {
showSeparator = true;
}
prevGroup.current = post.account.id;
}
return (
<Fragment key={`${post.id}-${showSeparator}`}>
{showSeparator && <li class="separator" />}
2024-03-02 13:25:54 +00:00
<IntersectionPostLineItem
to={`/${instance}/s/${id}`}
post={post}
root={scrollableRef.current}
/>
2024-02-26 06:02:58 +00:00
</Fragment>
);
})}
</ul>
<footer>
{filteredPosts.length > 5 && (
<p>
{selectedFilterCategory === 'Boosts'
? "You don't have to read everything."
: "That's all."}{' '}
<button
type="button"
class="textual"
onClick={() => {
scrollableRef.current.scrollTop = 0;
}}
>
Back to top
</button>
.
</p>
)}
</footer>
</>
)}
</main>
</div>
2024-03-04 06:36:47 +00:00
{showHelp && (
<Modal onClose={() => setShowHelp(false)}>
<div class="sheet" id="catchup-help-sheet">
<button
type="button"
class="sheet-close"
onClick={() => setShowHelp(false)}
>
<Icon icon="x" />
</button>
<header>
<h2>Help</h2>
</header>
<main>
<dl>
<dt>Top links</dt>
<dd>
Links shared by followings, sorted by shared counts, boosts
and likes.
</dd>
<dt>Sort: Density</dt>
<dd>
Posts are sorted by information density or depth. Shorter
posts are "lighter" while longer posts are "heavier". Posts
with photos are "heavier" than posts without photos.
</dd>
<dt>Group: Authors</dt>
<dd>
Posts are grouped by authors, sorted by posts count per
author.
</dd>
2024-03-15 10:06:52 +00:00
<dt>Keyboard shortcuts</dt>
<dd>
<kbd>j</kbd>: Next post
</dd>
<dd>
<kbd>k</kbd>: Previous post
</dd>
<dd>
<kbd>l</kbd>: Next author
</dd>
<dd>
<kbd>h</kbd>: Previous author
</dd>
<dd>
<kbd>Enter</kbd>: Open post details
</dd>
<dd>
<kbd>.</kbd>: Scroll to top
</dd>
2024-03-04 06:36:47 +00:00
</dl>
</main>
</div>
</Modal>
)}
2024-02-26 06:02:58 +00:00
</div>
);
}
const PostLine = memo(
function ({ post }) {
const {
id,
account,
group,
reblog,
inReplyToId,
inReplyToAccountId,
_followedTags: isFollowedTags,
_filtered: filterInfo,
visibility,
2024-03-10 15:24:17 +00:00
__BOOSTERS,
2024-02-26 06:02:58 +00:00
} = post;
const isReplyTo = inReplyToId && inReplyToAccountId !== account.id;
const isFiltered = !!filterInfo;
const debugHover = (e) => {
if (e.shiftKey) {
console.log({
...post,
});
}
};
return (
<article
class={`post-line ${
group
? 'group'
: reblog
? 'reblog'
: isFollowedTags?.length
? 'followed-tags'
: ''
} ${isReplyTo ? 'reply-to' : ''} ${
isFiltered ? 'filtered' : ''
} visibility-${visibility}`}
onMouseEnter={debugHover}
>
<span class="post-author">
{reblog ? (
<span class="post-reblog-avatar">
<Avatar
url={account.avatarStatic || account.avatar}
squircle={account.bot}
2024-03-10 15:24:17 +00:00
/>
{__BOOSTERS?.size > 0
? [...__BOOSTERS].map((b) => (
<Avatar url={b.avatarStatic || b.avatar} squircle={b.bot} />
))
: ''}{' '}
2024-02-26 06:02:58 +00:00
<Icon icon="rocket" />{' '}
{/* <Avatar
url={reblog.account.avatarStatic || reblog.account.avatar}
squircle={reblog.account.bot}
/> */}
<NameText account={reblog.account} showAvatar />
</span>
) : (
<NameText account={account} showAvatar />
)}
</span>
<PostPeek post={reblog || post} filterInfo={filterInfo} />
<span class="post-meta">
<PostStats post={reblog || post} />{' '}
<RelativeTime
datetime={new Date(reblog?.createdAt || post.createdAt)}
format="micro"
/>
</span>
</article>
);
},
(oldProps, newProps) => {
return oldProps?.post?.id === newProps?.post?.id;
},
);
2024-03-02 13:25:54 +00:00
const IntersectionPostLineItem = ({ root, to, ...props }) => {
2024-02-26 06:02:58 +00:00
const ref = useRef();
const [show, setShow] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting) {
queueMicrotask(() => setShow(true));
observer.unobserve(ref.current);
}
},
{
root,
rootMargin: `${Math.max(320, screen.height * 0.75)}px`,
},
);
if (ref.current) observer.observe(ref.current);
return () => {
if (ref.current) observer.unobserve(ref.current);
};
}, []);
return show ? (
2024-03-02 13:25:54 +00:00
<li>
<Link to={to}>
<PostLine {...props} />
</Link>
</li>
2024-02-26 06:02:58 +00:00
) : (
2024-03-02 13:25:54 +00:00
<li ref={ref} style={{ height: '4em' }} />
2024-02-26 06:02:58 +00:00
);
};
2024-03-01 08:03:45 +00:00
// A media speak a thousand words
const MEDIA_DENSITY = 8;
const CARD_DENSITY = 8;
function postDensity(post) {
const { spoilerText, content, poll, mediaAttachments, card } = post;
const pollContent = poll?.options?.length
? poll.options.reduce((acc, cur) => acc + cur.title, '')
: '';
const density =
(spoilerText.length + htmlContentLength(content) + pollContent.length) /
140 +
(mediaAttachments?.length
? MEDIA_DENSITY * mediaAttachments.length
: card?.image
? CARD_DENSITY
: 0);
return density;
}
2024-02-26 06:02:58 +00:00
const MEDIA_SIZE = 48;
function PostPeek({ post, filterInfo }) {
const {
spoilerText,
sensitive,
content,
emojis,
poll,
mediaAttachments,
card,
inReplyToId,
inReplyToAccountId,
account,
_thread,
} = post;
const isThread =
(inReplyToId && inReplyToAccountId === account.id) || !!_thread;
const readingExpandSpoilers = useMemo(() => {
const prefs = store.account.get('preferences') || {};
return !!prefs['reading:expand:spoilers'];
}, []);
// const readingExpandSpoilers = true;
const showMedia = readingExpandSpoilers || (!spoilerText && !sensitive);
2024-03-07 04:34:38 +00:00
const postText = content ? statusPeek(post) : '';
2024-02-26 06:02:58 +00:00
const showPostContent = !spoilerText || readingExpandSpoilers;
2024-02-26 06:02:58 +00:00
return (
<div class="post-peek" title={!spoilerText ? postText : ''}>
<span class="post-peek-content">
{isThread && !showPostContent && (
2024-02-26 06:02:58 +00:00
<>
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
2024-02-26 06:02:58 +00:00
</>
)}
{!!filterInfo ? (
<span class="post-peek-filtered">
Filtered{filterInfo?.titlesStr ? `: ${filterInfo.titlesStr}` : ''}
</span>
2024-02-26 06:02:58 +00:00
) : (
<>
{!!spoilerText && (
<span class="post-peek-spoiler">
<Icon
icon={`${readingExpandSpoilers ? 'eye-open' : 'eye-close'}`}
/>{' '}
{spoilerText}
</span>
2024-02-26 06:02:58 +00:00
)}
{showPostContent && (
<div class="post-peek-html">
{isThread && (
<>
<span class="post-peek-tag post-peek-thread">Thread</span>{' '}
</>
)}
{!!content && (
<div
dangerouslySetInnerHTML={{
__html: emojifyText(content, emojis),
}}
/>
)}
{!!poll?.options?.length &&
poll.options.map((o) => (
<div>
{poll.multiple ? '▪️' : '•'} {o.title}
</div>
))}
{!content &&
mediaAttachments?.length === 1 &&
mediaAttachments[0].description && (
<>
<span class="post-peek-tag post-peek-alt">ALT</span>{' '}
<div>{mediaAttachments[0].description}</div>
</>
)}
</div>
2024-03-07 04:34:38 +00:00
)}
</>
2024-02-26 06:02:58 +00:00
)}
</span>
{!filterInfo && (
<span class="post-peek-post-content">
{!!poll && (
<span class="post-peek-tag post-peek-poll">
<Icon icon="poll" size="s" />
Poll
</span>
)}
{!!mediaAttachments?.length
2024-02-27 10:01:47 +00:00
? mediaAttachments.map((m) => {
const mediaURL = m.previewUrl || m.url;
const remoteMediaURL = m.previewRemoteUrl || m.remoteUrl;
return (
<span key={m.id} class="post-peek-media">
{{
image:
(mediaURL || remoteMediaURL) && showMedia ? (
<img
src={mediaURL}
width={MEDIA_SIZE}
height={MEDIA_SIZE}
alt={m.description}
loading="lazy"
onError={(e) => {
const { src } = e.target;
if (src === mediaURL) {
e.target.src = remoteMediaURL;
}
}}
/>
) : (
<span class="post-peek-faux-media">🖼</span>
),
gifv:
(mediaURL || remoteMediaURL) && showMedia ? (
<img
src={mediaURL}
width={MEDIA_SIZE}
height={MEDIA_SIZE}
alt={m.description}
loading="lazy"
onError={(e) => {
const { src } = e.target;
if (src === mediaURL) {
e.target.src = remoteMediaURL;
}
}}
/>
) : (
<span class="post-peek-faux-media">🎞</span>
),
video:
(mediaURL || remoteMediaURL) && showMedia ? (
<img
src={mediaURL}
width={MEDIA_SIZE}
height={MEDIA_SIZE}
alt={m.description}
loading="lazy"
onError={(e) => {
const { src } = e.target;
if (src === mediaURL) {
e.target.src = remoteMediaURL;
}
}}
/>
) : (
<span class="post-peek-faux-media">📹</span>
),
audio: <span class="post-peek-faux-media">🎵</span>,
}[m.type] || null}
</span>
);
})
2024-02-26 06:02:58 +00:00
: !!card &&
card.image &&
showMedia && (
<span
class={`post-peek-media post-peek-card card-${
card.type || ''
}`}
>
{card.image ? (
<img
src={card.image}
width={MEDIA_SIZE}
height={MEDIA_SIZE}
alt={
card.title || card.description || card.imageDescription
}
loading="lazy"
/>
) : (
<span class="post-peek-faux-media">🔗</span>
)}
</span>
)}
</span>
)}
</div>
);
}
function PostStats({ post }) {
const { reblogsCount, repliesCount, favouritesCount } = post;
return (
<span class="post-stats">
{repliesCount > 0 && (
2024-03-05 05:32:40 +00:00
<span class="post-stat-replies">
2024-02-26 06:02:58 +00:00
<Icon icon="comment2" size="s" /> {shortenNumber(repliesCount)}
2024-03-05 05:32:40 +00:00
</span>
2024-02-26 06:02:58 +00:00
)}
{favouritesCount > 0 && (
2024-03-05 05:32:40 +00:00
<span class="post-stat-likes">
2024-02-26 06:02:58 +00:00
<Icon icon="heart" size="s" /> {shortenNumber(favouritesCount)}
2024-03-05 05:32:40 +00:00
</span>
2024-02-26 06:02:58 +00:00
)}
{reblogsCount > 0 && (
2024-03-05 05:32:40 +00:00
<span class="post-stat-boosts">
2024-02-26 06:02:58 +00:00
<Icon icon="rocket" size="s" /> {shortenNumber(reblogsCount)}
2024-03-05 05:32:40 +00:00
</span>
2024-02-26 06:02:58 +00:00
)}
</span>
);
}
const { locale } = new Intl.DateTimeFormat().resolvedOptions();
const dtf = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
});
2024-03-02 13:25:54 +00:00
function binByTime(data, key, numBins) {
// Extract dates from data objects
const dates = data.map((item) => new Date(item[key]));
// Find minimum and maximum dates directly (avoiding Math.min/max)
const minDate = dates.reduce(
(acc, date) => (date < acc ? date : acc),
dates[0],
);
const maxDate = dates.reduce(
(acc, date) => (date > acc ? date : acc),
dates[0],
);
// Calculate the time span in milliseconds
const range = maxDate.getTime() - minDate.getTime();
// Create empty bins and loop through data
const bins = Array.from({ length: numBins }, () => []);
data.forEach((item) => {
const date = new Date(item[key]);
const normalized = (date.getTime() - minDate.getTime()) / range;
const binIndex = Math.floor(normalized * (numBins - 1));
bins[binIndex].push(item);
});
return bins;
}
2024-02-26 06:02:58 +00:00
export default Catchup;