From e0bab6c70a18a320008faf654074474ab510a67c Mon Sep 17 00:00:00 2001
From: Lim Chee Aun <cheeaun@gmail.com>
Date: Fri, 3 Feb 2023 21:08:08 +0800
Subject: [PATCH] More refactoring work

---
 src/app.jsx                    |   2 +
 src/components/timeline.jsx    | 209 +++++++++++++++++++++++++--------
 src/pages/account-statuses.jsx |   6 +-
 src/pages/bookmarks.jsx        |   2 +
 src/pages/favourites.jsx       |   2 +
 src/pages/following.jsx        |  32 +++++
 src/pages/hashtags.jsx         |   2 +
 src/pages/home.jsx             | 110 +++++++++--------
 src/pages/lists.jsx            |   3 +
 src/pages/public.jsx           |   5 +-
 src/utils/useScroll.js         |  10 +-
 11 files changed, 281 insertions(+), 102 deletions(-)
 create mode 100644 src/pages/following.jsx

diff --git a/src/app.jsx b/src/app.jsx
index 92408411..99d5165d 100644
--- a/src/app.jsx
+++ b/src/app.jsx
@@ -26,6 +26,7 @@ import NotFound from './pages/404';
 import AccountStatuses from './pages/account-statuses';
 import Bookmarks from './pages/bookmarks';
 import Favourites from './pages/favourites';
+import Following from './pages/following';
 import Hashtags from './pages/hashtags';
 import Home from './pages/home';
 import Lists from './pages/lists';
