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 && (
} />
)}
+ {isLoggedIn && } />}
{isLoggedIn && } />}
{isLoggedIn && } />}
{isLoggedIn && } />}
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({
<>
{items.map((status) => {
- const { id: statusID, reblog } = status;
+ const { id: statusID, reblog, boosts } = status;
const actualStatusID = reblog?.id || statusID;
+ if (boosts) {
+ return (
+ -
+
+
+ );
+ }
return (
-
@@ -111,21 +128,19 @@ function Timeline({
);
})}
- {showMore && (
-
- )}
+ {uiState === 'default' &&
+ (showMore ? (
+
+ ) : (
+ The end.
+ ))}
>
) : uiState === 'loading' ? (
@@ -136,9 +151,9 @@ function Timeline({
))}
) : (
- uiState !== 'loading' && {emptyText}
+ uiState !== 'error' && {emptyText}
)}
- {uiState === 'error' ? (
+ {uiState === 'error' && (
{errorText}
@@ -150,14 +165,112 @@ function Timeline({
Try again
- ) : (
- uiState !== 'loading' &&
- !!items.length &&
- !showMore && The end.
)}
);
}
+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 (
+
+
+ {boosts.length} Boosts
+
+ {' '}
+
+
+
+
+ {boosts.map((boost) => {
+ const { id: statusID, reblog } = boost;
+ const actualStatusID = reblog?.id || statusID;
+ return (
+ -
+
+
+
+
+ );
+ })}
+
+
+ );
+}
+
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() {
}
- 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 (
+
+ );
+}
+
+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);
}}
>
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 (
=