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