Replace preact-router with react-router

Need more routing powers, hopefully things don't break 🤞
This commit is contained in:
Lim Chee Aun 2023-01-21 00:23:59 +08:00
parent baf139762c
commit 9bff95bcec
15 changed files with 662 additions and 362 deletions

View file

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

101
package-lock.json generated
View file

@ -14,15 +14,14 @@
"dayjs-twitter": "~0.5.0", "dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2", "fast-blurhash": "~1.1.2",
"fast-deep-equal": "~3.1.3", "fast-deep-equal": "~3.1.3",
"history": "~5.3.0",
"idb-keyval": "~6.2.0", "idb-keyval": "~6.2.0",
"just-debounce-it": "~3.2.0", "just-debounce-it": "~3.2.0",
"masto": "~5.5.0", "masto": "~5.5.0",
"mem": "~9.0.2", "mem": "~9.0.2",
"preact": "~10.11.3", "preact": "~10.11.3",
"preact-router": "~4.1.0",
"react-hotkeys-hook": "~4.3.2", "react-hotkeys-hook": "~4.3.2",
"react-intersection-observer": "~9.4.1", "react-intersection-observer": "~9.4.1",
"react-router-dom": "~6.7.0",
"string-length": "~5.0.1", "string-length": "~5.0.1",
"swiped-events": "~1.1.7", "swiped-events": "~1.1.7",
"toastify-js": "~1.12.0", "toastify-js": "~1.12.0",
@ -1653,6 +1652,7 @@
"version": "7.20.6", "version": "7.20.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz",
"integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==", "integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==",
"dev": true,
"dependencies": { "dependencies": {
"regenerator-runtime": "^0.13.11" "regenerator-runtime": "^0.13.11"
}, },
@ -2276,6 +2276,14 @@
"vite": ">=2.0.0-beta.3" "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": { "node_modules/@rollup/plugin-replace": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-5.0.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-5.0.2.tgz",
@ -3607,14 +3615,6 @@
"tslib": "^2.0.3" "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": { "node_modules/idb": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@ -4537,14 +4537,6 @@
"url": "https://opencollective.com/preact" "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": { "node_modules/prettier": {
"version": "2.8.0", "version": "2.8.0",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.0.tgz", "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" "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": { "node_modules/regenerate": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@ -4693,7 +4715,8 @@
"node_modules/regenerator-runtime": { "node_modules/regenerator-runtime": {
"version": "0.13.11", "version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "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": { "node_modules/regenerator-transform": {
"version": "0.15.1", "version": "0.15.1",
@ -7013,6 +7036,7 @@
"version": "7.20.6", "version": "7.20.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz",
"integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==", "integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==",
"dev": true,
"requires": { "requires": {
"regenerator-runtime": "^0.13.11" "regenerator-runtime": "^0.13.11"
} }
@ -7394,6 +7418,11 @@
"@rollup/pluginutils": "^4.1.0" "@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": { "@rollup/plugin-replace": {
"version": "5.0.2", "version": "5.0.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-5.0.2.tgz", "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-5.0.2.tgz",
@ -8413,14 +8442,6 @@
"tslib": "^2.0.3" "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": { "idb": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", "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", "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==" "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": { "prettier": {
"version": "2.8.0", "version": "2.8.0",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.0.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.0.tgz",
@ -9181,6 +9196,23 @@
"integrity": "sha512-IXpIsPe6BleFOEHKzKh5UjwRUaz/JYS0lT/HPsupWEQou2hDqjhLMStc5zyE3eQVT4Fk3FufM8Fw33qW1uyeiw==", "integrity": "sha512-IXpIsPe6BleFOEHKzKh5UjwRUaz/JYS0lT/HPsupWEQou2hDqjhLMStc5zyE3eQVT4Fk3FufM8Fw33qW1uyeiw==",
"requires": {} "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": { "regenerate": {
"version": "1.4.2", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@ -9199,7 +9231,8 @@
"regenerator-runtime": { "regenerator-runtime": {
"version": "0.13.11", "version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "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": { "regenerator-transform": {
"version": "0.15.1", "version": "0.15.1",

View file

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

View file

@ -46,6 +46,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
transition: opacity 0.1s ease-in-out; transition: opacity 0.1s ease-in-out;
overscroll-behavior: contain; overscroll-behavior: contain;
scroll-behavior: smooth; scroll-behavior: smooth;
background-color: var(--bg-color);
} }
.deck-container[hidden] { .deck-container[hidden] {
display: block; display: block;
@ -61,6 +62,14 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
scroll-padding-top: 3em; 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 { .deck {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh; min-height: 100dvh;
@ -364,7 +373,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
animation: appear 0.2s ease-out; 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); background-color: var(--link-bg-hover-color);
outline-offset: -2px; outline-offset: -2px;
} }
@ -508,11 +517,6 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
max-width: 40em; max-width: 40em;
} }
.decks {
flex-grow: 1;
width: 100%;
}
.deck-close { .deck-close {
color: var(--text-insignificant-color) !important; color: var(--text-insignificant-color) !important;
} }
@ -944,21 +948,63 @@ meter.donut:is(.danger, .explode):after {
gap: 4px; 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) { @media (min-width: 40em) {
html, html,
body { body {
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
} }
.deck-container {
background-color: var(--bg-faded-color);
}
#app { #app {
display: flex; 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 { .deck-backdrop .deck {
width: 50%; width: 50%;
min-width: 40em; min-width: 40em;
@ -995,6 +1041,22 @@ meter.donut:is(.danger, .explode):after {
border-radius: 16px; border-radius: 16px;
overflow: hidden; overflow: hidden;
box-shadow: 0px 1px var(--bg-blur-color); 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 { .box {
padding: 32px; padding: 32px;

View file

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

30
src/components/link.jsx Normal file
View file

@ -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;

View file

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

View file

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

144
src/pages/bookmarks.jsx Normal file
View file

@ -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;

View file

@ -1,10 +1,10 @@
import { Link } from 'preact-router/match';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import Icon from '../components/icon'; import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader'; import Loader from '../components/loader';
import Status from '../components/status'; import Status from '../components/status';
import db from '../utils/db'; import db from '../utils/db';
@ -36,9 +36,7 @@ function Home({ hidden }) {
states.homeNew = []; states.homeNew = [];
} }
const allStatuses = await homeIterator.current.next(); const allStatuses = await homeIterator.current.next();
if (allStatuses.value <= 0) { if (allStatuses.value?.length) {
return { done: true };
}
const homeValues = allStatuses.value.map((status) => { const homeValues = allStatuses.value.map((status) => {
saveStatus(status); saveStatus(status);
return { return {
@ -107,11 +105,10 @@ function Home({ hidden }) {
states.home.push(...homeValues); states.home.push(...homeValues);
} }
} }
}
states.homeLastFetchTime = Date.now(); states.homeLastFetchTime = Date.now();
return { return allStatuses;
done: false,
};
} }
const loadingStatuses = useRef(false); const loadingStatuses = useRef(false);
@ -276,6 +273,7 @@ function Home({ hidden }) {
}, []); }, []);
return ( return (
<>
<div <div
id="home-page" id="home-page"
class="deck-container" class="deck-container"
@ -283,24 +281,6 @@ function Home({ hidden }) {
ref={scrollableRef} ref={scrollableRef}
tabIndex="-1" tabIndex="-1"
> >
<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>
<div class="timeline-deck deck"> <div class="timeline-deck deck">
<header <header
hidden={scrollDirection === 'end' && !nearReachStart} hidden={scrollDirection === 'end' && !nearReachStart}
@ -327,8 +307,8 @@ function Home({ hidden }) {
<h1>Home</h1> <h1>Home</h1>
<div class="header-side"> <div class="header-side">
<Loader hidden={uiState !== 'loading'} />{' '} <Loader hidden={uiState !== 'loading'} />{' '}
<a <Link
href="#/notifications" to="/notifications"
class={`button plain ${ class={`button plain ${
snapStates.notificationsNew.length > 0 ? 'has-badge' : '' snapStates.notificationsNew.length > 0 ? 'has-badge' : ''
}`} }`}
@ -337,7 +317,7 @@ function Home({ hidden }) {
}} }}
> >
<Icon icon="notification" size="l" alt="Notifications" /> <Icon icon="notification" size="l" alt="Notifications" />
</a> </Link>
</div> </div>
</header> </header>
{snapStates.homeNew.length > 0 && {snapStates.homeNew.length > 0 &&
@ -380,11 +360,7 @@ function Home({ hidden }) {
} }
return ( return (
<li key={statusID}> <li key={statusID}>
<Link <Link class="status-link" to={`/s/${actualStatusID}`}>
activeClassName="active"
class="status-link"
href={`#/s/${actualStatusID}`}
>
<Status statusID={statusID} /> <Status statusID={statusID} />
</Link> </Link>
</li> </li>
@ -452,6 +428,25 @@ function Home({ hidden }) {
)} )}
</div> </div>
</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>
</>
); );
} }
@ -504,9 +499,9 @@ function BoostsCarousel({ boosts }) {
const actualStatusID = reblog || statusID; const actualStatusID = reblog || statusID;
return ( return (
<li> <li>
<a class="status-boost-link" href={`#/s/${actualStatusID}`}> <Link class="status-boost-link" to={`/s/${actualStatusID}`}>
<Status statusID={statusID} size="s" /> <Status statusID={statusID} size="s" />
</a> </Link>
</li> </li>
); );
})} })}

View file

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

View file

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

View file

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

View file

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

View file

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