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 && <>&middot; </>}
+                                  {!!publishedAt && (
+                                    <>
+                                      <RelativeTime
+                                        datetime={publishedAt}
+                                        format="micro"
+                                      />
+                                    </>
+                                  )}
+                                </div>
+                                {!!title && (
+                                  <h1 class="title" lang={language} dir="auto">
+                                    {title}
+                                  </h1>
+                                )}
+                              </header>
+                              {!!description && (
+                                <p
+                                  class="description"
+                                  lang={language}
+                                  dir="auto"
+                                >
+                                  {description}
+                                </p>
+                              )}
+                              <hr />
+                              <p
+                                style={{
+                                  whiteSpace: 'nowrap',
+                                }}
+                              >
+                                Shared by{' '}
+                                {sharers.map((s) => {
+                                  const { avatarStatic, displayName } = s;
+                                  return (
+                                    <Avatar
+                                      url={avatarStatic}
+                                      size="s"
+                                      alt={displayName}
+                                    />
+                                  );
+                                })}
+                              </p>
+                            </div>
+                          </article>
+                        </a>
+                      );
+                    })}
+                  </div>
+                </div>
+              </div>
+              {posts.length >= 5 && (
+                <div class="catchup-posts-viz-bar">{postsBar}</div>
+              )}
+              {posts.length >= 2 && (
+                <div class="catchup-filters">
+                  <label class="filter-cat">
+                    <input
+                      type="radio"
+                      name="filter-cat"
+                      checked={selectedFilterCategory.toLowerCase() === 'all'}
+                      onChange={() => {
+                        setSelectedFilterCategory('All');
+                      }}
+                    />
+                    All <span class="count">{posts.length}</span>
+                  </label>
+                  {[
+                    'Original',
+                    'Replies',
+                    'Boosts',
+                    'Followed tags',
+                    'Groups',
+                    'Filtered',
+                  ].map(
+                    (label) =>
+                      !!filterCounts[label] && (
+                        <label class="filter-cat" key={label}>
+                          <input
+                            type="radio"
+                            name="filter-cat"
+                            checked={
+                              selectedFilterCategory.toLowerCase() ===
+                              label.toLowerCase()
+                            }
+                            onChange={() => {
+                              setSelectedFilterCategory(label);
+                              // setSelectedAuthor(null);
+                            }}
+                          />
+                          {label}{' '}
+                          <span class="count">{filterCounts[label]}</span>
+                        </label>
+                      ),
+                  )}
+                </div>
+              )}
+              {posts.length >= 2 && !!authorCounts && (
+                <div
+                  class="catchup-filters authors-filters"
+                  ref={authorsListParent}
+                >
+                  {authorCountsList.map((author) => (
+                    <label
+                      class="filter-author"
+                      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'),
 };