@@ -205,6 +206,7 @@ function App() {
         {isLoggedIn && (
           <Route path="/notifications" element={<Notifications />} />
         )}
+        {isLoggedIn && <Route path="/l/f" element={<Following />} />}
         {isLoggedIn && <Route path="/b" element={<Bookmarks />} />}
         {isLoggedIn && <Route path="/f" element={<Favourites />} />}
         {isLoggedIn && <Route path="/l/:id" element={<Lists />} />}
diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx
index baaca095..c4c1ca7a 100644
--- a/src/components/timeline.jsx
+++ b/src/components/timeline.jsx
@@ -1,7 +1,7 @@
 import { useEffect, useRef, useState } from 'preact/hooks';
+import { useDebouncedCallback } from 'use-debounce';
 
 import useScroll from '../utils/useScroll';
-import useTitle from '../utils/useTitle';
 
 import Icon from './icon';
 import Link from './link';
@@ -11,45 +11,55 @@ import Status from './status';
 function Timeline({
   title,
   titleComponent,
-  path,
   id,
   emptyText,
   errorText,
+  boostsCarousel,
   fetchItems = () => {},
 }) {
-  if (title) {
-    useTitle(title, path);
-  }
   const [items, setItems] = useState([]);
   const [uiState, setUIState] = useState('default');
   const [showMore, setShowMore] = useState(false);
   const scrollableRef = useRef(null);
-  const { nearReachEnd, reachStart } = useScroll({
+  const { nearReachEnd, reachStart, reachEnd } = useScroll({
     scrollableElement: scrollableRef.current,
+    distanceFromEnd: 1,
   });
 
-  const loadItems = (firstLoad) => {
-    setUIState('loading');
-    (async () => {
-      try {
-        const { done, value } = await fetchItems(firstLoad);
-        if (value?.length) {
-          if (firstLoad) {
-            setItems(value);
+  const loadItems = useDebouncedCallback(
+    (firstLoad) => {
+      if (uiState === 'loading') return;
+      setUIState('loading');
+      (async () => {
+        try {
+          let { done, value } = await fetchItems(firstLoad);
+          if (value?.length) {
+            if (boostsCarousel) {
+              value = groupBoosts(value);
+            }
+            console.log(value);
+            if (firstLoad) {
+              setItems(value);
+            } else {
+              setItems([...items, ...value]);
+            }
+            setShowMore(!done);
           } else {
-            setItems([...items, ...value]);
+            setShowMore(false);
           }
-          setShowMore(!done);
-        } else {
-          setShowMore(false);
+          setUIState('default');
+        } catch (e) {
+          console.error(e);
+          setUIState('error');
         }
-        setUIState('default');
-      } catch (e) {
-        console.error(e);
-        setUIState('error');
-      }
-    })();
-  };
+      })();
+    },
+    1500,
+    {
+      leading: true,
+      trailing: false,
+    },
+  );
 
   useEffect(() => {
     scrollableRef.current?.scrollTo({ top: 0 });
@@ -63,7 +73,7 @@ function Timeline({
   }, [reachStart]);
 
   useEffect(() => {
-    if (nearReachEnd && showMore) {
+    if (nearReachEnd || (reachEnd && showMore)) {
       loadItems();
     }
   }, [nearReachEnd, showMore]);
@@ -100,8 +110,15 @@ function Timeline({
           <>
             <ul class="timeline">
               {items.map((status) => {
-                const { id: statusID, reblog } = status;
+                const { id: statusID, reblog, boosts } = status;
                 const actualStatusID = reblog?.id || statusID;
+                if (boosts) {
+                  return (
+                    <li key={`timeline-${statusID}`}>
+                      <BoostsCarousel boosts={boosts} />
+                    </li>
+                  );
+                }
                 return (
                   <li key={`timeline-${statusID}`}>
                     <Link class="status-link" to={`/s/${actualStatusID}`}>
@@ -111,21 +128,19 @@ function Timeline({
                 );
               })}
             </ul>
-            {showMore && (
-              <button
-                type="button"
-                class="plain block"
-                disabled={uiState === 'loading'}
-                onClick={() => loadItems()}
-                style={{ marginBlockEnd: '6em' }}
-              >
-                {uiState === 'loading' ? (
-                  <Loader abrupt />
-                ) : (
-                  <>Show more&hellip;</>
-                )}
-              </button>
-            )}
+            {uiState === 'default' &&
+              (showMore ? (
+                <button
+                  type="button"
+                  class="plain block"
+                  onClick={() => loadItems()}
+                  style={{ marginBlockEnd: '6em' }}
+                >
+                  Show more&hellip;
+                </button>
+              ) : (
+                <p class="ui-state insignificant">The end.</p>
+              ))}
           </>
         ) : uiState === 'loading' ? (
           <ul class="timeline">
@@ -136,9 +151,9 @@ function Timeline({
             ))}
           </ul>
         ) : (
-          uiState !== 'loading' && <p class="ui-state">{emptyText}</p>
+          uiState !== 'error' && <p class="ui-state">{emptyText}</p>
         )}
-        {uiState === 'error' ? (
+        {uiState === 'error' && (
           <p class="ui-state">
             {errorText}
             <br />
@@ -150,14 +165,112 @@ function Timeline({
               Try again
             </button>
           </p>
-        ) : (
-          uiState !== 'loading' &&
-          !!items.length &&
-          !showMore && <p class="ui-state insignificant">The end.</p>
         )}
       </div>
     </div>
   );
 }
 
+function groupBoosts(values) {
+  let newValues = [];
+  let boostStash = [];
+  let serialBoosts = 0;
+  for (let i = 0; i < values.length; i++) {
+    const item = values[i];
+    if (item.reblog) {
+      boostStash.push(item);
+      serialBoosts++;
+    } else {
+      newValues.push(item);
+      if (serialBoosts < 3) {
+        serialBoosts = 0;
+      }
+    }
+  }
+  // if boostStash is more than quarter of values
+  // or if there are 3 or more boosts in a row
+  if (boostStash.length > values.length / 4 || serialBoosts >= 3) {
+    // if boostStash is more than 3 quarter of values
+    const boostStashID = boostStash.map((status) => status.id);
+    if (boostStash.length > (values.length * 3) / 4) {
+      // insert boost array at the end of specialHome list
+      newValues = [...newValues, { id: boostStashID, boosts: boostStash }];
+    } else {
+      // insert boosts array in the middle of specialHome list
+      const half = Math.floor(newValues.length / 2);
+      newValues = [
+        ...newValues.slice(0, half),
+        {
+          id: boostStashID,
+          boosts: boostStash,
+        },
+        ...newValues.slice(half),
+      ];
+    }
+    return newValues;
+  } else {
+    return values;
+  }
+}
+
+function BoostsCarousel({ boosts }) {
+  const carouselRef = useRef();
+  const { reachStart, reachEnd, init } = useScroll({
+    scrollableElement: carouselRef.current,
+    direction: 'horizontal',
+  });
+  useEffect(() => {
+    init?.();
+  }, []);
+
+  return (
+    <div class="boost-carousel">
+      <header>
+        <h3>{boosts.length} Boosts</h3>
+        <span>
+          <button
+            type="button"
+            class="small plain2"
+            disabled={reachStart}
+            onClick={() => {
+              carouselRef.current?.scrollBy({
+                left: -Math.min(320, carouselRef.current?.offsetWidth),
+                behavior: 'smooth',
+              });
+            }}
+          >
+            <Icon icon="chevron-left" />
+          </button>{' '}
+          <button
+            type="button"
+            class="small plain2"
+            disabled={reachEnd}
+            onClick={() => {
+              carouselRef.current?.scrollBy({
+                left: Math.min(320, carouselRef.current?.offsetWidth),
+                behavior: 'smooth',
+              });
+            }}
+          >
+            <Icon icon="chevron-right" />
+          </button>
+        </span>
+      </header>
+      <ul ref={carouselRef}>
+        {boosts.map((boost) => {
+          const { id: statusID, reblog } = boost;
+          const actualStatusID = reblog?.id || statusID;
+          return (
+            <li key={statusID}>
+              <Link class="status-boost-link" to={`/s/${actualStatusID}`}>
+                <Status status={boost} size="s" />
+              </Link>
+            </li>
+          );
+        })}
+      </ul>
+    </div>
+  );
+}
+
 export default Timeline;
diff --git a/src/pages/account-statuses.jsx b/src/pages/account-statuses.jsx
index 69a8aca6..0b144a5c 100644
--- a/src/pages/account-statuses.jsx
+++ b/src/pages/account-statuses.jsx
@@ -1,12 +1,15 @@
 import { useEffect, useRef, useState } from 'preact/hooks';
 import { useParams } from 'react-router-dom';
+import { useSnapshot } from 'valtio';
 
 import Timeline from '../components/timeline';
 import states from '../utils/states';
+import useTitle from '../utils/useTitle';
 
 const LIMIT = 20;
 
 function AccountStatuses() {
+  const snapStates = useSnapshot(states);
   const { id } = useParams();
   const accountStatusesIterator = useRef();
   async function fetchAccountStatuses(firstLoad) {
@@ -19,6 +22,7 @@ function AccountStatuses() {
   }
 
   const [account, setAccount] = useState({});
+  useTitle(`${account?.acct ? '@' + account.acct : 'Posts'}`, '/a/:id');
   useEffect(() => {
     (async () => {
       try {
@@ -48,11 +52,11 @@ function AccountStatuses() {
           </div>
         </h1>
       }
-      path="/a/:id"
       id="account_statuses"
       emptyText="Nothing to see here yet."
       errorText="Unable to load statuses"
       fetchItems={fetchAccountStatuses}
+      boostsCarousel={snapStates.settings.boostsCarousel}
     />
   );
 }
diff --git a/src/pages/bookmarks.jsx b/src/pages/bookmarks.jsx
index fb25f9d6..8a5b1aff 100644
--- a/src/pages/bookmarks.jsx
+++ b/src/pages/bookmarks.jsx
@@ -1,10 +1,12 @@
 import { useRef } from 'preact/hooks';
 
 import Timeline from '../components/timeline';
+import useTitle from '../utils/useTitle';
 
 const LIMIT = 20;
 
 function Bookmarks() {
+  useTitle('Bookmarks', '/b');
   const bookmarksIterator = useRef();
   async function fetchBookmarks(firstLoad) {
     if (firstLoad || !bookmarksIterator.current) {
diff --git a/src/pages/favourites.jsx b/src/pages/favourites.jsx
index 61432832..4080c8b4 100644
--- a/src/pages/favourites.jsx
+++ b/src/pages/favourites.jsx
@@ -1,10 +1,12 @@
 import { useRef } from 'preact/hooks';
 
 import Timeline from '../components/timeline';
+import useTitle from '../utils/useTitle';
 
 const LIMIT = 20;
 
 function Favourites() {
+  useTitle('Favourites', '/f');
   const favouritesIterator = useRef();
   async function fetchFavourites(firstLoad) {
     if (firstLoad || !favouritesIterator.current) {
diff --git a/src/pages/following.jsx b/src/pages/following.jsx
new file mode 100644
index 00000000..015fae6e
--- /dev/null
+++ b/src/pages/following.jsx
@@ -0,0 +1,32 @@
+import { useRef } from 'preact/hooks';
+import { useSnapshot } from 'valtio';
+
+import Timeline from '../components/timeline';
+import useTitle from '../utils/useTitle';
+
+const LIMIT = 20;
+
+function Following() {
+  useTitle('Following', '/l/f');
+  const snapStates = useSnapshot(states);
+  const homeIterator = useRef();
+  async function fetchHome(firstLoad) {
+    if (firstLoad || !homeIterator.current) {
+      homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT });
+    }
+    return await homeIterator.current.next();
+  }
+
+  return (
+    <Timeline
+      title="Following"
+      id="following"
+      emptyText="Nothing to see here."
+      errorText="Unable to load posts."
+      fetchItems={fetchHome}
+      boostsCarousel={snapStates.settings.boostsCarousel}
+    />
+  );
+}
+
+export default Following;
diff --git a/src/pages/hashtags.jsx b/src/pages/hashtags.jsx
index efd145b1..61bab69a 100644
--- a/src/pages/hashtags.jsx
+++ b/src/pages/hashtags.jsx
@@ -2,11 +2,13 @@ import { useRef } from 'preact/hooks';
 import { useParams } from 'react-router-dom';
 
 import Timeline from '../components/timeline';
+import useTitle from '../utils/useTitle';
 
 const LIMIT = 20;
 
 function Hashtags() {
   const { hashtag } = useParams();
+  useTitle(`#${hashtag}`, `/t/${hashtag}`);
   const hashtagsIterator = useRef();
   async function fetchHashtags(firstLoad) {
     if (firstLoad || !hashtagsIterator.current) {
diff --git a/src/pages/home.jsx b/src/pages/home.jsx
index 741e782c..3facbb0d 100644
--- a/src/pages/home.jsx
+++ b/src/pages/home.jsx
@@ -118,28 +118,28 @@ function Home({ hidden }) {
     return allStatuses;
   }
 
-  const loadingStatuses = useRef(false);
-  const loadStatuses = (firstLoad) => {
-    if (loadingStatuses.current) return;
-    loadingStatuses.current = true;
-    setUIState('loading');
-    (async () => {
-      try {
-        const { done } = await fetchStatuses(firstLoad);
-        setShowMore(!done);
-        setUIState('default');
-      } catch (e) {
-        console.warn(e);
-        setUIState('error');
-      } finally {
-        loadingStatuses.current = false;
-      }
-    })();
-  };
-  const debouncedLoadStatuses = useDebouncedCallback(loadStatuses, 3000, {
-    leading: true,
-    trailing: false,
-  });
+  const loadStatuses = useDebouncedCallback(
+    (firstLoad) => {
+      if (uiState === 'loading') return;
+      setUIState('loading');
+      (async () => {
+        try {
+          const { done } = await fetchStatuses(firstLoad);
+          setShowMore(!done);
+          setUIState('default');
+        } catch (e) {
+          console.warn(e);
+          setUIState('error');
+        } finally {
+        }
+      })();
+    },
+    1500,
+    {
+      leading: true,
+      trailing: false,
+    },
+  );
 
   useEffect(() => {
     loadStatuses(true);
@@ -271,7 +271,6 @@ function Home({ hidden }) {
     reachEnd,
   } = useScroll({
     scrollableElement: scrollableRef.current,
-    distanceFromStart: 1,
     distanceFromEnd: 3,
     scrollThresholdStart: 44,
   });
@@ -284,7 +283,7 @@ function Home({ hidden }) {
 
   useEffect(() => {
     if (reachStart) {
-      debouncedLoadStatuses(true);
+      loadStatuses(true);
     }
   }, [reachStart]);
 
@@ -324,7 +323,7 @@ function Home({ hidden }) {
               scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
             }}
             onDblClick={() => {
-              debouncedLoadStatuses(true);
+              loadStatuses(true);
             }}
           >
             <div class="header-side">
@@ -372,7 +371,7 @@ function Home({ hidden }) {
                     );
                     states.home.unshift(...uniqueHomeNew);
                   }
-                  debouncedLoadStatuses(true);
+                  loadStatuses(true);
                   states.homeNew = [];
 
                   scrollableRef.current?.scrollTo({
@@ -404,7 +403,7 @@ function Home({ hidden }) {
                     </li>
                   );
                 })}
-                {showMore && (
+                {showMore && uiState === 'loading' && (
                   <>
                     <li
                       style={{
@@ -423,34 +422,45 @@ function Home({ hidden }) {
                   </>
                 )}
               </ul>
-            </>
-          ) : (
-            <>
-              {uiState === 'loading' && (
-                <ul class="timeline">
-                  {Array.from({ length: 5 }).map((_, i) => (
-                    <li key={i}>
-                      <Status skeleton />
-                    </li>
-                  ))}
-                </ul>
-              )}
-              {uiState === 'error' && (
-                <p class="ui-state">
-                  Unable to load statuses
-                  <br />
-                  <br />
+              {uiState === 'default' &&
+                (showMore ? (
                   <button
                     type="button"
-                    onClick={() => {
-                      debouncedLoadStatuses(true);
-                    }}
+                    class="plain block"
+                    onClick={() => loadStatuses()}
+                    style={{ marginBlockEnd: '6em' }}
                   >
-                    Try again
+                    Show more&hellip;
                   </button>
-                </p>
-              )}
+                ) : (
+                  <p class="ui-state insignificant">The end.</p>
+                ))}
             </>
+          ) : uiState === 'loading' ? (
+            <ul class="timeline">
+              {Array.from({ length: 5 }).map((_, i) => (
+                <li key={i}>
+                  <Status skeleton />
+                </li>
+              ))}
+            </ul>
+          ) : (
+            uiState !== 'error' && <p class="ui-state">Nothing to see here.</p>
+          )}
+          {uiState === 'error' && (
+            <p class="ui-state">
+              Unable to load statuses
+              <br />
+              <br />
+              <button
+                type="button"
+                onClick={() => {
+                  loadStatuses(true);
+                }}
+              >
+                Try again
+              </button>
+            </p>
           )}
         </div>
       </div>
diff --git a/src/pages/lists.jsx b/src/pages/lists.jsx
index b5b0ce4b..ee24bc1f 100644
--- a/src/pages/lists.jsx
+++ b/src/pages/lists.jsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'preact/hooks';
 import { useParams } from 'react-router-dom';
 
 import Timeline from '../components/timeline';
+import useTitle from '../utils/useTitle';
 
 const LIMIT = 20;
 
@@ -18,6 +19,7 @@ function Lists() {
   }
 
   const [title, setTitle] = useState(`List ${id}`);
+  useTitle(title, `/l/${id}`);
   useEffect(() => {
     (async () => {
       try {
@@ -36,6 +38,7 @@ function Lists() {
       emptyText="Nothing yet."
       errorText="Unable to load posts."
       fetchItems={fetchLists}
+      boostsCarousel
     />
   );
 }
diff --git a/src/pages/public.jsx b/src/pages/public.jsx
index 06963263..50e2deba 100644
--- a/src/pages/public.jsx
+++ b/src/pages/public.jsx
@@ -2,6 +2,7 @@
 import { useMatch, useParams } from 'react-router-dom';
 
 import Timeline from '../components/timeline';
+import useTitle from '../utils/useTitle';
 
 const LIMIT = 20;
 
@@ -11,6 +12,8 @@ function Public() {
   const isLocal = !!useMatch('/p/l/:instance');
   const params = useParams();
   const { instance = '' } = params;
+  const title = `${instance} (${isLocal ? 'local' : 'federated'})`;
+  useTitle(title, `/p/${instance}`);
   async function fetchPublic(firstLoad) {
     const url = firstLoad
       ? `https://${instance}/api/v1/timelines/public?limit=${LIMIT}&local=${isLocal}`
@@ -37,7 +40,7 @@ function Public() {
   return (
     <Timeline
       key={instance + isLocal}
-      title={`${instance} (${isLocal ? 'local' : 'federated'})`}
+      title={title}
       id="public"
       emptyText="No one has posted anything yet."
       errorText="Unable to load posts"
diff --git a/src/utils/useScroll.js b/src/utils/useScroll.js
index de59b6fa..d546a9cb 100644
--- a/src/utils/useScroll.js
+++ b/src/utils/useScroll.js
@@ -38,8 +38,14 @@ export default function useScroll({
       const scrollDimension = isVertical ? scrollHeight : scrollWidth;
       const clientDimension = isVertical ? clientHeight : clientWidth;
       const scrollDistance = Math.abs(scrollStart - previousScrollStart);
-      const distanceFromStartPx = clientDimension * distanceFromStart;
-      const distanceFromEndPx = clientDimension * distanceFromEnd;
+      const distanceFromStartPx = Math.min(
+        clientDimension * distanceFromStart,
+        scrollDimension,
+      );
+      const distanceFromEndPx = Math.min(
+        clientDimension * distanceFromEnd,
+        scrollDimension,
+      );
 
       if (
         scrollDistance >=