this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Refactor out a Timeline component

Also replace login() with createClient() for faster log in

+452 -182
+19
src/app.css
··· 115 115 padding: 0; 116 116 font-size: 1.2em; 117 117 text-align: center; 118 + white-space: nowrap; 118 119 } 119 120 .deck > header h1:first-child { 120 121 text-align: left; ··· 970 971 text-align: center; 971 972 padding: 16px 0; 972 973 display: block; 974 + } 975 + 976 + /* 404 */ 977 + 978 + #not-found-page { 979 + display: flex; 980 + align-items: center; 981 + justify-content: center; 982 + text-align: center; 983 + overflow: hidden; 984 + cursor: default; 985 + color: var(--text-insignificant-color); 986 + background-image: radial-gradient( 987 + circle at 50% 50%, 988 + var(--bg-color) 25%, 989 + var(--bg-faded-color) 990 + ); 991 + text-shadow: 0 1px var(--bg-color); 973 992 } 974 993 975 994 @media (min-width: 40em) {
+69 -52
src/app.jsx
··· 2 2 import 'toastify-js/src/toastify.css'; 3 3 4 4 import debounce from 'just-debounce-it'; 5 - import { login } from 'masto'; 5 + import { createClient } from 'masto'; 6 6 import { 7 7 useEffect, 8 8 useLayoutEffect, ··· 21 21 import Link from './components/link'; 22 22 import Loader from './components/loader'; 23 23 import Modal from './components/modal'; 24 + import NotFound from './pages/404'; 24 25 import Bookmarks from './pages/bookmarks'; 26 + import Favourites from './pages/favourites'; 27 + import Hashtags from './pages/hashtags'; 25 28 import Home from './pages/home'; 29 + import Lists from './pages/lists'; 26 30 import Login from './pages/login'; 27 31 import Notifications from './pages/notifications'; 32 + import Public from './pages/public'; 28 33 import Settings from './pages/settings'; 29 34 import Status from './pages/status'; 30 35 import Welcome from './pages/welcome'; ··· 74 79 const { access_token: accessToken } = tokenJSON; 75 80 store.session.set('accessToken', accessToken); 76 81 77 - window.masto = await login({ 82 + initMasto({ 78 83 url: `https://${instanceURL}`, 79 84 accessToken, 80 - disableVersionCheck: true, 81 - timeout: 30_000, 82 85 }); 83 86 84 87 const mastoAccount = await masto.v1.accounts.verifyCredentials(); ··· 112 115 const instanceURL = account.instanceURL; 113 116 const accessToken = account.accessToken; 114 117 store.session.set('currentAccount', account.info.id); 118 + if (accessToken) setIsLoggedIn(true); 115 119 116 - (async () => { 117 - try { 118 - setUIState('loading'); 119 - window.masto = await login({ 120 - url: `https://${instanceURL}`, 121 - accessToken, 122 - disableVersionCheck: true, 123 - timeout: 30_000, 124 - }); 125 - setIsLoggedIn(true); 126 - } catch (e) { 127 - setIsLoggedIn(false); 128 - } 129 - setUIState('default'); 130 - })(); 120 + initMasto({ 121 + url: `https://${instanceURL}`, 122 + accessToken, 123 + }); 131 124 } else { 132 125 setUIState('default'); 133 126 } ··· 164 157 useEffect(() => { 165 158 // HACK: prevent this from running again due to HMR 166 159 if (states.init) return; 167 - 168 160 if (isLoggedIn) { 169 - requestAnimationFrame(() => { 170 - // startStream(); 171 - startVisibility(); 172 - 173 - // Collect instance info 174 - (async () => { 175 - // Request v2, fallback to v1 if fail 176 - let info; 177 - try { 178 - info = await masto.v2.instance.fetch(); 179 - } catch (e) {} 180 - if (!info) { 181 - try { 182 - info = await masto.v1.instances.fetch(); 183 - } catch (e) {} 184 - } 185 - if (!info) return; 186 - console.log(info); 187 - const { uri, domain } = info; 188 - if (uri || domain) { 189 - const instances = store.local.getJSON('instances') || {}; 190 - instances[(domain || uri).toLowerCase()] = info; 191 - store.local.setJSON('instances', instances); 192 - } 193 - })(); 194 - }); 161 + requestAnimationFrame(startVisibility); 195 162 states.init = true; 196 163 } 197 164 }, [isLoggedIn]); ··· 211 178 212 179 const nonRootLocation = useMemo(() => { 213 180 const { pathname } = location; 214 - return !/\/(login|welcome)$/.test(pathname); 181 + return !/^\/(login|welcome|p)/.test(pathname); 215 182 }, [location]); 216 183 217 184 return ( ··· 236 203 {isLoggedIn && ( 237 204 <Route path="/notifications" element={<Notifications />} /> 238 205 )} 239 - {isLoggedIn && <Route path="/bookmarks" element={<Bookmarks />} />} 206 + {isLoggedIn && <Route path="/b" element={<Bookmarks />} />} 207 + {isLoggedIn && <Route path="/f" element={<Favourites />} />} 208 + {isLoggedIn && <Route path="/l/:id" element={<Lists />} />} 209 + {isLoggedIn && <Route path="/t/:hashtag" element={<Hashtags />} />} 210 + <Route path="/p/l?/:instance" element={<Public />} /> 211 + {/* <Route path="/:anything" element={<NotFound />} /> */} 240 212 </Routes> 241 213 <Routes> 242 214 {isLoggedIn && <Route path="/s/:id" element={<Status />} />} ··· 344 316 ); 345 317 } 346 318 319 + function initMasto(params) { 320 + const clientParams = { 321 + url: params.url || 'https://mastodon.social', 322 + accessToken: params.accessToken || null, 323 + disableVersionCheck: true, 324 + timeout: 30_000, 325 + }; 326 + window.masto = createClient(clientParams); 327 + 328 + (async () => { 329 + // Request v2, fallback to v1 if fail 330 + let info; 331 + try { 332 + info = await masto.v2.instance.fetch(); 333 + } catch (e) {} 334 + if (!info) { 335 + try { 336 + info = await masto.v1.instances.fetch(); 337 + } catch (e) {} 338 + } 339 + if (!info) return; 340 + console.log(info); 341 + const { 342 + // v1 343 + uri, 344 + urls: { streamingApi } = {}, 345 + // v2 346 + domain, 347 + configuration: { urls: { streaming } = {} } = {}, 348 + } = info; 349 + if (uri || domain) { 350 + const instances = store.local.getJSON('instances') || {}; 351 + instances[(domain || uri).toLowerCase()] = info; 352 + store.local.setJSON('instances', instances); 353 + } 354 + if (streamingApi || streaming) { 355 + window.masto = createClient({ 356 + ...clientParams, 357 + streamingApiUrl: streaming || streamingApi, 358 + }); 359 + } 360 + })(); 361 + } 362 + 347 363 let ws; 348 364 async function startStream() { 349 365 if ( ··· 417 433 }; 418 434 } 419 435 436 + let lastHidden; 420 437 function startVisibility() { 421 438 const handleVisible = (visible) => { 422 439 if (!visible) { 423 440 const timestamp = Date.now(); 424 - store.session.set('lastHidden', timestamp); 441 + lastHidden = timestamp; 425 442 } else { 426 443 const timestamp = Date.now(); 427 - const lastHidden = store.session.get('lastHidden'); 428 444 const diff = timestamp - lastHidden; 429 445 const diffMins = Math.round(diff / 1000 / 60); 430 - if (diffMins > 1) { 431 - console.log('visible', { lastHidden, diffMins }); 446 + console.log(`visible: ${visible}`, { lastHidden, diffMins }); 447 + if (!lastHidden || diffMins > 1) { 432 448 (async () => { 433 449 try { 434 450 const firstStatusID = states.homeLast?.id; ··· 492 508 console.log('VISIBILITY: ' + (hidden ? 'hidden' : 'visible')); 493 509 }; 494 510 document.addEventListener('visibilitychange', handleVisibilityChange); 511 + requestAnimationFrame(handleVisibilityChange); 495 512 return { 496 513 stop: () => { 497 514 document.removeEventListener('visibilitychange', handleVisibilityChange);
+5 -1
src/components/status.jsx
··· 33 33 import RelativeTime from './relative-time'; 34 34 35 35 function fetchAccount(id) { 36 - return masto.v1.accounts.fetch(id); 36 + try { 37 + return masto.v1.accounts.fetch(id); 38 + } catch (e) { 39 + return Promise.reject(e); 40 + } 37 41 } 38 42 const memFetchAccount = mem(fetchAccount); 39 43
+151
src/components/timeline.jsx
··· 1 + import { useEffect, useRef, useState } from 'preact/hooks'; 2 + 3 + import useScroll from '../utils/useScroll'; 4 + import useTitle from '../utils/useTitle'; 5 + 6 + import Icon from './icon'; 7 + import Link from './link'; 8 + import Loader from './loader'; 9 + import Status from './status'; 10 + 11 + function Timeline({ title, id, emptyText, errorText, fetchItems = () => {} }) { 12 + if (title) { 13 + useTitle(title); 14 + } 15 + const [items, setItems] = useState([]); 16 + const [uiState, setUIState] = useState('default'); 17 + const [showMore, setShowMore] = useState(false); 18 + const scrollableRef = useRef(null); 19 + const { nearReachEnd, reachStart } = useScroll({ 20 + scrollableElement: scrollableRef.current, 21 + }); 22 + 23 + const loadItems = (firstLoad) => { 24 + setUIState('loading'); 25 + (async () => { 26 + try { 27 + const { done, value } = await fetchItems(firstLoad); 28 + if (value?.length) { 29 + if (firstLoad) { 30 + setItems(value); 31 + } else { 32 + setItems([...items, ...value]); 33 + } 34 + setShowMore(!done); 35 + } else { 36 + setShowMore(false); 37 + } 38 + setUIState('default'); 39 + } catch (e) { 40 + console.error(e); 41 + setUIState('error'); 42 + } 43 + })(); 44 + }; 45 + 46 + useEffect(() => { 47 + scrollableRef.current?.scrollTo({ top: 0 }); 48 + loadItems(true); 49 + }, []); 50 + 51 + useEffect(() => { 52 + if (reachStart) { 53 + loadItems(true); 54 + } 55 + }, [reachStart]); 56 + 57 + useEffect(() => { 58 + if (nearReachEnd && showMore) { 59 + loadItems(); 60 + } 61 + }, [nearReachEnd, showMore]); 62 + 63 + return ( 64 + <div 65 + id={`${id}-page`} 66 + class="deck-container" 67 + ref={scrollableRef} 68 + tabIndex="-1" 69 + > 70 + <div class="timeline-deck deck"> 71 + <header 72 + onClick={(e) => { 73 + if (e.target === e.currentTarget) { 74 + scrollableRef.current?.scrollTo({ 75 + top: 0, 76 + behavior: 'smooth', 77 + }); 78 + } 79 + }} 80 + > 81 + <div class="header-side"> 82 + <Link to="/" class="button plain"> 83 + <Icon icon="home" size="l" /> 84 + </Link> 85 + </div> 86 + <h1>{title}</h1> 87 + <div class="header-side"> 88 + <Loader hidden={uiState !== 'loading'} /> 89 + </div> 90 + </header> 91 + {!!items.length ? ( 92 + <> 93 + <ul class="timeline"> 94 + {items.map((status) => ( 95 + <li key={`timeline-${status.id}`}> 96 + <Link class="status-link" to={`/s/${status.id}`}> 97 + <Status status={status} /> 98 + </Link> 99 + </li> 100 + ))} 101 + </ul> 102 + {showMore && ( 103 + <button 104 + type="button" 105 + class="plain block" 106 + disabled={uiState === 'loading'} 107 + onClick={() => loadItems()} 108 + style={{ marginBlockEnd: '6em' }} 109 + > 110 + {uiState === 'loading' ? ( 111 + <Loader abrupt /> 112 + ) : ( 113 + <>Show more&hellip;</> 114 + )} 115 + </button> 116 + )} 117 + </> 118 + ) : uiState === 'loading' ? ( 119 + <ul class="timeline"> 120 + {Array.from({ length: 5 }).map((_, i) => ( 121 + <li key={i}> 122 + <Status skeleton /> 123 + </li> 124 + ))} 125 + </ul> 126 + ) : ( 127 + uiState !== 'loading' && <p class="ui-state">{emptyText}</p> 128 + )} 129 + {uiState === 'error' ? ( 130 + <p class="ui-state"> 131 + {errorText} 132 + <br /> 133 + <br /> 134 + <button 135 + class="button plain" 136 + onClick={() => loadItems(!items.length)} 137 + > 138 + Try again 139 + </button> 140 + </p> 141 + ) : ( 142 + uiState !== 'loading' && 143 + !!items.length && 144 + !showMore && <p class="ui-state insignificant">The end.</p> 145 + )} 146 + </div> 147 + </div> 148 + ); 149 + } 150 + 151 + export default Timeline;
+3 -3
src/compose.jsx
··· 2 2 3 3 import './app.css'; 4 4 5 - import { login } from 'masto'; 5 + import { createClient } from 'masto'; 6 6 import { render } from 'preact'; 7 7 import { useEffect, useState } from 'preact/hooks'; 8 8 ··· 14 14 console = window.opener.console; 15 15 } 16 16 17 - (async () => { 17 + (() => { 18 18 if (window.masto) return; 19 19 console.warn('window.masto not found. Trying to log in...'); 20 20 try { 21 21 const { instanceURL, accessToken } = getCurrentAccount(); 22 - window.masto = await login({ 22 + window.masto = createClient({ 23 23 url: `https://${instanceURL}`, 24 24 accessToken, 25 25 disableVersionCheck: true,
+15
src/pages/404.jsx
··· 1 + import Link from '../components/link'; 2 + 3 + export default function NotFound() { 4 + return ( 5 + <div id="not-found-page" className="deck-container" tabIndex="-1"> 6 + <div> 7 + <h1>404</h1> 8 + <p>Page not found.</p> 9 + <p> 10 + <Link to="/">Go home</Link>. 11 + </p> 12 + </div> 13 + </div> 14 + ); 15 + }
+11 -126
src/pages/bookmarks.jsx
··· 1 - import { useEffect, useRef, useState } from 'preact/hooks'; 1 + import { useRef } from 'preact/hooks'; 2 2 3 - import Icon from '../components/icon'; 4 - import Link from '../components/link'; 5 - import Loader from '../components/loader'; 6 - import Status from '../components/status'; 7 - import useTitle from '../utils/useTitle'; 3 + import Timeline from '../components/timeline'; 8 4 9 - const LIMIT = 40; 5 + const LIMIT = 20; 10 6 11 7 function Bookmarks() { 12 - useTitle('Bookmarks'); 13 - const [bookmarks, setBookmarks] = useState([]); 14 - const [uiState, setUIState] = useState('default'); 15 - const [showMore, setShowMore] = useState(false); 16 - 17 8 const bookmarksIterator = useRef(); 18 9 async function fetchBookmarks(firstLoad) { 19 10 if (firstLoad || !bookmarksIterator.current) { 20 11 bookmarksIterator.current = masto.v1.bookmarks.list({ limit: LIMIT }); 21 12 } 22 - const allBookmarks = await bookmarksIterator.current.next(); 23 - const bookmarksValue = allBookmarks.value; 24 - if (bookmarksValue?.length) { 25 - if (firstLoad) { 26 - setBookmarks(bookmarksValue); 27 - } else { 28 - setBookmarks([...bookmarks, ...bookmarksValue]); 29 - } 30 - } 31 - return allBookmarks; 13 + return await bookmarksIterator.current.next(); 32 14 } 33 15 34 - const loadBookmarks = (firstLoad) => { 35 - setUIState('loading'); 36 - (async () => { 37 - try { 38 - const { done } = await fetchBookmarks(firstLoad); 39 - setShowMore(!done); 40 - setUIState('default'); 41 - } catch (e) { 42 - console.error(e); 43 - setUIState('error'); 44 - } 45 - })(); 46 - }; 47 - 48 - useEffect(() => { 49 - loadBookmarks(true); 50 - }, []); 51 - 52 - const scrollableRef = useRef(null); 53 - 54 16 return ( 55 - <div 56 - id="bookmarks-page" 57 - class="deck-container" 58 - ref={scrollableRef} 59 - tabIndex="-1" 60 - > 61 - <div class="timeline-deck deck"> 62 - <header 63 - onClick={(e) => { 64 - if (e.target === e.currentTarget) { 65 - scrollableRef.current?.scrollTo({ 66 - top: 0, 67 - behavior: 'smooth', 68 - }); 69 - } 70 - }} 71 - onDblClick={(e) => { 72 - loadBookmarks(true); 73 - }} 74 - > 75 - <div class="header-side"> 76 - <Link to="/" class="button plain"> 77 - <Icon icon="home" size="l" /> 78 - </Link> 79 - </div> 80 - <h1>Bookmarks</h1> 81 - <div class="header-side"> 82 - <Loader hidden={uiState !== 'loading'} /> 83 - </div> 84 - </header> 85 - {!!bookmarks.length ? ( 86 - <> 87 - <ul class="timeline"> 88 - {bookmarks.map((status) => ( 89 - <li key={`bookmark-${status.id}`}> 90 - <Link class="status-link" to={`/s/${status.id}`}> 91 - <Status status={status} /> 92 - </Link> 93 - </li> 94 - ))} 95 - </ul> 96 - {showMore && ( 97 - <button 98 - type="button" 99 - class="plain block" 100 - disabled={uiState === 'loading'} 101 - onClick={() => loadBookmarks()} 102 - style={{ marginBlockEnd: '6em' }} 103 - > 104 - {uiState === 'loading' ? <Loader /> : <>Show more&hellip;</>} 105 - </button> 106 - )} 107 - </> 108 - ) : ( 109 - uiState !== 'loading' && ( 110 - <p class="ui-state">No bookmarks yet. Go bookmark something!</p> 111 - ) 112 - )} 113 - {uiState === 'loading' ? ( 114 - <ul class="timeline"> 115 - {Array.from({ length: 5 }).map((_, i) => ( 116 - <li key={i}> 117 - <Status skeleton /> 118 - </li> 119 - ))} 120 - </ul> 121 - ) : uiState === 'error' ? ( 122 - <p class="ui-state"> 123 - Unable to load bookmarks. 124 - <br /> 125 - <br /> 126 - <button 127 - class="button plain" 128 - onClick={() => loadBookmarks(!bookmarks.length)} 129 - > 130 - Try again 131 - </button> 132 - </p> 133 - ) : ( 134 - bookmarks.length && 135 - !showMore && <p class="ui-state insignificant">The end.</p> 136 - )} 137 - </div> 138 - </div> 17 + <Timeline 18 + title="Bookmarks" 19 + id="bookmarks" 20 + emptyText="No bookmarks yet. Go bookmark something!" 21 + errorText="Unable to load bookmarks" 22 + fetchItems={fetchBookmarks} 23 + /> 139 24 ); 140 25 } 141 26
+27
src/pages/favourites.jsx
··· 1 + import { useRef } from 'preact/hooks'; 2 + 3 + import Timeline from '../components/timeline'; 4 + 5 + const LIMIT = 20; 6 + 7 + function Favourites() { 8 + const favouritesIterator = useRef(); 9 + async function fetchFavourites(firstLoad) { 10 + if (firstLoad || !favouritesIterator.current) { 11 + favouritesIterator.current = masto.v1.favourites.list({ limit: LIMIT }); 12 + } 13 + return await favouritesIterator.current.next(); 14 + } 15 + 16 + return ( 17 + <Timeline 18 + title="Favourites" 19 + id="favourites" 20 + emptyText="No favourites yet. Go favourite something!" 21 + errorText="Unable to load favourites" 22 + fetchItems={fetchFavourites} 23 + /> 24 + ); 25 + } 26 + 27 + export default Favourites;
+32
src/pages/hashtags.jsx
··· 1 + import { useRef } from 'preact/hooks'; 2 + import { useParams } from 'react-router-dom'; 3 + 4 + import Timeline from '../components/timeline'; 5 + 6 + const LIMIT = 20; 7 + 8 + function Hashtags() { 9 + const { hashtag } = useParams(); 10 + const hashtagsIterator = useRef(); 11 + async function fetchHashtags(firstLoad) { 12 + if (firstLoad || !hashtagsIterator.current) { 13 + hashtagsIterator.current = masto.v1.timelines.listHashtag(hashtag, { 14 + limit: LIMIT, 15 + }); 16 + } 17 + return await hashtagsIterator.current.next(); 18 + } 19 + 20 + return ( 21 + <Timeline 22 + key={hashtag} 23 + title={`#${hashtag}`} 24 + id="hashtags" 25 + emptyText="No one has posted anything with this tag yet." 26 + errorText="Unable to load posts with this tag" 27 + fetchItems={fetchHashtags} 28 + /> 29 + ); 30 + } 31 + 32 + export default Hashtags;
+43
src/pages/lists.jsx
··· 1 + import { useEffect, useRef, useState } from 'preact/hooks'; 2 + import { useParams } from 'react-router-dom'; 3 + 4 + import Timeline from '../components/timeline'; 5 + 6 + const LIMIT = 20; 7 + 8 + function Lists() { 9 + const { id } = useParams(); 10 + const listsIterator = useRef(); 11 + async function fetchLists(firstLoad) { 12 + if (firstLoad || !listsIterator.current) { 13 + listsIterator.current = masto.v1.timelines.listList(id, { 14 + limit: LIMIT, 15 + }); 16 + } 17 + return await listsIterator.current.next(); 18 + } 19 + 20 + const [title, setTitle] = useState(`List ${id}`); 21 + useEffect(() => { 22 + (async () => { 23 + try { 24 + const list = await masto.v1.lists.fetch(id); 25 + setTitle(list.title); 26 + } catch (e) { 27 + console.error(e); 28 + } 29 + })(); 30 + }, [id]); 31 + 32 + return ( 33 + <Timeline 34 + title={title} 35 + id="lists" 36 + emptyText="Nothing yet." 37 + errorText="Unable to load posts." 38 + fetchItems={fetchLists} 39 + /> 40 + ); 41 + } 42 + 43 + export default Lists;
+76
src/pages/public.jsx
··· 1 + // EXPERIMENTAL: This is a work in progress and may not work as expected. 2 + import { useMatch, useParams } from 'react-router-dom'; 3 + 4 + import Timeline from '../components/timeline'; 5 + 6 + const LIMIT = 20; 7 + 8 + let nextUrl = null; 9 + 10 + function Public() { 11 + const isLocal = !!useMatch('/p/l/:instance'); 12 + const params = useParams(); 13 + const { instance = '' } = params; 14 + async function fetchPublic(firstLoad) { 15 + const url = firstLoad 16 + ? `https://${instance}/api/v1/timelines/public?limit=${LIMIT}&local=${isLocal}` 17 + : nextUrl; 18 + if (!url) return { values: [], done: true }; 19 + const response = await fetch(url); 20 + let value = await response.json(); 21 + if (value) { 22 + value = camelCaseKeys(value); 23 + } 24 + const done = !response.headers.has('link'); 25 + nextUrl = done 26 + ? null 27 + : response.headers.get('link').match(/<(.+?)>; rel="next"/)?.[1]; 28 + console.debug({ 29 + url, 30 + value, 31 + done, 32 + nextUrl, 33 + }); 34 + return { value, done }; 35 + } 36 + 37 + return ( 38 + <Timeline 39 + key={instance + isLocal} 40 + title={`${instance} (${isLocal ? 'local' : 'federated'})`} 41 + id="public" 42 + emptyText="No one has posted anything yet." 43 + errorText="Unable to load posts" 44 + fetchItems={fetchPublic} 45 + /> 46 + ); 47 + } 48 + 49 + function camelCaseKeys(obj) { 50 + if (Array.isArray(obj)) { 51 + return obj.map((item) => camelCaseKeys(item)); 52 + } 53 + return new Proxy(obj, { 54 + get(target, prop) { 55 + let value = undefined; 56 + if (prop in target) { 57 + value = target[prop]; 58 + } 59 + if (!value) { 60 + const snakeCaseProp = prop.replace( 61 + /([A-Z])/g, 62 + (g) => `_${g.toLowerCase()}`, 63 + ); 64 + if (snakeCaseProp in target) { 65 + value = target[snakeCaseProp]; 66 + } 67 + } 68 + if (value && typeof value === 'object') { 69 + return camelCaseKeys(value); 70 + } 71 + return value; 72 + }, 73 + }); 74 + } 75 + 76 + export default Public;
+1
src/utils/emojify-text.js
··· 1 1 function emojifyText(text, emojis = []) { 2 + if (!text) return ''; 2 3 if (!emojis.length) return text; 3 4 // Replace shortcodes in text with emoji 4 5 // emojis = [{ shortcode: 'smile', url: 'https://example.com/emoji.png' }]