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 (
{
if (e.target === e.currentTarget) {
scrollableRef.current?.scrollTo({
@@ -202,6 +240,24 @@ function Timeline({
+ {items.length > 0 &&
+ uiState !== 'loading' &&
+ !hiddenUI &&
+ showNew && (
+
+ )}
{!!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]);
+}