slack status without the slack status.zzstoatzz.io/
quickslice

feat: add infinite scrolling to global feed

- add paginated database query with offset/limit support
- create /api/feed endpoint for fetching more statuses
- implement infinite scroll JavaScript with automatic loading
- show 'beginning of time' message when all statuses loaded
- preserve all existing functionality without breaking changes

+228
+26
src/db.rs
··· 216 216 .await 217 217 } 218 218 219 + /// Loads paginated statuses for infinite scrolling 220 + pub async fn load_statuses_paginated( 221 + pool: &Data<Arc<Pool>>, 222 + offset: i32, 223 + limit: i32, 224 + ) -> Result<Vec<Self>, async_sqlite::Error> { 225 + pool 226 + .conn(move |conn| { 227 + let mut stmt = conn.prepare( 228 + "SELECT * FROM status ORDER BY startedAt DESC LIMIT ?1 OFFSET ?2" 229 + )?; 230 + let status_iter = stmt 231 + .query_map(rusqlite::params![limit, offset], |row| { 232 + Ok(Self::map_from_row(row).unwrap()) 233 + }) 234 + .unwrap(); 235 + 236 + let mut statuses = Vec::new(); 237 + for status in status_iter { 238 + statuses.push(status?); 239 + } 240 + Ok(statuses) 241 + }) 242 + .await 243 + } 244 + 219 245 /// Loads the logged-in users current status 220 246 pub async fn my_status( 221 247 pool: &Data<Arc<Pool>>,
+54
src/main.rs
··· 553 553 Ok(web::Json(response)) 554 554 } 555 555 556 + /// Get paginated statuses for infinite scrolling 557 + #[get("/api/feed")] 558 + async fn api_feed( 559 + query: web::Query<HashMap<String, String>>, 560 + db_pool: web::Data<Arc<Pool>>, 561 + handle_resolver: web::Data<HandleResolver>, 562 + ) -> Result<impl Responder> { 563 + let offset = query.get("offset") 564 + .and_then(|s| s.parse::<i32>().ok()) 565 + .unwrap_or(0); 566 + let limit = query.get("limit") 567 + .and_then(|s| s.parse::<i32>().ok()) 568 + .unwrap_or(20) 569 + .min(50); // Cap at 50 items per request 570 + 571 + let mut statuses = StatusFromDb::load_statuses_paginated(&db_pool, offset, limit) 572 + .await 573 + .unwrap_or_else(|err| { 574 + log::error!("Error loading statuses: {err}"); 575 + vec![] 576 + }); 577 + 578 + // Resolve handles for each status 579 + let mut quick_resolve_map: HashMap<Did, String> = HashMap::new(); 580 + for db_status in &mut statuses { 581 + let authors_did = Did::new(db_status.author_did.clone()).expect("failed to parse did"); 582 + match quick_resolve_map.get(&authors_did) { 583 + None => {} 584 + Some(found_handle) => { 585 + db_status.handle = Some(found_handle.clone()); 586 + continue; 587 + } 588 + } 589 + db_status.handle = match handle_resolver.resolve(&authors_did).await { 590 + Ok(did_doc) => match did_doc.also_known_as { 591 + None => None, 592 + Some(also_known_as) => match also_known_as.is_empty() { 593 + true => None, 594 + false => { 595 + let full_handle = also_known_as.first().unwrap(); 596 + let handle = full_handle.replace("at://", ""); 597 + quick_resolve_map.insert(authors_did, handle.clone()); 598 + Some(handle) 599 + } 600 + }, 601 + }, 602 + Err(_) => None, 603 + }; 604 + } 605 + 606 + Ok(HttpResponse::Ok().json(statuses)) 607 + } 608 + 556 609 /// Get all custom emojis available on the site 557 610 #[get("/api/custom-emojis")] 558 611 async fn get_custom_emojis() -> Result<impl Responder> { ··· 1334 1387 .service(status_json) 1335 1388 .service(owner_status_json) 1336 1389 .service(get_custom_emojis) 1390 + .service(api_feed) 1337 1391 .service(user_status_page) 1338 1392 .service(user_status_json) 1339 1393 .service(status)
+148
templates/feed.html
··· 91 91 <p>no status updates yet</p> 92 92 </div> 93 93 {% endif %} 94 + 95 + <!-- Loading indicator --> 96 + <div id="loading-indicator" style="display: none; text-align: center; padding: 2rem;"> 97 + <span style="color: var(--text-tertiary);">Loading more...</span> 98 + </div> 99 + 100 + <!-- End of feed indicator --> 101 + <div id="end-of-feed" style="display: none; text-align: center; padding: 2rem;"> 102 + <span style="color: var(--text-tertiary);">You've reached the beginning of time ✨</span> 103 + </div> 94 104 </div> 95 105 96 106 <!-- Navigation --> ··· 533 543 }); 534 544 }; 535 545 546 + // Infinite scroll variables 547 + let isLoading = false; 548 + let offset = {% if !statuses.is_empty() %}{{ statuses.len() }}{% else %}0{% endif %}; 549 + let hasMore = true; 550 + 551 + // Load more statuses 552 + const loadMoreStatuses = async () => { 553 + if (isLoading || !hasMore) return; 554 + 555 + isLoading = true; 556 + const loadingIndicator = document.getElementById('loading-indicator'); 557 + loadingIndicator.style.display = 'block'; 558 + 559 + try { 560 + const response = await fetch(`/api/feed?offset=${offset}&limit=20`); 561 + const newStatuses = await response.json(); 562 + 563 + if (newStatuses.length === 0) { 564 + hasMore = false; 565 + loadingIndicator.style.display = 'none'; 566 + document.getElementById('end-of-feed').style.display = 'block'; 567 + return; 568 + } 569 + 570 + const statusList = document.querySelector('.status-list'); 571 + 572 + // Render new statuses 573 + newStatuses.forEach(status => { 574 + const statusItem = document.createElement('div'); 575 + statusItem.className = 'status-item'; 576 + 577 + let emojiHtml = ''; 578 + if (status.status.startsWith('custom:')) { 579 + const emojiName = status.status.substring(7); 580 + emojiHtml = `<img src="/emojis/${emojiName}.png" alt="${emojiName}" class="custom-emoji-display" onerror="this.onerror=null; this.src='/emojis/${emojiName}.gif';">`; 581 + } else { 582 + emojiHtml = status.status; 583 + } 584 + 585 + // Format the timestamp 586 + const date = new Date(status.started_at); 587 + const now = new Date(); 588 + const diffMs = now - date; 589 + const diffMins = Math.floor(diffMs / 60000); 590 + const diffHours = Math.floor(diffMs / 3600000); 591 + const diffDays = Math.floor(diffMs / 86400000); 592 + 593 + let timeText = ''; 594 + if (diffMins < 1) { 595 + timeText = 'just now'; 596 + } else if (diffMins < 60) { 597 + timeText = `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`; 598 + } else if (diffHours < 24) { 599 + timeText = `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`; 600 + } else if (diffDays < 7) { 601 + timeText = `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`; 602 + } else { 603 + timeText = date.toLocaleDateString(); 604 + } 605 + 606 + // Calculate expiry if present 607 + let expiryHtml = ''; 608 + if (status.expires_at) { 609 + const expiryDate = new Date(status.expires_at); 610 + if (expiryDate > now) { 611 + const futureMs = expiryDate - now; 612 + const futureMins = Math.floor(futureMs / 60000); 613 + const futureHours = Math.floor(futureMs / 3600000); 614 + const futureDays = Math.floor(futureMs / 86400000); 615 + 616 + let expiryText = ''; 617 + if (futureMins < 1) { 618 + expiryText = 'expires soon'; 619 + } else if (futureMins < 60) { 620 + expiryText = `expires in ${futureMins} minute${futureMins !== 1 ? 's' : ''}`; 621 + } else if (futureHours < 24) { 622 + expiryText = `expires in ${futureHours} hour${futureHours !== 1 ? 's' : ''}`; 623 + } else { 624 + expiryText = `expires in ${futureDays} day${futureDays !== 1 ? 's' : ''}`; 625 + } 626 + expiryHtml = ` • ${expiryText}`; 627 + } else { 628 + expiryHtml = ' • expired'; 629 + } 630 + } 631 + 632 + const displayName = status.handle || status.author_did; 633 + const profileUrl = status.handle ? `/${status.handle}` : '#'; 634 + 635 + statusItem.innerHTML = ` 636 + <span class="status-emoji">${emojiHtml}</span> 637 + <div class="status-content"> 638 + <div class="status-main"> 639 + <a class="status-author" href="${profileUrl}">${displayName}</a> 640 + ${status.text ? `<span class="status-text">${status.text}</span>` : ''} 641 + </div> 642 + <div class="status-meta"> 643 + <span>${timeText}</span>${expiryHtml} 644 + </div> 645 + </div> 646 + `; 647 + 648 + statusList.appendChild(statusItem); 649 + }); 650 + 651 + offset += newStatuses.length; 652 + loadingIndicator.style.display = 'none'; 653 + } catch (error) { 654 + console.error('Error loading more statuses:', error); 655 + loadingIndicator.style.display = 'none'; 656 + } finally { 657 + isLoading = false; 658 + } 659 + }; 660 + 661 + // Check scroll position 662 + const checkScroll = () => { 663 + const scrollHeight = document.documentElement.scrollHeight; 664 + const scrollTop = window.scrollY; 665 + const clientHeight = window.innerHeight; 666 + 667 + // Load more when user is 200px from the bottom 668 + if (scrollTop + clientHeight >= scrollHeight - 200) { 669 + loadMoreStatuses(); 670 + } 671 + }; 672 + 536 673 // Initialize on page load 537 674 document.addEventListener('DOMContentLoaded', () => { 538 675 initTheme(); ··· 543 680 if (themeToggle) { 544 681 themeToggle.addEventListener('click', toggleTheme); 545 682 } 683 + 684 + // Set up infinite scrolling 685 + window.addEventListener('scroll', checkScroll); 686 + 687 + // Check if we need to load more on initial page load 688 + // (in case the initial content doesn't fill the viewport) 689 + setTimeout(() => { 690 + if (document.documentElement.scrollHeight <= window.innerHeight) { 691 + loadMoreStatuses(); 692 + } 693 + }, 100); 546 694 547 695 // Update times every minute 548 696 setInterval(formatLocalTime, 60000);