import '../components/links-bar.css';
import './catchup.css';
import autoAnimate from '@formkit/auto-animate';
import { msg, select } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { getBlurHashAverageColor } from 'fast-blurhash';
import { Fragment } from 'preact';
import { memo } from 'preact/compat';
import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'preact/hooks';
import punycode from 'punycode/';
import { useHotkeys } from 'react-hotkeys-hook';
import { useSearchParams } from 'react-router-dom';
import { uid } from 'uid/single';
import catchupUrl from '../assets/features/catch-up.png';
import Avatar from '../components/avatar';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import Modal from '../components/modal';
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';
import htmlContentLength from '../utils/html-content-length';
import mem from '../utils/mem';
import niceDateTime from '../utils/nice-date-time';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
import states, { statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek';
import store from '../utils/store';
import { getCurrentAccountID, getCurrentAccountNS } from '../utils/store-utils';
import supports from '../utils/supports';
import { assignFollowedTags } from '../utils/timeline-utils';
import useTitle from '../utils/useTitle';
const FILTER_CONTEXT = 'home';
const RANGES = [
{ label: msg`last 1 hour`, value: 1 },
{ label: msg`last 2 hours`, value: 2 },
{ label: msg`last 3 hours`, value: 3 },
{ label: msg`last 4 hours`, value: 4 },
{ label: msg`last 5 hours`, value: 5 },
{ label: msg`last 6 hours`, value: 6 },
{ label: msg`last 7 hours`, value: 7 },
{ label: msg`last 8 hours`, value: 8 },
{ label: msg`last 9 hours`, value: 9 },
{ label: msg`last 10 hours`, value: 10 },
{ label: msg`last 11 hours`, value: 11 },
{ label: msg`last 12 hours`, value: 12 },
{ label: msg`beyond 12 hours`, value: 13 },
];
const FILTER_KEYS = {
original: msg`Original`,
replies: msg`Replies`,
boosts: msg`Boosts`,
followedTags: msg`Followed tags`,
groups: msg`Groups`,
filtered: msg`Filtered`,
};
const FILTER_SORTS = [
'createdAt',
'repliesCount',
'favouritesCount',
'reblogsCount',
'density',
];
const FILTER_GROUPS = [null, 'account'];
const DTF = mem(
(locale) =>
new Intl.DateTimeFormat(locale || undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
}),
);
function Catchup() {
const { i18n, _, t } = useLingui();
const dtf = DTF(i18n.locale);
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();
}, []);
const isSelf = (accountID) => accountID === currentAccount;
const supportsPixelfed = supports('@pixelfed/home-include-reblogs');
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;
}
}
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();
const NS = useMemo(() => getCurrentAccountNS(), []);
const handleCatchupClick = useCallback(async ({ duration } = {}) => {
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
const catchupID = `${NS}-${uid()}`;
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);
}
}, []);
useEffect(() => {
if (id) {
(async () => {
const catchup = await db.catchup.get(id);
if (catchup) {
catchup.posts.sort((a, b) => (a.createdAt > b.createdAt ? 1 : -1));
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 filtered = 0,
groups = 0,
boosts = 0,
replies = 0,
followedTags = 0,
original = 0;
const links = {};
for (const post of posts) {
if (post._filtered) {
filtered++;
post.__FILTER = 'filtered';
} else if (post.group) {
groups++;
post.__FILTER = 'groups';
} else if (post.reblog) {
boosts++;
post.__FILTER = 'boosts';
} else if (post._followedTags?.length) {
followedTags++;
post.__FILTER = 'followedTags';
} else if (
post.inReplyToId &&
post.inReplyToAccountId !== post.account?.id
) {
replies++;
post.__FILTER = 'replies';
} else {
original++;
post.__FILTER = 'original';
}
const thePost = post.reblog || post;
if (
post.__FILTER !== 'filtered' &&
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,
groups,
boosts,
replies,
followedTags,
original,
},
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(() => {
const authorsHash = {};
const authorCountsMap = new Map();
let filteredPosts = posts.filter((post) => {
const postFilterMatches =
selectedFilterCategory === 'all' ||
post.__FILTER === selectedFilterCategory;
if (postFilterMatches) {
authorsHash[post.account.id] = post.account;
authorCountsMap.set(
post.account.id,
(authorCountsMap.get(post.account.id) || 0) + 1,
);
}
return postFilterMatches;
});
// Deduplicate boosts
const boostedPosts = {};
filteredPosts.forEach((post) => {
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;
} else {
boostedPosts[post.reblog.id] = post;
}
}
});
if (selectedAuthor && authorCountsMap.has(selectedAuthor)) {
filteredPosts = filteredPosts.filter(
(post) =>
post.account.id === selectedAuthor ||
[...(post.__BOOSTERS || [])].find((a) => a.id === selectedAuthor),
);
}
return [filteredPosts, authorsHash, Object.fromEntries(authorCountsMap)];
}, [selectedFilterCategory, selectedAuthor, posts]);
const filteredPostsMap = useMemo(() => {
const map = {};
filteredPosts.forEach((post) => {
map[post.id] = post;
});
return map;
}, [filteredPosts]);
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;
}
}
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;
}
}
if (sortOrder === 'asc') {
return a[sortBy] > b[sortBy] ? 1 : -1;
} else {
return b[sortBy] > a[sortBy] ? 1 : -1;
}
});
}, [filteredPosts, sortBy, sortOrder, groupBy, authorCountsList]);
const prevGroup = useRef(null);
const authorsListParent = useRef(null);
const autoAnimated = useRef(false);
useEffect(() => {
if (posts.length > 100 || autoAnimated.current) return;
if (authorsListParent.current) {
autoAnimate(authorsListParent.current, {
duration: 200,
});
autoAnimated.current = true;
}
}, [posts, authorsListParent]);
const postsBarType = posts.length > 160 ? '3d' : '2d';
const postsBar = useMemo(() => {
if (postsBarType !== '2d') return null;
return posts.map((post) => {
// If part of filteredPosts
const isFiltered = filteredPostsMap[post.id];
return (
);
});
}, [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 (
{posts.map((post) => {
const isFiltered = filteredPostsMap[post.id];
return (
);
})}
);
});
}, [filteredPostsMap]);
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
// Note: I'm sorry, translators
text: t`Showing ${select(selectedFilterCategory, {
all: 'all posts',
original: 'original posts',
replies: 'replies',
boosts: 'boosts',
followedTags: 'followed tags',
groups: 'groups',
filtered: 'filtered posts',
})}, ${select(sortBy, {
createdAt: select(sortOrder, {
asc: 'oldest',
desc: 'latest',
}),
reblogsCount: select(sortOrder, {
asc: 'fewest boosts',
desc: 'most boosts',
}),
favouritesCount: select(sortOrder, {
asc: 'fewest likes',
desc: 'most likes',
}),
repliesCount: select(sortOrder, {
asc: 'fewest replies',
desc: 'most replies',
}),
density: select(sortOrder, { asc: 'least dense', desc: 'most dense' }),
})} first${select(groupBy, {
account: ', grouped by authors',
other: '',
})}`,
});
return () => {
toast?.hideToast?.();
};
}, [
uiState,
selectedFilterCategory,
selectedAuthor,
sortBy,
sortOrder,
groupBy,
authors,
]);
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',
});
} else if (authorRect.top < 0) {
authorElement.scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: 'smooth',
});
}
}
}
}, [selectedAuthor, authors]);
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
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]);
scrollableRef.current?.focus();
}
} else if (key === 'l') {
if (index < authorCountsList.length - 1 && index >= 0) {
setSelectedAuthor(authorCountsList[index + 1]);
scrollableRef.current?.focus();
}
}
} else if (key === 'l') {
setSelectedAuthor(authorCountsList[0]);
scrollableRef.current?.focus();
}
},
{
preventDefault: true,
ignoreModifiers: true,
enableOnFormTags: ['input'],
},
);
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'],
},
);
const handleArrowKeys = useCallback((e) => {
const activeElement = document.activeElement;
const isRadio =
activeElement?.tagName === 'INPUT' && activeElement.type === 'radio';
const isArrowKeys =
e.key === 'ArrowDown' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight';
if (isArrowKeys && isRadio) {
// Note: page scroll won't trigger on first arrow key press due to this. Subsequent presses will.
activeElement.blur();
return;
}
}, []);
return (
{
scrollableRef.current = node;
jRef(node);
kRef(node);
hlRef(node);
escRef(node);
}}
id="catchup-page"
class="deck-container"
tabIndex="-1"
>
{
if (!e.target.closest('a, button')) {
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}
}}
>
{uiState === 'start' && (
Catch-up beta
What is this?
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.
{
e.target.closest('details').open = false;
}}
>
Let's catch up
Let's catch up on the posts from your followings.
Show me all posts from…
setRange(+e.target.value)}
/>{' '}
{_(RANGES[range - 1].label)}
{range == RANGES[RANGES.length - 1].value
? t`until the max`
: niceDateTime(
new Date(Date.now() - range * 60 * 60 * 1000),
)}
{RANGES.map(({ label, value }) => (
))}
{' '}
{
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;
}
handleCatchupClick({ duration });
} else {
handleCatchupClick();
}
}}
>
Catch up
{lastCatchupRange && range > lastCatchupRange ? (
{' '}
Overlaps with your last catch-up
) : range === RANGES[RANGES.length - 1].value &&
lastCatchupEndAt ? (
{' '}
Until the last catch-up (
{dtf.format(new Date(lastCatchupEndAt))})
) : null}
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.
{!!prevCatchups?.length && (
Previously…
{prevCatchups.map((pc) => (
{' '}
{pc.startAt
? dtf.formatRange(
new Date(pc.startAt),
new Date(pc.endAt),
)
: `… – ${dtf.format(new Date(pc.endAt))}`}
{' '}
{' '}
{
const yes = confirm(t`Remove this catch-up?`);
if (yes) {
let st = showToast(
t`Removing Catch-up ${pc.id}`,
);
await db.catchup.del(pc.id);
st?.hideToast?.();
showToast(t`Catch-up ${pc.id} removed`);
reloadCatchups();
}
}}
>
))}
{prevCatchups.length >= 3 && (
Note: Only max 3 will be stored. The rest will be
automatically removed.
)}
)}
)}
{uiState === 'loading' && (
Fetching posts…
This might take a while.
)}
{uiState === 'results' && (
<>
{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(
URL.parse(url)
.hostname.replace(/^www\./, '')
.replace(/\/$/, ''),
);
let accentColor;
if (blurhash) {
const averageColor = getBlurHashAverageColor(blurhash);
const labAverageColor = rgb2oklab(averageColor);
accentColor = oklab2rgb([
0.6,
labAverageColor[1],
labAverageColor[2],
]);
}
return (
{!!description && (
{description}
)}
Shared by{' '}
{sharers.map((s) => {
const { avatarStatic, displayName } = s;
return (
{
e.preventDefault();
e.stopPropagation();
// Reset and filter to author
const { id } = s;
setSelectedAuthor(id);
setSelectedFilterCategory('all');
}}
>
);
})}
);
})}
{posts.length >= 5 &&
(postsBarType === '3d' ? (
{postsBins}
) : (
{postsBar}
))}
{posts.length >= 2 && (
{
setSelectedFilterCategory('all');
}}
/>
All {posts.length}
{Object.entries(FILTER_KEYS).map(
([key, label]) =>
!!filterCounts[key] && (
{
setSelectedFilterCategory(key);
if (key === 'boosts') {
setSortBy('reblogsCount');
setSortOrder('desc');
setGroupBy(null);
}
// setSelectedAuthor(null);
}}
/>
{_(label)}{' '}
{filterCounts[key]}
),
)}
)}
{posts.length >= 2 && !!authorCounts && (
{authorCountsList.map((author) => (
{
setSelectedAuthor(author);
// setGroupBy(null);
}}
onClick={() => {
if (selectedAuthor === author) {
setSelectedAuthor(null);
}
}}
/>
{' '}
{authorCounts[author]}
{authors[author].username}
))}
{authorCountsList.length > 5 && (
)}
)}
{posts.length >= 2 && (
Sort
{' '}
{FILTER_SORTS.map((key) => (
{
if (sortBy === key) {
e.preventDefault();
e.stopPropagation();
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
}
}}
>
{
setSortBy(key);
const order = /(replies|favourites|reblogs)/.test(
key,
)
? 'desc'
: 'asc';
setSortOrder(order);
}}
/>
{
{
createdAt: t`Date`,
repliesCount: t`Replies`,
favouritesCount: t`Likes`,
reblogsCount: t`Boosts`,
density: t`Density`,
}[key]
}
{sortBy === key && (sortOrder === 'asc' ? ' ↑' : ' ↓')}
))}
{/*
{['asc', 'desc'].map((key) => (
{
setSortOrder(key);
}}
/>
{key === 'asc' ? '↑' : '↓'}
))}
*/}
Group
{' '}
{FILTER_GROUPS.map((key) => (
{
setGroupBy(key);
}}
disabled={key === 'account' && selectedAuthor}
/>
{{
account: t`Authors`,
}[key] || t`None`}
))}
{
selectedAuthor && authorCountsList.length > 1 ? (
{
setSelectedAuthor(null);
}}
style={{
whiteSpace: 'nowrap',
}}
>
Show all authors
) : null
// {}}
// >
// Group by authors
//
}
)}
{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 (
{showSeparator && }
);
})}
>
)}
{showHelp && (
setShowHelp(false)}>
setShowHelp(false)}
>
Top links
Links shared by followings, sorted by shared counts, boosts
and likes.
Sort: Density
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.
Group: Authors
Posts are grouped by authors, sorted by posts count per
author.
Keyboard shortcuts
{/*
j : Next post
k : Previous post
l : Next author
h : Previous author
Enter : Open post details
. : Scroll to top
*/}
Next post
j
Previous post
k
Next author
l
Previous author
h
Open post details
Enter
Scroll to top
.
)}
);
}
const PostLine = memo(
function ({ post }) {
const {
id,
account,
group,
reblog,
inReplyToId,
inReplyToAccountId,
_followedTags: isFollowedTags,
_filtered: filterInfo,
visibility,
__BOOSTERS,
} = post;
const isReplyTo = inReplyToId && inReplyToAccountId !== account.id;
const isFiltered = !!filterInfo;
const debugHover = (e) => {
if (e.shiftKey) {
console.log({
...post,
});
}
};
return (
{reblog ? (
{__BOOSTERS?.size > 0
? [...__BOOSTERS].map((b) => (
))
: ''}{' '}
{' '}
{/* */}
) : (
)}
{' '}
);
},
(oldProps, newProps) => {
return oldProps?.post?.id === newProps?.post?.id;
},
);
const IntersectionPostLineItem = ({ root, to, ...props }) => {
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 ? (
) : (
);
};
// 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;
}
const MEDIA_SIZE = 48;
function PostPeek({ post, filterInfo }) {
const { t } = useLingui();
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);
const postText = content ? statusPeek(post) : '';
const showPostContent = !spoilerText || readingExpandSpoilers;
return (
{isThread && !showPostContent && (
<>
Thread {' '}
>
)}
{!!filterInfo ? (
{/* Filtered{filterInfo?.titlesStr ? `: ${filterInfo.titlesStr}` : ''} */}
{filterInfo?.titlesStr
? t`Filtered: ${filterInfo.titlesStr}`
: t`Filtered`}
) : (
<>
{!!spoilerText && (
{' '}
{spoilerText}
)}
{showPostContent && (
{isThread && (
<>
Thread
{' '}
>
)}
{!!content && (
)}
{!!poll?.options?.length &&
poll.options.map((o) => (
{poll.multiple ? '▪️' : '•'} {o.title}
))}
{!content &&
mediaAttachments?.length === 1 &&
mediaAttachments[0].description && (
<>
ALT {' '}
{mediaAttachments[0].description}
>
)}
)}
>
)}
{!filterInfo && (
{!!poll && (
Poll
)}
{!!mediaAttachments?.length
? mediaAttachments.map((m) => {
const mediaURL = m.previewUrl || m.url;
const remoteMediaURL = m.previewRemoteUrl || m.remoteUrl;
const width = m.meta?.original
? m.meta.original.width
: m.meta?.small?.width || m.meta?.original?.width;
const height = m.meta?.original
? m.meta.original.height
: m.meta?.small?.height || m.meta?.original?.height;
return (
{{
image:
(mediaURL || remoteMediaURL) && showMedia ? (
{
const { src } = e.target;
if (src === mediaURL) {
e.target.src = remoteMediaURL;
}
}}
style={{
'--anim-duration': `${Math.min(
Math.max(Math.max(width, height) / 100, 5),
120,
)}s`,
}}
/>
) : (
🖼
),
gifv:
(mediaURL || remoteMediaURL) && showMedia ? (
{
const { src } = e.target;
if (src === mediaURL) {
e.target.src = remoteMediaURL;
}
}}
/>
) : (
🎞️
),
video:
(mediaURL || remoteMediaURL) && showMedia ? (
{
const { src } = e.target;
if (src === mediaURL) {
e.target.src = remoteMediaURL;
}
}}
/>
) : (
📹
),
audio: 🎵 ,
}[m.type] || null}
);
})
: !!card &&
card.image &&
showMedia && (
{card.image ? (
) : (
🔗
)}
)}
)}
);
}
function PostStats({ post }) {
const { t } = useLingui();
const { reblogsCount, repliesCount, favouritesCount } = post;
return (
{repliesCount > 0 && (
{' '}
{shortenNumber(repliesCount)}
)}
{favouritesCount > 0 && (
{' '}
{shortenNumber(favouritesCount)}
)}
{reblogsCount > 0 && (
{' '}
{shortenNumber(reblogsCount)}
)}
);
}
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;
}
export default Catchup;