From 9921e487e87b98fc7d3ab99da1b1c8af9e2993da Mon Sep 17 00:00:00 2001
From: Lim Chee Aun <cheeaun@gmail.com>
Date: Wed, 8 Feb 2023 00:31:46 +0800
Subject: [PATCH] =?UTF-8?q?Minimum=20viable=20Home=20=E2=86=92=20Following?=
 =?UTF-8?q?=20port?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 src/app.css                    |  3 +-
 src/app.jsx                    |  6 ++-
 src/components/timeline.jsx    | 60 ++++++++++++++++++++++++++++-
 src/pages/following.jsx        | 69 +++++++++++++++++++++++++++++++++-
 src/pages/home.jsx             |  2 +-
 src/utils/api.js               |  1 +
 src/utils/usePageVisibility.js | 14 +++++++
 7 files changed, 147 insertions(+), 8 deletions(-)
 create mode 100644 src/utils/usePageVisibility.js

diff --git a/src/app.css b/src/app.css
index d620ea16..203ea665 100644
--- a/src/app.css
+++ b/src/app.css
@@ -701,9 +701,10 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
 .updates-button {
   position: absolute;
   z-index: 2;
+  top: 3em;
   animation: fade-from-top 0.3s ease-out;
   left: 50%;
-  margin-top: 8px;
+  margin-top: 16px;
   transform: translate(-50%, 0);
   font-size: 90%;
   background: linear-gradient(
diff --git a/src/app.jsx b/src/app.jsx
index 20033223..cfc762ff 100644
--- a/src/app.jsx
+++ b/src/app.jsx
@@ -94,8 +94,10 @@ function App() {
       if (account) {
         store.session.set('currentAccount', account.info.id);
         const { masto } = api({ account });
-        initInstance(masto);
-        setIsLoggedIn(true);
+        (async () => {
+          await initInstance(masto);
+          setIsLoggedIn(true);
+        })();
       }
 
       setUIState('default');
diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx
index cc502a15..77f30af2 100644
--- a/src/components/timeline.jsx
+++ b/src/components/timeline.jsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'preact/hooks';
 import { useHotkeys } from 'react-hotkeys-hook';
 import { useDebouncedCallback } from 'use-debounce';
 
+import usePageVisibility from '../utils/usePageVisibility';
 import useScroll from '../utils/useScroll';
 
 import Icon from './icon';
@@ -19,14 +20,17 @@ function Timeline({
   useItemID, // use statusID instead of status object, assuming it's already in states
   boostsCarousel,
   fetchItems = () => {},
+  checkForUpdates = () => {},
 }) {
   const [items, setItems] = useState([]);
   const [uiState, setUIState] = useState('default');
   const [showMore, setShowMore] = useState(false);
+  const [showNew, setShowNew] = useState(false);
   const scrollableRef = useRef();
 
   const loadItems = useDebouncedCallback(
     (firstLoad) => {
+      setShowNew(false);
       if (uiState === 'loading') return;
       setUIState('loading');
       (async () => {
@@ -148,9 +152,16 @@ function Timeline({
     }
   });
 
-  const { nearReachEnd, reachStart, reachEnd } = useScroll({
+  const {
+    scrollDirection,
+    nearReachStart,
+    nearReachEnd,
+    reachStart,
+    reachEnd,
+  } = useScroll({
     scrollableElement: scrollableRef.current,
-    distanceFromEnd: 1,
+    distanceFromEnd: 2,
+    scrollThresholdStart: 44,
   });
 
   useEffect(() => {
@@ -170,6 +181,32 @@ function Timeline({
     }
   }, [nearReachEnd, showMore]);
 
+  const lastHiddenTime = useRef();
+  usePageVisibility(
+    (visible) => {
+      if (visible) {
+        if (lastHiddenTime.current) {
+          const timeDiff = Date.now() - lastHiddenTime.current;
+          if (timeDiff > 1000 * 60) {
+            (async () => {
+              console.log('✨ Check updates');
+              const hasUpdate = await checkForUpdates();
+              if (hasUpdate) {
+                console.log('✨ Has new updates');
+                setShowNew(true);
+              }
+            })();
+          }
+        }
+      } else {
+        lastHiddenTime.current = Date.now();
+      }
+    },
+    [checkForUpdates],
+  );
+
+  const hiddenUI = scrollDirection === 'end' && !nearReachStart;
+
   return (
     <div
       id={`${id}-page`}
@@ -184,6 +221,7 @@ function Timeline({
     >
       <div class="timeline-deck deck">
         <header
+          hidden={hiddenUI}
           onClick={(e) => {
             if (e.target === e.currentTarget) {
               scrollableRef.current?.scrollTo({
@@ -202,6 +240,24 @@ function Timeline({
           <div class="header-side">
             <Loader hidden={uiState !== 'loading'} />
           </div>
+          {items.length > 0 &&
+            uiState !== 'loading' &&
+            !hiddenUI &&
+            showNew && (
+              <button
+                class="updates-button"
+                type="button"
+                onClick={() => {
+                  loadItems(true);
+                  scrollableRef.current?.scrollTo({
+                    top: 0,
+                    behavior: 'smooth',
+                  });
+                }}
+              >
+                <Icon icon="arrow-up" /> New posts
+              </button>
+            )}
         </header>
         {!!items.length ? (
           <>
diff --git a/src/pages/following.jsx b/src/pages/following.jsx
index dfb4f7e5..925483bb 100644
--- a/src/pages/following.jsx
+++ b/src/pages/following.jsx
@@ -1,10 +1,10 @@
-import { useRef } from 'preact/hooks';
+import { useEffect, useRef } from 'preact/hooks';
 import { useSnapshot } from 'valtio';
 
 import Timeline from '../components/timeline';
 import { api } from '../utils/api';
 import states from '../utils/states';
-import { saveStatus } from '../utils/states';
+import { getStatus, saveStatus } from '../utils/states';
 import useTitle from '../utils/useTitle';
 
 const LIMIT = 20;
@@ -14,6 +14,8 @@ function Following() {
   const { masto, instance } = api();
   const snapStates = useSnapshot(states);
   const homeIterator = useRef();
+  const latestItem = useRef();
+
   async function fetchHome(firstLoad) {
     if (firstLoad || !homeIterator.current) {
       homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT });
@@ -21,6 +23,10 @@ function Following() {
     const results = await homeIterator.current.next();
     const { value } = results;
     if (value?.length) {
+      if (firstLoad) {
+        latestItem.current = value[0].id;
+      }
+
       value.forEach((item) => {
         saveStatus(item, instance);
       });
@@ -35,6 +41,64 @@ function Following() {
     return results;
   }
 
+  async function checkForUpdates() {
+    try {
+      const results = await masto.v1.timelines
+        .listHome({
+          limit: 5,
+          since_id: latestItem.current,
+        })
+        .next();
+      const { value } = results;
+      console.log('checkForUpdates', value);
+      if (value?.some((item) => !item.reblog)) {
+        return true;
+      }
+      return false;
+    } catch (e) {
+      return false;
+    }
+  }
+
+  const ws = useRef();
+  async function streamUser() {
+    if (
+      ws.current &&
+      (ws.current.readyState === WebSocket.CONNECTING ||
+        ws.current.readyState === WebSocket.OPEN)
+    ) {
+      console.log('🎏 Streaming user already open');
+      return;
+    }
+    const stream = await masto.v1.stream.streamUser();
+    ws.current = stream.ws;
+    console.log('🎏 Streaming user');
+
+    stream.on('status.update', (status) => {
+      console.log(`🔄 Status ${status.id} updated`);
+      saveStatus(status, instance);
+    });
+
+    stream.on('delete', (statusID) => {
+      console.log(`❌ Status ${statusID} deleted`);
+      // delete states.statuses[statusID];
+      const s = getStatus(statusID, instance);
+      if (s) s._deleted = true;
+    });
+
+    return stream;
+  }
+  useEffect(() => {
+    streamUser();
+    return () => {
+      if (ws.current) {
+        console.log('🎏 Closing streaming user');
+        ws.current.close();
+        ws.current = null;
+      }
+    };
+  }, []);
+
   return (
     <Timeline
       title="Following"
@@ -42,6 +106,7 @@ function Following() {
       emptyText="Nothing to see here."
       errorText="Unable to load posts."
       fetchItems={fetchHome}
+      checkForUpdates={checkForUpdates}
       useItemID
       boostsCarousel={snapStates.settings.boostsCarousel}
     />
diff --git a/src/pages/home.jsx b/src/pages/home.jsx
index 95378351..0d94869d 100644
--- a/src/pages/home.jsx
+++ b/src/pages/home.jsx
@@ -294,7 +294,7 @@ function Home({ hidden }) {
       reachStart,
     );
     setShowUpdatesButton(isNewAndTop);
-  }, [snapStates.homeNew.length]);
+  }, [snapStates.homeNew.length, reachStart]);
 
   return (
     <>
diff --git a/src/utils/api.js b/src/utils/api.js
index 250c3d73..7cca00fd 100644
--- a/src/utils/api.js
+++ b/src/utils/api.js
@@ -83,6 +83,7 @@ export async function initInstance(client) {
   // This is a weird place to put this but here's updating the masto instance with the streaming API URL set in the configuration
   // Reason: Streaming WebSocket URL may change, unlike the standard API REST URLs
   if (streamingApi || streaming) {
+    console.log('🎏 Streaming API URL:', streaming || streamingApi);
     masto.config.props.streamingApiUrl = streaming || streamingApi;
   }
 }
diff --git a/src/utils/usePageVisibility.js b/src/utils/usePageVisibility.js
new file mode 100644
index 00000000..43849b15
--- /dev/null
+++ b/src/utils/usePageVisibility.js
@@ -0,0 +1,14 @@
+import { useEffect } from 'preact/hooks';
+
+export default function usePageVisibility(fn = () => {}, deps = []) {
+  useEffect(() => {
+    const handleVisibilityChange = () => {
+      const hidden = document.hidden || document.visibilityState === 'hidden';
+      fn(!hidden);
+    };
+
+    document.addEventListener('visibilitychange', handleVisibilityChange);
+    return () =>
+      document.removeEventListener('visibilitychange', handleVisibilityChange);
+  }, [fn, ...deps]);
+}