Finally replace Home

This commit is contained in:
Lim Chee Aun 2023-02-09 22:27:49 +08:00
parent 129417bad3
commit 58d4ca0ff2
7 changed files with 657 additions and 544 deletions

View file

@ -897,6 +897,7 @@ button.carousel-dot:is(.active, [disabled].active) {
0 10px 36px -4px var(--button-bg-blur-color);
transition: all 0.3s ease-in-out;
}
#home-page:has(header[hidden]) ~ #compose-button,
#compose-button[hidden] {
transform: translateY(200%);
pointer-events: none;

View file

@ -9,7 +9,13 @@ import {
useRef,
useState,
} from 'preact/hooks';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import {
matchPath,
Route,
Routes,
useLocation,
useNavigate,
} from 'react-router-dom';
import Toastify from 'toastify-js';
import { useSnapshot } from 'valtio';
@ -28,6 +34,7 @@ import Favourites from './pages/favourites';
import Following from './pages/following';
import Hashtags from './pages/hashtags';
import Home from './pages/home';
import HomeV1 from './pages/home-v1';
import Lists from './pages/lists';
import Login from './pages/login';
import Notifications from './pages/notifications';
@ -132,14 +139,14 @@ function App() {
}
}, [snapStates.showCompose, snapStates.showSettings, snapStates.showAccount]);
useEffect(() => {
// HACK: prevent this from running again due to HMR
if (states.init) return;
if (isLoggedIn) {
requestAnimationFrame(startVisibility);
states.init = true;
}
}, [isLoggedIn]);
// useEffect(() => {
// // HACK: prevent this from running again due to HMR
// if (states.init) return;
// if (isLoggedIn) {
// requestAnimationFrame(startVisibility);
// states.init = true;
// }
// }, [isLoggedIn]);
const { prevLocation } = snapStates;
const backgroundLocation = useRef(prevLocation || null);
@ -184,6 +191,7 @@ function App() {
<Route path="/notifications" element={<Notifications />} />
)}
{isLoggedIn && <Route path="/l/f" element={<Following />} />}
{isLoggedIn && <Route path="/homev1" element={<HomeV1 />} />}
{isLoggedIn && <Route path="/b" element={<Bookmarks />} />}
{isLoggedIn && <Route path="/f" element={<Favourites />} />}
{isLoggedIn && <Route path="/l/:id" element={<Lists />} />}

View file

