draw things doodl.waow.tech
draw atproto
0

Configure Feed

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

explore: paint immediately, lazy avatars, skeletons

nothing rendered until the cold corpus fetch AND a 76-author avatar batch both
finished in series. now: skeleton shimmer the instant you open explore; render
cards as soon as the drawings JSON arrives (no avatar wait); resolve avatars
lazily per visible page and patch them onto cards in place; cards fade in. also
widen the api edge cache (fresh 2min, stale 10min) so cold enumeration is rare.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

+74 -17
+6 -5
src/data/explore.ts
··· 31 31 }; 32 32 33 33 // batch-resolve avatars via typeahead's getProfiles (tangled + bsky, PDS-rewritten 34 - // avatars). best-effort; missing avatars just don't render. 35 - async function fetchAvatars(dids: string[]): Promise<Map<string, string>> { 34 + // avatars). best-effort; missing avatars just don't render. exported so the 35 + // gallery can resolve them lazily, per visible page, AFTER the cards are painted. 36 + export async function fetchAvatars(dids: string[]): Promise<Map<string, string>> { 36 37 const out = new Map<string, string>(); 37 38 for (let i = 0; i < dids.length; i += 25) { 38 39 const batch = dids.slice(i, i + 25); ··· 57 58 // default ?? show); the icon picker passes false so you can use any drawing — 58 59 // including your own hidden ones — as an icon. (moderation is applied upstream 59 60 // by the worker, so hidden drawings never reach here.) 60 - export async function fetchGallery( 61 + export async function fetchDrawings( 61 62 opts: { hideOptedOut?: boolean } = {}, 62 63 ): Promise<LogoItem[]> { 63 64 const res = await fetch(DRAWINGS); ··· 75 76 ); 76 77 } 77 78 78 - const avatars = await fetchAvatars([...new Set(items.map((d) => d.did))]); 79 + // no avatars here — the gallery paints cards immediately and fills avatars 80 + // lazily per page. callers that want them call fetchAvatars() themselves. 79 81 return items.map((d) => ({ 80 82 did: d.did, 81 83 handle: d.handle, 82 84 rkey: d.rkey, 83 85 imageUrl: d.thumbUrl ?? d.imageUrl, // resized thumb for the grid; falls back to full 84 86 createdAt: d.createdAt, 85 - avatar: avatars.get(d.did), 86 87 parentUri: d.parentUri, 87 88 })); 88 89 }
+45 -9
src/features/gallery.ts
··· 1 1 import { getSession, on } from "../app"; 2 2 import { slotNode, slotUrl } from "../ui/slots"; 3 3 import { el } from "../ui/dom"; 4 - import { fetchGallery, type LogoItem } from "../data/explore"; 4 + import { fetchDrawings, fetchAvatars, type LogoItem } from "../data/explore"; 5 5 import { deleteDrawing } from "../data/pds"; 6 6 import { resolveOriginal, REMIX } from "../data/lineage"; 7 7 import type { OAuthSession } from "@atproto/oauth-client-browser"; ··· 26 26 img.alt = `drawing by @${l.handle}`; 27 27 img.loading = "lazy"; 28 28 29 - // avatar tucked top-left, out of the way 30 - const ava = document.createElement(l.avatar ? "img" : "div"); 29 + // avatar tucked top-left. starts as an initial; the real image is patched in 30 + // lazily (see paintAvatars) so cards never wait on a profile lookup to render. 31 + const ava = document.createElement("div"); 31 32 ava.className = "card-ava"; 32 - if (l.avatar) (ava as HTMLImageElement).src = l.avatar; 33 - else ava.textContent = l.handle[0]?.toUpperCase() ?? "?"; 33 + ava.dataset.did = l.did; 34 + ava.textContent = l.handle[0]?.toUpperCase() ?? "?"; 34 35 ava.title = `@${l.handle}`; 36 + if (l.avatar) setAvatar(ava, l.avatar); 35 37 36 38 const who = document.createElement("div"); 37 39 who.className = "who"; ··· 103 105 return card; 104 106 } 105 107 108 + // apply a resolved avatar to a placeholder in place — no node swap, so the 109 + // initial just fades into the image. 110 + function setAvatar(ava: HTMLElement, url: string) { 111 + ava.style.backgroundImage = `url("${url}")`; 112 + ava.classList.add("has-img"); 113 + } 114 + 115 + // shimmer placeholders shown the instant you open explore, before any data — 116 + // the screen is never blank during the cold fetch. 117 + function skeletons(n: number): HTMLElement[] { 118 + return Array.from({ length: n }, () => el("div", { class: "card skeleton" }, [el("div", { class: "card-img" })])); 119 + } 120 + 106 121 // the corpus can be large, so render a page at a time and append more as you 107 122 // scroll near the end (a sentinel inside the grid drives infinite scroll). 108 123 const PAGE = 18; 109 124 let observer: IntersectionObserver | null = null; 125 + let loadId = 0; 110 126 111 127 async function loadGallery() { 112 128 const g = $("gallery"); 113 129 observer?.disconnect(); 114 - g.innerHTML = "<p class='hint'>loading…</p>"; 130 + const mine = ++loadId; // a newer load supersedes this one 131 + g.replaceChildren(...skeletons(PAGE)); 115 132 try { 116 - const items = await fetchGallery({ hideOptedOut: true }); 117 - g.innerHTML = ""; 133 + const items = await fetchDrawings({ hideOptedOut: true }); 134 + if (mine !== loadId) return; // superseded while fetching 118 135 if (!items.length) { 119 136 g.replaceChildren(el("p", { class: "hint" }, ["no drawings yet — be the first."])); 120 137 return; 121 138 } 122 139 const session = getSession(); 140 + g.replaceChildren(); 123 141 let shown = 0; 124 142 const sentinel = el("div", { class: "gallery-sentinel" }); 125 143 observer = new IntersectionObserver( ··· 128 146 }, 129 147 { rootMargin: "600px" }, 130 148 ); 149 + // resolve the avatars for one page's authors, then patch them onto the cards 150 + // already on screen. fire-and-forget so it never blocks rendering or paging. 151 + const paintAvatars = (slice: LogoItem[]) => { 152 + const dids = [...new Set(slice.map((l) => l.did))]; 153 + fetchAvatars(dids) 154 + .then((map) => { 155 + if (mine !== loadId) return; 156 + for (const [did, url] of map) { 157 + for (const ava of g.querySelectorAll<HTMLElement>(`.card-ava[data-did="${did}"]`)) { 158 + if (!ava.classList.contains("has-img")) setAvatar(ava, url); 159 + } 160 + } 161 + }) 162 + .catch(() => {}); 163 + }; 131 164 const showNext = () => { 132 165 // re-arm: moving the same sentinel within the margin won't re-fire the 133 166 // observer on its own, so unobserve before appending and re-observe after. 134 167 // that re-evaluates intersection and keeps paging while it's still in view 135 168 // (filling the viewport), then naturally waits for scroll once it's not. 136 169 observer?.unobserve(sentinel); 137 - for (const l of items.slice(shown, shown + PAGE)) g.append(buildCard(l, session)); 170 + const slice = items.slice(shown, shown + PAGE); 171 + for (const l of slice) g.append(buildCard(l, session)); 138 172 shown = Math.min(shown + PAGE, items.length); 139 173 g.append(sentinel); // keep the sentinel last 140 174 if (shown >= items.length) { ··· 143 177 } else { 144 178 observer?.observe(sentinel); 145 179 } 180 + paintAvatars(slice); 146 181 }; 147 182 showNext(); 148 183 } catch (err) { 184 + if (mine !== loadId) return; 149 185 g.replaceChildren(el("p", { class: "hint" }, [`failed to load: ${String(err)}`])); 150 186 } 151 187 }
+2 -2
src/features/settings.ts
··· 35 35 clearIcon, 36 36 type IconAssignment, 37 37 } from "../data/pds"; 38 - import { fetchGallery } from "../data/explore"; 38 + import { fetchDrawings } from "../data/explore"; 39 39 40 40 const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T; 41 41 ··· 375 375 376 376 try { 377 377 // any drawing on the network can be an icon — yours first, then everyone's 378 - const all = await fetchGallery(); 378 + const all = await fetchDrawings(); 379 379 const mine = session.did; 380 380 all.sort((a, b) => (a.did === mine ? -1 : 0) - (b.did === mine ? -1 : 0)); 381 381 if (!all.length) {
+17
src/style.css
··· 370 370 } 371 371 .card:hover { border-color: var(--muted); } 372 372 .card-img { width: 100%; aspect-ratio: 1; object-fit: contain; } 373 + /* real cards gently fade in as they're appended */ 374 + .card:not(.skeleton) { animation: card-in .22s ease both; } 375 + @keyframes card-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } 376 + /* shimmer placeholders shown before data arrives */ 377 + .card.skeleton { cursor: default; pointer-events: none; } 378 + .card.skeleton .card-img { 379 + border-radius: 8px; 380 + background: linear-gradient(100deg, var(--hover) 30%, color-mix(in srgb, var(--hover) 50%, var(--surface)) 50%, var(--hover) 70%); 381 + background-size: 200% 100%; 382 + animation: shimmer 1.2s linear infinite; 383 + } 384 + @keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } } 373 385 /* handles are single tight lines, truncated — never wrap or spill the card */ 374 386 .card .who { 375 387 margin-top: .4rem; font-size: .72rem; color: var(--muted); ··· 410 422 font-weight: 600; 411 423 color: var(--muted); 412 424 border: 1px solid var(--surface); 425 + background-size: cover; 426 + background-position: center; 427 + transition: background-image .15s; 413 428 } 429 + /* once the real avatar is patched in, hide the initial letter */ 430 + .card-ava.has-img { color: transparent; } 414 431 415 432 /* subtle corner actions, top-right — never cover the handle */ 416 433 .card-actions {
+4 -1
worker/share.js
··· 435 435 return Response.json(items, { 436 436 headers: { 437 437 "access-control-allow-origin": "*", 438 - "cache-control": "public, max-age=30, stale-while-revalidate=120", 438 + // fresh 2min, then served stale up to 10min while it revalidates in 439 + // the background — so a visitor almost never waits on the cold 440 + // enumeration; a new drawing shows in explore within ~2min. 441 + "cache-control": "public, max-age=120, stale-while-revalidate=600", 439 442 }, 440 443 }); 441 444 } catch (e) {