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 { useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks'; 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 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 getHTMLText from '../utils/getHTMLText'; 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 store from '../utils/store'; import { getCurrentAccountNS } from '../utils/store-utils'; import { assignFollowedTags } from '../utils/timeline-utils'; import useTitle from '../utils/useTitle'; const FILTER_CONTEXT = 'home'; 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 store.session.get('currentAccount'); }, []); 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(); async function handleCatchupClick({ 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 ns = getCurrentAccountNS(); 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); // setUIState('error'); } // setPosts(results); // setUIState('results'); } useEffect(() => { if (id) { (async () => { const catchup = await db.catchup.get(id); if (catchup) { 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 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 [sortBy, setSortBy] = useState('createdAt'); const [sortOrder, setSortOrder] = useState('asc'); const [groupBy, setGroupBy] = useState(null); const [filteredPosts, authors, authorCounts] = useMemo(() => { let authors = []; const authorCounts = {}; let filteredPosts = posts.filter((post) => { return ( selectedFilterCategory === 'All' || post.__FILTER === { Filtered: 'filtered', Groups: 'group', Boosts: 'boost', Replies: 'reply', 'Followed tags': 'followedTags', Original: 'original', }[selectedFilterCategory] ); }); filteredPosts.forEach((post) => { if (!authors.find((a) => a.id === post.account.id)) { authors.push(post.account); } authorCounts[post.account.id] = (authorCounts[post.account.id] || 0) + 1; }); if (selectedAuthor && authorCounts[selectedAuthor]) { filteredPosts = filteredPosts.filter( (post) => post.account.id === selectedAuthor, ); } const authorsHash = {}; for (const author of authors) { authorsHash[author.id] = author; } return [filteredPosts, authorsHash, authorCounts]; }, [selectedFilterCategory, selectedAuthor, posts]); 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.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); useEffect(() => { if (authorsListParent.current && authorCountsList.length < 30) { autoAnimate(authorsListParent.current, { duration: 200, }); } }, [selectedFilterCategory, authorCountsList, authorsListParent]); const postsBar = useMemo(() => { return posts.map((post) => { // If part of filteredPosts const isFiltered = filteredPosts.find((p) => p.id === post.id); return ( <span key={post.id} class={`post-dot ${isFiltered ? 'post-dot-highlight' : ''}`} /> ); }); }, [posts, filteredPosts]); 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 filterCategoryText = { Filtered: 'filtered posts', Groups: 'group posts', Boosts: 'boosts', Replies: 'replies', 'Followed tags': 'followed-tag posts', Original: 'original posts', }; const authorUsername = selectedAuthor && authors[selectedAuthor] ? authors[selectedAuthor].username : ''; const sortOrderIndex = sortOrder === 'asc' ? 0 : 1; const sortByText = { // 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'], }; const groupByText = { account: 'authors', }; let toast = showToast({ duration: 5_000, // 5 seconds text: `Showing ${ filterCategoryText[selectedFilterCategory] || 'all posts' }${authorUsername ? ` by @${authorUsername}` : ''}, ${ sortByText[sortBy][sortOrderIndex] } first${ !!groupBy ? `, grouped by ${groupBy === 'account' ? groupByText[groupBy] : ''}` : '' }`, }); return () => { toast?.hideToast?.(); }; }, [ uiState, selectedFilterCategory, selectedAuthor, sortBy, sortOrder, groupBy, authors, ]); const prevSelectedAuthorMissing = useRef(false); useEffect(() => { console.log({ prevSelectedAuthorMissing, selectedAuthor, authors, }); let timer; if (selectedAuthor) { if (authors[selectedAuthor]) { if (prevSelectedAuthorMissing.current) { timer = setTimeout(() => { authorsListParent.current .querySelector(`[data-author="${selectedAuthor}"]`) ?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center', }); }, 500); prevSelectedAuthorMissing.current = false; } } else { prevSelectedAuthorMissing.current = true; } } return () => { clearTimeout(timer); }; }, [selectedAuthor, authors]); return ( <div ref={scrollableRef} 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 /> <Link to="/" class="button plain home-button"> <Icon icon="home" size="l" /> </Link> </div> <h1> {uiState !== 'start' && ( <> Catch-up <sup>beta</sup> </> )} </h1> <div class="header-side"> {uiState !== 'start' && uiState !== 'loading' && ( <button type="button" class="plain" onClick={() => { setSearchParams({}); }} > Start over </button> )} </div> </div> </header> <main> {uiState === 'start' && ( <div class="catchup-start"> <h1> Catch-up <sup>beta</sup> </h1> <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> <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} min={ranges[0].value} max={ranges[ranges.length - 1].value} step="1" list="catchup-ranges" onChange={(e) => setRange(+e.target.value)} />{' '} <span style={{ width: '8em', }} > {ranges[range - 1].label} <br /> <small class="insignificant"> {range == ranges[ranges.length - 1].value ? 'until the max' : niceDateTime( new Date(Date.now() - range * 60 * 60 * 1000), )} </small> </span> <datalist id="catchup-ranges"> {ranges.map(({ label, value }) => ( <option value={value} label={label} /> ))} </datalist>{' '} <button type="button" onClick={() => { if (range < ranges[ranges.length - 1].value) { const duration = range * 60 * 60 * 1000; handleCatchupClick({ duration }); } else { handleCatchupClick(); } }} > Catch up </button> </div> {lastCatchupRange && range > lastCatchupRange && ( <p class="catchup-info"> <Icon icon="info" /> Overlaps with your last catch-up </p> )} <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}`}> <Icon icon="history" />{' '} <span> {formatRange( new Date(pc.startAt), new Date(pc.endAt), )}{' '} <small class="ib insignificant"> {pc.count} posts </small> </span> </Link>{' '} <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> </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"> {formatRange( new Date(posts[posts.length - 1].createdAt), new Date(posts[0].createdAt), )} </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 = new URL(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 ( <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 && <>· </>} {!!publishedAt && ( <> <RelativeTime datetime={publishedAt} format="micro" /> </> )} </div> {!!title && ( <h1 class="title" lang={language} dir="auto"> {title} </h1> )} </header> {!!description && ( <p class="description" lang={language} dir="auto" > {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> {posts.length >= 5 && ( <div class="catchup-posts-viz-bar">{postsBar}</div> )} {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> {[ 'Original', 'Replies', 'Boosts', 'Followed tags', 'Groups', 'Filtered', ].map( (label) => !!filterCounts[label] && ( <label class="filter-cat" key={label}> <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" data-author={author} 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" alt={`${authors[author].displayName} (@${authors[author].username})`} />{' '} <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"> {[ 'createdAt', 'repliesCount', 'favouritesCount', 'reblogsCount', 'density', // 'account', ].map((key) => ( <label class="filter-sort" key={key}> <input type="radio" name="filter-sort-cat" checked={sortBy === key} onChange={() => { setSortBy(key); const order = /(replies|favourites|reblogs|density)/.test(key) ? 'desc' : 'asc'; setSortOrder(order); }} // disabled={key === 'account' && selectedAuthor} /> { { createdAt: 'Date', repliesCount: 'Replies', favouritesCount: 'Likes', reblogsCount: 'Boosts', density: 'Density', }[key] } </label> ))} </fieldset> <fieldset class="radio-field-group"> {['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> ))} </fieldset> <span class="filter-label">Group</span>{' '} <fieldset class="radio-field-group"> {[null, 'account'].map((key) => ( <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="plain small" onClick={() => { setSelectedAuthor(null); }} style={{ whiteSpace: 'nowrap', }} > Show all authors </button> ) : null // <button // type="button" // class="plain4 small" // onClick={() => {}} // > // Group by authors // </button> } </div> )} <ul class="catchup-list"> {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" />} <li> <Link to={`/${instance}/s/${id}`}> <IntersectionPostLine post={post} root={scrollableRef.current} /> </Link> </li> </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> </div> ); } const PostLine = memo( function ({ post }) { const { id, account, group, reblog, inReplyToId, inReplyToAccountId, _followedTags: isFollowedTags, _filtered: filterInfo, visibility, } = 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} />{' '} <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; }, ); const IntersectionPostLine = ({ root, ...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 ? ( <PostLine {...props} /> ) : ( <div ref={ref} style={{ height: '4em' }} /> ); }; // 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 { spoilerText, sensitive, content, emojis, poll, mediaAttachments, card, inReplyToId, inReplyToAccountId, account, _thread, } = post; const isThread = (inReplyToId && inReplyToAccountId === account.id) || !!_thread; const showMedia = !spoilerText && !sensitive; const postText = content ? getHTMLText(content) : ''; return ( <div class="post-peek" title={!spoilerText ? postText : ''}> <span class="post-peek-content"> {!!filterInfo ? ( <> {isThread && ( <> <span class="post-peek-tag post-peek-thread">Thread</span>{' '} </> )} <span class="post-peek-filtered"> Filtered{filterInfo?.titlesStr ? `: ${filterInfo.titlesStr}` : ''} </span> </> ) : !!spoilerText ? ( <> {isThread && ( <> <span class="post-peek-tag post-peek-thread">Thread</span>{' '} </> )} <span class="post-peek-spoiler"> <Icon icon="eye-close" /> {spoilerText} </span> </> ) : ( <div class="post-peek-html"> {isThread && ( <> <span class="post-peek-tag post-peek-thread">Thread</span>{' '} </> )} {content ? ( <div dangerouslySetInnerHTML={{ __html: emojifyText(content, emojis), }} /> ) : mediaAttachments?.length === 1 && mediaAttachments[0].description ? ( <> <span class="post-peek-tag post-peek-alt">ALT</span>{' '} <div>{mediaAttachments[0].description}</div> </> ) : null} </div> )} </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 ? 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> ); }) : !!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 && ( <> <Icon icon="comment2" size="s" /> {shortenNumber(repliesCount)} </> )} {favouritesCount > 0 && ( <> <Icon icon="heart" size="s" /> {shortenNumber(favouritesCount)} </> )} {reblogsCount > 0 && ( <> <Icon icon="rocket" size="s" /> {shortenNumber(reblogsCount)} </> )} </span> ); } const { locale } = new Intl.DateTimeFormat().resolvedOptions(); const dtf = new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric', }); function formatRange(startDate, endDate) { return dtf.formatRange(startDate, endDate); } export default Catchup;