Replace preact-router with react-router

Need more routing powers, hopefully things don't break 🤞
pull/51/head
Lim Chee Aun 2023-01-21 00:23:59 +08:00
rodzic baf139762c
commit 9bff95bcec
15 zmienionych plików z 662 dodań i 362 usunięć

Wyświetl plik

@ -62,6 +62,7 @@ Prerequisites: Node.js 18+
- [Vite](https://vitejs.dev/) - Build tool
- [Preact](https://preactjs.com/) - UI library
- [Valtio](https://valtio.pmnd.rs/) - State management
- [React Router](https://reactrouter.com/) - Routing
- [masto.js](https://github.com/neet/masto.js/) - Mastodon API client
- [Iconify](https://iconify.design/) - Icon library
- Vanilla CSS - *Yes, I'm old school.*

101
package-lock.json wygenerowano
Wyświetl plik

@ -14,15 +14,14 @@
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
"fast-deep-equal": "~3.1.3",
"history": "~5.3.0",
"idb-keyval": "~6.2.0",
"just-debounce-it": "~3.2.0",
"masto": "~5.5.0",
"mem": "~9.0.2",
"preact": "~10.11.3",
"preact-router": "~4.1.0",
"react-hotkeys-hook": "~4.3.2",
"react-intersection-observer": "~9.4.1",
"react-router-dom": "~6.7.0",
"string-length": "~5.0.1",
"swiped-events": "~1.1.7",
"toastify-js": "~1.12.0",
@ -1653,6 +1652,7 @@
"version": "7.20.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz",
"integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==",
"dev": true,
"dependencies": {
"regenerator-runtime": "^0.13.11"
},
@ -2276,6 +2276,14 @@
"vite": ">=2.0.0-beta.3"
}
},
"node_modules/@remix-run/router": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.0.tgz",
"integrity": "sha512-nwQoYb3m4DDpHTeOwpJEuDt8lWVcujhYYSFGLluC+9es2PyLjm+jjq3IeRBQbwBtPLJE/lkuHuGHr8uQLgmJRA==",
"engines": {
"node": ">=14"
}
},
"node_modules/@rollup/plugin-replace": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-5.0.2.tgz",
@ -3607,14 +3615,6 @@
"tslib": "^2.0.3"
}
},
"node_modules/history": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
"integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==",
"dependencies": {
"@babel/runtime": "^7.7.6"
}
},
"node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@ -4537,14 +4537,6 @@
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-router": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/preact-router/-/preact-router-4.1.0.tgz",
"integrity": "sha512-y1w2YvVpKAju9FMV+fAVR1NpH4MW5q07BZrziMZeg6F/rGJ9KvLUZtjOqsy2I8fDYiX36AM1AQTXIIK3jigBhA==",
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prettier": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.0.tgz",
@ -4672,6 +4664,36 @@
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-router": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.7.0.tgz",
"integrity": "sha512-KNWlG622ddq29MAM159uUsNMdbX8USruoKnwMMQcs/QWZgFUayICSn2oB7reHce1zPj6CG18kfkZIunSSRyGHg==",
"dependencies": {
"@remix-run/router": "1.3.0"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/react-router-dom": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.7.0.tgz",
"integrity": "sha512-jQtXUJyhso3kFw430+0SPCbmCmY1/kJv8iRffGHwHy3CkoomGxeYzMkmeSPYo6Egzh3FKJZRAL22yg5p2tXtfg==",
"dependencies": {
"@remix-run/router": "1.3.0",
"react-router": "6.7.0"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@ -4693,7 +4715,8 @@
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"dev": true
},
"node_modules/regenerator-transform": {
"version": "0.15.1",
@ -7013,6 +7036,7 @@
"version": "7.20.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz",
"integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==",
"dev": true,
"requires": {
"regenerator-runtime": "^0.13.11"
}
@ -7394,6 +7418,11 @@
"@rollup/pluginutils": "^4.1.0"
}
},
"@remix-run/router": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.0.tgz",
"integrity": "sha512-nwQoYb3m4DDpHTeOwpJEuDt8lWVcujhYYSFGLluC+9es2PyLjm+jjq3IeRBQbwBtPLJE/lkuHuGHr8uQLgmJRA=="
},
"@rollup/plugin-replace": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-5.0.2.tgz",
@ -8413,14 +8442,6 @@
"tslib": "^2.0.3"
}
},
"history": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
"integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==",
"requires": {
"@babel/runtime": "^7.7.6"
}
},
"idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@ -9097,12 +9118,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg=="
},
"preact-router": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/preact-router/-/preact-router-4.1.0.tgz",
"integrity": "sha512-y1w2YvVpKAju9FMV+fAVR1NpH4MW5q07BZrziMZeg6F/rGJ9KvLUZtjOqsy2I8fDYiX36AM1AQTXIIK3jigBhA==",
"requires": {}
},
"prettier": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.0.tgz",
@ -9181,6 +9196,23 @@
"integrity": "sha512-IXpIsPe6BleFOEHKzKh5UjwRUaz/JYS0lT/HPsupWEQou2hDqjhLMStc5zyE3eQVT4Fk3FufM8Fw33qW1uyeiw==",
"requires": {}
},
"react-router": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.7.0.tgz",
"integrity": "sha512-KNWlG622ddq29MAM159uUsNMdbX8USruoKnwMMQcs/QWZgFUayICSn2oB7reHce1zPj6CG18kfkZIunSSRyGHg==",
"requires": {
"@remix-run/router": "1.3.0"
}
},
"react-router-dom": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.7.0.tgz",
"integrity": "sha512-jQtXUJyhso3kFw430+0SPCbmCmY1/kJv8iRffGHwHy3CkoomGxeYzMkmeSPYo6Egzh3FKJZRAL22yg5p2tXtfg==",
"requires": {
"@remix-run/router": "1.3.0",
"react-router": "6.7.0"
}
},
"regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@ -9199,7 +9231,8 @@
"regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"dev": true
},
"regenerator-transform": {
"version": "0.15.1",

Wyświetl plik

@ -16,15 +16,14 @@
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
"fast-deep-equal": "~3.1.3",
"history": "~5.3.0",
"idb-keyval": "~6.2.0",
"just-debounce-it": "~3.2.0",
"masto": "~5.5.0",
"mem": "~9.0.2",
"preact": "~10.11.3",
"preact-router": "~4.1.0",
"react-hotkeys-hook": "~4.3.2",
"react-intersection-observer": "~9.4.1",
"react-router-dom": "~6.7.0",
"string-length": "~5.0.1",
"swiped-events": "~1.1.7",
"toastify-js": "~1.12.0",

Wyświetl plik

@ -46,6 +46,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
transition: opacity 0.1s ease-in-out;
overscroll-behavior: contain;
scroll-behavior: smooth;
background-color: var(--bg-color);
}
.deck-container[hidden] {
display: block;
@ -61,6 +62,14 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
scroll-padding-top: 3em;
}
.deck-container {
transition: transform 0.4s var(--timing-function);
}
.deck-container:has(~ .deck-backdrop) {
transition: transform 0.4s ease-out;
transform: translate3d(-5vw, 0, 0);
}
.deck {
min-height: 100vh;
min-height: 100dvh;
@ -364,7 +373,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
-webkit-tap-highlight-color: transparent;
animation: appear 0.2s ease-out;
}
.status-link:is(:hover, :focus) {
.status-link:is(:hover, :focus, .is-active) {
background-color: var(--link-bg-hover-color);
outline-offset: -2px;
}
@ -508,11 +517,6 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
max-width: 40em;
}
.decks {
flex-grow: 1;
width: 100%;
}
.deck-close {
color: var(--text-insignificant-color) !important;
}
@ -944,21 +948,63 @@ meter.donut:is(.danger, .explode):after {
gap: 4px;
}
.deck-container {
width: 100%;
flex-grow: 1;
}
#home-page ~ .deck-container {
z-index: 10;
position: fixed;
inset: 0;
}
#home-page:has(~ .deck-container) {
display: block;
position: absolute;
user-select: none;
pointer-events: none;
opacity: 0;
content-visibility: hidden;
}
/* TAB BAR */
#tab-bar:not([hidden]) {
position: fixed;
bottom: 16px;
bottom: max(16px, env(safe-area-inset-bottom));
width: calc(100% - 32px);
max-width: calc(40em - 32px);
z-index: 100;
display: flex;
background-color: var(--bg-blur-color);
backdrop-filter: blur(16px) saturate(3);
border: var(--hairline-width) solid var(--outline-color);
border-radius: 16px;
box-shadow: 0 8px 32px var(--outline-color);
}
#tab-bar li {
flex-grow: 1;
margin: 0;
padding: 0;
list-style: none;
}
#tab-bar li a {
text-align: center;
padding: 16px 0;
display: block;
}
@media (min-width: 40em) {
html,
body {
background-color: var(--bg-faded-color);
}
.deck-container {
background-color: var(--bg-faded-color);
}
#app {
display: flex;
}
.decks {
transition: transform 0.4s var(--timing-function);
}
.decks:has(~ .deck-backdrop) {
transition: transform 0.4s ease-out;
transform: translate3d(-5vw, 0, 0);
}
.deck-backdrop .deck {
width: 50%;
min-width: 40em;
@ -995,6 +1041,22 @@ meter.donut:is(.danger, .explode):after {
border-radius: 16px;
overflow: hidden;
box-shadow: 0px 1px var(--bg-blur-color);
transition: transform 0.4s var(--timing-function);
--back-transition: transform 0.4s ease-out;
}
.timeline-deck .timeline:not(.flat) > li:has(.status-link.is-active) {
transition: var(--back-transition);
transform: translate3d(-2.5vw, 0, 0);
}
.timeline-deck
.timeline:not(.flat)
> li:not(:has(.boost-carousel)):has(+ li .status-link.is-active),
.timeline-deck
.timeline:not(.flat)
> li:not(:has(.boost-carousel)):has(.status-link.is-active)
+ li {
transition: var(--back-transition);
transform: translate3d(-1.25vw, 0, 0);
}
.box {
padding: 32px;

Wyświetl plik

@ -1,19 +1,21 @@
import './app.css';
import 'toastify-js/src/toastify.css';
import { createHashHistory } from 'history';
import debounce from 'just-debounce-it';
import { login } from 'masto';
import Router, { route } from 'preact-router';
import { useEffect, useLayoutEffect, useState } from 'preact/hooks';
import { useEffect, useLayoutEffect, useMemo, useState } from 'preact/hooks';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import Toastify from 'toastify-js';
import { useSnapshot } from 'valtio';
import Account from './components/account';
import Compose from './components/compose';
import Drafts from './components/drafts';
import Icon from './components/icon';
import Link from './components/link';
import Loader from './components/loader';
import Modal from './components/modal';
import Bookmarks from './pages/bookmarks';
import Home from './pages/home';
import Login from './pages/login';
import Notifications from './pages/notifications';
@ -24,14 +26,13 @@ import { getAccessToken } from './utils/auth';
import states, { saveStatus } from './utils/states';
import store from './utils/store';
const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
window.__STATES__ = states;
function App() {
const snapStates = useSnapshot(states);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [uiState, setUIState] = useState('loading');
const navigate = useNavigate();
useLayoutEffect(() => {
const theme = store.local.get('theme');
@ -126,20 +127,22 @@ function App() {
}
}, []);
const [currentDeck, setCurrentDeck] = useState('home');
const [currentModal, setCurrentModal] = useState(null);
let location = useLocation();
const locationDeckMap = {
'/': 'home-page',
'/notifications': 'notifications-page',
};
const focusDeck = () => {
if (currentModal) return;
let timer = setTimeout(() => {
const page = document.getElementById(`${currentDeck}-page`);
console.debug('FOCUS', currentDeck, page);
const page = document.getElementById(locationDeckMap[location.pathname]);
console.debug('FOCUS', location.pathname, page);
if (page) {
page.focus();
}
}, 100);
return () => clearTimeout(timer);
};
useEffect(focusDeck, [currentDeck, currentModal]);
useEffect(focusDeck, [location]);
useEffect(() => {
if (
!snapStates.showCompose &&
@ -173,44 +176,66 @@ function App() {
}
}, [isLoggedIn]);
const backgroundLocation = useMemo(() => {
const { prevLocation } = snapStates;
console.debug({ location, prevLocation });
const { pathname } = location;
const { pathname: prevPathname } = prevLocation || {};
console.debug({ prevPathname, pathname });
const isModalPage = /^\/s\//i.test(pathname);
return isModalPage ? prevLocation : null;
}, [location]);
const nonRootLocation = useMemo(() => {
const { pathname } = location;
return !/\/(login|welcome)$/.test(pathname);
}, [location]);
return (
<>
{isLoggedIn && currentDeck && (
<div class="decks">
{/* Home will never be unmounted */}
<Home hidden={currentDeck !== 'home'} />
{/* Notifications can be unmounted */}
{currentDeck === 'notifications' && <Notifications />}
</div>
)}
{!isLoggedIn && uiState === 'loading' && <Loader />}
<Router
history={createHashHistory()}
onChange={(e) => {
console.debug('ROUTER onChange', e);
// Special handling for Home and Notifications
const { url } = e;
if (/notifications/i.test(url)) {
setCurrentDeck('notifications');
setCurrentModal(null);
} else if (url === '/') {
setCurrentDeck('home');
document.title = `Home / ${CLIENT_NAME}`;
setCurrentModal(null);
} else if (/^\/s\//i.test(url)) {
setCurrentModal('status');
} else {
setCurrentModal(null);
setCurrentDeck(null);
<Routes location={nonRootLocation || location}>
<Route
path="/"
element={
isLoggedIn ? (
<Home />
) : uiState === 'loading' ? (
<Loader />
) : (
<Welcome />
)
}
states.history.push(url);
}}
>
{!isLoggedIn && uiState !== 'loading' && <Welcome path="/" />}
<Welcome path="/welcome" />
{isLoggedIn && <Status path="/s/:id" />}
<Login path="/login" />
</Router>
/>
<Route path="/login" element={<Login />} />
<Route path="/welcome" element={<Welcome />} />
</Routes>
<Routes location={backgroundLocation || location}>
{isLoggedIn && (
<Route path="/notifications" element={<Notifications />} />
)}
{isLoggedIn && <Route path="/bookmarks" element={<Bookmarks />} />}
</Routes>
<Routes>
{isLoggedIn && <Route path="/s/:id" element={<Status />} />}
</Routes>
<nav id="tab-bar" hidden>
<li>
<Link to="/">
<Icon icon="home" alt="Home" size="xl" />
</Link>
</li>
<li>
<Link to="/notifications">
<Icon icon="notification" alt="Notifications" size="xl" />
</Link>
</li>
<li>
<Link to="/bookmarks">
<Icon icon="bookmark" alt="Bookmarks" size="xl" />
</Link>
</li>
</nav>
{!!snapStates.showCompose && (
<Modal>
<Compose
@ -244,7 +269,8 @@ function App() {
// destination: `/#/s/${newStatus.id}`,
onClick: () => {
toast.hideToast();
route(`/s/${newStatus.id}`);
states.prevLocation = location;
navigate(`/s/${newStatus.id}`);
},
});
toast.showToast();

Wyświetl plik

@ -0,0 +1,30 @@
import { useLocation } from 'react-router-dom';
import states from '../utils/states';
/* NOTES
=====
Initially this uses <NavLink> from react-router-dom, but it doesn't work:
1. It interferes with nested <a> inside <a> and it's difficult to preventDefault/stopPropagation from the nested <a>
2. isActive doesn't work properly with the weird routes that's set up in this app, due to the faux "location" to make the modals work and prevent unmounting
3. Not using <Link state/> because it modifies history.state that *persists* across page reloads. I don't need that, so using valtio's states instead.
*/
const Link = (props) => {
const routerLocation = useLocation();
let hash = (location.hash || '').replace(/^#/, '').trim();
if (hash === '') hash = '/';
const isActive = hash === props.to;
return (
<a
href={`#${props.to}`}
{...props}
class={`${props.class || ''} ${isActive ? 'is-active' : ''}`}
onClick={() => {
states.prevLocation = routerLocation;
}}
/>
);
};
export default Link;

Wyświetl plik

@ -29,6 +29,7 @@ import visibilityIconsMap from '../utils/visibility-icons-map';
import Avatar from './avatar';
import Icon from './icon';
import Link from './link';
import RelativeTime from './relative-time';
function fetchAccount(id) {
@ -251,18 +252,14 @@ function Status({
{/* </span> */}{' '}
{size !== 'l' &&
(uri ? (
<a
href={`#/s/${id}
`}
class="time"
>
<Link to={`/s/${id}`} class="time">
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibility}
size="s"
/>{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
</a>
</Link>
) : (
<span class="time">
<Icon

Wyświetl plik

@ -1,6 +1,7 @@
import './index.css';
import { render } from 'preact';
import { HashRouter } from 'react-router-dom';
import { App } from './app';
@ -8,7 +9,12 @@ if (import.meta.env.DEV) {
import('preact/debug');
}
render(<App />, document.getElementById('app'));
render(
<HashRouter>
<App />
</HashRouter>,
document.getElementById('app'),
);
// Clean up iconify localStorage
// TODO: Remove this after few weeks?

Wyświetl plik

@ -0,0 +1,144 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import Icon from '../components/Icon';
import Link from '../components/link';
import Loader from '../components/Loader';
import Status from '../components/status';
import { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle';
const LIMIT = 40;
function Bookmarks() {
useTitle('Bookmarks');
const [bookmarks, setBookmarks] = useState([]);
const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false);
const bookmarksIterator = useRef(masto.v1.bookmarks.list({ limit: LIMIT }));
async function fetchBookmarks(firstLoad) {
console.log('fetchBookmarks', firstLoad);
if (firstLoad) {
bookmarksIterator.current = masto.v1.bookmarks.list({ limit: LIMIT });
}
const allBookmarks = await bookmarksIterator.current.next();
if (allBookmarks.value?.length) {
const bookmarksValue = allBookmarks.value.map((status) => {
saveStatus(status, {
skipThreading: true,
override: false,
});
return status;
});
if (firstLoad) {
setBookmarks(bookmarksValue);
} else {
setBookmarks([...bookmarks, ...bookmarksValue]);
}
}
return allBookmarks;
}
const loadBookmarks = (firstLoad) => {
setUIState('loading');
(async () => {
try {
console.log('loadBookmarks', firstLoad);
const { done } = await fetchBookmarks(firstLoad);
console.log('loadBookmarks', firstLoad);
setShowMore(!done);
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
};
useEffect(() => {
loadBookmarks(true);
}, []);
const scrollableRef = useRef(null);
return (
<div
id="bookmarks-page"
class="deck-container"
ref={scrollableRef}
tabIndex="-1"
>
<div class="timeline-deck deck">
<header
onClick={(e) => {
if (e.target === e.currentTarget) {
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}
}}
>
<div class="header-side">
<Link to="/" class="button plain">
<Icon icon="home" size="l" />
</Link>
</div>
<h1>Bookmarks</h1>
<div class="header-side"></div>{' '}
</header>
{!!bookmarks.length ? (
<>
<ul class="timeline">
{bookmarks.map((status) => (
<li key={`bookmark-${status.id}`}>
<Link class="status-link" to={`/s/${status.id}`}>
<Status status={status} />
</Link>
</li>
))}
</ul>
{showMore && (
<button
type="button"
class="plain block"
disabled={uiState === 'loading'}
onClick={() => loadBookmarks()}
style={{ marginBlockEnd: '6em' }}
>
{uiState === 'loading' ? <Loader /> : <>Show more&hellip;</>}
</button>
)}
</>
) : (
uiState !== 'loading' && (
<p class="ui-state">No bookmarks yet. Go bookmark something!</p>
)
)}
{uiState === 'loading' ? (
<div class="ui-state">
<Loader />
</div>
) : uiState === 'error' ? (
<p class="ui-state">
Unable to load bookmarks.
<br />
<br />
<button
class="button plain"
onClick={() => loadBookmarks(!bookmarks.length)}
>
Try again
</button>
</p>
) : (
bookmarks.length &&
!showMore && <p class="ui-state insignificant">The end.</p>
)}
</div>
</div>
);
}
export default Bookmarks;

Wyświetl plik

@ -1,10 +1,10 @@
import { Link } from 'preact-router/match';
import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
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 db from '../utils/db';
@ -36,82 +36,79 @@ function Home({ hidden }) {
states.homeNew = [];
}
const allStatuses = await homeIterator.current.next();
if (allStatuses.value <= 0) {
return { done: true };
}
const homeValues = allStatuses.value.map((status) => {
saveStatus(status);
return {
id: status.id,
reblog: status.reblog?.id,
reply: !!status.inReplyToAccountId,
};
});
if (allStatuses.value?.length) {
const homeValues = allStatuses.value.map((status) => {
saveStatus(status);
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;
// 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 },
];
// 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 {
// 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),
];
// Untouched, this is fine
specialHome = homeValues;
}
console.log({
specialHome,
});
if (firstLoad) {
states.home = specialHome;
} else {
states.home.push(...specialHome);
}
} else {
// Untouched, this is fine
specialHome = homeValues;
}
console.log({
specialHome,
});
if (firstLoad) {
states.home = specialHome;
} else {
states.home.push(...specialHome);
}
} else {
if (firstLoad) {
states.home = homeValues;
} else {
states.home.push(...homeValues);
if (firstLoad) {
states.home = homeValues;
} else {
states.home.push(...homeValues);
}
}
}
states.homeLastFetchTime = Date.now();
return {
done: false,
};
return allStatuses;
}
const loadingStatuses = useRef(false);
@ -276,13 +273,161 @@ function Home({ hidden }) {
}, []);
return (
<div
id="home-page"
class="deck-container"
hidden={hidden}
ref={scrollableRef}
tabIndex="-1"
>
<>
<div
id="home-page"
class="deck-container"
hidden={hidden}
ref={scrollableRef}
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-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>
</header>
{snapStates.homeNew.length > 0 &&
scrollDirection === 'start' &&
!nearReachStart &&
!nearReachEnd && (
<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>
)}
{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 && (
<>
{/* <InView
as="li"
style={{
height: '20vh',
}}
onChange={(inView) => {
if (inView) loadStatuses();
}}
root={scrollableRef.current}
rootMargin="100px 0px"
> */}
<li
style={{
height: '20vh',
}}
>
<Status skeleton />
</li>
{/* </InView> */}
<li
style={{
height: '25vh',
}}
>
<Status skeleton />
</li>
</>
)}
</ul>
</>
) : (
<>
{uiState === 'loading' && (
<ul class="timeline">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i}>
<Status skeleton />
</li>
))}
</ul>
)}
{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"
@ -301,157 +446,7 @@ function Home({ hidden }) {
>
<Icon icon="quill" size="xxl" alt="Compose" />
</button>
<div class="timeline-deck deck">
<header
hidden={scrollDirection === 'end' && !nearReachStart}
onClick={() => {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}}
onDblClick={() => {
loadStatuses(true);
}}
>
<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'} />{' '}
<a
href="#/notifications"
class={`button plain ${
snapStates.notificationsNew.length > 0 ? 'has-badge' : ''
}`}
onClick={(e) => {
e.stopPropagation();
}}
>
<Icon icon="notification" size="l" alt="Notifications" />
</a>
</div>
</header>
{snapStates.homeNew.length > 0 &&
scrollDirection === 'start' &&
!nearReachStart &&
!nearReachEnd && (
<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>
)}
{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
activeClassName="active"
class="status-link"
href={`#/s/${actualStatusID}`}
>
<Status statusID={statusID} />
</Link>
</li>
);
})}
{showMore && (
<>
{/* <InView
as="li"
style={{
height: '20vh',
}}
onChange={(inView) => {
if (inView) loadStatuses();
}}
root={scrollableRef.current}
rootMargin="100px 0px"
> */}
<li
style={{
height: '20vh',
}}
>
<Status skeleton />
</li>
{/* </InView> */}
<li
style={{
height: '25vh',
}}
>
<Status skeleton />
</li>
</>
)}
</ul>
</>
) : (
<>
{uiState === 'loading' && (
<ul class="timeline">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i}>
<Status skeleton />
</li>
))}
</ul>
)}
{uiState === 'error' && (
<p class="ui-state">
Unable to load statuses
<br />
<br />
<button
type="button"
onClick={() => {
loadStatuses(true);
}}
>
Try again
</button>
</p>
)}
</>
)}
</div>
</div>
</>
);
}
@ -504,9 +499,9 @@ function BoostsCarousel({ boosts }) {
const actualStatusID = reblog || statusID;
return (
<li>
<a class="status-boost-link" href={`#/s/${actualStatusID}`}>
<Link class="status-boost-link" to={`/s/${actualStatusID}`}>
<Status statusID={statusID} size="s" />
</a>
</Link>
</li>
);
})}

