interactive intro to open social at-me.zzstoatzz.io

feat: add avatar caching and improve identity panel UX

performance improvements:
- implement server-side avatar caching with 1-hour TTL
- add concurrent avatar fetching on backend and frontend
- reduce avatar load time from N×300ms to ~300ms total

identity panel improvements:
- change handle label to "You" for clarity
- move bluesky profile link out of technical details
- add bluesky logo for visual recognition
- prevent JavaScript from overwriting static handle text

technical details:
- add once_cell dependency for in-memory cache
- use futures_util::future::join_all for concurrent resolution
- use Promise.all for parallel frontend avatar fetching

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+146 -57
src
static
+1
Cargo.lock
··· 457 457 "futures-util", 458 458 "hickory-resolver", 459 459 "log", 460 + "once_cell", 460 461 "reqwest", 461 462 "rocketman", 462 463 "serde",
+1
Cargo.toml
··· 23 23 anyhow = "1.0" 24 24 async-stream = "0.3" 25 25 async-trait = "0.1" 26 + once_cell = "1.20"
+92 -22
src/routes.rs
··· 1 1 use actix_web::{get, web, HttpResponse, Responder}; 2 2 use serde::Deserialize; 3 + use once_cell::sync::Lazy; 4 + use std::collections::HashMap; 5 + use std::sync::Mutex; 6 + use std::time::{Duration, Instant}; 7 + use futures_util::future; 3 8 4 9 use crate::firehose::FirehoseManager; 5 10 use crate::mst; 6 11 use crate::templates; 12 + 13 + // Avatar cache with 1 hour TTL 14 + struct CachedAvatar { 15 + url: Option<String>, 16 + timestamp: Instant, 17 + } 18 + 19 + static AVATAR_CACHE: Lazy<Mutex<HashMap<String, CachedAvatar>>> = 20 + Lazy::new(|| Mutex::new(HashMap::new())); 21 + 22 + const CACHE_TTL: Duration = Duration::from_secs(3600); 7 23 8 24 const FAVICON_SVG: &str = include_str!("../static/favicon.svg"); 9 25 ··· 215 231 pub async fn get_avatar(query: web::Query<AvatarQuery>) -> HttpResponse { 216 232 let namespace = &query.namespace; 217 233 234 + // Check cache first 235 + { 236 + let cache = AVATAR_CACHE.lock().unwrap(); 237 + if let Some(cached) = cache.get(namespace) { 238 + if cached.timestamp.elapsed() < CACHE_TTL { 239 + return HttpResponse::Ok() 240 + .insert_header(("Cache-Control", "public, max-age=3600")) 241 + .json(serde_json::json!({ 242 + "avatarUrl": cached.url 243 + })); 244 + } 245 + } 246 + } 247 + 248 + // Cache miss or expired - fetch avatar 249 + let avatar_url = fetch_avatar_for_namespace(namespace).await; 250 + 251 + // Cache the result 252 + { 253 + let mut cache = AVATAR_CACHE.lock().unwrap(); 254 + cache.insert(namespace.clone(), CachedAvatar { 255 + url: avatar_url.clone(), 256 + timestamp: Instant::now(), 257 + }); 258 + } 259 + 260 + HttpResponse::Ok() 261 + .insert_header(("Cache-Control", "public, max-age=3600")) 262 + .json(serde_json::json!({ 263 + "avatarUrl": avatar_url 264 + })) 265 + } 266 + 267 + async fn fetch_avatar_for_namespace(namespace: &str) -> Option<String> { 218 268 // Reverse namespace to get domain (e.g., io.zzstoatzz -> zzstoatzz.io) 219 269 let reversed: String = namespace.split('.').rev().collect::<Vec<&str>>().join("."); 220 270 let handles = vec![ ··· 222 272 format!("{}.bsky.social", reversed), 223 273 ]; 224 274 225 - for handle in handles { 226 - // Try to resolve handle to DID 227 - let resolve_url = format!("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={}", handle); 228 - if let Ok(response) = reqwest::get(&resolve_url).await { 229 - if let Ok(data) = response.json::<serde_json::Value>().await { 230 - if let Some(did) = data["did"].as_str() { 231 - // Try to get profile 232 - let profile_url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}", did); 233 - if let Ok(profile_response) = reqwest::get(&profile_url).await { 234 - if let Ok(profile) = profile_response.json::<serde_json::Value>().await { 235 - if let Some(avatar) = profile["avatar"].as_str() { 236 - return HttpResponse::Ok().json(serde_json::json!({ 237 - "avatarUrl": avatar 238 - })); 239 - } 240 - } 241 - } 242 - } 243 - } 275 + // Try all handles concurrently 276 + let futures: Vec<_> = handles.iter() 277 + .map(|handle| try_fetch_avatar_for_handle(handle)) 278 + .collect(); 279 + 280 + // Wait for all futures and return first successful result 281 + let results = future::join_all(futures).await; 282 + 283 + for result in results { 284 + if let Some(avatar) = result { 285 + return Some(avatar); 244 286 } 245 287 } 246 288 247 - HttpResponse::Ok().json(serde_json::json!({ 248 - "avatarUrl": null 249 - })) 289 + None 290 + } 291 + 292 + async fn try_fetch_avatar_for_handle(handle: &str) -> Option<String> { 293 + // Try to resolve handle to DID 294 + let resolve_url = format!( 295 + "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={}", 296 + handle 297 + ); 298 + 299 + let did = match reqwest::get(&resolve_url).await { 300 + Ok(response) => match response.json::<serde_json::Value>().await { 301 + Ok(data) => data["did"].as_str()?.to_string(), 302 + Err(_) => return None, 303 + }, 304 + Err(_) => return None, 305 + }; 306 + 307 + // Try to get profile 308 + let profile_url = format!( 309 + "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}", 310 + did 311 + ); 312 + 313 + match reqwest::get(&profile_url).await { 314 + Ok(response) => match response.json::<serde_json::Value>().await { 315 + Ok(profile) => profile["avatar"].as_str().map(String::from), 316 + Err(_) => None, 317 + }, 318 + Err(_) => None, 319 + } 250 320 } 251 321 252 322 #[derive(Deserialize)]
+17 -12
src/templates.rs
··· 704 704 flex-direction: column; 705 705 align-items: center; 706 706 justify-content: center; 707 - gap: clamp(0.2rem, 1vmin, 0.3rem); 708 - padding: clamp(0.4rem, 1vmin, 0.6rem); 709 707 z-index: 10; 710 708 cursor: pointer; 711 709 transition: all 0.2s ease; ··· 726 724 }} 727 725 728 726 .identity-value {{ 729 - font-size: clamp(0.6rem, 1.2vmin, 0.7rem); 727 + font-size: 0.7rem; 730 728 color: var(--text-lighter); 731 729 text-align: center; 732 - word-break: break-word; 733 - max-width: 90%; 730 + white-space: nowrap; 734 731 font-weight: 400; 735 732 line-height: 1.2; 733 + }} 734 + 735 + .identity-value:hover {{ 736 + opacity: 0.7; 736 737 }} 737 738 738 739 ··· 743 744 color: var(--text-light); 744 745 letter-spacing: 0.05em; 745 746 font-weight: 500; 747 + text-decoration: none; 748 + white-space: nowrap; 749 + transition: opacity 0.2s ease; 750 + }} 751 + 752 + .identity-pds-label:hover {{ 753 + opacity: 0.7; 746 754 }} 747 755 748 756 .identity-avatar {{ 749 - width: clamp(30px, 6vmin, 45px); 750 - height: clamp(30px, 6vmin, 45px); 757 + width: 100%; 758 + height: 100%; 751 759 border-radius: 50%; 752 760 object-fit: cover; 753 - border: 2px solid var(--text-light); 754 - margin-bottom: clamp(0.2rem, 1vmin, 0.3rem); 755 761 }} 756 762 757 763 .app-view {{ ··· 1631 1637 <p>this visualization shows your <a href="https://atproto.com/guides/data-repos" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Personal Data Server</a> - where your social data actually lives. unlike traditional platforms that lock everything in their database, your posts, likes, and follows are stored here, on infrastructure you control.</p> 1632 1638 <p>each circle represents an app that writes to your space. <a href="https://bsky.app" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">bluesky</a> for microblogging. <a href="https://whtwnd.com" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">whitewind</a> for long-form posts. <a href="https://tangled.org" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">tangled.org</a> for code hosting. they're all just different views of the same underlying data - <strong>your</strong> data.</p> 1633 1639 <p>this is what "<a href="https://overreacted.io/open-social/" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">open social</a>" means: your followers, your content, your connections - they all belong to you, not the app. switch apps anytime and take everything with you. no platform can hold your social graph hostage.</p> 1634 - <p><strong>how to explore:</strong> click your @ in the center to see your account details. click any app to browse the records it's created in your repository.</p> 1640 + <p><strong>how to explore:</strong> click your avatar in the center to see the details of your identity. click any app to browse the records it's created in your repository.</p> 1635 1641 <button id="closeInfo">got it</button> 1636 1642 <button id="restartTour" onclick="window.restartOnboarding()" style="margin-left: 0.5rem; background: var(--surface-hover);">restart tour</button> 1637 1643 </div> ··· 1643 1649 1644 1650 <div class="canvas"> 1645 1651 <div class="identity"> 1646 - <div class="identity-label">@</div> 1647 - <div class="identity-value" id="handle">loading...</div> 1652 + <img class="identity-avatar" id="avatar" /> 1648 1653 <div class="identity-pds-label">You</div> 1649 1654 </div> 1650 1655 <div id="field" class="loading">loading...</div>
+35 -23
static/app.js
··· 75 75 globalPds = initData.pds; 76 76 globalHandle = initData.handle; 77 77 78 - // Update identity display with handle and adapt text size 79 - const handleEl = document.getElementById('handle'); 80 - handleEl.textContent = initData.handle; 81 - adaptHandleTextSize(handleEl); 82 - 83 78 // Display user's avatar if available 84 - if (initData.avatar) { 85 - const identity = document.querySelector('.identity'); 86 - const avatarImg = document.createElement('img'); 87 - avatarImg.src = initData.avatar; 88 - avatarImg.className = 'identity-avatar'; 89 - avatarImg.alt = initData.handle; 90 - // Insert avatar before the @ label 91 - identity.insertBefore(avatarImg, identity.firstChild); 79 + const avatarEl = document.getElementById('avatar'); 80 + if (initData.avatar && avatarEl) { 81 + avatarEl.src = initData.avatar; 82 + avatarEl.alt = initData.handle; 92 83 } 93 84 94 85 // Convert apps array to object for easier access ··· 134 125 <div class="ownership-text">want to see everything stored on your PDS? check out <a href="https://pdsls.dev/${pdsHost}" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">pdsls.dev/${pdsHost}</a> - a tool for browsing all the records in your repository.</div> 135 126 </div> 136 127 128 + <a href="https://bsky.app/profile/${globalHandle}" target="_blank" rel="noopener noreferrer" class="tree-item" style="text-decoration: none; display: block; margin-top: 1rem;"> 129 + <div class="tree-item-header"> 130 + <div style="display: flex; align-items: center; gap: 0.5rem;"> 131 + <svg width="16" height="16" viewBox="0 0 600 530" fill="none" xmlns="http://www.w3.org/2000/svg"> 132 + <path d="M135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" fill="var(--text)" /> 133 + </svg> 134 + <span style="color: var(--text-light);">view profile on bluesky</span> 135 + </div> 136 + <span style="font-size: 0.6rem; color: var(--text);">↗</span> 137 + </div> 138 + </a> 139 + 137 140 <div style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border);"> 138 141 <div style="font-size: 0.65rem; color: var(--text-light); margin-bottom: 0.5rem;">technical details</div> 139 142 <div class="tree-item"> ··· 219 222 // Store circle size for resize handler 220 223 globalApps._circleSize = circleSize; 221 224 222 - appNames.forEach((namespace, i) => { 225 + // Create all app divs first 226 + const appDivs = appNames.map((namespace, i) => { 223 227 const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; // Start from top 224 228 const circleOffset = circleSize / 2; 225 229 const x = centerX + radius * Math.cos(angle) - circleOffset; ··· 241 245 <a href="${url}" target="_blank" rel="noopener noreferrer" class="app-name" data-url="${url}">${displayName} ↗</a> 242 246 `; 243 247 244 - // Try to fetch and display avatar 245 - fetchAppAvatar(namespace).then(avatarUrl => { 246 - if (avatarUrl) { 247 - const circle = div.querySelector('.app-circle'); 248 - circle.innerHTML = `<img src="${avatarUrl}" class="app-logo" alt="${namespace}" />`; 249 - } 248 + return { div, namespace }; 249 + }); 250 + 251 + // Fetch all avatars concurrently 252 + Promise.all(appNames.map(namespace => fetchAppAvatar(namespace))) 253 + .then(avatarUrls => { 254 + avatarUrls.forEach((avatarUrl, i) => { 255 + if (avatarUrl) { 256 + const circle = appDivs[i].div.querySelector('.app-circle'); 257 + circle.innerHTML = `<img src="${avatarUrl}" class="app-logo" alt="${appDivs[i].namespace}" />`; 258 + } 259 + }); 250 260 }); 261 + 262 + appDivs.forEach(({ div, namespace }, i) => { 263 + // Reverse namespace for display and create URL 264 + const displayName = namespace.split('.').reverse().join('.'); 265 + const url = `https://${displayName}`; 251 266 252 267 // Validate URL 253 268 fetch(`/api/validate-url?url=${encodeURIComponent(url)}`) ··· 562 577 clearTimeout(resizeTimeout); 563 578 resizeTimeout = setTimeout(() => { 564 579 repositionAppCircles(); 565 - // Re-adapt handle text size on resize 566 - const handleEl = document.getElementById('handle'); 567 - if (handleEl) adaptHandleTextSize(handleEl); 568 580 }, 50); // Faster debounce for smoother updates 569 581 }); 570 582 })