{title}
)}{description}
)}
Shared by{' '}
{sharers.map((s) => {
const { avatarStatic, displayName } = s;
return (
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'; 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 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 { assignFollowedTags } from '../utils/timeline-utils'; import useTitle from '../utils/useTitle'; const FILTER_CONTEXT = 'home'; 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 }, ]; const FILTER_LABELS = [ 'Original', 'Replies', 'Boosts', 'Followed tags', 'Groups', 'Filtered', ]; const FILTER_SORTS = [ 'createdAt', 'repliesCount', 'favouritesCount', 'reblogsCount', 'density', ]; const FILTER_GROUPS = [null, 'account']; 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'], }; 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(); }, []); const isSelf = (accountID) => accountID === currentAccount; 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 { 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 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' && 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(() => { const authorsHash = {}; const authorCountsMap = new Map(); let filteredPosts = posts.filter((post) => { const postFilterMatches = selectedFilterCategory === 'All' || post.__FILTER === FILTER_VALUES[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 (
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.
Let's catch up on the posts from your followings.
Show me all posts from…
) : 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…
Note: Only max 3 will be stored. The rest will be automatically removed.
)}Fetching posts…
This might take a while.
{dtf.formatRange( new Date(posts[0].createdAt), new Date(posts[posts.length - 1].createdAt), )}
)}