diff --git a/src/app.css b/src/app.css index 3e329eea..08b3f00f 100644 --- a/src/app.css +++ b/src/app.css @@ -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 { diff --git a/src/app.jsx b/src/app.jsx index 83c83aee..8ffe6206 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -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 && ( } /> )} - {isLoggedIn && } />} + {isLoggedIn && } />} + {isLoggedIn && } />} + {isLoggedIn && } />} + {isLoggedIn && } />} + } /> + {/* } /> */} {isLoggedIn && } />} @@ -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); diff --git a/src/components/status.jsx b/src/components/status.jsx index 0ed8569b..a9186df1 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -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); diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx new file mode 100644 index 00000000..8bfa3853 --- /dev/null +++ b/src/components/timeline.jsx @@ -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 ( +
+
+
{ + if (e.target === e.currentTarget) { + scrollableRef.current?.scrollTo({ + top: 0, + behavior: 'smooth', + }); + } + }} + > +
+ + + +
+

{title}

+
+
+
+ {!!items.length ? ( + <> +
    + {items.map((status) => ( +
  • + + + +
  • + ))} +
+ {showMore && ( + + )} + + ) : uiState === 'loading' ? ( +
    + {Array.from({ length: 5 }).map((_, i) => ( +
  • + +
  • + ))} +
+ ) : ( + uiState !== 'loading' &&

{emptyText}

+ )} + {uiState === 'error' ? ( +

+ {errorText} +
+
+ +

+ ) : ( + uiState !== 'loading' && + !!items.length && + !showMore &&

The end.

+ )} +
+
+ ); +} + +export default Timeline; diff --git a/src/compose.jsx b/src/compose.jsx index 8c5e0a41..8a339378 100644 --- a/src/compose.jsx +++ b/src/compose.jsx @@ -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, diff --git a/src/pages/404.jsx b/src/pages/404.jsx new file mode 100644 index 00000000..42bef41a --- /dev/null +++ b/src/pages/404.jsx @@ -0,0 +1,15 @@ +import Link from '../components/link'; + +export default function NotFound() { + return ( +
+
+

404

+

Page not found.

+

+ Go home. +

+
+
+ ); +} diff --git a/src/pages/bookmarks.jsx b/src/pages/bookmarks.jsx index e5b7a941..fb25f9d6 100644 --- a/src/pages/bookmarks.jsx +++ b/src/pages/bookmarks.jsx @@ -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 ( -
-
-
{ - if (e.target === e.currentTarget) { - scrollableRef.current?.scrollTo({ - top: 0, - behavior: 'smooth', - }); - } - }} - onDblClick={(e) => { - loadBookmarks(true); - }} - > -
- - - -
-

Bookmarks

-
-
-
- {!!bookmarks.length ? ( - <> -
    - {bookmarks.map((status) => ( -
  • - - - -
  • - ))} -
- {showMore && ( - - )} - - ) : ( - uiState !== 'loading' && ( -

No bookmarks yet. Go bookmark something!

- ) - )} - {uiState === 'loading' ? ( -
    - {Array.from({ length: 5 }).map((_, i) => ( -
  • - -
  • - ))} -
- ) : uiState === 'error' ? ( -

- Unable to load bookmarks. -
-
- -

- ) : ( - bookmarks.length && - !showMore &&

The end.

- )} -
-
+ ); } diff --git a/src/pages/favourites.jsx b/src/pages/favourites.jsx new file mode 100644 index 00000000..61432832 --- /dev/null +++ b/src/pages/favourites.jsx @@ -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 ( + + ); +} + +export default Favourites; diff --git a/src/pages/hashtags.jsx b/src/pages/hashtags.jsx new file mode 100644 index 00000000..efd145b1 --- /dev/null +++ b/src/pages/hashtags.jsx @@ -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 ( + + ); +} + +export default Hashtags; diff --git a/src/pages/lists.jsx b/src/pages/lists.jsx new file mode 100644 index 00000000..b5b0ce4b --- /dev/null +++ b/src/pages/lists.jsx @@ -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 ( + + ); +} + +export default Lists; diff --git a/src/pages/public.jsx b/src/pages/public.jsx new file mode 100644 index 00000000..06963263 --- /dev/null +++ b/src/pages/public.jsx @@ -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 ( + + ); +} + +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; diff --git a/src/utils/emojify-text.js b/src/utils/emojify-text.js index 468476b5..be4a96ad 100644 --- a/src/utils/emojify-text.js +++ b/src/utils/emojify-text.js @@ -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' }]