Refactor out a Timeline component

Also replace login() with createClient() for faster log in
This commit is contained in:
Lim Chee Aun 2023-01-28 18:52:18 +08:00
parent 1a5816f886
commit aaeca7dd03
12 changed files with 452 additions and 182 deletions

View file

@ -115,6 +115,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
padding: 0;
font-size: 1.2em;
text-align: center;
white-space: nowrap;
}
.deck > header h1:first-child {
text-align: left;
@ -972,6 +973,24 @@ meter.donut:is(.danger, .explode):after {
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) {
html,
body {

View file

@ -2,7 +2,7 @@ import './app.css';
import 'toastify-js/src/toastify.css';
import debounce from 'just-debounce-it';
import { login } from 'masto';
import { createClient } from 'masto';
import {
useEffect,
useLayoutEffect,
@ -21,10 +21,15 @@ import Icon from './components/icon';
import Link from './components/link';
import Loader from './components/loader';
import Modal from './components/modal';
import NotFound from './pages/404';
import Bookmarks from './pages/bookmarks';
import Favourites from './pages/favourites';
import Hashtags from './pages/hashtags';
import Home from './pages/home';
import Lists from './pages/lists';
import Login from './pages/login';
import Notifications from './pages/notifications';
import Public from './pages/public';
import Settings from './pages/settings';
import Status from './pages/status';
import Welcome from './pages/welcome';
@ -74,11 +79,9 @@ function App() {
const { access_token: accessToken } = tokenJSON;
store.session.set('accessToken', accessToken);
window.masto = await login({
initMasto({
url: `https://${instanceURL}`,
accessToken,
disableVersionCheck: true,
timeout: 30_000,
});
const mastoAccount = await masto.v1.accounts.verifyCredentials();
@ -112,22 +115,12 @@ function App() {
const instanceURL = account.instanceURL;
const accessToken = account.accessToken;
store.session.set('currentAccount', account.info.id);
if (accessToken) setIsLoggedIn(true);
(async () => {
try {
setUIState('loading');
window.masto = await login({
url: `https://${instanceURL}`,
accessToken,
disableVersionCheck: true,
timeout: 30_000,
});
setIsLoggedIn(true);
} catch (e) {
setIsLoggedIn(false);
}
setUIState('default');
})();
initMasto({
url: `https://${instanceURL}`,
accessToken,
});
} else {
setUIState('default');
}
@ -164,34 +157,8 @@ function App() {
useEffect(() => {
// HACK: prevent this from running again due to HMR
if (states.init) return;
if (isLoggedIn) {
requestAnimationFrame(() => {
// 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);
}
})();
});
requestAnimationFrame(startVisibility);
states.init = true;
}
}, [isLoggedIn]);
@ -211,7 +178,7 @@ function App() {
const nonRootLocation = useMemo(() => {
const { pathname } = location;
return !/\/(login|welcome)$/.test(pathname);
return !/^\/(login|welcome|p)/.test(pathname);
}, [location]);
return (
@ -236,7 +203,12 @@ function App() {
{isLoggedIn && (
<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>
{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;
async function startStream() {
if (
@ -417,18 +433,18 @@ async function startStream() {
};
}
let lastHidden;
function startVisibility() {
const handleVisible = (visible) => {
if (!visible) {
const timestamp = Date.now();
store.session.set('lastHidden', timestamp);
lastHidden = timestamp;
} else {
const timestamp = Date.now();
const lastHidden = store.session.get('lastHidden');
const diff = timestamp - lastHidden;
const diffMins = Math.round(diff / 1000 / 60);
if (diffMins > 1) {
console.log('visible', { lastHidden, diffMins });
console.log(`visible: ${visible}`, { lastHidden, diffMins });
if (!lastHidden || diffMins > 1) {
(async () => {
try {
const firstStatusID = states.homeLast?.id;
@ -492,6 +508,7 @@ function startVisibility() {
console.log('VISIBILITY: ' + (hidden ? 'hidden' : 'visible'));
};
document.addEventListener('visibilitychange', handleVisibilityChange);
requestAnimationFrame(handleVisibilityChange);
return {
stop: () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);

View file

@ -33,7 +33,11 @@ import Link from './link';
import RelativeTime from './relative-time';
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);

151
src/components/timeline.jsx Normal file
View 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&hellip;</>
)}
</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;

View file

@ -2,7 +2,7 @@ import './index.css';
import './app.css';
import { login } from 'masto';
import { createClient } from 'masto';
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';
@ -14,12 +14,12 @@ if (window.opener) {
console = window.opener.console;
}
(async () => {
(() => {
if (window.masto) return;
console.warn('window.masto not found. Trying to log in...');
try {
const { instanceURL, accessToken } = getCurrentAccount();
window.masto = await login({
window.masto = createClient({
url: `https://${instanceURL}`,
accessToken,
disableVersionCheck: true,

15
src/pages/404.jsx Normal file
View 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>
);
}

View file

@ -1,141 +1,26 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { useRef } from 'preact/hooks';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import Status from '../components/status';
import useTitle from '../utils/useTitle';
import Timeline from '../components/timeline';
const LIMIT = 40;
const LIMIT = 20;
function Bookmarks() {
useTitle('Bookmarks');
const [bookmarks, setBookmarks] = useState([]);
const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false);
const bookmarksIterator = useRef();
async function fetchBookmarks(firstLoad) {
if (firstLoad || !bookmarksIterator.current) {
bookmarksIterator.current = masto.v1.bookmarks.list({ limit: LIMIT });
}
const allBookmarks = await bookmarksIterator.current.next();
const bookmarksValue = allBookmarks.value;
if (bookmarksValue?.length) {
if (firstLoad) {
setBookmarks(bookmarksValue);
} else {
setBookmarks([...bookmarks, ...bookmarksValue]);
}
}
return allBookmarks;
return await bookmarksIterator.current.next();
}
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 (
<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',
});
}
}}
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&hellip;</>}
</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>
<Timeline
title="Bookmarks"
id="bookmarks"
emptyText="No bookmarks yet. Go bookmark something!"
errorText="Unable to load bookmarks"
fetchItems={fetchBookmarks}
/>
);
}

27
src/pages/favourites.jsx Normal file
View 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
View 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
View 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
View 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;

View file

@ -1,4 +1,5 @@
function emojifyText(text, emojis = []) {
if (!text) return '';
if (!emojis.length) return text;
// Replace shortcodes in text with emoji
// emojis = [{ shortcode: 'smile', url: 'https://example.com/emoji.png' }]