From 9921e487e87b98fc7d3ab99da1b1c8af9e2993da Mon Sep 17 00:00:00 2001 From: Lim Chee Aun 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 (
{!!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 ( 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]); +}