phanpy/src/pages/catchup.jsx
2024-03-02 10:01:22 +08:00

1510 lines
50 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
)}
</span>
</Link>{' '}
<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>
</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 && <>&middot; </>}
{!!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;