Wyświetl plik

@ -2,6 +2,7 @@ import './login.css';
import { useEffect, useRef, useState } from 'preact/hooks';
import Link from '../components/link';
import Loader from '../components/loader';
import instancesListURL from '../data/instances.json?url';
import { getAuthorizationURL, registerApplication } from '../utils/auth';
@ -111,7 +112,7 @@ function Login() {
</a>
</p>
<p>
<a href="/#">Go home</a>
<Link to="/">Go home</Link>
</p>
</form>
</main>

Wyświetl plik

@ -1,17 +1,17 @@
import './notifications.css';
import { Link } from 'preact-router/match';
import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import Avatar from '../components/avatar';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import NameText from '../components/name-text';
import RelativeTime from '../components/relative-time';
import Status from '../components/status';
import states from '../utils/states';
import states, { saveStatus } from '../utils/states';
import store from '../utils/store';
import useTitle from '../utils/useTitle';
@ -156,7 +156,7 @@ function Notification({ notification }) {
{status && (
<Link
class={`status-link status-type-${type}`}
href={`#/s/${actualStatusID}`}
to={`/s/${actualStatusID}`}
>
<Status status={status} size="s" />
</Link>
@ -232,19 +232,19 @@ function Notifications() {
states.notificationsNew = [];
}
const allNotifications = await notificationsIterator.current.next();
if (allNotifications.value <= 0) {
return { done: true };
}
const notificationsValues = allNotifications.value.map((notification) => {
if (notification.status) {
states.statuses[notification.status.id] = notification.status;
if (allNotifications.value?.length) {
const notificationsValues = allNotifications.value.map((notification) => {
saveStatus(notification.status, {
skipThreading: true,
override: false,
});
return notification;
});
if (firstLoad) {
states.notifications = notificationsValues;
} else {
states.notifications.push(...notificationsValues);
}
return notification;
});
if (firstLoad) {
states.notifications = notificationsValues;
} else {
states.notifications.push(...notificationsValues);
}
states.notificationsLastFetchTime = Date.now();
return allNotifications;
@ -310,9 +310,9 @@ function Notifications() {
}}
>
<div class="header-side">
<a href="#" class="button plain">
<Link to="/" class="button plain">
<Icon icon="home" size="l" />
</a>
</Link>
</div>
<h1>Notifications</h1>
<div class="header-side">

Wyświetl plik

@ -5,6 +5,7 @@ import { useSnapshot } from 'valtio';
import Avatar from '../components/avatar';
import Icon from '../components/icon';
import Link from '../components/link';
import NameText from '../components/name-text';
import RelativeTime from '../components/relative-time';
import states from '../utils/states';
@ -124,9 +125,9 @@ function Settings({ onClose }) {
</p>
)}
<p style={{ textAlign: 'end' }}>
<a href="/#/login" class="button" onClick={onClose}>
<Link to="/login" class="button" onClick={onClose}>
Add new account
</a>
</Link>
</p>
</section>
<h2>Settings</h2>

Wyświetl plik

@ -1,13 +1,14 @@
import './status.css';
import debounce from 'just-debounce-it';
import { Link } from 'preact-router/match';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useLocation, useParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import NameText from '../components/name-text';
import RelativeTime from '../components/relative-time';
@ -23,7 +24,9 @@ import useTitle from '../utils/useTitle';
const LIMIT = 40;
function StatusPage({ id }) {
function StatusPage() {
const { id } = useParams();
const location = useLocation();
const snapStates = useSnapshot(states);
const [statuses, setStatuses] = useState([]);
const [uiState, setUIState] = useState('default');
@ -270,10 +273,11 @@ function StatusPage({ id }) {
: 'Status',
);
const prevRoute = states.history.findLast((h) => {
return h === '/' || /notifications/i.test(h);
});
const closeLink = `#${prevRoute || '/'}`;
const closeLink = useMemo(() => {
const pathname = snapStates.prevLocation?.pathname;
if (!pathname || pathname.startsWith('/s/')) return '/';
return pathname;
}, []);
const [limit, setLimit] = useState(LIMIT);
const showMore = useMemo(() => {
@ -305,7 +309,7 @@ function StatusPage({ id }) {
return (
<div class="deck-backdrop">
<Link href={closeLink}></Link>
<Link to={closeLink}></Link>
<div
tabIndex="-1"
ref={scrollableRef}
@ -383,7 +387,7 @@ function StatusPage({ id }) {
</h1>
<div class="header-side">
<Loader hidden={uiState !== 'loading'} />
<Link class="button plain deck-close" href={closeLink}>
<Link class="button plain deck-close" to={closeLink}>
<Icon icon="x" size="xl" />
</Link>
</div>
@ -420,7 +424,7 @@ function StatusPage({ id }) {
class="
status-link
"
href={`#/s/${statusID}`}
to={`/s/${statusID}`}
>
<Status
statusID={statusID}
@ -551,7 +555,7 @@ function SubComments({
<li key={r.id}>
<Link
class="status-link"
href={`#/s/${r.id}`}
to={`/s/${r.id}`}
onClick={onStatusLinkClick}
>
<Status statusID={r.id} withinContext size="s" />

Wyświetl plik

@ -1,6 +1,7 @@
import './welcome.css';
import logo from '../assets/logo.svg';
import Link from '../components/link';
import useTitle from '../utils/useTitle';
function Welcome() {
@ -28,9 +29,9 @@ function Welcome() {
<p>
<big>
<b>
<a href="#/login" class="button">
<Link to="/login" class="button">
Log in
</a>
</Link>
</b>
</big>
</p>