Minimum viable Home → Following port

This commit is contained in:
Lim Chee Aun 2023-02-08 00:31:46 +08:00
parent c6c18aae09
commit 9921e487e8
7 changed files with 147 additions and 8 deletions

View file

@ -701,9 +701,10 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
.updates-button { .updates-button {
position: absolute; position: absolute;
z-index: 2; z-index: 2;
top: 3em;
animation: fade-from-top 0.3s ease-out; animation: fade-from-top 0.3s ease-out;
left: 50%; left: 50%;
margin-top: 8px; margin-top: 16px;
transform: translate(-50%, 0); transform: translate(-50%, 0);
font-size: 90%; font-size: 90%;
background: linear-gradient( background: linear-gradient(

View file

@ -94,8 +94,10 @@ function App() {
if (account) { if (account) {
store.session.set('currentAccount', account.info.id); store.session.set('currentAccount', account.info.id);
const { masto } = api({ account }); const { masto } = api({ account });
initInstance(masto); (async () => {
setIsLoggedIn(true); await initInstance(masto);
setIsLoggedIn(true);
})();
} }
setUIState('default'); setUIState('default');

View file

@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import usePageVisibility from '../utils/usePageVisibility';
import useScroll from '../utils/useScroll'; import useScroll from '../utils/useScroll';
import Icon from './icon'; import Icon from './icon';
@ -19,14 +20,17 @@ function Timeline({
useItemID, // use statusID instead of status object, assuming it's already in states useItemID, // use statusID instead of status object, assuming it's already in states
boostsCarousel, boostsCarousel,
fetchItems = () => {}, fetchItems = () => {},
checkForUpdates = () => {},
}) { }) {
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false); const [showMore, setShowMore] = useState(false);
const [showNew, setShowNew] = useState(false);
const scrollableRef = useRef(); const scrollableRef = useRef();
const loadItems = useDebouncedCallback( const loadItems = useDebouncedCallback(
(firstLoad) => { (firstLoad) => {
setShowNew(false);
if (uiState === 'loading') return; if (uiState === 'loading') return;
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
@ -148,9 +152,16 @@ function Timeline({
} }
}); });
const { nearReachEnd, reachStart, reachEnd } = useScroll({ const {
scrollDirection,
nearReachStart,
nearReachEnd,
reachStart,
reachEnd,
} = useScroll({
scrollableElement: scrollableRef.current, scrollableElement: scrollableRef.current,
distanceFromEnd: 1, distanceFromEnd: 2,
scrollThresholdStart: 44,
}); });
useEffect(() => { useEffect(() => {
@ -170,6 +181,32 @@ function Timeline({
} }
}, [nearReachEnd, showMore]); }, [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 ( return (
<div <div
id={`${id}-page`} id={`${id}-page`}
@ -184,6 +221,7 @@ function Timeline({
> >
<div class="timeline-deck deck"> <div class="timeline-deck deck">
<header <header
hidden={hiddenUI}
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
scrollableRef.current?.scrollTo({ scrollableRef.current?.scrollTo({
@ -202,6 +240,24 @@ function Timeline({
<div class="header-side"> <div class="header-side">
<Loader hidden={uiState !== 'loading'} /> <Loader hidden={uiState !== 'loading'} />
</div> </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> </header>
{!!items.length ? ( {!!items.length ? (
<> <>

View file

@ -1,10 +1,10 @@
import { useRef } from 'preact/hooks'; import { useEffect, useRef } from 'preact/hooks';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import Timeline from '../components/timeline'; import Timeline from '../components/timeline';
import { api } from '../utils/api'; import { api } from '../utils/api';
import states from '../utils/states'; import states from '../utils/states';
import { saveStatus } from '../utils/states'; import { getStatus, saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
const LIMIT = 20; const LIMIT = 20;
@ -14,6 +14,8 @@ function Following() {
const { masto, instance } = api(); const { masto, instance } = api();
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const homeIterator = useRef(); const homeIterator = useRef();
const latestItem = useRef();
async function fetchHome(firstLoad) { async function fetchHome(firstLoad) {
if (firstLoad || !homeIterator.current) { if (firstLoad || !homeIterator.current) {
homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT }); homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT });
@ -21,6 +23,10 @@ function Following() {
const results = await homeIterator.current.next(); const results = await homeIterator.current.next();
const { value } = results; const { value } = results;
if (value?.length) { if (value?.length) {
if (firstLoad) {
latestItem.current = value[0].id;
}
value.forEach((item) => { value.forEach((item) => {
saveStatus(item, instance); saveStatus(item, instance);
}); });
@ -35,6 +41,64 @@ function Following() {
return results; 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 ( return (
<Timeline <Timeline
title="Following" title="Following"
@ -42,6 +106,7 @@ function Following() {
emptyText="Nothing to see here." emptyText="Nothing to see here."
errorText="Unable to load posts." errorText="Unable to load posts."
fetchItems={fetchHome} fetchItems={fetchHome}
checkForUpdates={checkForUpdates}
useItemID useItemID
boostsCarousel={snapStates.settings.boostsCarousel} boostsCarousel={snapStates.settings.boostsCarousel}
/> />

View file

@ -294,7 +294,7 @@ function Home({ hidden }) {
reachStart, reachStart,
); );
setShowUpdatesButton(isNewAndTop); setShowUpdatesButton(isNewAndTop);
}, [snapStates.homeNew.length]); }, [snapStates.homeNew.length, reachStart]);
return ( return (
<> <>

View file

@ -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 // 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 // Reason: Streaming WebSocket URL may change, unlike the standard API REST URLs
if (streamingApi || streaming) { if (streamingApi || streaming) {
console.log('🎏 Streaming API URL:', streaming || streamingApi);
masto.config.props.streamingApiUrl = streaming || streamingApi; masto.config.props.streamingApiUrl = streaming || streamingApi;
} }
} }

View file

@ -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]);
}