From 400bc6f696dc098916644d4cc804c01f68d15ed8 Mon Sep 17 00:00:00 2001
From: Lim Chee Aun <cheeaun@gmail.com>
Date: Sat, 17 Dec 2022 21:06:51 +0800
Subject: [PATCH] Truncate long posts on timeline, show "Read more"

10-line clamping for now
---
 package-lock.json         | 74 ++++++++++++++++++++++++++++++++++++++-
 package.json              |  3 +-
 src/components/status.css | 24 +++++++++++++
 src/components/status.jsx | 39 ++++++++++++++++++++-
 4 files changed, 137 insertions(+), 3 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index c3886135..e1bfe48e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
         "preact-router": "~4.1.0",
         "react-intersection-observer": "~9.4.1",
         "string-length": "~5.0.1",
+        "use-resize-observer": "~9.1.0",
         "valtio": "~1.7.6"
       },
       "devDependencies": {
@@ -27,7 +28,7 @@
         "autoprefixer": "~10.4.13",
         "postcss": "~8.4.20",
         "postcss-dark-theme-class": "~0.7.3",
-        "vite": "4.0.1"
+        "vite": "~4.0.1"
       }
     },
     "node_modules/@ampproject/remapping": {
@@ -848,6 +849,11 @@
         "@jridgewell/sourcemap-codec": "1.4.14"
       }
     },
+    "node_modules/@juggle/resize-observer": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
+      "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
+    },
     "node_modules/@preact/preset-vite": {
       "version": "2.5.0",
       "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.5.0.tgz",
@@ -1996,6 +2002,19 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/react-dom": {
+      "version": "18.2.0",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
+      "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
+      "peer": true,
+      "dependencies": {
+        "loose-envify": "^1.1.0",
+        "scheduler": "^0.23.0"
+      },
+      "peerDependencies": {
+        "react": "^18.2.0"
+      }
+    },
     "node_modules/react-intersection-observer": {
       "version": "9.4.1",
       "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.1.tgz",
@@ -2042,6 +2061,15 @@
         "fsevents": "~2.3.2"
       }
     },
+    "node_modules/scheduler": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
+      "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
+      "peer": true,
+      "dependencies": {
+        "loose-envify": "^1.1.0"
+      }
+    },
     "node_modules/semver": {
       "version": "6.3.0",
       "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
@@ -2205,6 +2233,18 @@
         "tslib": "^2.0.3"
       }
     },
+    "node_modules/use-resize-observer": {
+      "version": "9.1.0",
+      "resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz",
+      "integrity": "sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==",
+      "dependencies": {
+        "@juggle/resize-observer": "^3.3.1"
+      },
+      "peerDependencies": {
+        "react": "16.8.0 - 18",
+        "react-dom": "16.8.0 - 18"
+      }
+    },
     "node_modules/use-sync-external-store": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
@@ -2836,6 +2876,11 @@
         "@jridgewell/sourcemap-codec": "1.4.14"
       }
     },
+    "@juggle/resize-observer": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
+      "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
+    },
     "@preact/preset-vite": {
       "version": "2.5.0",
       "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.5.0.tgz",
@@ -3702,6 +3747,16 @@
         "loose-envify": "^1.1.0"
       }
     },
+    "react-dom": {
+      "version": "18.2.0",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
+      "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
+      "peer": true,
+      "requires": {
+        "loose-envify": "^1.1.0",
+        "scheduler": "^0.23.0"
+      }
+    },
     "react-intersection-observer": {
       "version": "9.4.1",
       "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.1.tgz",
@@ -3733,6 +3788,15 @@
         "fsevents": "~2.3.2"
       }
     },
+    "scheduler": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
+      "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
+      "peer": true,
+      "requires": {
+        "loose-envify": "^1.1.0"
+      }
+    },
     "semver": {
       "version": "6.3.0",
       "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
@@ -3847,6 +3911,14 @@
         "tslib": "^2.0.3"
       }
     },
