From 201ca6ce4abf04d57f07f300d9e30d9cf8f59d67 Mon Sep 17 00:00:00 2001 From: Lim Chee Aun <cheeaun@gmail.com> Date: Mon, 26 Feb 2024 14:02:58 +0800 Subject: [PATCH] Catch-up (beta) --- src/app.css | 4 + src/app.jsx | 4 +- src/cloak-mode.css | 8 +- .../trending.css => components/links-bar.css} | 61 +- src/components/nav-menu.jsx | 4 + src/pages/catchup.css | 832 ++++++++++ src/pages/catchup.jsx | 1371 +++++++++++++++++ src/pages/trending.jsx | 2 +- src/utils/db.js | 28 +- 9 files changed, 2267 insertions(+), 47 deletions(-) rename src/{pages/trending.css => components/links-bar.css} (78%) create mode 100644 src/pages/catchup.css create mode 100644 src/pages/catchup.jsx diff --git a/src/app.css b/src/app.css index 6aa65765..2dbdc43c 100644 --- a/src/app.css +++ b/src/app.css @@ -103,6 +103,10 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { max-width: 100%; background-color: var(--bg-color); overflow-anchor: auto; + + &.wide { + width: 60em; + } } .deck.contained { overflow: auto; diff --git a/src/app.jsx b/src/app.jsx index b9a55979..aa9d1ee5 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -24,6 +24,7 @@ import Shortcuts from './components/shortcuts'; import NotFound from './pages/404'; import AccountStatuses from './pages/account-statuses'; import Bookmarks from './pages/bookmarks'; +import Catchup from './pages/catchup'; import Favourites from './pages/favourites'; import FollowedHashtags from './pages/followed-hashtags'; import Following from './pages/following'; @@ -394,7 +395,7 @@ function PrimaryRoutes({ isLoggedIn, loading }) { const location = useLocation(); const nonRootLocation = useMemo(() => { const { pathname } = location; - return !/^\/(login|welcome)/.test(pathname); + return !/^\/(login|welcome)/i.test(pathname); }, [location]); return ( @@ -457,6 +458,7 @@ function SecondaryRoutes({ isLoggedIn }) { <Route path=":id" element={<List />} /> </Route> <Route path="/ft" element={<FollowedHashtags />} /> + <Route path="/catchup" element={<Catchup />} /> </> )} <Route path="/:instance?/t/:hashtag" element={<Hashtag />} /> diff --git a/src/cloak-mode.css b/src/cloak-mode.css index c9e03051..cf5bf757 100644 --- a/src/cloak-mode.css +++ b/src/cloak-mode.css @@ -12,7 +12,8 @@ body.cloak, .account-container :is(header, main > *:not(.actions)), .account-container :is(header, main > *:not(.actions)) *, .header-double-lines, - .account-block { + .account-block, + .post-peek-html * { text-decoration-thickness: 1.1em; text-decoration-line: line-through; /* text-rendering: optimizeSpeed; */ @@ -26,9 +27,10 @@ body.cloak, .status :is(img, video, audio), .media-post .media, - .avatar, + .avatar *, .emoji, - .header-banner { + .header-banner, + .post-peek-media { filter: contrast(0) !important; background-color: #000 !important; } diff --git a/src/pages/trending.css b/src/components/links-bar.css similarity index 78% rename from src/pages/trending.css rename to src/components/links-bar.css index 31b0ed8c..f9668c89 100644 --- a/src/pages/trending.css +++ b/src/components/links-bar.css @@ -15,7 +15,7 @@ text-shadow: 0 1px var(--bg-blur-color); transition: opacity 0.3s ease-out; - &:not(#columns &) { + #trending-page &:not(#columns &) { @media (min-width: 40em) { width: 95vw; max-width: calc(320px * 3.3); @@ -96,6 +96,7 @@ } article { + width: 100%; display: flex; flex-direction: column; justify-content: flex-end; @@ -113,34 +114,34 @@ margin: 0 0 -16px; padding: 0; position: relative; - } - img { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: cover; - vertical-align: top; - mask-image: linear-gradient( - to bottom, - hsl(0, 0%, 0%) 0%, - hsla(0, 0%, 0%, 0.987) 14%, - hsla(0, 0%, 0%, 0.951) 26.2%, - hsla(0, 0%, 0%, 0.896) 36.8%, - hsla(0, 0%, 0%, 0.825) 45.9%, - hsla(0, 0%, 0%, 0.741) 53.7%, - hsla(0, 0%, 0%, 0.648) 60.4%, - hsla(0, 0%, 0%, 0.55) 66.2%, - hsla(0, 0%, 0%, 0.45) 71.2%, - hsla(0, 0%, 0%, 0.352) 75.6%, - hsla(0, 0%, 0%, 0.259) 79.6%, - hsla(0, 0%, 0%, 0.175) 83.4%, - hsla(0, 0%, 0%, 0.104) 87.2%, - hsla(0, 0%, 0%, 0.049) 91.1%, - hsla(0, 0%, 0%, 0.013) 95.3%, - hsla(0, 0%, 0%, 0) 100% - ); + img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + vertical-align: top; + mask-image: linear-gradient( + to bottom, + hsl(0, 0%, 0%) 0%, + hsla(0, 0%, 0%, 0.987) 14%, + hsla(0, 0%, 0%, 0.951) 26.2%, + hsla(0, 0%, 0%, 0.896) 36.8%, + hsla(0, 0%, 0%, 0.825) 45.9%, + hsla(0, 0%, 0%, 0.741) 53.7%, + hsla(0, 0%, 0%, 0.648) 60.4%, + hsla(0, 0%, 0%, 0.55) 66.2%, + hsla(0, 0%, 0%, 0.45) 71.2%, + hsla(0, 0%, 0%, 0.352) 75.6%, + hsla(0, 0%, 0%, 0.259) 79.6%, + hsla(0, 0%, 0%, 0.175) 83.4%, + hsla(0, 0%, 0%, 0.104) 87.2%, + hsla(0, 0%, 0%, 0.049) 91.1%, + hsla(0, 0%, 0%, 0.013) 95.3%, + hsla(0, 0%, 0%, 0) 100% + ); + } } } @@ -187,5 +188,9 @@ overflow: hidden; font-size: 90%; } + + hr { + margin: 4px 0; + } } } diff --git a/src/components/nav-menu.jsx b/src/components/nav-menu.jsx index 053cb010..ff11a6bb 100644 --- a/src/components/nav-menu.jsx +++ b/src/components/nav-menu.jsx @@ -176,6 +176,10 @@ function NavMenu(props) { <Icon icon="following" size="l" /> <span>Following</span> </MenuLink> )} + <MenuLink to="/catchup"> + <Icon icon="history" /> + <span>Catch-up</span> + </MenuLink> <MenuLink to="/mentions"> <Icon icon="at" size="l" /> <span>Mentions</span> </MenuLink> diff --git a/src/pages/catchup.css b/src/pages/catchup.css new file mode 100644 index 00000000..30ce0096 --- /dev/null +++ b/src/pages/catchup.css @@ -0,0 +1,832 @@ +#catchup-page { + transform: none; + padding-bottom: 0 !important; + + .deck { + background-color: var(--bg-faded-color); + } + + /* Hide the shortcuts nav + adjustments */ + ~ :is(#shortcuts, #compose-button) { + display: none; + } + .timeline-deck { + margin-top: 0 !important; + } + header { + /* --margin-top: 8px !important; */ + position: static; + } + + h1 sup { + font-size: 12px; + text-transform: uppercase; + font-weight: 500; + color: var(--text-insignificant-color); + } + + .catchup-start { + padding: 16px; + padding-top: 15vh; + text-align: center; + max-width: 40em; + margin-inline: auto; + + .catchup-info { + animation: appear 0.3s ease-out; + display: flex; + gap: 0.25em; + align-items: center; + justify-content: center; + } + } + + .catchup-prev { + margin: 2em auto; + padding: 1em; + width: fit-content; + color: var(--text-insignificant-color); + border-top: 1px solid var(--bg-color); + + ul, + ul li { + margin: 0; + padding: 0; + list-style: none; + } + + ul li { + display: flex; + margin-bottom: 8px; + align-items: center; + gap: 8px; + text-align: left; + justify-content: space-between; + + a { + display: flex; + gap: 0.25em; + align-items: center; + } + } + } +} + +.catchup-form { + display: inline-flex; + align-items: center; + gap: 16px; + padding: 16px 16px; + background-color: var(--link-bg-color); + border-radius: 32px; + flex-wrap: wrap; + + * { + flex-grow: 1; + } + + input[type='range'] { + accent-color: var(--link-color); + direction: rtl; + } +} + +.catchup-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + gap: 8px; + + aside { + display: flex; + align-items: center; + gap: 8px; + font-size: 90%; + + button[hidden] { + display: inline; + opacity: 0; + pointer-events: none; + } + } +} + +.catchup-posts-viz-bar { + margin: 0 16px; + border-radius: 3px; + border: 1px solid var(--bg-color); + display: flex; + gap: 1px; + pointer-events: none; + justify-content: stretch; + height: 3px; + + &:has(.post-dot:nth-child(320)) { + gap: 0; + } + + .post-dot { + display: block; + width: 100%; + height: 3px; + border-radius: 3px; + opacity: 0.5; + background-color: var(--link-color); + transition: 0.25s ease-in-out; + transition-property: opacity, transform; + + &.post-dot-highlight { + opacity: 1; + } + } + + &:has(.post-dot:not(.post-dot-highlight)) .post-dot-highlight { + /* transform: scaleY(2); */ + transform: scale3d(1, 2, 1); + } +} + +.catchup-filters { + padding: 8px 16px; + display: flex; + /* flex-wrap: wrap; */ + gap: 8px; + align-items: center; + overflow-x: auto; + overflow-y: hidden; + max-width: 100%; + mask-image: linear-gradient( + to right, + transparent, + black 16px calc(100% - 16px), + transparent + ); + padding-inline-end: 25%; + + .filter-label { + text-transform: uppercase; + font-size: 12px; + font-weight: 500; + color: var(--text-insignificant-color); + } + + label { + font-size: 80%; + white-space: nowrap; + cursor: pointer; + user-select: none; + color: var(--text-insignificant-color); + position: relative; + + select { + /* appearance: none; + background-color: var(--bg-faded-color); + color: var(--link-color); + border: 0; + border-radius: 12px; */ + padding: 4px; + margin: 0; + } + + input[type] { + left: 0; + position: absolute; + opacity: 0; + pointer-events: none; + } + + &.filter-cat { + padding: 8px 12px; + background-color: var(--bg-blur-color); + border-radius: 24px; + display: inline-block; + display: flex; + align-items: center; + gap: 4px; + + &:is(:hover, :focus) { + background-color: var(--link-bg-color); + } + + &:has(:checked) { + color: var(--link-color); + background-color: var(--link-bg-color); + box-shadow: inset 0 0 0 2px var(--link-color); + } + + .count { + font-size: 70%; + margin-left: 4px; + background-color: var(--bg-color); + padding: 4px 6px; + border-radius: 12px; + display: inline-block; + } + } + + &.filter-author { + flex-direction: column; + padding: 0; + background-color: transparent; + position: relative; + width: 50px; + /* transition: filter 0.15s ease; */ + + .avatar { + margin-bottom: 2px; + /* transition: box-shadow 0.15s ease; */ + } + + &:is(:hover, :focus) { + filter: none; + + .avatar { + box-shadow: 0 0 0 0.5px var(--bg-color), + 0 0 0 3px var(--link-faded-color) !important; + + img { + filter: none; + } + } + + .count { + color: var(--text-color); + } + } + + .avatar { + &.has-alpha { + border-radius: 2px; + } + + img { + transition: filter 0.15s ease; + } + } + + &:has(:checked) { + box-shadow: none; + filter: none; + + .avatar { + box-shadow: 0 0 0 1px var(--bg-color), 0 0 0 3px var(--link-color); + } + + .username { + color: var(--link-color); + font-weight: 500; + } + + .count { + color: var(--link-color); + border-color: var(--link-color); + box-shadow: 0 0 0 1px var(--link-color); + } + } + + .count { + position: absolute; + right: -4px; + top: -4px; + font-size: 10px; + background-color: var(--bg-color); + border-radius: 12px; + display: inline-block; + border: 1px solid var(--link-faded-color); + padding: 0 2px; + min-width: 16px; + min-height: 16px; + text-align: center; + line-height: 14px; + } + + .username { + display: block; + width: 100%; + overflow: hidden; + text-align: center; + mask-image: linear-gradient( + to right, + black calc(100% - 0.5em), + transparent 100% + ); + } + } + } + + &:has(.filter-author :checked) + .filter-author:not(:has(:checked)):not(:is(:hover, :focus)) { + .avatar img { + filter: grayscale(1) contrast(2) opacity(0.5); + } + } + + .radio-field-group { + display: flex; + border: 0; + padding: 0; + margin: 0; + border-radius: 4px; + overflow: hidden; + gap: 1px; + + label { + padding: 4px 8px; + line-height: 2em; + min-width: 32px; + text-align: center; + background-color: var(--bg-blur-color); + margin: 0; + cursor: pointer; + + &:is(:hover, :focus) { + background-color: var(--link-bg-color); + } + + &:has(:checked) { + font-weight: 500; + color: var(--text-color); + background-color: var(--link-bg-color); + box-shadow: inset 0 -2px 0 var(--link-color); + } + + &:has(:disabled) { + opacity: 0.5; + cursor: not-allowed; + } + } + } +} + +.catchup-list { + margin: 0; + padding: 0; + list-style: none; + /* background-color: var(--bg-color); */ + border-top: var(--hairline-width) solid var(--bg-faded-color); + --corner-radius: 8px; + + @media (min-width: 40em) { + border-radius: var(--corner-radius); + /* border: var(--hairline-width) solid var(--outline-color); */ + + > li { + &:first-child > a { + border-top-left-radius: var(--corner-radius); + border-top-right-radius: var(--corner-radius); + } + + &:last-child > a { + border-bottom-left-radius: var(--corner-radius); + border-bottom-right-radius: var(--corner-radius); + } + } + } + + > li { + margin: 0 0 1px; + padding: 0; + list-style: none; + /* border-bottom: var(--hairline-width) solid var(--outline-color); */ + + &.separator { + height: 16px; + pointer-events: none; + + @media (min-width: 40em) { + height: 32px; + } + } + + @media (min-width: 40em) { + &.separator + li a { + border-top-left-radius: var(--corner-radius); + border-top-right-radius: var(--corner-radius); + } + + &:has(+ .separator) a { + border-bottom-left-radius: var(--corner-radius); + border-bottom-right-radius: var(--corner-radius); + } + } + + > a { + background-color: var(--bg-color); + text-decoration: none; + color: inherit; + display: block; + /* transition: background-color 0.3s ease; */ + + &:is(:hover, :focus) { + position: relative; + z-index: 1; + background-color: var(--bg-faded-color); + box-shadow: 0 8px 16px -8px var(--drop-shadow-color), + inset 0 1px var(--bg-color); + outline: 1px solid var(--outline-color); + text-shadow: 0 1px var(--bg-color); + } + + &:active { + filter: brightness(0.95); + box-shadow: none; + text-shadow: none; + } + + &:visited { + color: var(--outline-color); + + *, + .post-peek-html * a[href] { + color: var(--outline-color) !important; + + * { + color: var(--outline-color) !important; + } + } + } + } + } + + .post-line { + border-radius: inherit; + animation: appear-smooth 0.3s ease-in-out both; + --pad: 16px; + min-height: 44px; + padding: var(--pad); + column-gap: calc(0.5 * var(--pad)); + row-gap: 4px; + width: 100%; + /* display: flex; + flex-direction: column; */ + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + grid-template-areas: + 'author meta' + 'content content'; + /* align-items: center; */ + background-image: linear-gradient( + 160deg, + var(--post-bg-color), + transparent min(80px, 50%) + ); + /* background-image: linear-gradient( + 90deg, + var(--post-bg-color), + var(--post-bg-color) 8px, + transparent 8px + ); */ + + @media (min-width: 40em) { + /* flex-direction: row; + align-items: center; */ + grid-template-columns: auto 1fr auto; + grid-template-rows: 1fr; + grid-template-areas: 'author content meta'; + } + + &.reblog { + --post-bg-color: var(--reblog-faded-color); + } + &.group { + --post-bg-color: var(--group-faded-color); + } + &.reply-to { + --post-bg-color: var(--reply-to-faded-color); + } + &.followed-tags { + --post-bg-color: var(--hashtag-faded-color); + } + &.filtered { + filter: grayscale(1); + background-image: none; + + .post-author { + opacity: 0.5; + } + } + &.visibility-direct { + --yellow-stripes: repeating-linear-gradient( + -45deg, + var(--reply-to-faded-color), + var(--reply-to-faded-color) 10px, + var(--reply-to-faded-color) 10px, + transparent 10px, + transparent 20px + ); + background-image: var(--yellow-stripes); + } + + .post-reblog-avatar { + display: flex; + gap: 4px; + align-items: center; + flex-shrink: 0; + + .icon { + color: var(--reblog-color); + } + } + + .post-author { + grid-area: author; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + mask-image: linear-gradient( + to right, + black calc(100% - 1em), + transparent 100% + ); + + @media (min-width: 40em) { + --width: 25vw; + width: var(--width); + min-width: 9em; + max-width: 13em; + } + + b { + font-weight: normal; + /* font-weight: 500; */ + opacity: 0.7; + } + i { + opacity: 0.5; + } + } + } + + > li:nth-child(10) ~ li .post-line { + animation: none; + } + + .post-peek { + grid-area: content; + display: flex; + flex: 1; + column-gap: 8px; + row-gap: 4px; + align-self: stretch; + /* align-items: center; */ + /* margin-left: 24px; */ + flex-direction: row-reverse; + flex-wrap: wrap; + justify-content: flex-end; + + /* CLOAK - uncomment when taking screenshots */ + /* text-decoration-thickness: 1.1em; + text-decoration-line: line-through; + text-rendering: optimizeSpeed; + filter: opacity(0.5); + pointer-events: none; + img { + filter: contrast(0) !important; + background-color: #000 !important; + } */ + + .post-peek-content { + flex-shrink: 1; + flex-grow: 1; + flex-basis: 20em; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.3; + opacity: 0.9; + /* font-size: 0.9em; */ + text-wrap: balance; + + &:empty { + display: none; + } + + .post-peek-html { + pointer-events: none; + + * { + margin: 0; + padding: 0; + display: inline; + white-space: normal; + } + + pre, + code { + font-size: 0.9em; + color: var(--green-color); + } + + strong, + b { + font-weight: 500; + } + + br { + content: ' '; + } + + /* all block level elements */ + p, + div, + blockquote, + h1, + h2, + h3, + h4, + h5, + h6, + li, + pre, + br { + &:after { + content: ' '; + } + } + + br:after { + font-size: 0.75em; + content: ' ↵ '; + opacity: 0.35; + } + + .ellipsis:after { + content: '...'; + } + + .invisible { + display: none; + } + + /* Links are not clickable, so remove the underlines */ + a { + text-decoration: none; + text-decoration-color: transparent; + color: var(--link-text-color); + } + } + + .post-peek-spoiler { + line-height: 1.5; + border-radius: 1em; + padding-inline: 0.5em; + border: 1px dashed var(--button-bg-color); + + .icon { + vertical-align: middle; + color: var(--button-bg-color); + } + } + + .post-peek-filtered { + border-radius: 1em; + padding-inline: 0.5em; + border: 1px dashed var(--outline-hover-color); + + .icon { + vertical-align: middle; + color: var(--outline-hover-color); + } + } + } + + .post-peek-post-content { + flex-shrink: 0; + display: flex; + gap: 4px; + align-self: center; + transition: transform 0.05s ease-in-out; + + &:empty { + display: none; + } + + &:has(.post-peek-media):hover { + transform: scale(1.5); + } + + img { + border-radius: 2px; + outline: var(--hairline-width) solid var(--outline-color); + vertical-align: middle; + object-fit: cover; + box-shadow: 0 0 0 1px var(--outline-color); + object-fit: cover; + transition: transform 0.05s ease-in-out; + background-color: var(--bg-color); + + &:hover { + transform: scale(1.5); + position: relative; + z-index: 1; + animation: position-object 5s ease-in-out 5; + + /* @media (min-width: 40em) and (min-height: 600px) { + transform: scale(3); + } */ + } + } + + @media (max-width: 40em) { + &:has(.post-peek-media), + .post-peek-media:first-child img { + transform-origin: left center; + } + } + + .post-peek-faux-media { + width: 48px; + height: 48px; + border-radius: 4px; + background-color: var(--bg-faded-color); + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 0 1px var(--outline-color); + } + + .post-peek-card img { + outline: 3px double var(--link-faded-color); + } + } + + .post-peek-tag { + display: inline-block; + border-radius: 4px; + font-size: 12px; + color: var(--text-insignificant-color); + font-weight: 500; + text-transform: uppercase; + border: 1px solid var(--outline-color); + padding: 2px !important; + align-self: center; + background-color: var(--bg-faded-color); + line-height: 1; + + &.post-peek-poll { + display: inline-flex; + align-items: center; + gap: 2px; + flex-direction: column; + color: var(--text-color); + } + + &.post-peek-thread { + font-size: 10px; + /* padding: 2px 4px; */ + font-weight: 700; + background-color: var(--bg-color); + color: var(--reply-to-text-color) !important; + border-color: var(--reply-to-color); + background-image: repeating-linear-gradient( + -70deg, + transparent, + transparent 3px, + var(--reply-to-faded-color) 3px, + var(--reply-to-faded-color) 4px + ); + } + } + } + > li > a:is(:hover, :focus) .post-peek-content { + opacity: 1; + } + + .post-meta { + grid-area: meta; + font-size: 90%; + color: var(--text-insignificant-color); + white-space: nowrap; + display: flex; + align-items: center; + align-self: flex-start; + column-gap: 8px; + } + + .post-stats { + opacity: 0; + display: inline-flex; + gap: 2px; + align-items: center; + transform: translateX(4px); + /* transition: all 0.25s ease-out; */ + + &:empty { + display: none; + } + } + .post-line:hover .post-stats { + opacity: 1; + transform: translateX(0); + } + + + footer { + min-height: 15vh; + color: var(--text-insignificant-color); + padding-block: 15vh; + text-align: center; + } +} diff --git a/src/pages/catchup.jsx b/src/pages/catchup.jsx new file mode 100644 index 00000000..d05e9f31 --- /dev/null +++ b/src/pages/catchup.jsx @@ -0,0 +1,1371 @@ +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 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 niceDateTime from '../utils/nice-date-time'; +import shortenNumber from '../utils/shorten-number'; +import showToast from '../utils/show-toast'; +import states, { getStatus, saveStatus, statusKey } from '../utils/states'; +import store from '../utils/store'; +import { + getCurrentAccount, + getCurrentAccountNS, + getCurrentInstance, + getCurrentInstanceConfiguration, +} from '../utils/store-utils'; +import { + assignFollowedTags, + clearFollowedTagsState, + dedupeBoosts, +} from '../utils/timeline-utils'; +import useScrollFn from '../utils/useScrollFn'; +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 ( + 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 (a[sortBy] === b[sortBy]) { + return a.createdAt > b.createdAt ? 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); + const headerRef = useRef(null); + + useScrollFn( + { + scrollableRef, + }, + ({ scrollDirection, nearReachStart }) => { + if (headerRef.current) { + const hiddenUI = scrollDirection === 'end' && !nearReachStart; + headerRef.current.hidden = hiddenUI; + } + }, + [], + ); + + // 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]); + + return ( + <div + ref={scrollableRef} + id="catchup-page" + class="deck-container" + tabIndex="-1" + > + <div class="timeline-deck deck wide"> + <header + ref={headerRef} + 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> + <p>Let's catch up on the posts from your followings.</p> + <p> + <b>Show me all posts from…</b> + </p> + <div class="catchup-form"> + <input + ref={catchupRangeRef} + type="range" + value={range} + min={ranges[0].value} + max={ranges[ranges.length - 1].value} + step="1" + list="catchup-ranges" + onChange={(e) => setRange(+e.target.value)} + />{' '} + <span + style={{ + width: '8em', + }} + > + {ranges[range - 1].label} + <br /> + <small class="insignificant"> + {range == ranges[ranges.length - 1].value + ? 'until the max' + : niceDateTime( + new Date(Date.now() - range * 60 * 60 * 1000), + )} + </small> + </span> + <datalist id="catchup-ranges"> + {ranges.map(({ label, value }) => ( + <option value={value} label={label} /> + ))} + </datalist>{' '} + <button + type="button" + onClick={() => { + if (range < ranges[ranges.length - 1].value) { + const duration = range * 60 * 60 * 1000; + handleCatchupClick({ duration }); + } else { + handleCatchupClick(); + } + }} + > + Catch up + </button> + </div> + {lastCatchupRange && range > lastCatchupRange && ( + <p class="catchup-info"> + <Icon icon="info" /> Overlaps with your last catch-up + </p> + )} + <p class="insignificant"> + <small> + Note: your instance might only show a maximum of 800 posts in + the Home timeline regardless of the time range. Could be less + or more. + </small> + </p> + {!!prevCatchups?.length && ( + <div class="catchup-prev"> + <p>Previously…</p> + <ul> + {prevCatchups.map((pc) => ( + <li key={pc.id}> + <Link to={`/catchup?id=${pc.id}`}> + <Icon icon="history" />{' '} + <span> + {formatRange( + new Date(pc.startAt), + new Date(pc.endAt), + )}{' '} + <small class="ib insignificant"> + {pc.count} posts + </small> + </span> + </Link>{' '} + <button + type="button" + class="light danger small" + onClick={async () => { + const yes = confirm('Remove this catch-up?'); + if (yes) { + let t = showToast(`Removing Catch-up ${pc.id}`); + await db.catchup.del(pc.id); + t?.hideToast?.(); + showToast(`Catch-up ${pc.id} removed`); + reloadCatchups(); + } + }} + > + <Icon icon="x" /> + </button> + </li> + ))} + </ul> + {prevCatchups.length >= 3 && ( + <p> + <small> + Note: Only max 3 will be stored. The rest will be + automatically removed. + </small> + </p> + )} + </div> + )} + </div> + )} + {uiState === 'loading' && ( + <div class="ui-state catchup-start"> + <Loader abrupt /> + <p class="insignificant">Fetching posts…</p> + <p class="insignificant">This might take a while.</p> + </div> + )} + {uiState === 'results' && ( + <> + <div class="catchup-header"> + {posts.length > 0 && ( + <p> + <b class="ib"> + {formatRange( + new Date(posts[posts.length - 1].createdAt), + new Date(posts[0].createdAt), + )} + </b> + </p> + )} + <aside> + <button + hidden={ + selectedFilterCategory === 'All' && + !selectedAuthor && + sortBy === 'createdAt' && + sortOrder === 'asc' + } + type="button" + class="plain4 small" + onClick={() => { + setSelectedFilterCategory('All'); + setSelectedAuthor(null); + setSortBy('createdAt'); + setGroupBy(null); + setSortOrder('asc'); + }} + > + Reset filters + </button> + {links?.length > 0 && ( + <button + type="button" + class="plain small" + onClick={() => setShowTopLinks(!showTopLinks)} + > + Top links{' '} + <Icon + icon="chevron-down" + style={{ + transform: showTopLinks + ? 'rotate(180deg)' + : 'rotate(0deg)', + }} + /> + </button> + )} + </aside> + </div> + <div class="shazam-container no-animation" hidden={!showTopLinks}> + <div class="shazam-container-inner"> + <div class="catchup-top-links links-bar"> + {links.map((link) => { + const { card, shared, sharers, likes, boosts } = link; + const { + blurhash, + title, + description, + url, + image, + imageDescription, + language, + width, + height, + publishedAt, + } = card; + const domain = new URL(url).hostname + .replace(/^www\./, '') + .replace(/\/$/, ''); + let accentColor; + if (blurhash) { + const averageColor = getBlurHashAverageColor(blurhash); + const labAverageColor = rgb2oklab(averageColor); + accentColor = oklab2rgb([ + 0.6, + labAverageColor[1], + labAverageColor[2], + ]); + } + + return ( + <a + key={url} + href={url} + target="_blank" + rel="noopener noreferrer" + style={ + accentColor + ? { + '--accent-color': `rgb(${accentColor.join( + ',', + )})`, + '--accent-alpha-color': `rgba(${accentColor.join( + ',', + )}, 0.4)`, + } + : {} + } + > + <article> + <figure> + <img + src={image} + alt={imageDescription} + width={width} + height={height} + loading="lazy" + /> + </figure> + <div class="article-body"> + <header> + <div class="article-meta"> + <span class="domain">{domain}</span>{' '} + {!!publishedAt && <>· </>} + {!!publishedAt && ( + <> + <RelativeTime + datetime={publishedAt} + format="micro" + /> + </> + )} + </div> + {!!title && ( + <h1 class="title" lang={language} dir="auto"> + {title} + </h1> + )} + </header> + {!!description && ( + <p + class="description" + lang={language} + dir="auto" + > + {description} + </p> + )} + <hr /> + <p + style={{ + whiteSpace: 'nowrap', + }} + > + Shared by{' '} + {sharers.map((s) => { + const { avatarStatic, displayName } = s; + return ( + <Avatar + url={avatarStatic} + size="s" + alt={displayName} + /> + ); + })} + </p> + </div> + </article> + </a> + ); + })} + </div> + </div> + </div> + {posts.length >= 5 && ( + <div class="catchup-posts-viz-bar">{postsBar}</div> + )} + {posts.length >= 2 && ( + <div class="catchup-filters"> + <label class="filter-cat"> + <input + type="radio" + name="filter-cat" + checked={selectedFilterCategory.toLowerCase() === 'all'} + onChange={() => { + setSelectedFilterCategory('All'); + }} + /> + All <span class="count">{posts.length}</span> + </label> + {[ + 'Original', + 'Replies', + 'Boosts', + 'Followed tags', + 'Groups', + 'Filtered', + ].map( + (label) => + !!filterCounts[label] && ( + <label class="filter-cat" key={label}> + <input + type="radio" + name="filter-cat" + checked={ + selectedFilterCategory.toLowerCase() === + label.toLowerCase() + } + onChange={() => { + setSelectedFilterCategory(label); + // setSelectedAuthor(null); + }} + /> + {label}{' '} + <span class="count">{filterCounts[label]}</span> + </label> + ), + )} + </div> + )} + {posts.length >= 2 && !!authorCounts && ( + <div + class="catchup-filters authors-filters" + ref={authorsListParent} + > + {authorCountsList.map((author) => ( + <label + class="filter-author" + 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" + />{' '} + <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', + // '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)/.test( + key, + ) + ? 'desc' + : 'asc'; + setSortOrder(order); + }} + // disabled={key === 'account' && selectedAuthor} + /> + { + { + createdAt: 'Date', + repliesCount: 'Replies', + favouritesCount: 'Likes', + reblogsCount: 'Boosts', + // account: 'Authors', + }[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' }} /> + ); +}; + +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) => ( + <span key={m.id} class="post-peek-media"> + {{ + image: + (m.previewUrl || m.url) && showMedia ? ( + <img + src={m.previewUrl || m.url} + width={MEDIA_SIZE} + height={MEDIA_SIZE} + alt={m.description} + loading="lazy" + /> + ) : ( + <span class="post-peek-faux-media">🖼</span> + ), + gifv: + m.previewUrl && showMedia ? ( + <img + src={m.previewUrl} + width={MEDIA_SIZE} + height={MEDIA_SIZE} + alt={m.description} + loading="lazy" + /> + ) : ( + <span class="post-peek-faux-media">🎞️</span> + ), + video: + m.previewUrl && showMedia ? ( + <img + src={m.previewUrl} + width={MEDIA_SIZE} + height={MEDIA_SIZE} + alt={m.description} + loading="lazy" + /> + ) : ( + <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; diff --git a/src/pages/trending.jsx b/src/pages/trending.jsx index c1126aa1..5d293150 100644 --- a/src/pages/trending.jsx +++ b/src/pages/trending.jsx @@ -1,4 +1,4 @@ -import './trending.css'; +import '../components/links-bar.css'; import { MenuItem } from '@szhsin/react-menu'; import { getBlurHashAverageColor } from 'fast-blurhash'; diff --git a/src/utils/db.js b/src/utils/db.js index 5db67ffd..589fa016 100644 --- a/src/utils/db.js +++ b/src/utils/db.js @@ -9,20 +9,20 @@ import { set, } from 'idb-keyval'; -const draftsStore = createStore('drafts-db', 'drafts-store'); - -// Add additonal `draftsStore` parameter to all methods - -const drafts = { - set: (key, val) => set(key, val, draftsStore), - get: (key) => get(key, draftsStore), - getMany: (keys) => getMany(keys, draftsStore), - del: (key) => del(key, draftsStore), - delMany: (keys) => delMany(keys, draftsStore), - clear: () => clear(draftsStore), - keys: () => keys(draftsStore), -}; +function initDB(dbName, storeName) { + const store = createStore(dbName, storeName); + return { + set: (key, val) => set(key, val, store), + get: (key) => get(key, store), + getMany: (keys) => getMany(keys, store), + del: (key) => del(key, store), + delMany: (keys) => delMany(keys, store), + clear: () => clear(store), + keys: () => keys(store), + }; +} export default { - drafts, + drafts: initDB('drafts-db', 'drafts-store'), + catchup: initDB('catchup-db', 'catchup-store'), };