this repo has no description

Posts timeline for trending links

Timeline logic changed slightly, so might be buggy.

+308 -75
+19
public/sw.js
··· 96 96 ); 97 97 registerRoute(apiExtendedRoute); 98 98 99 + const apiIntermediateRoute = new RegExpRoute( 100 + // Matches: 101 + // - trends/* 102 + // - timelines/link 103 + /^https?:\/\/[^\/]+\/api\/v\d+\/(trends|timelines\/link)/, 104 + new StaleWhileRevalidate({ 105 + cacheName: 'api-intermediate', 106 + plugins: [ 107 + new ExpirationPlugin({ 108 + maxAgeSeconds: 10 * 60, // 10 minutes 109 + }), 110 + new CacheableResponsePlugin({ 111 + statuses: [0, 200], 112 + }), 113 + ], 114 + }), 115 + ); 116 + registerRoute(apiIntermediateRoute); 117 + 99 118 const apiRoute = new RegExpRoute( 100 119 // Matches: 101 120 // - statuses/:id/context - some contexts are really huge
+17 -4
src/components/timeline.jsx
··· 55 55 filterContext, 56 56 showFollowedTags, 57 57 showReplyParent, 58 + clearWhenRefresh, 58 59 }) { 59 60 const snapStates = useSnapshot(states); 60 61 const [items, setItems] = useState([]); ··· 69 70 const mediaFirst = useMemo(() => isMediaFirstInstance(), []); 70 71 71 72 const allowGrouping = view !== 'media'; 73 + const loadItemsTS = useRef(0); // Ensures only one loadItems at a time 72 74 const loadItems = useDebouncedCallback( 73 75 (firstLoad) => { 74 76 setShowNew(false); 75 - if (uiState === 'loading') return; 77 + // if (uiState === 'loading') return; 76 78 setUIState('loading'); 77 79 (async () => { 78 80 try { 81 + const ts = (loadItemsTS.current = Date.now()); 79 82 let { done, value } = await fetchItems(firstLoad); 83 + if (ts !== loadItemsTS.current) return; 80 84 if (Array.isArray(value)) { 81 85 // Avoid grouping for pinned posts 82 86 const [pinnedPosts, otherPosts] = value.reduce( ··· 120 124 } 121 125 })(); 122 126 }, 123 - 1500, 127 + 1_000, 124 128 { 125 129 leading: true, 126 - trailing: false, 130 + // trailing: false, 127 131 }, 128 132 ); 129 133 ··· 273 277 scrollableRef.current?.scrollTo({ top: 0 }); 274 278 loadItems(true); 275 279 }, []); 280 + const firstLoad = useRef(true); 276 281 useEffect(() => { 282 + if (firstLoad.current) { 283 + firstLoad.current = false; 284 + return; 285 + } 286 + if (clearWhenRefresh && items?.length) { 287 + loadItems.cancel?.(); 288 + setItems([]); 289 + } 277 290 loadItems(true); 278 - }, [refresh]); 291 + }, [clearWhenRefresh, refresh]); 279 292 280 293 // useEffect(() => { 281 294 // if (reachStart) {
+2 -1
src/data/features.json
··· 2 2 "@mastodon/edit-media-attributes": ">=4.1", 3 3 "@mastodon/list-exclusive": ">=4.2", 4 4 "@mastodon/filtered-notifications": "~4.3 || >=4.3", 5 - "@mastodon/fetch-multiple-statuses": "~4.3 || >=4.3" 5 + "@mastodon/fetch-multiple-statuses": "~4.3 || >=4.3", 6 + "@mastodon/trending-link-posts": "~4.3 || >=4.3" 6 7 }
+55
src/pages/trending.css
··· 1 + #trending-page { 2 + .timeline-header-block { 3 + display: flex; 4 + gap: 12px; 5 + align-items: center; 6 + padding: 16px; 7 + 8 + &.blended { 9 + background-image: linear-gradient( 10 + to bottom, 11 + var(--bg-faded-color), 12 + transparent 13 + ); 14 + } 15 + 16 + @media (min-width: 40em) { 17 + padding: 0 16px; 18 + } 19 + 20 + &.loading { 21 + color: var(--text-insignificant-color); 22 + } 23 + 24 + p { 25 + margin: 0; 26 + padding: 0; 27 + flex-grow: 1; 28 + min-width: 0; 29 + } 30 + 31 + .link-text { 32 + color: var(--text-insignificant-color); 33 + display: block; 34 + font-weight: normal; 35 + white-space: nowrap; 36 + overflow: hidden; 37 + text-overflow: ellipsis; 38 + font-size: 0.9em; 39 + } 40 + } 41 + 42 + .timeline { 43 + transition: opacity 0.3s ease-in-out; 44 + } 45 + .timeline.loading { 46 + pointer-events: none; 47 + opacity: 0.2; 48 + } 49 + 50 + .timeline-link-mentions { 51 + .status .card { 52 + display: none; 53 + } 54 + } 55 + }
+192 -70
src/pages/trending.jsx
··· 1 1 import '../components/links-bar.css'; 2 + import './trending.css'; 2 3 3 4 import { MenuItem } from '@szhsin/react-menu'; 4 5 import { getBlurHashAverageColor } from 'fast-blurhash'; 5 - import { useMemo, useRef, useState } from 'preact/hooks'; 6 + import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; 6 7 import punycode from 'punycode/'; 7 8 import { useNavigate, useParams } from 'react-router-dom'; 8 9 import { useSnapshot } from 'valtio'; 9 10 10 11 import Icon from '../components/icon'; 11 12 import Link from '../components/link'; 13 + import Loader from '../components/loader'; 12 14 import Menu2 from '../components/menu2'; 13 15 import RelativeTime from '../components/relative-time'; 14 16 import Timeline from '../components/timeline'; ··· 23 25 import useTitle from '../utils/useTitle'; 24 26 25 27 const LIMIT = 20; 28 + const TREND_CACHE_TIME = 10 * 60 * 1000; // 10 minutes 26 29 27 30 const fetchLinks = pmem( 28 31 (masto) => { 29 32 return masto.v1.trends.links.list().next(); 30 33 }, 31 34 { 32 - // News last much longer 33 - maxAge: 10 * 60 * 1000, // 10 minutes 35 + maxAge: TREND_CACHE_TIME, 36 + }, 37 + ); 38 + 39 + const fetchHashtags = pmem( 40 + (masto) => { 41 + return masto.v1.trends.tags.list().next(); 42 + }, 43 + { 44 + maxAge: TREND_CACHE_TIME, 34 45 }, 35 46 ); 36 47 37 - function fetchTrends(masto) { 48 + function fetchTrendsStatuses(masto) { 38 49 if (supports('@pixelfed/trending')) { 39 50 return masto.pixelfed.v2.discover.posts.trending.list({ 40 51 range: 'daily', ··· 43 54 return masto.v1.trends.statuses.list({ 44 55 limit: LIMIT, 45 56 }); 57 + } 58 + 59 + function fetchLinkList(masto, params) { 60 + return masto.v1.timelines.link.list(params); 46 61 } 47 62 48 63 function Trending({ columnMode, ...props }) { ··· 61 76 const [links, setLinks] = useState([]); 62 77 const trendIterator = useRef(); 63 78 64 - async function fetchTrend(firstLoad) { 79 + async function fetchTrends(firstLoad) { 80 + console.log('fetchTrend', firstLoad); 65 81 if (firstLoad || !trendIterator.current) { 66 - trendIterator.current = fetchTrends(masto); 82 + trendIterator.current = fetchTrendsStatuses(masto); 67 83 68 84 // Get hashtags 69 85 if (supports('@mastodon/trending-hashtags')) { 70 86 try { 71 - const iterator = masto.v1.trends.tags.list(); 72 - const { value: tags } = await iterator.next(); 87 + // const iterator = masto.v1.trends.tags.list(); 88 + const { value: tags } = await fetchHashtags(masto); 73 89 console.log('tags', tags); 74 90 if (tags?.length) { 75 91 setHashtags(tags); ··· 113 129 }; 114 130 } 115 131 132 + // Link mentions 133 + // https://github.com/mastodon/mastodon/pull/30381 134 + const [currentLinkMentionsLoading, setCurrentLinkMentionsLoading] = 135 + useState(false); 136 + const currentLinkMentionsIterator = useRef(); 137 + const [currentLink, setCurrentLink] = useState(null); 138 + const hasCurrentLink = !!currentLink; 139 + const currentLinkRef = useRef(); 140 + const supportsTrendingLinkPosts = supports('@mastodon/trending-hashtags'); 141 + 142 + useEffect(() => { 143 + if (currentLink && currentLinkRef.current) { 144 + currentLinkRef.current.scrollIntoView({ 145 + behavior: 'smooth', 146 + block: 'nearest', 147 + inline: 'center', 148 + }); 149 + } 150 + }, [currentLink]); 151 + 152 + const prevCurrentLink = useRef(); 153 + async function fetchLinkMentions(firstLoad) { 154 + if (firstLoad || !currentLinkMentionsIterator.current) { 155 + setCurrentLinkMentionsLoading(true); 156 + currentLinkMentionsIterator.current = fetchLinkList(masto, { 157 + url: currentLink, 158 + }); 159 + } 160 + prevCurrentLink.current = currentLink; 161 + const results = await currentLinkMentionsIterator.current.next(); 162 + let { value } = results; 163 + if (value?.length) { 164 + value = filteredItems(value, 'public'); 165 + value.forEach((item) => { 166 + saveStatus(item, instance); 167 + }); 168 + } 169 + if (prevCurrentLink.current === currentLink) { 170 + setCurrentLinkMentionsLoading(false); 171 + } 172 + return { 173 + ...results, 174 + value, 175 + }; 176 + } 177 + 116 178 async function checkForUpdates() { 117 179 try { 118 180 const results = await masto.v1.trends.statuses ··· 194 256 } 195 257 196 258 return ( 197 - <a 198 - key={url} 199 - href={url} 200 - target="_blank" 201 - rel="noopener noreferrer" 202 - style={ 203 - accentColor 204 - ? { 205 - '--accent-color': `rgb(${accentColor.join(',')})`, 206 - '--accent-alpha-color': `rgba(${accentColor.join( 207 - ',', 208 - )}, 0.4)`, 209 - } 210 - : {} 211 - } 212 - > 213 - <article> 214 - <figure> 215 - <img 216 - src={image} 217 - alt={imageDescription} 218 - width={width} 219 - height={height} 220 - loading="lazy" 221 - /> 222 - </figure> 223 - <div class="article-body"> 224 - <header> 225 - <div class="article-meta"> 226 - <span class="domain">{domain}</span>{' '} 227 - {!!publishedAt && <>&middot; </>} 228 - {!!publishedAt && ( 229 - <> 230 - <RelativeTime 231 - datetime={publishedAt} 232 - format="micro" 233 - /> 234 - </> 259 + <div key={url}> 260 + <a 261 + ref={currentLink === url ? currentLinkRef : null} 262 + href={url} 263 + target="_blank" 264 + rel="noopener noreferrer" 265 + class={ 266 + hasCurrentLink 267 + ? currentLink === url 268 + ? 'active' 269 + : 'inactive' 270 + : '' 271 + } 272 + style={ 273 + accentColor 274 + ? { 275 + '--accent-color': `rgb(${accentColor.join(',')})`, 276 + '--accent-alpha-color': `rgba(${accentColor.join( 277 + ',', 278 + )}, 0.4)`, 279 + } 280 + : {} 281 + } 282 + > 283 + <article> 284 + <figure> 285 + <img 286 + src={image} 287 + alt={imageDescription} 288 + width={width} 289 + height={height} 290 + loading="lazy" 291 + /> 292 + </figure> 293 + <div class="article-body"> 294 + <header> 295 + <div class="article-meta"> 296 + <span class="domain">{domain}</span>{' '} 297 + {!!publishedAt && <>&middot; </>} 298 + {!!publishedAt && ( 299 + <> 300 + <RelativeTime 301 + datetime={publishedAt} 302 + format="micro" 303 + /> 304 + </> 305 + )} 306 + </div> 307 + {!!title && ( 308 + <h1 309 + class="title" 310 + lang={language} 311 + dir="auto" 312 + title={title} 313 + > 314 + {title} 315 + </h1> 235 316 )} 236 - </div> 237 - {!!title && ( 238 - <h1 239 - class="title" 317 + </header> 318 + {!!description && ( 319 + <p 320 + class="description" 240 321 lang={language} 241 322 dir="auto" 242 - title={title} 323 + title={description} 243 324 > 244 - {title} 245 - </h1> 325 + {description} 326 + </p> 246 327 )} 247 - </header> 248 - {!!description && ( 249 - <p 250 - class="description" 251 - lang={language} 252 - dir="auto" 253 - title={description} 254 - > 255 - {description} 256 - </p> 257 - )} 258 - </div> 259 - </article> 260 - </a> 328 + </div> 329 + </article> 330 + </a> 331 + {supportsTrendingLinkPosts && ( 332 + <button 333 + type="button" 334 + class="small plain4 block" 335 + onClick={() => { 336 + setCurrentLink(url); 337 + }} 338 + disabled={url === currentLink} 339 + > 340 + <Icon icon="comment2" /> <span>Mentions</span>{' '} 341 + <Icon icon="chevron-down" /> 342 + </button> 343 + )} 344 + </div> 261 345 ); 262 346 })} 263 347 </div> 264 348 )} 349 + {supportsTrendingLinkPosts && !!links.length && ( 350 + <div 351 + class={`timeline-header-block ${hasCurrentLink ? 'blended' : ''}`} 352 + > 353 + {hasCurrentLink ? ( 354 + <> 355 + <div style={{ width: 50, flexShrink: 0, textAlign: 'center' }}> 356 + {currentLinkMentionsLoading ? ( 357 + <Loader abrupt /> 358 + ) : ( 359 + <button 360 + type="button" 361 + class="light" 362 + onClick={() => { 363 + setCurrentLink(null); 364 + }} 365 + > 366 + <Icon icon="x" /> 367 + </button> 368 + )} 369 + </div> 370 + <p> 371 + Showing posts mentioning{' '} 372 + <span class="link-text"> 373 + {currentLink 374 + .replace(/^https?:\/\/(www\.)?/i, '') 375 + .replace(/\/$/, '')} 376 + </span> 377 + </p> 378 + </> 379 + ) : ( 380 + <p class="insignificant">Trending posts</p> 381 + )} 382 + </div> 383 + )} 265 384 </> 266 385 ); 267 - }, [hashtags, links]); 386 + }, [hashtags, links, currentLink, currentLinkMentionsLoading]); 268 387 269 388 return ( 270 389 <Timeline ··· 280 399 instance={instance} 281 400 emptyText="No trending posts." 282 401 errorText="Unable to load posts" 283 - fetchItems={fetchTrend} 284 - checkForUpdates={checkForUpdates} 402 + fetchItems={hasCurrentLink ? fetchLinkMentions : fetchTrends} 403 + checkForUpdates={hasCurrentLink ? undefined : checkForUpdates} 285 404 checkForUpdatesInterval={5 * 60 * 1000} // 5 minutes 286 405 useItemID 287 406 headerStart={<></>} ··· 289 408 // allowFilters 290 409 filterContext="public" 291 410 timelineStart={TimelineStart} 411 + refresh={currentLink} 412 + clearWhenRefresh 413 + view={hasCurrentLink ? 'link-mentions' : undefined} 292 414 headerEnd={ 293 415 <Menu2 294 416 portal