this repo has no description
0
fork

Configure Feed

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

Perf fixes + 3d posts viz

+204 -98
+34 -3
src/pages/catchup.css
··· 168 168 border-radius: 3px; 169 169 border: 1px solid var(--bg-color); 170 170 display: flex; 171 - gap: 1px; 171 + gap: var(--hairline-width); 172 172 pointer-events: none; 173 173 justify-content: stretch; 174 174 height: 3px; 175 175 176 - &:has(.post-dot:nth-child(320)) { 176 + /* &:has(.post-dot:nth-child(320)) { 177 177 gap: 0; 178 - } 178 + } */ 179 179 180 180 .post-dot { 181 181 display: block; ··· 195 195 &:has(.post-dot:not(.post-dot-highlight)) .post-dot-highlight { 196 196 /* transform: scaleY(2); */ 197 197 transform: scale3d(1, 2, 1); 198 + } 199 + } 200 + 201 + .catchup-posts-viz-time-bar { 202 + margin: 0 16px; 203 + padding: 1px; 204 + display: flex; 205 + gap: var(--hairline-width); 206 + pointer-events: none; 207 + justify-content: stretch; 208 + background-image: linear-gradient(to bottom, transparent, var(--bg-color)); 209 + 210 + .posts-bin { 211 + display: flex; 212 + gap: var(--hairline-width); 213 + flex-direction: column-reverse; 214 + width: 100%; 215 + 216 + .post-dot { 217 + display: block; 218 + width: 100%; 219 + height: 2px; 220 + opacity: 0.2; 221 + background-color: var(--link-color); 222 + transition: 0.25s ease-in-out; 223 + transition-property: opacity, transform; 224 + 225 + &.post-dot-highlight { 226 + opacity: 1; 227 + } 228 + } 198 229 } 199 230 } 200 231
+170 -95
src/pages/catchup.jsx
··· 5 5 import { getBlurHashAverageColor } from 'fast-blurhash'; 6 6 import { Fragment } from 'preact'; 7 7 import { memo } from 'preact/compat'; 8 - import { useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks'; 8 + import { 9 + useCallback, 10 + useEffect, 11 + useMemo, 12 + useReducer, 13 + useRef, 14 + useState, 15 + } from 'preact/hooks'; 9 16 import { useSearchParams } from 'react-router-dom'; 10 17 import { uid } from 'uid/single'; 11 18 ··· 36 43 37 44 const FILTER_CONTEXT = 'home'; 38 45 46 + const RANGES = [ 47 + { label: 'last 1 hour', value: 1 }, 48 + { label: 'last 2 hours', value: 2 }, 49 + { label: 'last 3 hours', value: 3 }, 50 + { label: 'last 4 hours', value: 4 }, 51 + { label: 'last 5 hours', value: 5 }, 52 + { label: 'last 6 hours', value: 6 }, 53 + { label: 'last 7 hours', value: 7 }, 54 + { label: 'last 8 hours', value: 8 }, 55 + { label: 'last 9 hours', value: 9 }, 56 + { label: 'last 10 hours', value: 10 }, 57 + { label: 'last 11 hours', value: 11 }, 58 + { label: 'last 12 hours', value: 12 }, 59 + { label: 'beyond 12 hours', value: 13 }, 60 + ]; 61 + 62 + const FILTER_VALUES = { 63 + Filtered: 'filtered', 64 + Groups: 'group', 65 + Boosts: 'boost', 66 + Replies: 'reply', 67 + 'Followed tags': 'followedTags', 68 + Original: 'original', 69 + }; 70 + const FILTER_CATEGORY_TEXT = { 71 + Filtered: 'filtered posts', 72 + Groups: 'group posts', 73 + Boosts: 'boosts', 74 + Replies: 'replies', 75 + 'Followed tags': 'followed-tag posts', 76 + Original: 'original posts', 77 + }; 78 + const SORT_BY_TEXT = { 79 + // asc, desc 80 + createdAt: ['oldest', 'latest'], 81 + repliesCount: ['fewest replies', 'most replies'], 82 + favouritesCount: ['fewest likes', 'most likes'], 83 + reblogsCount: ['fewest boosts', 'most boosts'], 84 + density: ['least dense', 'most dense'], 85 + }; 86 + 39 87 function Catchup() { 40 88 useTitle('Catch-up', '/catchup'); 41 89 const { masto, instance } = api(); ··· 125 173 126 174 const [posts, setPosts] = useState([]); 127 175 const catchupRangeRef = useRef(); 128 - async function handleCatchupClick({ duration } = {}) { 176 + const NS = useMemo(() => getCurrentAccountNS(), []); 177 + const handleCatchupClick = useCallback(async ({ duration } = {}) => { 129 178 const now = Date.now(); 130 179 const maxCreatedAt = duration ? now - duration : null; 131 180 setUIState('loading'); 132 181 const results = await fetchHome({ maxCreatedAt }); 133 182 // Namespaced by account ID 134 183 // Possible conflict if ID matches between different accounts from different instances 135 - const ns = getCurrentAccountNS(); 136 - const catchupID = `${ns}-${uid()}`; 184 + const catchupID = `${NS}-${uid()}`; 137 185 try { 138 186 await db.catchup.set(catchupID, { 139 187 id: catchupID, ··· 145 193 setSearchParams({ id: catchupID }); 146 194 } catch (e) { 147 195 console.error(e, results); 148 - // setUIState('error'); 149 196 } 150 - // setPosts(results); 151 - // setUIState('results'); 152 - } 197 + }, []); 153 198 154 199 useEffect(() => { 155 200 if (id) { 156 201 (async () => { 157 202 const catchup = await db.catchup.get(id); 158 203 if (catchup) { 204 + catchup.posts.sort((a, b) => (a.createdAt > b.createdAt ? 1 : -1)); 159 205 setPosts(catchup.posts); 160 206 setUIState('results'); 161 207 } ··· 340 386 const [selectedAuthor, setSelectedAuthor] = useState(null); 341 387 342 388 const [range, setRange] = useState(1); 343 - const ranges = [ 344 - { label: 'last 1 hour', value: 1 }, 345 - { label: 'last 2 hours', value: 2 }, 346 - { label: 'last 3 hours', value: 3 }, 347 - { label: 'last 4 hours', value: 4 }, 348 - { label: 'last 5 hours', value: 5 }, 349 - { label: 'last 6 hours', value: 6 }, 350 - { label: 'last 7 hours', value: 7 }, 351 - { label: 'last 8 hours', value: 8 }, 352 - { label: 'last 9 hours', value: 9 }, 353 - { label: 'last 10 hours', value: 10 }, 354 - { label: 'last 11 hours', value: 11 }, 355 - { label: 'last 12 hours', value: 12 }, 356 - { label: 'beyond 12 hours', value: 13 }, 357 - ]; 358 389 359 390 const [sortBy, setSortBy] = useState('createdAt'); 360 391 const [sortOrder, setSortOrder] = useState('asc'); 361 392 const [groupBy, setGroupBy] = useState(null); 362 393 363 394 const [filteredPosts, authors, authorCounts] = useMemo(() => { 364 - let authors = []; 365 - const authorCounts = {}; 395 + const authorsHash = {}; 396 + const authorCountsMap = new Map(); 397 + 366 398 let filteredPosts = posts.filter((post) => { 367 - return ( 399 + const postFilterMatches = 368 400 selectedFilterCategory === 'All' || 369 - post.__FILTER === 370 - { 371 - Filtered: 'filtered', 372 - Groups: 'group', 373 - Boosts: 'boost', 374 - Replies: 'reply', 375 - 'Followed tags': 'followedTags', 376 - Original: 'original', 377 - }[selectedFilterCategory] 378 - ); 379 - }); 401 + post.__FILTER === FILTER_VALUES[selectedFilterCategory]; 380 402 381 - filteredPosts.forEach((post) => { 382 - if (!authors.find((a) => a.id === post.account.id)) { 383 - authors.push(post.account); 403 + if (postFilterMatches) { 404 + authorsHash[post.account.id] = post.account; 405 + authorCountsMap.set( 406 + post.account.id, 407 + (authorCountsMap.get(post.account.id) || 0) + 1, 408 + ); 384 409 } 385 - authorCounts[post.account.id] = (authorCounts[post.account.id] || 0) + 1; 410 + 411 + return postFilterMatches; 386 412 }); 387 413 388 - if (selectedAuthor && authorCounts[selectedAuthor]) { 414 + if (selectedAuthor && authorCountsMap.has(selectedAuthor)) { 389 415 filteredPosts = filteredPosts.filter( 390 416 (post) => post.account.id === selectedAuthor, 391 417 ); 392 418 } 393 419 394 - const authorsHash = {}; 395 - for (const author of authors) { 396 - authorsHash[author.id] = author; 397 - } 420 + return [filteredPosts, authorsHash, Object.fromEntries(authorCountsMap)]; 421 + }, [selectedFilterCategory, selectedAuthor, posts]); 398 422 399 - return [filteredPosts, authorsHash, authorCounts]; 400 - }, [selectedFilterCategory, selectedAuthor, posts]); 423 + const filteredPostsMap = useMemo(() => { 424 + const map = {}; 425 + filteredPosts.forEach((post) => { 426 + map[post.id] = post; 427 + }); 428 + return map; 429 + }, [filteredPosts]); 401 430 402 431 const authorCountsList = useMemo( 403 432 () => ··· 450 479 const prevGroup = useRef(null); 451 480 452 481 const authorsListParent = useRef(null); 482 + const autoAnimated = useRef(false); 453 483 useEffect(() => { 454 - if (authorsListParent.current && authorCountsList.length < 30) { 484 + if (posts.length > 100 || autoAnimated.current) return; 485 + if (authorsListParent.current) { 455 486 autoAnimate(authorsListParent.current, { 456 487 duration: 200, 457 488 }); 489 + autoAnimated.current = true; 458 490 } 459 - }, [selectedFilterCategory, authorCountsList, authorsListParent]); 491 + }, [posts, authorsListParent]); 492 + 493 + const postsBarType = posts.length > 160 ? '3d' : '2d'; 460 494 461 495 const postsBar = useMemo(() => { 496 + if (postsBarType !== '2d') return null; 462 497 return posts.map((post) => { 463 498 // If part of filteredPosts 464 - const isFiltered = filteredPosts.find((p) => p.id === post.id); 499 + const isFiltered = filteredPostsMap[post.id]; 465 500 return ( 466 501 <span 502 + data-id={post.id} 467 503 key={post.id} 468 504 class={`post-dot ${isFiltered ? 'post-dot-highlight' : ''}`} 469 505 /> 470 506 ); 471 507 }); 472 - }, [posts, filteredPosts]); 508 + }, [filteredPostsMap]); 509 + 510 + const postsBins = useMemo(() => { 511 + if (postsBarType !== '3d') return null; 512 + if (!posts?.length) return null; 513 + const bins = binByTime(posts, 'createdAt', 320); 514 + return bins.map((posts, i) => { 515 + return ( 516 + <div class="posts-bin" key={i}> 517 + {posts.map((post) => { 518 + const isFiltered = filteredPostsMap[post.id]; 519 + return ( 520 + <span 521 + data-id={post.id} 522 + key={post.id} 523 + class={`post-dot ${isFiltered ? 'post-dot-highlight' : ''}`} 524 + /> 525 + ); 526 + })} 527 + </div> 528 + ); 529 + }); 530 + }, [filteredPostsMap]); 473 531 474 532 const scrollableRef = useRef(null); 475 533 ··· 482 540 483 541 useEffect(() => { 484 542 if (uiState !== 'results') return; 485 - const filterCategoryText = { 486 - Filtered: 'filtered posts', 487 - Groups: 'group posts', 488 - Boosts: 'boosts', 489 - Replies: 'replies', 490 - 'Followed tags': 'followed-tag posts', 491 - Original: 'original posts', 492 - }; 493 543 const authorUsername = 494 544 selectedAuthor && authors[selectedAuthor] 495 545 ? authors[selectedAuthor].username 496 546 : ''; 497 547 const sortOrderIndex = sortOrder === 'asc' ? 0 : 1; 498 - const sortByText = { 499 - // asc, desc 500 - createdAt: ['oldest', 'latest'], 501 - repliesCount: ['fewest replies', 'most replies'], 502 - favouritesCount: ['fewest likes', 'most likes'], 503 - reblogsCount: ['fewest boosts', 'most boosts'], 504 - density: ['least dense', 'most dense'], 505 - }; 506 548 const groupByText = { 507 549 account: 'authors', 508 550 }; 509 551 let toast = showToast({ 510 552 duration: 5_000, // 5 seconds 511 553 text: `Showing ${ 512 - filterCategoryText[selectedFilterCategory] || 'all posts' 554 + FILTER_CATEGORY_TEXT[selectedFilterCategory] || 'all posts' 513 555 }${authorUsername ? ` by @${authorUsername}` : ''}, ${ 514 - sortByText[sortBy][sortOrderIndex] 556 + SORT_BY_TEXT[sortBy][sortOrderIndex] 515 557 } first${ 516 558 !!groupBy 517 559 ? `, grouped by ${groupBy === 'account' ? groupByText[groupBy] : ''}` ··· 533 575 534 576 const prevSelectedAuthorMissing = useRef(false); 535 577 useEffect(() => { 536 - console.log({ 537 - prevSelectedAuthorMissing, 538 - selectedAuthor, 539 - authors, 540 - }); 578 + // console.log({ 579 + // prevSelectedAuthorMissing, 580 + // selectedAuthor, 581 + // authors, 582 + // }); 541 583 let timer; 542 584 if (selectedAuthor) { 543 585 if (authors[selectedAuthor]) { ··· 649 691 ref={catchupRangeRef} 650 692 type="range" 651 693 value={range} 652 - min={ranges[0].value} 653 - max={ranges[ranges.length - 1].value} 694 + min={RANGES[0].value} 695 + max={RANGES[RANGES.length - 1].value} 654 696 step="1" 655 697 list="catchup-ranges" 656 698 onChange={(e) => setRange(+e.target.value)} ··· 660 702 width: '8em', 661 703 }} 662 704 > 663 - {ranges[range - 1].label} 705 + {RANGES[range - 1].label} 664 706 <br /> 665 707 <small class="insignificant"> 666 - {range == ranges[ranges.length - 1].value 708 + {range == RANGES[RANGES.length - 1].value 667 709 ? 'until the max' 668 710 : niceDateTime( 669 711 new Date(Date.now() - range * 60 * 60 * 1000), ··· 671 713 </small> 672 714 </span> 673 715 <datalist id="catchup-ranges"> 674 - {ranges.map(({ label, value }) => ( 716 + {RANGES.map(({ label, value }) => ( 675 717 <option value={value} label={label} /> 676 718 ))} 677 719 </datalist>{' '} 678 720 <button 679 721 type="button" 680 722 onClick={() => { 681 - if (range < ranges[ranges.length - 1].value) { 723 + if (range < RANGES[RANGES.length - 1].value) { 682 724 const duration = range * 60 * 60 * 1000; 683 725 handleCatchupClick({ duration }); 684 726 } else { ··· 926 968 </div> 927 969 </div> 928 970 </div> 929 - {posts.length >= 5 && ( 930 - <div class="catchup-posts-viz-bar">{postsBar}</div> 931 - )} 971 + {posts.length >= 5 && 972 + (postsBarType === '3d' ? ( 973 + <div class="catchup-posts-viz-time-bar">{postsBins}</div> 974 + ) : ( 975 + <div class="catchup-posts-viz-bar">{postsBar}</div> 976 + ))} 932 977 {posts.length >= 2 && ( 933 978 <div class="catchup-filters"> 934 979 <label class="filter-cat"> ··· 1139 1184 return ( 1140 1185 <Fragment key={`${post.id}-${showSeparator}`}> 1141 1186 {showSeparator && <li class="separator" />} 1142 - <li> 1143 - <Link to={`/${instance}/s/${id}`}> 1144 - <IntersectionPostLine 1145 - post={post} 1146 - root={scrollableRef.current} 1147 - /> 1148 - </Link> 1149 - </li> 1187 + <IntersectionPostLineItem 1188 + to={`/${instance}/s/${id}`} 1189 + post={post} 1190 + root={scrollableRef.current} 1191 + /> 1150 1192 </Fragment> 1151 1193 ); 1152 1194 })} ··· 1251 1293 }, 1252 1294 ); 1253 1295 1254 - const IntersectionPostLine = ({ root, ...props }) => { 1296 + const IntersectionPostLineItem = ({ root, to, ...props }) => { 1255 1297 const ref = useRef(); 1256 1298 const [show, setShow] = useState(false); 1257 1299 useEffect(() => { ··· 1275 1317 }, []); 1276 1318 1277 1319 return show ? ( 1278 - <PostLine {...props} /> 1320 + <li> 1321 + <Link to={to}> 1322 + <PostLine {...props} /> 1323 + </Link> 1324 + </li> 1279 1325 ) : ( 1280 - <div ref={ref} style={{ height: '4em' }} /> 1326 + <li ref={ref} style={{ height: '4em' }} /> 1281 1327 ); 1282 1328 }; 1283 1329 ··· 1505 1551 }); 1506 1552 function formatRange(startDate, endDate) { 1507 1553 return dtf.formatRange(startDate, endDate); 1554 + } 1555 + 1556 + function binByTime(data, key, numBins) { 1557 + // Extract dates from data objects 1558 + const dates = data.map((item) => new Date(item[key])); 1559 + 1560 + // Find minimum and maximum dates directly (avoiding Math.min/max) 1561 + const minDate = dates.reduce( 1562 + (acc, date) => (date < acc ? date : acc), 1563 + dates[0], 1564 + ); 1565 + const maxDate = dates.reduce( 1566 + (acc, date) => (date > acc ? date : acc), 1567 + dates[0], 1568 + ); 1569 + 1570 + // Calculate the time span in milliseconds 1571 + const range = maxDate.getTime() - minDate.getTime(); 1572 + 1573 + // Create empty bins and loop through data 1574 + const bins = Array.from({ length: numBins }, () => []); 1575 + data.forEach((item) => { 1576 + const date = new Date(item[key]); 1577 + const normalized = (date.getTime() - minDate.getTime()) / range; 1578 + const binIndex = Math.floor(normalized * (numBins - 1)); 1579 + bins[binIndex].push(item); 1580 + }); 1581 + 1582 + return bins; 1508 1583 } 1509 1584 1510 1585 export default Catchup;