@ -23,6 +23,8 @@ function Timeline({
fetchItems = () => {},
checkForUpdates = () => {},
checkForUpdatesInterval = 60_000, // 1 minute
headerStart,
headerEnd,
}) {
const [items, setItems] = useState([]);
const [uiState, setUIState] = useState('default');
@ -185,27 +187,24 @@ function Timeline({
}, [nearReachEnd, showMore]);
const lastHiddenTime = useRef();
usePageVisibility(
(visible) => {
if (visible) {
const timeDiff = Date.now() - lastHiddenTime.current;
if (!lastHiddenTime.current || 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();
usePageVisibility((visible) => {
if (visible) {
const timeDiff = Date.now() - lastHiddenTime.current;
if (!lastHiddenTime.current || timeDiff > 1000 * 60) {
(async () => {
console.log('✨ Check updates');
const hasUpdate = await checkForUpdates();
if (hasUpdate) {
console.log('✨ Has new updates');
setShowNew(true);
}
})();
}
setVisible(visible);
},
[checkForUpdates],
);
} else {
lastHiddenTime.current = Date.now();
}
setVisible(visible);
}, []);
// checkForUpdates interval
useInterval(
@ -240,23 +239,31 @@ function Timeline({
<header
hidden={hiddenUI}
onClick={(e) => {
if (e.target === e.currentTarget) {
if (!e.target.closest('a, button')) {
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}
}}
onDblClick={(e) => {
if (!e.target.closest('a, button')) {
loadItems(true);
}
}}
>
<div class="header-grid">
<div class="header-side">
<Link to="/" class="button plain">
<Icon icon="home" size="l" />
</Link>
{headerStart || (
<Link to="/" class="button plain">
<Icon icon="home" size="l" />
</Link>
)}
</div>
{title && (titleComponent ? titleComponent : <h1>{title}</h1>)}
<div class="header-side">
<Loader hidden={uiState !== 'loading'} />
{!!headerEnd && headerEnd}
</div>
</div>
{items.length > 0 &&

View file

@ -1,6 +1,8 @@
import { useEffect, useRef } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import Icon from '../components/icon';
import Link from '../components/link';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import states from '../utils/states';
@ -9,7 +11,7 @@ import useTitle from '../utils/useTitle';
const LIMIT = 20;
function Following() {
function Following({ title, id, headerStart }) {
useTitle('Following', '/l/f');
const { masto, instance } = api();
const snapStates = useSnapshot(states);
@ -50,8 +52,8 @@ function Following() {
})
.next();
const { value } = results;
console.log('checkForUpdates', value);
if (value?.some((item) => !item.reblog)) {
console.log('checkForUpdates', latestItem.current, value);
if (value?.length && value.some((item) => !item.reblog)) {
return true;
}
return false;
@ -88,6 +90,17 @@ function Following() {
if (s) s._deleted = true;
});
stream.on('notification', (notification) => {
console.log('🔔 Notification', notification);
const inNotifications =
notification.id === snapStates.notificationsLast?.id;
if (inNotifications) return;
states.notificationsNew.unshift(notification);
saveStatus(notification.status, instance, {
override: false,
});
});
stream.ws.onclose = () => {
console.log('🎏 Streaming user closed');
};
@ -107,15 +120,31 @@ function Following() {
};
}, []);
const headerEnd = (
<Link
to="/notifications"
class={`button plain ${
snapStates.notificationsNew.length > 0 ? 'has-badge' : ''
}`}
onClick={(e) => {
e.stopPropagation();
}}
>
<Icon icon="notification" size="l" alt="Notifications" />
</Link>
);
return (
<Timeline
title="Following"
id="following"
title={title || 'Following'}
id={id || 'following'}
emptyText="Nothing to see here."
errorText="Unable to load posts."
fetchItems={fetchHome}
checkForUpdates={checkForUpdates}
useItemID
headerStart={headerStart}
headerEnd={headerEnd}
boostsCarousel={snapStates.settings.boostsCarousel}
/>
);

546
src/pages/home-v1.jsx Normal file
View file

@ -0,0 +1,546 @@
import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import Status from '../components/status';
import { api } from '../utils/api';
import db from '../utils/db';
import states, { saveStatus } from '../utils/states';
import { getCurrentAccountNS } from '../utils/store-utils';
import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
function Home({ hidden }) {
useTitle('Home', '/');
const { masto, instance } = api();
const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false);
console.debug('RENDER Home');
const homeIterator = useRef();
async function fetchStatuses(firstLoad) {
if (firstLoad) {
// Reset iterator
homeIterator.current = masto.v1.timelines.listHome({
limit: LIMIT,
});
states.homeNew = [];
}
const allStatuses = await homeIterator.current.next();
if (allStatuses.value?.length) {
// ENFORCE sort by datetime (Latest first)
allStatuses.value.sort((a, b) => {
const aDate = new Date(a.createdAt);
const bDate = new Date(b.createdAt);
return bDate - aDate;
});
const homeValues = allStatuses.value.map((status) => {
saveStatus(status, instance);
return {
id: status.id,
reblog: status.reblog?.id,
reply: !!status.inReplyToAccountId,
};
});
// BOOSTS CAROUSEL
if (snapStates.settings.boostsCarousel) {
let specialHome = [];
let boostStash = [];
let serialBoosts = 0;
for (let i = 0; i < homeValues.length; i++) {
const status = homeValues[i];
if (status.reblog) {
boostStash.push(status);
serialBoosts++;
} else {
specialHome.push(status);
if (serialBoosts < 3) {
serialBoosts = 0;
}
}
}
// if boostStash is more than quarter of homeValues
// or if there are 3 or more boosts in a row
if (boostStash.length > homeValues.length / 4 || serialBoosts >= 3) {
// if boostStash is more than 3 quarter of homeValues
const boostStashID = boostStash.map((status) => status.id);
if (boostStash.length > (homeValues.length * 3) / 4) {
// insert boost array at the end of specialHome list
specialHome = [
...specialHome,
{ id: boostStashID, boosts: boostStash },
];
} else {
// insert boosts array in the middle of specialHome list
const half = Math.floor(specialHome.length / 2);
specialHome = [
...specialHome.slice(0, half),
{
id: boostStashID,
boosts: boostStash,
},
...specialHome.slice(half),
];
}
} else {
// Untouched, this is fine
specialHome = homeValues;
}
console.log({
specialHome,
});
if (firstLoad) {
states.homeLast = specialHome[0];
states.home = specialHome;
} else {
states.home.push(...specialHome);
}
} else {
if (firstLoad) {
states.homeLast = homeValues[0];
states.home = homeValues;
} else {
states.home.push(...homeValues);
}
}
}
states.homeLastFetchTime = Date.now();
return allStatuses;
}
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);
}, []);
const scrollableRef = useRef();
const jRef = useHotkeys('j, shift+j', (_, handler) => {
// focus on next status after active status
// Traverses .timeline li .status-link, focus on .status-link
const activeStatus = document.activeElement.closest(
'.status-link, .status-boost-link',
);
const activeStatusRect = activeStatus?.getBoundingClientRect();
const allStatusLinks = Array.from(
scrollableRef.current.querySelectorAll(
'.status-link, .status-boost-link',
),
);
if (
activeStatus &&
activeStatusRect.top < scrollableRef.current.clientHeight &&
activeStatusRect.bottom > 0
) {
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
let nextStatus = allStatusLinks[activeStatusIndex + 1];
if (handler.shift) {
// get next status that's not .status-boost-link
nextStatus = allStatusLinks.find(
(statusLink, index) =>
index > activeStatusIndex &&
!statusLink.classList.contains('status-boost-link'),
);
}
if (nextStatus) {
nextStatus.focus();
nextStatus.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
const topmostStatusLink = allStatusLinks.find((statusLink) => {
const statusLinkRect = statusLink.getBoundingClientRect();
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
});
if (topmostStatusLink) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
}
}
});
const kRef = useHotkeys('k, shift+k', (_, handler) => {
// focus on previous status after active status
// Traverses .timeline li .status-link, focus on .status-link
const activeStatus = document.activeElement.closest(
'.status-link, .status-boost-link',
);
const activeStatusRect = activeStatus?.getBoundingClientRect();
const allStatusLinks = Array.from(
scrollableRef.current.querySelectorAll(
'.status-link, .status-boost-link',
),
);
if (
activeStatus &&
activeStatusRect.top < scrollableRef.current.clientHeight &&
activeStatusRect.bottom > 0
) {
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
let prevStatus = allStatusLinks[activeStatusIndex - 1];
if (handler.shift) {
// get prev status that's not .status-boost-link
prevStatus = allStatusLinks.findLast(
(statusLink, index) =>
index < activeStatusIndex &&
!statusLink.classList.contains('status-boost-link'),
);
}
if (prevStatus) {
prevStatus.focus();
prevStatus.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
const topmostStatusLink = allStatusLinks.find((statusLink) => {
const statusLinkRect = statusLink.getBoundingClientRect();
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
});
if (topmostStatusLink) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
}
}
});
const oRef = useHotkeys(['enter', 'o'], () => {
// open active status
const activeStatus = document.activeElement.closest(
'.status-link, .status-boost-link',
);
if (activeStatus) {
activeStatus.click();
}
});
const {
scrollDirection,
reachStart,
nearReachStart,
nearReachEnd,
reachEnd,
} = useScroll({
scrollableElement: scrollableRef.current,
distanceFromEnd: 3,
scrollThresholdStart: 44,
});
useEffect(() => {
if (nearReachEnd || (reachEnd && showMore)) {
loadStatuses();
}
}, [nearReachEnd, reachEnd]);
useEffect(() => {
if (reachStart) {
loadStatuses(true);
}
}, [reachStart]);
useEffect(() => {
(async () => {
const keys = await db.drafts.keys();
if (keys.length) {
const ns = getCurrentAccountNS();
const ownKeys = keys.filter((key) => key.startsWith(ns));
if (ownKeys.length) {
states.showDrafts = true;
}
}
})();
}, []);
// const showUpdatesButton = snapStates.homeNew.length > 0 && reachStart;
const [showUpdatesButton, setShowUpdatesButton] = useState(false);
useEffect(() => {
const isNewAndTop = snapStates.homeNew.length > 0 && reachStart;
console.log(
'isNewAndTop',
isNewAndTop,
snapStates.homeNew.length,
reachStart,
);
setShowUpdatesButton(isNewAndTop);
}, [snapStates.homeNew.length, reachStart]);
return (
<>
<div
id="home-page"
class="deck-container"
hidden={hidden}
ref={(node) => {
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
oRef.current = node;
}}
tabIndex="-1"
>
<div class="timeline-deck deck">
<header
hidden={scrollDirection === 'end' && !nearReachStart}
onClick={() => {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}}
onDblClick={() => {
loadStatuses(true);
}}
>
<div class="header-grid">
<div class="header-side">
<button
type="button"
class="plain"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showSettings = true;
}}
>
<Icon icon="gear" size="l" alt="Settings" />
</button>
</div>
<h1>Home</h1>
<div class="header-side">
<Loader hidden={uiState !== 'loading'} />{' '}
<Link
to="/notifications"
class={`button plain ${
snapStates.notificationsNew.length > 0 ? 'has-badge' : ''
}`}
onClick={(e) => {
e.stopPropagation();
}}
>
<Icon icon="notification" size="l" alt="Notifications" />
</Link>
</div>
</div>
{snapStates.homeNew.length > 0 &&
uiState !== 'loading' &&
((scrollDirection === 'start' &&
!nearReachStart &&
!nearReachEnd) ||
showUpdatesButton) && (
<button
class="updates-button"
type="button"
onClick={() => {
if (!snapStates.settings.boostsCarousel) {
const uniqueHomeNew = snapStates.homeNew.filter(
(status) =>
!states.home.some((s) => s.id === status.id),
);
states.home.unshift(...uniqueHomeNew);
}
loadStatuses(true);
states.homeNew = [];
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-up" /> New posts
</button>
)}
</header>
{snapStates.home.length ? (
<>
<ul class="timeline">
{snapStates.home.map(({ id: statusID, reblog, boosts }) => {
const actualStatusID = reblog || statusID;
if (boosts) {
return (
<li key={statusID}>
<BoostsCarousel boosts={boosts} />
</li>
);
}
return (
<li key={statusID}>
<Link class="status-link" to={`/s/${actualStatusID}`}>
<Status statusID={statusID} />
</Link>
</li>
);
})}
{showMore && uiState === 'loading' && (
<>
<li
style={{
height: '20vh',
}}
>
<Status skeleton />
</li>
<li
style={{
height: '25vh',
}}
>
<Status skeleton />
</li>
</>
)}
</ul>
{uiState === 'default' &&
(showMore ? (
<button
type="button"
class="plain block"
onClick={() => loadStatuses()}
style={{ marginBlockEnd: '6em' }}
>
Show more&hellip;
</button>
) : (
<p class="ui-state insignificant">The end.</p>
))}
</>
) : uiState === 'loading' ? (
<ul class="timeline">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i}>
<Status skeleton />
</li>
))}
</ul>
) : (
uiState !== 'error' && <p class="ui-state">Nothing to see here.</p>
)}
{uiState === 'error' && (
<p class="ui-state">
Unable to load statuses
<br />
<br />
<button
type="button"
onClick={() => {
loadStatuses(true);
}}
>
Try again
</button>
</p>
)}
</div>
</div>
<button
hidden={scrollDirection === 'end' && !nearReachStart}
type="button"
id="compose-button"
onClick={(e) => {
if (e.shiftKey) {
const newWin = openCompose();
if (!newWin) {
alert('Looks like your browser is blocking popups.');
states.showCompose = true;
}
} else {
states.showCompose = true;
}
}}
>
<Icon icon="quill" size="xxl" alt="Compose" />
</button>
</>
);
}
function BoostsCarousel({ boosts }) {
const carouselRef = useRef();
const { reachStart, reachEnd, init } = useScroll({
scrollableElement: carouselRef.current,
direction: 'horizontal',
});
useEffect(() => {
init?.();
}, []);
return (
<div class="boost-carousel">
<header>
<h3>{boosts.length} Boosts</h3>
<span>
<button
type="button"
class="small plain2"
disabled={reachStart}
onClick={() => {
carouselRef.current?.scrollBy({
left: -Math.min(320, carouselRef.current?.offsetWidth),
behavior: 'smooth',
});
}}
>
<Icon icon="chevron-left" />
</button>{' '}
<button
type="button"
class="small plain2"
disabled={reachEnd}
onClick={() => {
carouselRef.current?.scrollBy({
left: Math.min(320, carouselRef.current?.offsetWidth),
behavior: 'smooth',
});
}}
>
<Icon icon="chevron-right" />
</button>
</span>
</header>
<ul ref={carouselRef}>
{boosts.map((boost) => {
const { id: statusID, reblog } = boost;
const actualStatusID = reblog || statusID;
return (
<li key={statusID}>
<Link class="status-boost-link" to={`/s/${actualStatusID}`}>
<Status statusID={statusID} size="s" />
</Link>
</li>
);
})}
</ul>
</div>
);
}
export default memo(Home);