+    "use-resize-observer": {
+      "version": "9.1.0",
+      "resolved": "https://registry.npmjs.org/use-resize-observer/-/use-resize-observer-9.1.0.tgz",
+      "integrity": "sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==",
+      "requires": {
+        "@juggle/resize-observer": "^3.3.1"
+      }
+    },
     "use-sync-external-store": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
diff --git a/package.json b/package.json
index 1dede8e4..c0b64ec2 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
     "preact-router": "~4.1.0",
     "react-intersection-observer": "~9.4.1",
     "string-length": "~5.0.1",
+    "use-resize-observer": "~9.1.0",
     "valtio": "~1.7.6"
   },
   "devDependencies": {
@@ -28,7 +29,7 @@
     "autoprefixer": "~10.4.13",
     "postcss": "~8.4.20",
     "postcss-dark-theme-class": "~0.7.3",
-    "vite": "4.0.1"
+    "vite": "~4.0.1"
   },
   "postcss": {
     "plugins": {
diff --git a/src/components/status.css b/src/components/status.css
index 731dfb4d..eda87067 100644
--- a/src/components/status.css
+++ b/src/components/status.css
@@ -174,6 +174,30 @@
 .status .content {
   margin-top: 8px;
 }
+.timeline-deck .status .content {
+  display: -webkit-box;
+  -webkit-line-clamp: 10;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+  position: relative;
+}
+.timeline-deck .status .content.truncated:after {
+  content: attr(data-read-more);
+  line-height: 1;
+  display: inline-block;
+  position: absolute;
+  inset-block-end: 0;
+  inset-inline-end: 0;
+  color: var(--link-color);
+  background-color: var(--link-faded-color);
+  backdrop-filter: blur(4px) brightness(2);
+  padding: 0.5em 0.5em 0.5em 2em;
+  border-radius: 0 1em 1em 0;
+  font-size: 12px;
+  font-weight: bold;
+  text-transform: uppercase;
+  mask-image: linear-gradient(to right, transparent, black 2em);
+}
 .status .content p {
   margin-block: 0.75em;
 }
diff --git a/src/components/status.jsx b/src/components/status.jsx
index 818dc0ba..3704e191 100644
--- a/src/components/status.jsx
+++ b/src/components/status.jsx
@@ -4,6 +4,7 @@ import { getBlurHashAverageColor } from 'fast-blurhash';
 import mem from 'mem';
 import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
 import { InView } from 'react-intersection-observer';
+import useResizeObserver from 'use-resize-observer';
 import { useSnapshot } from 'valtio';
 
 import Loader from '../components/loader';
@@ -619,6 +620,34 @@ function Status({
   const carouselRef = useRef(null);
   const currentYear = new Date().getFullYear();
 
+  const spoilerContentRef = useRef(null);
+  useResizeObserver({
+    ref: spoilerContentRef,
+    onResize: () => {
+      if (spoilerContentRef.current) {
+        const { scrollHeight, clientHeight } = spoilerContentRef.current;
+        spoilerContentRef.current.classList.toggle(
+          'truncated',
+          scrollHeight > clientHeight,
+        );
+      }
+    },
+  });
+  const contentRef = useRef(null);
+  useResizeObserver({
+    ref: contentRef,
+    onResize: () => {
+      if (contentRef.current) {
+        const { scrollHeight, clientHeight } = contentRef.current;
+        contentRef.current.classList.toggle(
+          'truncated',
+          scrollHeight > clientHeight,
+        );
+      }
+    },
+  });
+  const readMoreText = 'read more →';
+
   return (
     <div
       class={`status ${
@@ -714,7 +743,12 @@ function Status({
         >
           {!!spoilerText && sensitive && (
             <>
-              <div class="content">
+              <div
+                class="content"
+                lang={language}
+                ref={spoilerContentRef}
+                data-read-more={readMoreText}
+              >
                 <p>{spoilerText}</p>
               </div>
               <button
@@ -733,6 +767,9 @@ function Status({
           )}
           <div
             class="content"
+            lang={language}
+            ref={contentRef}
+            data-read-more={readMoreText}
             onClick={(e) => {
               let { target } = e;
               if (target.parentNode.tagName.toLowerCase() === 'a') {