this repo has no description
0
fork

Configure Feed

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

More refactoring work

+281 -102
+2
src/app.jsx
··· 26 26 import AccountStatuses from './pages/account-statuses'; 27 27 import Bookmarks from './pages/bookmarks'; 28 28 import Favourites from './pages/favourites'; 29 + import Following from './pages/following'; 29 30 import Hashtags from './pages/hashtags'; 30 31 import Home from './pages/home'; 31 32 import Lists from './pages/lists'; ··· 205 206 {isLoggedIn && ( 206 207 <Route path="/notifications" element={<Notifications />} /> 207 208 )} 209 + {isLoggedIn && <Route path="/l/f" element={<Following />} />} 208 210 {isLoggedIn && <Route path="/b" element={<Bookmarks />} />} 209 211 {isLoggedIn && <Route path="/f" element={<Favourites />} />} 210 212 {isLoggedIn && <Route path="/l/:id" element={<Lists />} />}
+161 -48
src/components/timeline.jsx
··· 1 1 import { useEffect, useRef, useState } from 'preact/hooks'; 2 + import { useDebouncedCallback } from 'use-debounce'; 2 3 3 4 import useScroll from '../utils/useScroll'; 4 - import useTitle from '../utils/useTitle'; 5 5 6 6 import Icon from './icon'; 7 7 import Link from './link'; ··· 11 11 function Timeline({ 12 12 title, 13 13 titleComponent, 14 - path, 15 14 id, 16 15 emptyText, 17 16 errorText, 17 + boostsCarousel, 18 18 fetchItems = () => {}, 19 19 }) { 20 - if (title) { 21 - useTitle(title, path); 22 - } 23 20 const [items, setItems] = useState([]); 24 21 const [uiState, setUIState] = useState('default'); 25 22 const [showMore, setShowMore] = useState(false); 26 23 const scrollableRef = useRef(null); 27 - const { nearReachEnd, reachStart } = useScroll({ 24 + const { nearReachEnd, reachStart, reachEnd } = useScroll({ 28 25 scrollableElement: scrollableRef.current, 26 + distanceFromEnd: 1, 29 27 }); 30 28 31 - const loadItems = (firstLoad) => { 32 - setUIState('loading'); 33 - (async () => { 34 - try { 35 - const { done, value } = await fetchItems(firstLoad); 36 - if (value?.length) { 37 - if (firstLoad) { 38 - setItems(value); 29 + const loadItems = useDebouncedCallback( 30 + (firstLoad) => { 31 + if (uiState === 'loading') return; 32 + setUIState('loading'); 33 + (async () => { 34 + try { 35 + let { done, value } = await fetchItems(firstLoad); 36 + if (value?.length) { 37 + if (boostsCarousel) { 38 + value = groupBoosts(value); 39 + } 40 + console.log(value); 41 + if (firstLoad) { 42 + setItems(value); 43 + } else { 44 + setItems([...items, ...value]); 45 + } 46 + setShowMore(!done); 39 47 } else { 40 - setItems([...items, ...value]); 48 + setShowMore(false); 41 49 } 42 - setShowMore(!done); 43 - } else { 44 - setShowMore(false); 50 + setUIState('default'); 51 + } catch (e) { 52 + console.error(e); 53 + setUIState('error'); 45 54 } 46 - setUIState('default'); 47 - } catch (e) { 48 - console.error(e); 49 - setUIState('error'); 50 - } 51 - })(); 52 - }; 55 + })(); 56 + }, 57 + 1500, 58 + { 59 + leading: true, 60 + trailing: false, 61 + }, 62 + ); 53 63 54 64 useEffect(() => { 55 65 scrollableRef.current?.scrollTo({ top: 0 }); ··· 63 73 }, [reachStart]); 64 74 65 75 useEffect(() => { 66 - if (nearReachEnd && showMore) { 76 + if (nearReachEnd || (reachEnd && showMore)) { 67 77 loadItems(); 68 78 } 69 79 }, [nearReachEnd, showMore]); ··· 100 110 <> 101 111 <ul class="timeline"> 102 112 {items.map((status) => { 103 - const { id: statusID, reblog } = status; 113 + const { id: statusID, reblog, boosts } = status; 104 114 const actualStatusID = reblog?.id || statusID; 115 + if (boosts) { 116 + return ( 117 + <li key={`timeline-${statusID}`}> 118 + <BoostsCarousel boosts={boosts} /> 119 + </li> 120 + ); 121 + } 105 122 return ( 106 123 <li key={`timeline-${statusID}`}> 107 124 <Link class="status-link" to={`/s/${actualStatusID}`}> ··· 111 128 ); 112 129 })} 113 130 </ul> 114 - {showMore && ( 115 - <button 116 - type="button" 117 - class="plain block" 118 - disabled={uiState === 'loading'} 119 - onClick={() => loadItems()} 120 - style={{ marginBlockEnd: '6em' }} 121 - > 122 - {uiState === 'loading' ? ( 123 - <Loader abrupt /> 124 - ) : ( 125 - <>Show more&hellip;</> 126 - )} 127 - </button> 128 - )} 131 + {uiState === 'default' && 132 + (showMore ? ( 133 + <button 134 + type="button" 135 + class="plain block" 136 + onClick={() => loadItems()} 137 + style={{ marginBlockEnd: '6em' }} 138 + > 139 + Show more&hellip; 140 + </button> 141 + ) : ( 142 + <p class="ui-state insignificant">The end.</p> 143 + ))} 129 144 </> 130 145 ) : uiState === 'loading' ? ( 131 146 <ul class="timeline"> ··· 136 151 ))} 137 152 </ul> 138 153 ) : ( 139 - uiState !== 'loading' && <p class="ui-state">{emptyText}</p> 154 + uiState !== 'error' && <p class="ui-state">{emptyText}</p> 140 155 )} 141 - {uiState === 'error' ? ( 156 + {uiState === 'error' && ( 142 157 <p class="ui-state"> 143 158 {errorText} 144 159 <br /> ··· 150 165 Try again 151 166 </button> 152 167 </p> 153 - ) : ( 154 - uiState !== 'loading' && 155 - !!items.length && 156 - !showMore && <p class="ui-state insignificant">The end.</p> 157 168 )} 158 169 </div> 170 + </div> 171 + ); 172 + } 173 + 174 + function groupBoosts(values) { 175 + let newValues = []; 176 + let boostStash = []; 177 + let serialBoosts = 0; 178 + for (let i = 0; i < values.length; i++) { 179 + const item = values[i]; 180 + if (item.reblog) { 181 + boostStash.push(item); 182 + serialBoosts++; 183 + } else { 184 + newValues.push(item); 185 + if (serialBoosts < 3) { 186 + serialBoosts = 0; 187 + } 188 + } 189 + } 190 + // if boostStash is more than quarter of values 191 + // or if there are 3 or more boosts in a row 192 + if (boostStash.length > values.length / 4 || serialBoosts >= 3) { 193 + // if boostStash is more than 3 quarter of values 194 + const boostStashID = boostStash.map((status) => status.id); 195 + if (boostStash.length > (values.length * 3) / 4) { 196 + // insert boost array at the end of specialHome list 197 + newValues = [...newValues, { id: boostStashID, boosts: boostStash }]; 198 + } else { 199 + // insert boosts array in the middle of specialHome list 200 + const half = Math.floor(newValues.length / 2); 201 + newValues = [ 202 + ...newValues.slice(0, half), 203 + { 204 + id: boostStashID, 205 + boosts: boostStash, 206 + }, 207 + ...newValues.slice(half), 208 + ]; 209 + } 210 + return newValues; 211 + } else { 212 + return values; 213 + } 214 + } 215 + 216 + function BoostsCarousel({ boosts }) { 217 + const carouselRef = useRef(); 218 + const { reachStart, reachEnd, init } = useScroll({ 219 + scrollableElement: carouselRef.current, 220 + direction: 'horizontal', 221 + }); 222 + useEffect(() => { 223 + init?.(); 224 + }, []); 225 + 226 + return ( 227 + <div class="boost-carousel"> 228 + <header> 229 + <h3>{boosts.length} Boosts</h3> 230 + <span> 231 + <button 232 + type="button" 233 + class="small plain2" 234 + disabled={reachStart} 235 + onClick={() => { 236 + carouselRef.current?.scrollBy({ 237 + left: -Math.min(320, carouselRef.current?.offsetWidth), 238 + behavior: 'smooth', 239 + }); 240 + }} 241 + > 242 + <Icon icon="chevron-left" /> 243 + </button>{' '} 244 + <button 245 + type="button" 246 + class="small plain2" 247 + disabled={reachEnd} 248 + onClick={() => { 249 + carouselRef.current?.scrollBy({ 250 + left: Math.min(320, carouselRef.current?.offsetWidth), 251 + behavior: 'smooth', 252 + }); 253 + }} 254 + > 255 + <Icon icon="chevron-right" /> 256 + </button> 257 + </span> 258 + </header> 259 + <ul ref={carouselRef}> 260 + {boosts.map((boost) => { 261 + const { id: statusID, reblog } = boost; 262 + const actualStatusID = reblog?.id || statusID; 263 + return ( 264 + <li key={statusID}> 265 + <Link class="status-boost-link" to={`/s/${actualStatusID}`}> 266 + <Status status={boost} size="s" /> 267 + </Link> 268 + </li> 269 + ); 270 + })} 271 + </ul> 159 272 </div> 160 273 ); 161 274 }
+5 -1
src/pages/account-statuses.jsx
··· 1 1 import { useEffect, useRef, useState } from 'preact/hooks'; 2 2 import { useParams } from 'react-router-dom'; 3 + import { useSnapshot } from 'valtio'; 3 4 4 5 import Timeline from '../components/timeline'; 5 6 import states from '../utils/states'; 7 + import useTitle from '../utils/useTitle'; 6 8 7 9 const LIMIT = 20; 8 10 9 11 function AccountStatuses() { 12 + const snapStates = useSnapshot(states); 10 13 const { id } = useParams(); 11 14 const accountStatusesIterator = useRef(); 12 15 async function fetchAccountStatuses(firstLoad) { ··· 19 22 } 20 23 21 24 const [account, setAccount] = useState({}); 25 + useTitle(`${account?.acct ? '@' + account.acct : 'Posts'}`, '/a/:id'); 22 26 useEffect(() => { 23 27 (async () => { 24 28 try { ··· 48 52 </div> 49 53 </h1> 50 54 } 51 - path="/a/:id" 52 55 id="account_statuses" 53 56 emptyText="Nothing to see here yet." 54 57 errorText="Unable to load statuses" 55 58 fetchItems={fetchAccountStatuses} 59 + boostsCarousel={snapStates.settings.boostsCarousel} 56 60 /> 57 61 ); 58 62 }
+2
src/pages/bookmarks.jsx
··· 1 1 import { useRef } from 'preact/hooks'; 2 2 3 3 import Timeline from '../components/timeline'; 4 + import useTitle from '../utils/useTitle'; 4 5 5 6 const LIMIT = 20; 6 7 7 8 function Bookmarks() { 9 + useTitle('Bookmarks', '/b'); 8 10 const bookmarksIterator = useRef(); 9 11 async function fetchBookmarks(firstLoad) { 10 12 if (firstLoad || !bookmarksIterator.current) {
+2
src/pages/favourites.jsx
··· 1 1 import { useRef } from 'preact/hooks'; 2 2 3 3 import Timeline from '../components/timeline'; 4 + import useTitle from '../utils/useTitle'; 4 5 5 6 const LIMIT = 20; 6 7 7 8 function Favourites() { 9 + useTitle('Favourites', '/f'); 8 10 const favouritesIterator = useRef(); 9 11 async function fetchFavourites(firstLoad) { 10 12 if (firstLoad || !favouritesIterator.current) {
+32
src/pages/following.jsx
··· 1 + import { useRef } from 'preact/hooks'; 2 + import { useSnapshot } from 'valtio'; 3 + 4 + import Timeline from '../components/timeline'; 5 + import useTitle from '../utils/useTitle'; 6 + 7 + const LIMIT = 20; 8 + 9 + function Following() { 10 + useTitle('Following', '/l/f'); 11 + const snapStates = useSnapshot(states); 12 + const homeIterator = useRef(); 13 + async function fetchHome(firstLoad) { 14 + if (firstLoad || !homeIterator.current) { 15 + homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT }); 16 + } 17 + return await homeIterator.current.next(); 18 + } 19 + 20 + return ( 21 + <Timeline 22 + title="Following" 23 + id="following" 24 + emptyText="Nothing to see here." 25 + errorText="Unable to load posts." 26 + fetchItems={fetchHome} 27 + boostsCarousel={snapStates.settings.boostsCarousel} 28 + /> 29 + ); 30 + } 31 + 32 + export default Following;
+2
src/pages/hashtags.jsx
··· 2 2 import { useParams } from 'react-router-dom'; 3 3 4 4 import Timeline from '../components/timeline'; 5 + import useTitle from '../utils/useTitle'; 5 6 6 7 const LIMIT = 20; 7 8 8 9 function Hashtags() { 9 10 const { hashtag } = useParams(); 11 + useTitle(`#${hashtag}`, `/t/${hashtag}`); 10 12 const hashtagsIterator = useRef(); 11 13 async function fetchHashtags(firstLoad) { 12 14 if (firstLoad || !hashtagsIterator.current) {
+60 -50
src/pages/home.jsx
··· 118 118 return allStatuses; 119 119 } 120 120 121 - const loadingStatuses = useRef(false); 122 - const loadStatuses = (firstLoad) => { 123 - if (loadingStatuses.current) return; 124 - loadingStatuses.current = true; 125 - setUIState('loading'); 126 - (async () => { 127 - try { 128 - const { done } = await fetchStatuses(firstLoad); 129 - setShowMore(!done); 130 - setUIState('default'); 131 - } catch (e) { 132 - console.warn(e); 133 - setUIState('error'); 134 - } finally { 135 - loadingStatuses.current = false; 136 - } 137 - })(); 138 - }; 139 - const debouncedLoadStatuses = useDebouncedCallback(loadStatuses, 3000, { 140 - leading: true, 141 - trailing: false, 142 - }); 121 + const loadStatuses = useDebouncedCallback( 122 + (firstLoad) => { 123 + if (uiState === 'loading') return; 124 + setUIState('loading'); 125 + (async () => { 126 + try { 127 + const { done } = await fetchStatuses(firstLoad); 128 + setShowMore(!done); 129 + setUIState('default'); 130 + } catch (e) { 131 + console.warn(e); 132 + setUIState('error'); 133 + } finally { 134 + } 135 + })(); 136 + }, 137 + 1500, 138 + { 139 + leading: true, 140 + trailing: false, 141 + }, 142 + ); 143 143 144 144 useEffect(() => { 145 145 loadStatuses(true); ··· 271 271 reachEnd, 272 272 } = useScroll({ 273 273 scrollableElement: scrollableRef.current, 274 - distanceFromStart: 1, 275 274 distanceFromEnd: 3, 276 275 scrollThresholdStart: 44, 277 276 }); ··· 284 283 285 284 useEffect(() => { 286 285 if (reachStart) { 287 - debouncedLoadStatuses(true); 286 + loadStatuses(true); 288 287 } 289 288 }, [reachStart]); 290 289 ··· 324 323 scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); 325 324 }} 326 325 onDblClick={() => { 327 - debouncedLoadStatuses(true); 326 + loadStatuses(true); 328 327 }} 329 328 > 330 329 <div class="header-side"> ··· 372 371 ); 373 372 states.home.unshift(...uniqueHomeNew); 374 373 } 375 - debouncedLoadStatuses(true); 374 + loadStatuses(true); 376 375 states.homeNew = []; 377 376 378 377 scrollableRef.current?.scrollTo({ ··· 404 403 </li> 405 404 ); 406 405 })} 407 - {showMore && ( 406 + {showMore && uiState === 'loading' && ( 408 407 <> 409 408 <li 410 409 style={{ ··· 423 422 </> 424 423 )} 425 424 </ul> 426 - </> 427 - ) : ( 428 - <> 429 - {uiState === 'loading' && ( 430 - <ul class="timeline"> 431 - {Array.from({ length: 5 }).map((_, i) => ( 432 - <li key={i}> 433 - <Status skeleton /> 434 - </li> 435 - ))} 436 - </ul> 437 - )} 438 - {uiState === 'error' && ( 439 - <p class="ui-state"> 440 - Unable to load statuses 441 - <br /> 442 - <br /> 425 + {uiState === 'default' && 426 + (showMore ? ( 443 427 <button 444 428 type="button" 445 - onClick={() => { 446 - debouncedLoadStatuses(true); 447 - }} 429 + class="plain block" 430 + onClick={() => loadStatuses()} 431 + style={{ marginBlockEnd: '6em' }} 448 432 > 449 - Try again 433 + Show more&hellip; 450 434 </button> 451 - </p> 452 - )} 435 + ) : ( 436 + <p class="ui-state insignificant">The end.</p> 437 + ))} 453 438 </> 439 + ) : uiState === 'loading' ? ( 440 + <ul class="timeline"> 441 + {Array.from({ length: 5 }).map((_, i) => ( 442 + <li key={i}> 443 + <Status skeleton /> 444 + </li> 445 + ))} 446 + </ul> 447 + ) : ( 448 + uiState !== 'error' && <p class="ui-state">Nothing to see here.</p> 449 + )} 450 + {uiState === 'error' && ( 451 + <p class="ui-state"> 452 + Unable to load statuses 453 + <br /> 454 + <br /> 455 + <button 456 + type="button" 457 + onClick={() => { 458 + loadStatuses(true); 459 + }} 460 + > 461 + Try again 462 + </button> 463 + </p> 454 464 )} 455 465 </div> 456 466 </div>
+3
src/pages/lists.jsx
··· 2 2 import { useParams } from 'react-router-dom'; 3 3 4 4 import Timeline from '../components/timeline'; 5 + import useTitle from '../utils/useTitle'; 5 6 6 7 const LIMIT = 20; 7 8 ··· 18 19 } 19 20 20 21 const [title, setTitle] = useState(`List ${id}`); 22 + useTitle(title, `/l/${id}`); 21 23 useEffect(() => { 22 24 (async () => { 23 25 try { ··· 36 38 emptyText="Nothing yet." 37 39 errorText="Unable to load posts." 38 40 fetchItems={fetchLists} 41 + boostsCarousel 39 42 /> 40 43 ); 41 44 }
+4 -1
src/pages/public.jsx
··· 2 2 import { useMatch, useParams } from 'react-router-dom'; 3 3 4 4 import Timeline from '../components/timeline'; 5 + import useTitle from '../utils/useTitle'; 5 6 6 7 const LIMIT = 20; 7 8 ··· 11 12 const isLocal = !!useMatch('/p/l/:instance'); 12 13 const params = useParams(); 13 14 const { instance = '' } = params; 15 + const title = `${instance} (${isLocal ? 'local' : 'federated'})`; 16 + useTitle(title, `/p/${instance}`); 14 17 async function fetchPublic(firstLoad) { 15 18 const url = firstLoad 16 19 ? `https://${instance}/api/v1/timelines/public?limit=${LIMIT}&local=${isLocal}` ··· 37 40 return ( 38 41 <Timeline 39 42 key={instance + isLocal} 40 - title={`${instance} (${isLocal ? 'local' : 'federated'})`} 43 + title={title} 41 44 id="public" 42 45 emptyText="No one has posted anything yet." 43 46 errorText="Unable to load posts"
+8 -2
src/utils/useScroll.js
··· 38 38 const scrollDimension = isVertical ? scrollHeight : scrollWidth; 39 39 const clientDimension = isVertical ? clientHeight : clientWidth; 40 40 const scrollDistance = Math.abs(scrollStart - previousScrollStart); 41 - const distanceFromStartPx = clientDimension * distanceFromStart; 42 - const distanceFromEndPx = clientDimension * distanceFromEnd; 41 + const distanceFromStartPx = Math.min( 42 + clientDimension * distanceFromStart, 43 + scrollDimension, 44 + ); 45 + const distanceFromEndPx = Math.min( 46 + clientDimension * distanceFromEnd, 47 + scrollDimension, 48 + ); 43 49 44 50 if ( 45 51 scrollDistance >=