View file

@ -1,275 +1,14 @@
import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio';
import { useEffect } from 'preact/hooks';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import Status from '../components/status';
import { api } from '../utils/api';
import db from '../utils/db';
import states, { saveStatus } from '../utils/states';
import openCompose from '../utils/open-compose';
import states from '../utils/states';
import { getCurrentAccountNS } from '../utils/store-utils';
import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
function Home({ hidden }) {
useTitle('Home', '/');
const { masto, instance } = api();
const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false);
console.debug('RENDER Home');
const homeIterator = useRef();
async function fetchStatuses(firstLoad) {
if (firstLoad) {
// Reset iterator
homeIterator.current = masto.v1.timelines.listHome({
limit: LIMIT,
});
states.homeNew = [];
}
const allStatuses = await homeIterator.current.next();
if (allStatuses.value?.length) {
// ENFORCE sort by datetime (Latest first)
allStatuses.value.sort((a, b) => {
const aDate = new Date(a.createdAt);
const bDate = new Date(b.createdAt);
return bDate - aDate;
});
const homeValues = allStatuses.value.map((status) => {
saveStatus(status, instance);
return {
id: status.id,
reblog: status.reblog?.id,
reply: !!status.inReplyToAccountId,
};
});
// BOOSTS CAROUSEL
if (snapStates.settings.boostsCarousel) {
let specialHome = [];
let boostStash = [];
let serialBoosts = 0;
for (let i = 0; i < homeValues.length; i++) {
const status = homeValues[i];
if (status.reblog) {
boostStash.push(status);
serialBoosts++;
} else {
specialHome.push(status);
if (serialBoosts < 3) {
serialBoosts = 0;
}
}
}
// if boostStash is more than quarter of homeValues
// or if there are 3 or more boosts in a row
if (boostStash.length > homeValues.length / 4 || serialBoosts >= 3) {
// if boostStash is more than 3 quarter of homeValues
const boostStashID = boostStash.map((status) => status.id);
if (boostStash.length > (homeValues.length * 3) / 4) {
// insert boost array at the end of specialHome list
specialHome = [
...specialHome,
{ id: boostStashID, boosts: boostStash },
];
} else {
// insert boosts array in the middle of specialHome list
const half = Math.floor(specialHome.length / 2);
specialHome = [
...specialHome.slice(0, half),
{
id: boostStashID,
boosts: boostStash,
},
...specialHome.slice(half),
];
}
} else {
// Untouched, this is fine
specialHome = homeValues;
}
console.log({
specialHome,
});
if (firstLoad) {
states.homeLast = specialHome[0];
states.home = specialHome;
} else {
states.home.push(...specialHome);
}
} else {
if (firstLoad) {
states.homeLast = homeValues[0];
states.home = homeValues;
} else {
states.home.push(...homeValues);
}
}
}
states.homeLastFetchTime = Date.now();
return allStatuses;
}
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);
}, []);
const scrollableRef = useRef();
const jRef = useHotkeys('j, shift+j', (_, handler) => {
// focus on next status after active status
// Traverses .timeline li .status-link, focus on .status-link
const activeStatus = document.activeElement.closest(
'.status-link, .status-boost-link',
);
const activeStatusRect = activeStatus?.getBoundingClientRect();
const allStatusLinks = Array.from(
scrollableRef.current.querySelectorAll(
'.status-link, .status-boost-link',
),
);
if (
activeStatus &&
activeStatusRect.top < scrollableRef.current.clientHeight &&
activeStatusRect.bottom > 0
) {
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
let nextStatus = allStatusLinks[activeStatusIndex + 1];
if (handler.shift) {
// get next status that's not .status-boost-link
nextStatus = allStatusLinks.find(
(statusLink, index) =>
index > activeStatusIndex &&
!statusLink.classList.contains('status-boost-link'),
);
}
if (nextStatus) {
nextStatus.focus();
nextStatus.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
const topmostStatusLink = allStatusLinks.find((statusLink) => {
const statusLinkRect = statusLink.getBoundingClientRect();
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
});
if (topmostStatusLink) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
}
}
});
const kRef = useHotkeys('k, shift+k', (_, handler) => {
// focus on previous status after active status
// Traverses .timeline li .status-link, focus on .status-link
const activeStatus = document.activeElement.closest(
'.status-link, .status-boost-link',
);
const activeStatusRect = activeStatus?.getBoundingClientRect();
const allStatusLinks = Array.from(
scrollableRef.current.querySelectorAll(
'.status-link, .status-boost-link',
),
);
if (
activeStatus &&
activeStatusRect.top < scrollableRef.current.clientHeight &&
activeStatusRect.bottom > 0
) {
const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
let prevStatus = allStatusLinks[activeStatusIndex - 1];
if (handler.shift) {
// get prev status that's not .status-boost-link
prevStatus = allStatusLinks.findLast(
(statusLink, index) =>
index < activeStatusIndex &&
!statusLink.classList.contains('status-boost-link'),
);
}
if (prevStatus) {
prevStatus.focus();
prevStatus.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
const topmostStatusLink = allStatusLinks.find((statusLink) => {
const statusLinkRect = statusLink.getBoundingClientRect();
return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
});
if (topmostStatusLink) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoViewIfNeeded?.();
}
}
});
const oRef = useHotkeys(['enter', 'o'], () => {
// open active status
const activeStatus = document.activeElement.closest(
'.status-link, .status-boost-link',
);
if (activeStatus) {
activeStatus.click();
}
});
const {
scrollDirection,
reachStart,
nearReachStart,
nearReachEnd,
reachEnd,
} = useScroll({
scrollableElement: scrollableRef.current,
distanceFromEnd: 3,
scrollThresholdStart: 44,
});
useEffect(() => {
if (nearReachEnd || (reachEnd && showMore)) {
loadStatuses();
}
}, [nearReachEnd, reachEnd]);
useEffect(() => {
if (reachStart) {
loadStatuses(true);
}
}, [reachStart]);
import Following from './following';
function Home() {
useEffect(() => {
(async () => {
const keys = await db.drafts.keys();
@ -283,186 +22,27 @@ function Home({ hidden }) {
})();
}, []);
// const showUpdatesButton = snapStates.homeNew.length > 0 && reachStart;
const [showUpdatesButton, setShowUpdatesButton] = useState(false);
useEffect(() => {
const isNewAndTop = snapStates.homeNew.length > 0 && reachStart;
console.log(
'isNewAndTop',
isNewAndTop,
snapStates.homeNew.length,
reachStart,
);
setShowUpdatesButton(isNewAndTop);
}, [snapStates.homeNew.length, reachStart]);
return (
<>
<div
id="home-page"
class="deck-container"
hidden={hidden}
ref={(node) => {
scrollableRef.current = node;
jRef.current = node;
kRef.current = node;
oRef.current = node;
}}
tabIndex="-1"
>
<div class="timeline-deck deck">
<header
hidden={scrollDirection === 'end' && !nearReachStart}
onClick={() => {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}}
onDblClick={() => {
loadStatuses(true);
<Following
title="Home"
id="home"
headerStart={
<button
type="button"
class="plain"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showSettings = true;
}}
>
<div class="header-grid">
<div class="header-side">
<button
type="button"
class="plain"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showSettings = true;
}}
>
<Icon icon="gear" size="l" alt="Settings" />
</button>
</div>
<h1>Home</h1>
<div class="header-side">
<Loader hidden={uiState !== 'loading'} />{' '}
<Link
to="/notifications"
class={`button plain ${
snapStates.notificationsNew.length > 0 ? 'has-badge' : ''
}`}
onClick={(e) => {
e.stopPropagation();
}}
>
<Icon icon="notification" size="l" alt="Notifications" />
</Link>
</div>
</div>
{snapStates.homeNew.length > 0 &&
uiState !== 'loading' &&
((scrollDirection === 'start' &&
!nearReachStart &&
!nearReachEnd) ||
showUpdatesButton) && (
<button
class="updates-button"
type="button"
onClick={() => {
if (!snapStates.settings.boostsCarousel) {
const uniqueHomeNew = snapStates.homeNew.filter(
(status) =>
!states.home.some((s) => s.id === status.id),
);
states.home.unshift(...uniqueHomeNew);
}
loadStatuses(true);
states.homeNew = [];
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-up" /> New posts
</button>
)}
</header>
{snapStates.home.length ? (
<>
<ul class="timeline">
{snapStates.home.map(({ id: statusID, reblog, boosts }) => {
const actualStatusID = reblog || statusID;
if (boosts) {
return (
<li key={statusID}>
<BoostsCarousel boosts={boosts} />
</li>
);
}
return (
<li key={statusID}>
<Link class="status-link" to={`/s/${actualStatusID}`}>
<Status statusID={statusID} />
</Link>
</li>
);
})}
{showMore && uiState === 'loading' && (
<>
<li
style={{
height: '20vh',
}}
>
<Status skeleton />
</li>
<li
style={{
height: '25vh',
}}
>
<Status skeleton />
</li>
</>
)}
</ul>
{uiState === 'default' &&
(showMore ? (
<button
type="button"
class="plain block"
onClick={() => loadStatuses()}
style={{ marginBlockEnd: '6em' }}
>
Show more&hellip;
</button>
) : (
<p class="ui-state insignificant">The end.</p>
))}
</>
) : uiState === 'loading' ? (
<ul class="timeline">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i}>
<Status skeleton />
</li>
))}
</ul>
) : (
uiState !== 'error' && <p class="ui-state">Nothing to see here.</p>
)}
{uiState === 'error' && (
<p class="ui-state">
Unable to load statuses
<br />
<br />
<button
type="button"
onClick={() => {
loadStatuses(true);
}}
>
Try again
</button>
</p>
)}
</div>
</div>
<Icon icon="gear" size="l" alt="Settings" />
</button>
}
/>
<button
hidden={scrollDirection === 'end' && !nearReachStart}
// hidden={scrollDirection === 'end' && !nearReachStart}
type="button"
id="compose-button"
onClick={(e) => {
@ -483,64 +63,4 @@ function Home({ hidden }) {
);
}
function BoostsCarousel({ boosts }) {
const carouselRef = useRef();
const { reachStart, reachEnd, init } = useScroll({
scrollableElement: carouselRef.current,
direction: 'horizontal',
});
useEffect(() => {
init?.();
}, []);
return (
<div class="boost-carousel">
<header>
<h3>{boosts.length} Boosts</h3>
<span>
<button
type="button"
class="small plain2"
disabled={reachStart}
onClick={() => {
carouselRef.current?.scrollBy({
left: -Math.min(320, carouselRef.current?.offsetWidth),
behavior: 'smooth',
});
}}
>
<Icon icon="chevron-left" />
</button>{' '}
<button
type="button"
class="small plain2"
disabled={reachEnd}
onClick={() => {
carouselRef.current?.scrollBy({
left: Math.min(320, carouselRef.current?.offsetWidth),
behavior: 'smooth',
});
}}
>
<Icon icon="chevron-right" />
</button>
</span>
</header>
<ul ref={carouselRef}>
{boosts.map((boost) => {
const { id: statusID, reblog } = boost;
const actualStatusID = reblog || statusID;
return (
<li key={statusID}>
<Link class="status-boost-link" to={`/s/${actualStatusID}`}>
<Status statusID={statusID} size="s" />
</Link>
</li>
);
})}
</ul>
</div>
);
}
export default memo(Home);
export default Home;

View file

@ -1,15 +1,17 @@
import { useEffect } from 'preact/hooks';
import { useEffect, useRef } from 'preact/hooks';
export default function usePageVisibility(fn = () => {}, deps = []) {
const savedCallback = useRef(fn, deps);
useEffect(() => {
const handleVisibilityChange = () => {
const hidden = document.hidden || document.visibilityState === 'hidden';
console.log('👀 Page visibility changed', hidden);
fn(!hidden);
console.log('👀 Page visibility changed', hidden ? 'hidden' : 'visible');
savedCallback.current(!hidden);
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () =>
document.removeEventListener('visibilitychange', handleVisibilityChange);
}, [fn, ...deps]);
}, []);
}