mirror of
https://github.com/cheeaun/phanpy.git
synced 2025-02-02 14:16:39 +01:00
Refactor out a Timeline component
Also replace login() with createClient() for faster log in
This commit is contained in:
parent
1a5816f886
commit
aaeca7dd03
12 changed files with 452 additions and 182 deletions
19
src/app.css
19
src/app.css
|
@ -115,6 +115,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.deck > header h1:first-child {
|
.deck > header h1:first-child {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
@ -972,6 +973,24 @@ meter.donut:is(.danger, .explode):after {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 404 */
|
||||||
|
|
||||||
|
#not-found-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: default;
|
||||||
|
color: var(--text-insignificant-color);
|
||||||
|
background-image: radial-gradient(
|
||||||
|
circle at 50% 50%,
|
||||||
|
var(--bg-color) 25%,
|
||||||
|
var(--bg-faded-color)
|
||||||
|
);
|
||||||
|
text-shadow: 0 1px var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 40em) {
|
@media (min-width: 40em) {
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
|
|
121
src/app.jsx
121
src/app.jsx
|
@ -2,7 +2,7 @@ import './app.css';
|
||||||
import 'toastify-js/src/toastify.css';
|
import 'toastify-js/src/toastify.css';
|
||||||
|
|
||||||
import debounce from 'just-debounce-it';
|
import debounce from 'just-debounce-it';
|
||||||
import { login } from 'masto';
|
import { createClient } from 'masto';
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
|
@ -21,10 +21,15 @@ import Icon from './components/icon';
|
||||||
import Link from './components/link';
|
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 NotFound from './pages/404';
|
||||||
import Bookmarks from './pages/bookmarks';
|
import Bookmarks from './pages/bookmarks';
|
||||||
|
import Favourites from './pages/favourites';
|
||||||
|
import Hashtags from './pages/hashtags';
|
||||||
import Home from './pages/home';
|
import Home from './pages/home';
|
||||||
|
import Lists from './pages/lists';
|
||||||
import Login from './pages/login';
|
import Login from './pages/login';
|
||||||
import Notifications from './pages/notifications';
|
import Notifications from './pages/notifications';
|
||||||
|
import Public from './pages/public';
|
||||||
import Settings from './pages/settings';
|
import Settings from './pages/settings';
|
||||||
import Status from './pages/status';
|
import Status from './pages/status';
|
||||||
import Welcome from './pages/welcome';
|
import Welcome from './pages/welcome';
|
||||||
|
@ -74,11 +79,9 @@ function App() {
|
||||||
const { access_token: accessToken } = tokenJSON;
|
const { access_token: accessToken } = tokenJSON;
|
||||||
store.session.set('accessToken', accessToken);
|
store.session.set('accessToken', accessToken);
|
||||||
|
|
||||||
window.masto = await login({
|
initMasto({
|
||||||
url: `https://${instanceURL}`,
|
url: `https://${instanceURL}`,
|
||||||
accessToken,
|
accessToken,
|
||||||
disableVersionCheck: true,
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mastoAccount = await masto.v1.accounts.verifyCredentials();
|
const mastoAccount = await masto.v1.accounts.verifyCredentials();
|
||||||
|
@ -112,22 +115,12 @@ function App() {
|
||||||
const instanceURL = account.instanceURL;
|
const instanceURL = account.instanceURL;
|
||||||
const accessToken = account.accessToken;
|
const accessToken = account.accessToken;
|
||||||
store.session.set('currentAccount', account.info.id);
|
store.session.set('currentAccount', account.info.id);
|
||||||
|
if (accessToken) setIsLoggedIn(true);
|
||||||
|
|
||||||
(async () => {
|
initMasto({
|
||||||
try {
|
url: `https://${instanceURL}`,
|
||||||
setUIState('loading');
|
accessToken,
|
||||||
window.masto = await login({
|
});
|
||||||
url: `https://${instanceURL}`,
|
|
||||||
accessToken,
|
|
||||||
disableVersionCheck: true,
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
setIsLoggedIn(true);
|
|
||||||
} catch (e) {
|
|
||||||
setIsLoggedIn(false);
|
|
||||||
}
|
|
||||||
setUIState('default');
|
|
||||||
})();
|
|
||||||
} else {
|
} else {
|
||||||
setUIState('default');
|
setUIState('default');
|
||||||
}
|
}
|
||||||
|
@ -164,34 +157,8 @@ function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// HACK: prevent this from running again due to HMR
|
// HACK: prevent this from running again due to HMR
|
||||||
if (states.init) return;
|
if (states.init) return;
|
||||||
|
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(startVisibility);
|
||||||
// startStream();
|
|
||||||
startVisibility();
|
|
||||||
|
|
||||||
// Collect instance info
|
|
||||||
(async () => {
|
|
||||||
// Request v2, fallback to v1 if fail
|
|
||||||
let info;
|
|
||||||
try {
|
|
||||||
info = await masto.v2.instance.fetch();
|
|
||||||
} catch (e) {}
|
|
||||||
if (!info) {
|
|
||||||
try {
|
|
||||||
info = await masto.v1.instances.fetch();
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
if (!info) return;
|
|
||||||
console.log(info);
|
|
||||||
const { uri, domain } = info;
|
|
||||||
if (uri || domain) {
|
|
||||||
const instances = store.local.getJSON('instances') || {};
|
|
||||||
instances[(domain || uri).toLowerCase()] = info;
|
|
||||||
store.local.setJSON('instances', instances);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
states.init = true;
|
states.init = true;
|
||||||
}
|
}
|
||||||
}, [isLoggedIn]);
|
}, [isLoggedIn]);
|
||||||
|
@ -211,7 +178,7 @@ function App() {
|
||||||
|
|
||||||
const nonRootLocation = useMemo(() => {
|
const nonRootLocation = useMemo(() => {
|
||||||
const { pathname } = location;
|
const { pathname } = location;
|
||||||
return !/\/(login|welcome)$/.test(pathname);
|
return !/^\/(login|welcome|p)/.test(pathname);
|
||||||
}, [location]);
|
}, [location]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -236,7 +203,12 @@ function App() {
|
||||||
{isLoggedIn && (
|
{isLoggedIn && (
|
||||||
<Route path="/notifications" element={<Notifications />} />
|
<Route path="/notifications" element={<Notifications />} />
|
||||||
)}
|
)}
|
||||||
{isLoggedIn && <Route path="/bookmarks" element={<Bookmarks />} />}
|
{isLoggedIn && <Route path="/b" element={<Bookmarks />} />}
|
||||||
|
{isLoggedIn && <Route path="/f" element={<Favourites />} />}
|
||||||
|
{isLoggedIn && <Route path="/l/:id" element={<Lists />} />}
|
||||||
|
{isLoggedIn && <Route path="/t/:hashtag" element={<Hashtags />} />}
|
||||||
|
<Route path="/p/l?/:instance" element={<Public />} />
|
||||||
|
{/* <Route path="/:anything" element={<NotFound />} /> */}
|
||||||
</Routes>
|
</Routes>
|
||||||
<Routes>
|
<Routes>
|
||||||
{isLoggedIn && <Route path="/s/:id" element={<Status />} />}
|
{isLoggedIn && <Route path="/s/:id" element={<Status />} />}
|
||||||
|
@ -344,6 +316,50 @@ function App() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initMasto(params) {
|
||||||
|
const clientParams = {
|
||||||
|
url: params.url || 'https://mastodon.social',
|
||||||
|
accessToken: params.accessToken || null,
|
||||||
|
disableVersionCheck: true,
|
||||||
|
timeout: 30_000,
|
||||||
|
};
|
||||||
|
window.masto = createClient(clientParams);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// Request v2, fallback to v1 if fail
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
info = await masto.v2.instance.fetch();
|
||||||
|
} catch (e) {}
|
||||||
|
if (!info) {
|
||||||
|
try {
|
||||||
|
info = await masto.v1.instances.fetch();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
if (!info) return;
|
||||||
|
console.log(info);
|
||||||
|
const {
|
||||||
|
// v1
|
||||||
|
uri,
|
||||||
|
urls: { streamingApi } = {},
|
||||||
|
// v2
|
||||||
|
domain,
|
||||||
|
configuration: { urls: { streaming } = {} } = {},
|
||||||
|
} = info;
|
||||||
|
if (uri || domain) {
|
||||||
|
const instances = store.local.getJSON('instances') || {};
|
||||||
|
instances[(domain || uri).toLowerCase()] = info;
|
||||||
|
store.local.setJSON('instances', instances);
|
||||||
|
}
|
||||||
|
if (streamingApi || streaming) {
|
||||||
|
window.masto = createClient({
|
||||||
|
...clientParams,
|
||||||
|
streamingApiUrl: streaming || streamingApi,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
let ws;
|
let ws;
|
||||||
async function startStream() {
|
async function startStream() {
|
||||||
if (
|
if (
|
||||||
|
@ -417,18 +433,18 @@ async function startStream() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastHidden;
|
||||||
function startVisibility() {
|
function startVisibility() {
|
||||||
const handleVisible = (visible) => {
|
const handleVisible = (visible) => {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
store.session.set('lastHidden', timestamp);
|
lastHidden = timestamp;
|
||||||
} else {
|
} else {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const lastHidden = store.session.get('lastHidden');
|
|
||||||
const diff = timestamp - lastHidden;
|
const diff = timestamp - lastHidden;
|
||||||
const diffMins = Math.round(diff / 1000 / 60);
|
const diffMins = Math.round(diff / 1000 / 60);
|
||||||
if (diffMins > 1) {
|
console.log(`visible: ${visible}`, { lastHidden, diffMins });
|
||||||
console.log('visible', { lastHidden, diffMins });
|
if (!lastHidden || diffMins > 1) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const firstStatusID = states.homeLast?.id;
|
const firstStatusID = states.homeLast?.id;
|
||||||
|
@ -492,6 +508,7 @@ function startVisibility() {
|
||||||
console.log('VISIBILITY: ' + (hidden ? 'hidden' : 'visible'));
|
console.log('VISIBILITY: ' + (hidden ? 'hidden' : 'visible'));
|
||||||
};
|
};
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
requestAnimationFrame(handleVisibilityChange);
|
||||||
return {
|
return {
|
||||||
stop: () => {
|
stop: () => {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
|
|
@ -33,7 +33,11 @@ import Link from './link';
|
||||||
import RelativeTime from './relative-time';
|
import RelativeTime from './relative-time';
|
||||||
|
|
||||||
function fetchAccount(id) {
|
function fetchAccount(id) {
|
||||||
return masto.v1.accounts.fetch(id);
|
try {
|
||||||
|
return masto.v1.accounts.fetch(id);
|
||||||
|
} catch (e) {
|
||||||
|
return Promise.reject(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const memFetchAccount = mem(fetchAccount);
|
const memFetchAccount = mem(fetchAccount);
|
||||||
|
|
||||||
|
|
151
src/components/timeline.jsx
Normal file
151
src/components/timeline.jsx
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
import useScroll from '../utils/useScroll';
|
||||||
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
|
import Icon from './icon';
|
||||||
|
import Link from './link';
|
||||||
|
import Loader from './loader';
|
||||||
|
import Status from './status';
|
||||||
|
|
||||||
|
function Timeline({ title, id, emptyText, errorText, fetchItems = () => {} }) {
|
||||||
|
if (title) {
|
||||||
|
useTitle(title);
|
||||||
|
}
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [uiState, setUIState] = useState('default');
|
||||||
|
const [showMore, setShowMore] = useState(false);
|
||||||
|
const scrollableRef = useRef(null);
|
||||||
|
const { nearReachEnd, reachStart } = useScroll({
|
||||||
|
scrollableElement: scrollableRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadItems = (firstLoad) => {
|
||||||
|
setUIState('loading');
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { done, value } = await fetchItems(firstLoad);
|
||||||
|
if (value?.length) {
|
||||||
|
if (firstLoad) {
|
||||||
|
setItems(value);
|
||||||
|
} else {
|
||||||
|
setItems([...items, ...value]);
|
||||||
|
}
|
||||||
|
setShowMore(!done);
|
||||||
|
} else {
|
||||||
|
setShowMore(false);
|
||||||
|
}
|
||||||
|
setUIState('default');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setUIState('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollableRef.current?.scrollTo({ top: 0 });
|
||||||
|
loadItems(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reachStart) {
|
||||||
|
loadItems(true);
|
||||||
|
}
|
||||||
|
}, [reachStart]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (nearReachEnd && showMore) {
|
||||||
|
loadItems();
|
||||||
|
}
|
||||||
|
}, [nearReachEnd, showMore]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={`${id}-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>{title}</h1>
|
||||||
|
<div class="header-side">
|
||||||
|
<Loader hidden={uiState !== 'loading'} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{!!items.length ? (
|
||||||
|
<>
|
||||||
|
<ul class="timeline">
|
||||||
|
{items.map((status) => (
|
||||||
|
<li key={`timeline-${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={() => loadItems()}
|
||||||
|
style={{ marginBlockEnd: '6em' }}
|
||||||
|
>
|
||||||
|
{uiState === 'loading' ? (
|
||||||
|
<Loader abrupt />
|
||||||
|
) : (
|
||||||
|
<>Show more…</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : uiState === 'loading' ? (
|
||||||
|
<ul class="timeline">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<Status skeleton />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
uiState !== 'loading' && <p class="ui-state">{emptyText}</p>
|
||||||
|
)}
|
||||||
|
{uiState === 'error' ? (
|
||||||
|
<p class="ui-state">
|
||||||
|
{errorText}
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<button
|
||||||
|
class="button plain"
|
||||||
|
onClick={() => loadItems(!items.length)}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
uiState !== 'loading' &&
|
||||||
|
!!items.length &&
|
||||||
|
!showMore && <p class="ui-state insignificant">The end.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Timeline;
|
|
@ -2,7 +2,7 @@ import './index.css';
|
||||||
|
|
||||||
import './app.css';
|
import './app.css';
|
||||||
|
|
||||||
import { login } from 'masto';
|
import { createClient } from 'masto';
|
||||||
import { render } from 'preact';
|
import { render } from 'preact';
|
||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
|
|
||||||
|
@ -14,12 +14,12 @@ if (window.opener) {
|
||||||
console = window.opener.console;
|
console = window.opener.console;
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(() => {
|
||||||
if (window.masto) return;
|
if (window.masto) return;
|
||||||
console.warn('window.masto not found. Trying to log in...');
|
console.warn('window.masto not found. Trying to log in...');
|
||||||
try {
|
try {
|
||||||
const { instanceURL, accessToken } = getCurrentAccount();
|
const { instanceURL, accessToken } = getCurrentAccount();
|
||||||
window.masto = await login({
|
window.masto = createClient({
|
||||||
url: `https://${instanceURL}`,
|
url: `https://${instanceURL}`,
|
||||||
accessToken,
|
accessToken,
|
||||||
disableVersionCheck: true,
|
disableVersionCheck: true,
|
||||||
|
|
15
src/pages/404.jsx
Normal file
15
src/pages/404.jsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import Link from '../components/link';
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div id="not-found-page" className="deck-container" tabIndex="-1">
|
||||||
|
<div>
|
||||||
|
<h1>404</h1>
|
||||||
|
<p>Page not found.</p>
|
||||||
|
<p>
|
||||||
|
<Link to="/">Go home</Link>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,141 +1,26 @@
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useRef } from 'preact/hooks';
|
||||||
|
|
||||||
import Icon from '../components/icon';
|
import Timeline from '../components/timeline';
|
||||||
import Link from '../components/link';
|
|
||||||
import Loader from '../components/loader';
|
|
||||||
import Status from '../components/status';
|
|
||||||
import useTitle from '../utils/useTitle';
|
|
||||||
|
|
||||||
const LIMIT = 40;
|
const LIMIT = 20;
|
||||||
|
|
||||||
function Bookmarks() {
|
function Bookmarks() {
|
||||||
useTitle('Bookmarks');
|
|
||||||
const [bookmarks, setBookmarks] = useState([]);
|
|
||||||
const [uiState, setUIState] = useState('default');
|
|
||||||
const [showMore, setShowMore] = useState(false);
|
|
||||||
|
|
||||||
const bookmarksIterator = useRef();
|
const bookmarksIterator = useRef();
|
||||||
async function fetchBookmarks(firstLoad) {
|
async function fetchBookmarks(firstLoad) {
|
||||||
if (firstLoad || !bookmarksIterator.current) {
|
if (firstLoad || !bookmarksIterator.current) {
|
||||||
bookmarksIterator.current = masto.v1.bookmarks.list({ limit: LIMIT });
|
bookmarksIterator.current = masto.v1.bookmarks.list({ limit: LIMIT });
|
||||||
}
|
}
|
||||||
const allBookmarks = await bookmarksIterator.current.next();
|
return await bookmarksIterator.current.next();
|
||||||
const bookmarksValue = allBookmarks.value;
|
|
||||||
if (bookmarksValue?.length) {
|
|
||||||
if (firstLoad) {
|
|
||||||
setBookmarks(bookmarksValue);
|
|
||||||
} else {
|
|
||||||
setBookmarks([...bookmarks, ...bookmarksValue]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return allBookmarks;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadBookmarks = (firstLoad) => {
|
|
||||||
setUIState('loading');
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const { done } = await fetchBookmarks(firstLoad);
|
|
||||||
setShowMore(!done);
|
|
||||||
setUIState('default');
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
setUIState('error');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadBookmarks(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scrollableRef = useRef(null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Timeline
|
||||||
id="bookmarks-page"
|
title="Bookmarks"
|
||||||
class="deck-container"
|
id="bookmarks"
|
||||||
ref={scrollableRef}
|
emptyText="No bookmarks yet. Go bookmark something!"
|
||||||
tabIndex="-1"
|
errorText="Unable to load bookmarks"
|
||||||
>
|
fetchItems={fetchBookmarks}
|
||||||
<div class="timeline-deck deck">
|
/>
|
||||||
<header
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
scrollableRef.current?.scrollTo({
|
|
||||||
top: 0,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDblClick={(e) => {
|
|
||||||
loadBookmarks(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="header-side">
|
|
||||||
<Link to="/" class="button plain">
|
|
||||||
<Icon icon="home" size="l" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<h1>Bookmarks</h1>
|
|
||||||
<div class="header-side">
|
|
||||||
<Loader hidden={uiState !== 'loading'} />
|
|
||||||
</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…</>}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
uiState !== 'loading' && (
|
|
||||||
<p class="ui-state">No bookmarks yet. Go bookmark something!</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">
|
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
27
src/pages/favourites.jsx
Normal file
27
src/pages/favourites.jsx
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { useRef } from 'preact/hooks';
|
||||||
|
|
||||||
|
import Timeline from '../components/timeline';
|
||||||
|
|
||||||
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
function Favourites() {
|
||||||
|
const favouritesIterator = useRef();
|
||||||
|
async function fetchFavourites(firstLoad) {
|
||||||
|
if (firstLoad || !favouritesIterator.current) {
|
||||||
|
favouritesIterator.current = masto.v1.favourites.list({ limit: LIMIT });
|
||||||
|
}
|
||||||
|
return await favouritesIterator.current.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline
|
||||||
|
title="Favourites"
|
||||||
|
id="favourites"
|
||||||
|
emptyText="No favourites yet. Go favourite something!"
|
||||||
|
errorText="Unable to load favourites"
|
||||||
|
fetchItems={fetchFavourites}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Favourites;
|
32
src/pages/hashtags.jsx
Normal file
32
src/pages/hashtags.jsx
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { useRef } from 'preact/hooks';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Timeline from '../components/timeline';
|
||||||
|
|
||||||
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
function Hashtags() {
|
||||||
|
const { hashtag } = useParams();
|
||||||
|
const hashtagsIterator = useRef();
|
||||||
|
async function fetchHashtags(firstLoad) {
|
||||||
|
if (firstLoad || !hashtagsIterator.current) {
|
||||||
|
hashtagsIterator.current = masto.v1.timelines.listHashtag(hashtag, {
|
||||||
|
limit: LIMIT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await hashtagsIterator.current.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline
|
||||||
|
key={hashtag}
|
||||||
|
title={`#${hashtag}`}
|
||||||
|
id="hashtags"
|
||||||
|
emptyText="No one has posted anything with this tag yet."
|
||||||
|
errorText="Unable to load posts with this tag"
|
||||||
|
fetchItems={fetchHashtags}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Hashtags;
|
43
src/pages/lists.jsx
Normal file
43
src/pages/lists.jsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Timeline from '../components/timeline';
|
||||||
|
|
||||||
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
function Lists() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const listsIterator = useRef();
|
||||||
|
async function fetchLists(firstLoad) {
|
||||||
|
if (firstLoad || !listsIterator.current) {
|
||||||
|
listsIterator.current = masto.v1.timelines.listList(id, {
|
||||||
|
limit: LIMIT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await listsIterator.current.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [title, setTitle] = useState(`List ${id}`);
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const list = await masto.v1.lists.fetch(id);
|
||||||
|
setTitle(list.title);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline
|
||||||
|
title={title}
|
||||||
|
id="lists"
|
||||||
|
emptyText="Nothing yet."
|
||||||
|
errorText="Unable to load posts."
|
||||||
|
fetchItems={fetchLists}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Lists;
|
76
src/pages/public.jsx
Normal file
76
src/pages/public.jsx
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
// EXPERIMENTAL: This is a work in progress and may not work as expected.
|
||||||
|
import { useMatch, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Timeline from '../components/timeline';
|
||||||
|
|
||||||
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
let nextUrl = null;
|
||||||
|
|
||||||
|
function Public() {
|
||||||
|
const isLocal = !!useMatch('/p/l/:instance');
|
||||||
|
const params = useParams();
|
||||||
|
const { instance = '' } = params;
|
||||||
|
async function fetchPublic(firstLoad) {
|
||||||
|
const url = firstLoad
|
||||||
|
? `https://${instance}/api/v1/timelines/public?limit=${LIMIT}&local=${isLocal}`
|
||||||
|
: nextUrl;
|
||||||
|
if (!url) return { values: [], done: true };
|
||||||
|
const response = await fetch(url);
|
||||||
|
let value = await response.json();
|
||||||
|
if (value) {
|
||||||
|
value = camelCaseKeys(value);
|
||||||
|
}
|
||||||
|
const done = !response.headers.has('link');
|
||||||
|
nextUrl = done
|
||||||
|
? null
|
||||||
|
: response.headers.get('link').match(/<(.+?)>; rel="next"/)?.[1];
|
||||||
|
console.debug({
|
||||||
|
url,
|
||||||
|
value,
|
||||||
|
done,
|
||||||
|
nextUrl,
|
||||||
|
});
|
||||||
|
return { value, done };
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Timeline
|
||||||
|
key={instance + isLocal}
|
||||||
|
title={`${instance} (${isLocal ? 'local' : 'federated'})`}
|
||||||
|
id="public"
|
||||||
|
emptyText="No one has posted anything yet."
|
||||||
|
errorText="Unable to load posts"
|
||||||
|
fetchItems={fetchPublic}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function camelCaseKeys(obj) {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map((item) => camelCaseKeys(item));
|
||||||
|
}
|
||||||
|
return new Proxy(obj, {
|
||||||
|
get(target, prop) {
|
||||||
|
let value = undefined;
|
||||||
|
if (prop in target) {
|
||||||
|
value = target[prop];
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
const snakeCaseProp = prop.replace(
|
||||||
|
/([A-Z])/g,
|
||||||
|
(g) => `_${g.toLowerCase()}`,
|
||||||
|
);
|
||||||
|
if (snakeCaseProp in target) {
|
||||||
|
value = target[snakeCaseProp];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return camelCaseKeys(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Public;
|
|
@ -1,4 +1,5 @@
|
||||||
function emojifyText(text, emojis = []) {
|
function emojifyText(text, emojis = []) {
|
||||||
|
if (!text) return '';
|
||||||
if (!emojis.length) return text;
|
if (!emojis.length) return text;
|
||||||
// Replace shortcodes in text with emoji
|
// Replace shortcodes in text with emoji
|
||||||
// emojis = [{ shortcode: 'smile', url: 'https://example.com/emoji.png' }]
|
// emojis = [{ shortcode: 'smile', url: 'https://example.com/emoji.png' }]
|
||||||
|
|
Loading…
Reference in a new issue