Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

feat: profile — activity + portfolio hybrid redesign

Replace stats scorecard with painting thumbnail strip and tappable
activity feed. Mood + presence condensed to compact header.

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

+366 -196
+366 -196
system/public/aesthetic.computer/disks/profile.mjs
··· 1 - // Profile, 2026.02.27.12.40.00 2 - // Public user scorecard page with live presence + recent activity. 1 + // Profile, 2026.03.26.00.00.00 2 + // @handle profile — activity + portfolio hybrid. 3 3 4 4 const FETCHING = "Fetching"; 5 5 const REFRESH_MS = 30000; 6 6 const RECONNECT_MS = 3000; 7 7 const MOOD_LIMIT = 40; 8 8 const CHAT_LIMIT = 120; 9 + const THUMB_MAX = 4; 10 + const ACTIVITY_MAX = 30; 9 11 10 - const { max, floor } = Math; 12 + const { max, min, floor } = Math; 11 13 12 14 let debug; 13 15 let visiting; ··· 16 18 let noprofileAction; 17 19 let noprofileBtn; 18 20 let paintingsBtn; 19 - let refreshBtn; 20 21 let ellipsisTicker; 21 22 22 23 let loading = false; ··· 26 27 let scorecard = makeEmptyScorecard(); 27 28 let presence = makeOfflinePresence(); 28 29 30 + // Painting thumbnails. 31 + let thumbs = []; // [{ slug, code, img, when, btn, route }] 32 + let thumbsLoading = false; 33 + let getApi = null; // stored from boot for async image loading 34 + 29 35 let statusSocket = null; 30 36 let reconnectTimer = null; 31 37 let refreshTimer = null; 32 38 let disposed = false; 33 39 40 + // Activity button regions (rebuilt each frame). 41 + let activityBtns = []; 42 + 43 + // Scroll state for activity feed. 44 + let scrollOffset = 0; 45 + let maxScroll = 0; 46 + 34 47 function makeEmptyScorecard() { 35 48 return { 36 49 counts: { ··· 69 82 function meta({ piece }) { 70 83 return { 71 84 title: `${piece} - aesthetic.computer`, 72 - desc: `Live activity scorecard for ${piece}.`, 85 + desc: `Profile for ${piece}.`, 73 86 }; 74 87 } 75 88 ··· 80 93 handle, 81 94 hud, 82 95 net, 96 + get, 83 97 debug: d, 84 98 }) { 85 99 disposed = false; 86 100 debug = d; 101 + getApi = get; 87 102 88 103 const hand = normalizeHandle(handle()); 89 104 visiting = normalizeHandle(params[0] || hand); ··· 96 111 presence = makeOfflinePresence(); 97 112 profile = null; 98 113 dataError = null; 114 + thumbs = []; 115 + thumbsLoading = false; 116 + scrollOffset = 0; 99 117 100 118 if (visiting) { 101 119 hud.label(visiting); ··· 123 141 } 124 142 } 125 143 126 - function paint({ api, wipe, help, ink, screen, ui, pen }) { 144 + function paint({ api, geo, wipe, help, ink, screen, ui, pen, paste }) { 127 145 if (!pen?.drawing) wipe(98); 128 146 129 147 if (!visiting) { ··· 131 149 return; 132 150 } 133 151 134 - // Reserve vertical space so content never overlaps the HUD corner label. 135 152 const HUD_H = 22; 136 153 const M = 4; 137 154 const BTN_H = 16; 138 - const isLandscape = screen.width > screen.height && screen.width >= 200; 139 155 140 - // Loading / error states (centered, no layout needed) 156 + // Loading state. 141 157 if (!profile && noprofile === FETCHING) { 142 158 ink(255).write( 143 159 `${FETCHING}${ellipsisTicker.text(help.repeat)}`, ··· 147 163 return; 148 164 } 149 165 166 + // Error / no-profile state. 150 167 if (!profile && noprofile && noprofile !== FETCHING) { 151 168 ink(255).write(noprofile, { center: "xy" }, "black"); 152 169 return; 153 170 } 154 171 155 - // Thin divider below HUD area 172 + // Thin divider below HUD. 156 173 ink(50).line(0, HUD_H - 2, screen.width, HUD_H - 2); 157 174 158 175 const maxY = screen.height - BTN_H - 2; 176 + const isLandscape = screen.width > screen.height && screen.width >= 200; 159 177 160 178 if (isLandscape) { 161 - paintLandscapeLayout({ help, ink, screen, HUD_H, M, maxY }); 179 + paintLandscape({ geo, help, ink, screen, paste, HUD_H, M, maxY }); 162 180 } else { 163 - paintPortraitLayout({ help, ink, screen, HUD_H, M, maxY }); 181 + paintPortrait({ geo, help, ink, screen, paste, HUD_H, M, maxY }); 164 182 } 165 183 166 - // Bottom action buttons 184 + // Bottom bar. 167 185 paintingsBtn ||= new ui.TextButton("Paintings", { 168 186 x: M, 169 187 y: screen.height - BTN_H + 1, 170 188 }); 171 - refreshBtn ||= new ui.TextButton("Refresh", { 172 - x: screen.width - 47, 173 - y: screen.height - BTN_H + 1, 174 - }); 175 189 paintingsBtn.paint(api); 176 - refreshBtn.paint(api); 177 190 178 - // Refreshing indicator (below HUD, right-aligned) 191 + // Refreshing indicator. 179 192 if (refreshing) { 180 193 ink(100).write( 181 194 `Refreshing${ellipsisTicker.text(help.repeat)}`, 182 - { x: screen.width - 84, y: HUD_H }, 195 + { x: screen.width - 84, y: screen.height - BTN_H + 1 }, 183 196 "black", 184 197 80, 185 198 ); 186 199 } 187 200 } 188 201 189 - function act({ event: e, jump, store, user }) { 202 + function act({ event: e, jump, store, user, geo }) { 203 + // Paintings button. 190 204 paintingsBtn?.act(e, () => { 191 205 if (visiting) jump(`paintings~${visiting}`); 192 - }); 193 - 194 - refreshBtn?.act(e, () => { 195 - refreshProfile(true); 196 206 }); 197 207 198 208 if (e.is("keyboard:down:g") && visiting) { ··· 203 213 refreshProfile(true); 204 214 } 205 215 216 + // Thumbnail taps — use simple hit testing. 217 + if (e.is("touch") || e.is("lift")) { 218 + for (const t of thumbs) { 219 + if (t.btn?.box && t.route && e.is("lift")) { 220 + const b = t.btn.box; 221 + const px = e.x ?? e.pen?.x; 222 + const py = e.y ?? e.pen?.y; 223 + if ( 224 + px >= b.x && 225 + px <= b.x + b.w && 226 + py >= b.y && 227 + py <= b.y + b.h 228 + ) { 229 + jump(t.route); 230 + break; 231 + } 232 + } 233 + } 234 + } 235 + 236 + // Activity item taps — simple hit testing. 237 + if (e.is("lift")) { 238 + const px = e.x ?? e.pen?.x; 239 + const py = e.y ?? e.pen?.y; 240 + for (const ab of activityBtns) { 241 + if (ab.route && ab.box) { 242 + const b = ab.box; 243 + if ( 244 + px >= b.x && 245 + px <= b.x + b.w && 246 + py >= b.y && 247 + py <= b.y + b.h 248 + ) { 249 + jump(ab.route); 250 + break; 251 + } 252 + } 253 + } 254 + } 255 + 256 + // Scroll activity feed. 257 + if (e.is("scroll")) { 258 + scrollOffset = max(0, min(maxScroll, scrollOffset + (e.delta || 0))); 259 + } 260 + 261 + // No-profile button. 206 262 noprofileBtn?.act(e, { 207 263 push: () => { 208 264 let slug; ··· 221 277 if (e.is("reframed")) { 222 278 noprofileBtn = null; 223 279 paintingsBtn = null; 224 - refreshBtn = null; 280 + activityBtns = []; 281 + for (const t of thumbs) t.btn = null; 225 282 } 226 283 } 227 284 ··· 234 291 clearTimersAndSocket(); 235 292 } 236 293 237 - function paintNoProfileState({ api, help, ink, screen, ui }) { 238 - const retrieving = noprofile === FETCHING; 239 - const label = noprofile || "No profile."; 240 - 241 - if (!noprofileAction) { 242 - const text = retrieving ? `${label}${ellipsisTicker.text(help.repeat)}` : label; 243 - ink(255).write(text, { center: "xy" }, retrieving ? 64 : "black"); 244 - return; 245 - } 294 + // --- Layout --- 246 295 247 - noprofileBtn ||= new ui.TextButton(label, { center: "xy", screen }); 248 - noprofileBtn.paint(api); 249 - } 250 - 251 - // --- Responsive layout helpers --- 252 - 253 - function paintPortraitLayout({ help, ink, screen, HUD_H, M, maxY }) { 296 + function paintPortrait({ geo, help, ink, screen, paste, HUD_H, M, maxY }) { 254 297 let y = HUD_H; 255 298 const w = screen.width - M * 2; 256 - const useCompactStats = screen.height < 250; 299 + 300 + // Mood. 301 + y = paintMood(ink, M, y, w); 257 302 258 - // Mood 259 - if (profile?.mood) { 260 - ink(220).write(profile.mood, { x: M, y }, "black", w); 261 - } else { 262 - ink(80).write("no mood", { x: M, y }, "black", w); 263 - } 264 - y += 12; 303 + // Presence line. 304 + y = paintPresenceLine(ink, M, y, w); 265 305 266 - // Presence 267 - y = paintPresenceBlock(ink, y, M, w); 306 + // Divider. 307 + ink(50).line(M, y, screen.width - M, y); 308 + y += 4; 268 309 269 - // Stats (skip on very small screens) 270 - if (screen.height >= 180) { 271 - ink(50).line(M, y, screen.width - M, y); 272 - y += 4; 273 - y = paintStatsBlock(ink, y, M, w, useCompactStats); 274 - } 310 + // Thumbnail strip. 311 + y = paintThumbStrip(ink, paste, M, y, w); 275 312 276 - // Divider before activity 313 + // Divider. 277 314 ink(50).line(M, y, screen.width - M, y); 278 315 y += 4; 279 316 280 - paintActivityBlock(help, ink, y, maxY, M, w); 317 + // Activity feed. 318 + paintActivityFeed(help, ink, M, y, maxY, w); 281 319 } 282 320 283 - function paintLandscapeLayout({ help, ink, screen, HUD_H, M, maxY }) { 321 + function paintLandscape({ geo, help, ink, screen, paste, HUD_H, M, maxY }) { 284 322 const midX = floor(screen.width / 2); 285 323 const leftW = midX - M * 2; 286 324 const rightX = midX + M; 287 325 const rightW = screen.width - midX - M * 2; 288 326 289 - // Vertical divider 327 + // Vertical divider. 290 328 ink(50).line(midX - 1, HUD_H, midX - 1, maxY); 291 329 292 - // Left column: mood + presence + stats 330 + // Left column: mood + presence + thumbnails. 293 331 let y = HUD_H; 294 332 295 - if (profile?.mood) { 296 - ink(220).write(profile.mood, { x: M, y }, "black", leftW); 297 - } else { 298 - ink(80).write("no mood", { x: M, y }, "black", leftW); 299 - } 300 - y += 12; 301 - 302 - y = paintPresenceBlock(ink, y, M, leftW); 333 + y = paintMood(ink, M, y, leftW); 334 + y = paintPresenceLine(ink, M, y, leftW); 303 335 ink(50).line(M, y, midX - M, y); 304 336 y += 4; 305 - paintStatsBlock(ink, y, M, leftW, false); 337 + paintThumbStrip(ink, paste, M, y, leftW); 306 338 307 - // Right column: activity 308 - paintActivityBlock(help, ink, HUD_H, maxY, rightX, rightW); 339 + // Right column: activity feed. 340 + paintActivityFeed(help, ink, rightX, HUD_H, maxY, rightW); 309 341 } 310 342 311 - function paintPresenceBlock(ink, y, x, w) { 343 + // --- Sections --- 344 + 345 + function paintMood(ink, x, y, w) { 346 + if (profile?.mood) { 347 + ink(220).write(profile.mood, { x, y }, "black", w); 348 + } else { 349 + ink(80).write("no mood", { x, y }, "black", w); 350 + } 351 + return y + 12; 352 + } 353 + 354 + function paintPresenceLine(ink, x, y, w) { 312 355 const on = presence.online; 313 356 314 - // Colored status indicator dot 357 + // Status dot. 315 358 ink(on ? 80 : 70, on ? 220 : 70, on ? 80 : 70).box(x, y + 2, 5, 5); 316 359 317 - const status = on ? "online" : "offline"; 318 - const piece = formatPieceLabel(presence.currentPiece) || "-"; 319 - const ping = presence.ping ? ` ${presence.ping}ms` : ""; 360 + const parts = []; 361 + parts.push(on ? "online" : "offline"); 362 + 363 + if (on && presence.currentPiece) { 364 + parts.push(formatPieceLabel(presence.currentPiece)); 365 + } 366 + 367 + if (presence.ping) parts.push(`${presence.ping}ms`); 368 + 369 + if (!on && presence.lastSeenAt) { 370 + parts.push(`seen ${formatTimeAgo(presence.lastSeenAt)}`); 371 + } 372 + 320 373 ink(on ? 255 : 120).write( 321 - `${status} ${piece}${ping}`, 374 + parts.join(" "), 322 375 { x: x + 8, y }, 323 376 "black", 324 377 w - 8, 325 378 ); 326 - y += 10; 379 + return y + 12; 380 + } 327 381 328 - if (presence.worldPiece || presence.showing) { 329 - ink(160).write( 330 - `world ${presence.worldPiece || "-"} showing ${presence.showing || "-"}`, 331 - { x: x + 8, y }, 332 - "black", 333 - w - 8, 334 - ); 335 - y += 10; 382 + function paintThumbStrip(ink, paste, x, y, w) { 383 + const paintings = scorecard.recentMedia.paintings; 384 + 385 + if (paintings.length === 0 && !thumbsLoading) { 386 + ink(80).write("no paintings yet", { x, y }, "black", w); 387 + return y + 14; 336 388 } 337 389 338 - if (!on && presence.lastSeenAt) { 339 - ink(90).write( 340 - `seen ${formatTimeAgo(presence.lastSeenAt)}`, 341 - { x: x + 8, y }, 342 - "black", 343 - w - 8, 344 - ); 345 - y += 10; 390 + if (thumbs.length === 0 && thumbsLoading) { 391 + ink(120).write("loading thumbnails...", { x, y }, "black", w); 392 + return y + 14; 346 393 } 347 394 348 - y += 2; 349 - return y; 395 + // Compute thumbnail size to fit across the width. 396 + const gap = 3; 397 + const count = min(thumbs.length, THUMB_MAX); 398 + if (count === 0) return y + 2; 399 + 400 + const thumbW = floor((w - gap * (count - 1)) / count); 401 + const thumbH = floor(thumbW * 0.75); // 4:3 aspect 402 + 403 + for (let i = 0; i < count; i++) { 404 + const t = thumbs[i]; 405 + if (!t) continue; 406 + const tx = x + i * (thumbW + gap); 407 + 408 + // Background. 409 + ink(60).box(tx, y, thumbW, thumbH); 410 + 411 + // Paste image if loaded. 412 + if (t.img && paste) { 413 + const scale = min(thumbW / t.img.width, thumbH / t.img.height); 414 + const ix = tx + floor((thumbW - t.img.width * scale) / 2); 415 + const iy = y + floor((thumbH - t.img.height * scale) / 2); 416 + paste(t.img, ix, iy, { scale }); 417 + } else if (!t.img) { 418 + ink(90).write("...", { x: tx + 2, y: y + 2 }, "black"); 419 + } 420 + 421 + // Code label below thumbnail. 422 + const label = t.code ? `#${t.code}` : shortSlug(t.slug); 423 + ink(160).write(label, { x: tx, y: y + thumbH + 1 }, "black", thumbW); 424 + 425 + // Store hit region for tap handling in act(). 426 + t.btn = { box: { x: tx, y, w: thumbW, h: thumbH + 10 } }; 427 + t.route = t.slug ? `painting~${visiting}/${t.slug}` : null; 428 + } 429 + 430 + return y + thumbH + 14; 350 431 } 351 432 352 - function paintStatsBlock(ink, y, x, w, compact) { 353 - const c = scorecard.counts; 433 + function paintActivityFeed(help, ink, x, y, maxY, w) { 434 + const startY = y; 435 + activityBtns = []; 354 436 355 - if (compact) { 356 - ink(255).write( 357 - `P${c.paintings} Pc${c.pieces} K${c.kidlisp} Cl${c.clocks} T${c.tapes}`, 437 + if ((loading || refreshing) && scorecard.activity.length === 0) { 438 + ink(200).write( 439 + `${FETCHING}${ellipsisTicker.text(help.repeat)}`, 358 440 { x, y }, 359 441 "black", 360 442 w, 361 443 ); 362 - y += 10; 363 - ink(255).write(`M${c.moods} Ch${c.chats}`, { x, y }, "black", w); 364 - y += 12; 365 - return y; 444 + return; 366 445 } 367 446 368 - ink("yellow").write("Stats", { x, y }, "black", w); 369 - y += 10; 447 + if (scorecard.activity.length === 0) { 448 + ink(80).write(dataError || "no activity yet", { x, y }, "black", w); 449 + return; 450 + } 451 + 452 + const lineH = 11; 453 + const items = scorecard.activity; 454 + const totalH = items.length * lineH; 455 + maxScroll = max(0, totalH - (maxY - startY)); 456 + 457 + let drawY = startY - scrollOffset; 458 + 459 + for (let i = 0; i < items.length; i++) { 460 + const item = items[i]; 461 + const iy = drawY + i * lineH; 462 + 463 + // Clip. 464 + if (iy + lineH < startY) continue; 465 + if (iy > maxY) break; 466 + 467 + const c = ACTIVITY_COLORS[item.type] || ACTIVITY_COLORS.default; 370 468 371 - const rows = [ 372 - { label: "Paint", val: c.paintings, r: 100, g: 200, b: 255 }, 373 - { label: "Piece", val: c.pieces, r: 255, g: 180, b: 80 }, 374 - { label: "Kid", val: c.kidlisp, r: 120, g: 255, b: 120 }, 375 - { label: "Clock", val: c.clocks, r: 255, g: 200, b: 80 }, 376 - { label: "Tape", val: c.tapes, r: 200, g: 140, b: 255 }, 377 - { label: "Mood", val: c.moods, r: 255, g: 140, b: 180 }, 378 - { label: "Chat", val: c.chats, r: 200, g: 200, b: 200 }, 379 - ]; 469 + // Type prefix in color. 470 + const prefix = ACTIVITY_PREFIX[item.type] || item.type || "?"; 471 + ink(c.r, c.g, c.b).write(prefix, { x, y: iy }, "black", 36); 380 472 381 - const hi = max(1, ...rows.map((s) => s.val)); 382 - const labelEnd = x + 60; 383 - const barMax = max(8, w - 64); 473 + // Label. 474 + const labelX = x + 38; 475 + const labelW = w - 38 - 40; 476 + ink(item.route ? 240 : 180).write( 477 + item.label || "", 478 + { x: labelX, y: iy }, 479 + "black", 480 + labelW, 481 + ); 384 482 385 - rows.forEach((s) => { 386 - ink(255).write(`${s.label} ${s.val}`, { x, y }, "black", 58); 387 - if (s.val > 0) { 388 - const barW = max(1, floor((s.val / hi) * barMax)); 389 - ink(s.r, s.g, s.b).box(labelEnd, y + 2, barW, 5); 483 + // Timestamp right-aligned. 484 + const when = item.when ? formatTimeAgo(item.when) : ""; 485 + if (when) { 486 + ink(100).write(when, { x: x + w - 38, y: iy }, "black", 38); 390 487 } 391 - y += 9; 392 - }); 393 488 394 - y += 2; 395 - return y; 489 + // Store hit region for tappable items. 490 + if (item.route) { 491 + activityBtns.push({ 492 + box: { x, y: iy, w, h: lineH }, 493 + route: item.route, 494 + }); 495 + } 496 + } 396 497 } 397 498 398 499 const ACTIVITY_COLORS = { ··· 401 502 kidlisp: { r: 120, g: 255, b: 120 }, 402 503 clock: { r: 255, g: 200, b: 80 }, 403 504 chat: { r: 200, g: 200, b: 200 }, 505 + default: { r: 180, g: 180, b: 180 }, 404 506 }; 405 507 406 - function paintActivityBlock(help, ink, y, maxY, x, w) { 407 - ink("yellow").write("Recent Activity", { x, y }, "black", w); 408 - y += 10; 508 + const ACTIVITY_PREFIX = { 509 + mood: "mood", 510 + painting: "paint", 511 + kidlisp: "kid", 512 + clock: "clock", 513 + chat: "chat", 514 + event: "event", 515 + }; 409 516 410 - if ((loading || refreshing) && scorecard.activity.length === 0) { 411 - ink(200).write( 412 - `${FETCHING}${ellipsisTicker.text(help.repeat)}`, 413 - { x, y }, 414 - "black", 415 - w, 416 - ); 417 - return; 418 - } 517 + function paintNoProfileState({ api, help, ink, screen, ui }) { 518 + const retrieving = noprofile === FETCHING; 519 + const label = noprofile || "No profile."; 419 520 420 - if (scorecard.activity.length === 0) { 421 - ink(80).write(dataError || "No activity yet.", { x, y }, "black", w); 521 + if (!noprofileAction) { 522 + const text = retrieving 523 + ? `${label}${ellipsisTicker.text(help.repeat)}` 524 + : label; 525 + ink(255).write(text, { center: "xy" }, retrieving ? 64 : "black"); 422 526 return; 423 527 } 424 528 425 - const lineH = 10; 426 - const maxRows = max(3, floor((maxY - y) / lineH)); 427 - const rows = scorecard.activity.slice(0, maxRows); 428 - 429 - rows.forEach((item) => { 430 - if (y + lineH > maxY) return; 431 - const c = ACTIVITY_COLORS[item.type] || { r: 180, g: 180, b: 180 }; 432 - ink(c.r, c.g, c.b).box(x, y + 3, 3, 3); 433 - const when = item.when ? formatTimeAgo(item.when) : "recent"; 434 - ink(200).write(`${when} ${item.label}`, { x: x + 6, y }, "black", w - 6); 435 - y += lineH; 436 - }); 529 + noprofileBtn ||= new ui.TextButton(label, { center: "xy", screen }); 530 + noprofileBtn.paint(api); 437 531 } 532 + 533 + // --- Data loading --- 438 534 439 535 async function refreshProfile(force = false) { 440 536 if (!visiting || disposed) return; ··· 449 545 await loadIdentity(); 450 546 if (!profile) return; 451 547 await loadScorecard(); 548 + loadThumbnails(); // fire and forget 452 549 } catch (err) { 453 550 dataError = "Could not load activity."; 454 551 if (debug) console.warn("Profile refresh failed:", err); ··· 501 598 chatSystemRes, 502 599 chatClockRes, 503 600 ] = await Promise.all([ 504 - fetchJson(`/media-collection?for=${encodeURIComponent(`${handle}/painting`)}`), 505 - fetchJson(`/media-collection?for=${encodeURIComponent(`${handle}/piece`)}`), 506 - fetchJson(`/media-collection?for=${encodeURIComponent(`${handle}/tape`)}`), 601 + fetchJson( 602 + `/media-collection?for=${encodeURIComponent(`${handle}/painting`)}`, 603 + ), 604 + fetchJson( 605 + `/media-collection?for=${encodeURIComponent(`${handle}/piece`)}`, 606 + ), 607 + fetchJson( 608 + `/media-collection?for=${encodeURIComponent(`${handle}/tape`)}`, 609 + ), 507 610 fetchJson( 508 611 `/api/store-kidlisp?recent=true&limit=30&handle=${encodeURIComponent(handle)}`, 509 612 ), ··· 513 616 fetchJson(`/api/chat-messages?instance=clock&limit=${CHAT_LIMIT}`), 514 617 ]); 515 618 516 - const paintingFiles = Array.isArray(paintingRes?.files) ? paintingRes.files : []; 619 + const paintingFiles = Array.isArray(paintingRes?.files) 620 + ? paintingRes.files 621 + : []; 517 622 const pieceFiles = Array.isArray(pieceRes?.files) ? pieceRes.files : []; 518 623 const tapeFiles = Array.isArray(tapeRes?.files) ? tapeRes.files : []; 519 - const kidlispRecent = Array.isArray(kidlispRes?.recent) ? kidlispRes.recent : []; 624 + const kidlispRecent = Array.isArray(kidlispRes?.recent) 625 + ? kidlispRes.recent 626 + : []; 520 627 const allClocks = Array.isArray(clockRes?.recent) ? clockRes.recent : []; 521 - const clocksForHandle = allClocks.filter((item) => sameHandle(item?.handle, handle)); 628 + const clocksForHandle = allClocks.filter((item) => 629 + sameHandle(item?.handle, handle), 630 + ); 522 631 const moodsRaw = Array.isArray(moodRes?.moods) ? moodRes.moods : []; 523 632 const recentMoods = moodsRaw 524 633 .filter((item) => sameHandle(item?.handle, handle)) ··· 533 642 when: toTimestamp(profile.moodWhen) || Date.now(), 534 643 }); 535 644 } 536 - const systemMessages = Array.isArray(chatSystemRes?.messages) ? chatSystemRes.messages : []; 537 - const clockMessages = Array.isArray(chatClockRes?.messages) ? chatClockRes.messages : []; 645 + const systemMessages = Array.isArray(chatSystemRes?.messages) 646 + ? chatSystemRes.messages 647 + : []; 648 + const clockMessages = Array.isArray(chatClockRes?.messages) 649 + ? chatClockRes.messages 650 + : []; 538 651 const recentChats = [...systemMessages, ...clockMessages] 539 652 .filter((item) => sameHandle(item?.from, handle)) 540 653 .map((item) => ({ ··· 575 688 hits: item.hits || 0, 576 689 })); 577 690 691 + // Build unified activity list. 578 692 const activity = []; 579 693 580 694 recentMoods.slice(0, 12).forEach((item) => { ··· 582 696 activity.push({ 583 697 type: "mood", 584 698 when: item.when, 585 - label: `Mood: ${truncate(compact(item.mood), 44)}`, 699 + label: truncate(compact(item.mood), 50), 586 700 }); 587 701 }); 588 702 589 703 recentPaintings.forEach((item) => { 590 - const label = item.code 591 - ? `Painting #${item.code}` 592 - : `Painting ${shortSlug(item.slug)}`; 704 + const label = item.code ? `#${item.code}` : shortSlug(item.slug); 593 705 activity.push({ 594 706 type: "painting", 595 707 when: item.when, ··· 602 714 activity.push({ 603 715 type: "kidlisp", 604 716 when: item.when, 605 - label: `KidLisp $${item.code}`, 717 + label: `$${item.code}`, 606 718 route: `$${item.code}`, 607 719 }); 608 720 }); ··· 611 723 activity.push({ 612 724 type: "clock", 613 725 when: item.when, 614 - label: `Clock *${item.code}`, 726 + label: `*${item.code}`, 615 727 route: `*${item.code}`, 616 728 }); 617 729 }); ··· 622 734 activity.push({ 623 735 type: "chat", 624 736 when: item.when, 625 - label: `Chat: ${truncate(text, 40)}`, 737 + label: truncate(text, 46), 626 738 }); 627 739 }); 628 740 ··· 649 761 moods: recentMoods.slice(0, 10), 650 762 chats: recentChats.slice(0, 10), 651 763 }, 652 - activity: activity.slice(0, 20), 764 + activity: activity.slice(0, ACTIVITY_MAX), 653 765 updatedAt: Date.now(), 654 766 }; 655 767 } 656 768 769 + async function loadThumbnails() { 770 + if (!getApi || thumbsLoading || disposed) return; 771 + 772 + const paintings = scorecard.recentMedia.paintings; 773 + if (paintings.length === 0) return; 774 + 775 + thumbsLoading = true; 776 + 777 + const toLoad = paintings.slice(0, THUMB_MAX); 778 + 779 + // Preserve already-loaded thumbs that match. 780 + const existing = new Map(thumbs.map((t) => [t.slug, t])); 781 + 782 + const next = []; 783 + for (const p of toLoad) { 784 + if (existing.has(p.slug) && existing.get(p.slug).img) { 785 + next.push(existing.get(p.slug)); 786 + } else { 787 + next.push({ 788 + slug: p.slug, 789 + code: p.code, 790 + img: null, 791 + when: p.when, 792 + btn: null, 793 + route: null, 794 + }); 795 + } 796 + } 797 + thumbs = next; 798 + 799 + // Load missing images. 800 + await Promise.all( 801 + thumbs.map(async (t) => { 802 + if (t.img || disposed) return; 803 + try { 804 + const got = await getApi.painting(t.slug).by(bareHandle(visiting)); 805 + if (!disposed) t.img = got?.img || null; 806 + } catch (err) { 807 + if (debug) console.warn("Thumb load failed:", t.slug, err); 808 + } 809 + }), 810 + ); 811 + 812 + thumbsLoading = false; 813 + } 814 + 815 + // --- WebSocket profile stream --- 816 + 657 817 function startRefreshLoop() { 658 818 clearInterval(refreshTimer); 659 819 refreshTimer = setInterval(() => { ··· 698 858 return; 699 859 } 700 860 if (msg?.type === "status") { 701 - const clients = Array.isArray(msg?.data?.clients) ? msg.data.clients : []; 861 + const clients = Array.isArray(msg?.data?.clients) 862 + ? msg.data.clients 863 + : []; 702 864 applyPresence(clients); 703 865 } 704 866 } catch (err) { ··· 721 883 } 722 884 723 885 function applyPresence(clients) { 724 - const matched = clients.find((client) => sameHandle(client?.handle, visiting)); 886 + const matched = clients.find((client) => 887 + sameHandle(client?.handle, visiting), 888 + ); 725 889 726 890 if (!matched) { 727 891 if (presence.online) presence.lastSeenAt = Date.now(); ··· 779 943 if (!label) return; 780 944 781 945 const when = toTimestamp(event.when) || Date.now(); 782 - scorecard.activity = [{ type: event.type || "event", when, label }, ...scorecard.activity] 946 + scorecard.activity = [ 947 + { type: event.type || "event", when, label }, 948 + ...scorecard.activity, 949 + ] 783 950 .sort((a, b) => (b.when || 0) - (a.when || 0)) 784 - .slice(0, 20); 951 + .slice(0, ACTIVITY_MAX); 785 952 } 786 953 787 954 function mergeCounts(nextCounts) { 788 955 if (!nextCounts || typeof nextCounts !== "object") return; 789 - scorecard.counts = { 790 - ...scorecard.counts, 791 - ...nextCounts, 792 - }; 956 + scorecard.counts = { ...scorecard.counts, ...nextCounts }; 793 957 } 794 958 795 959 function applyCountDelta(delta) { ··· 807 971 scorecard.counts = next; 808 972 } 809 973 974 + // --- Timers & socket --- 975 + 810 976 function formatShowing(showing) { 811 977 if (!showing) return null; 812 978 if (typeof showing === "string") return truncate(showing, 18); 813 979 if (showing.slug) return truncate(showing.slug, 18); 814 980 if (showing.code) return `#${showing.code}`; 815 981 if (showing.piece) return truncate(showing.piece, 18); 816 - if (showing.url) return truncate(`${showing.url}`.split("/").pop() || "", 18); 982 + if (showing.url) 983 + return truncate(`${showing.url}`.split("/").pop() || "", 18); 817 984 return null; 818 985 } 819 986 ··· 869 1036 function resetUiState() { 870 1037 noprofileBtn = null; 871 1038 paintingsBtn = null; 872 - refreshBtn = null; 1039 + activityBtns = []; 1040 + for (const t of thumbs) t.btn = null; 873 1041 } 1042 + 1043 + // --- Utilities --- 874 1044 875 1045 async function fetchJson(url) { 876 1046 try { ··· 949 1119 950 1120 function formatTimeAgo(when) { 951 1121 const timestamp = typeof when === "number" ? when : toTimestamp(when); 952 - if (!timestamp) return "recent"; 1122 + if (!timestamp) return ""; 953 1123 954 - const diff = Math.max(0, Date.now() - timestamp); 955 - const seconds = Math.floor(diff / 1000); 956 - if (seconds < 60) return `${seconds}s ago`; 1124 + const diff = max(0, Date.now() - timestamp); 1125 + const seconds = floor(diff / 1000); 1126 + if (seconds < 60) return `${seconds}s`; 957 1127 958 - const minutes = Math.floor(seconds / 60); 959 - if (minutes < 60) return `${minutes}m ago`; 1128 + const minutes = floor(seconds / 60); 1129 + if (minutes < 60) return `${minutes}m`; 960 1130 961 - const hours = Math.floor(minutes / 60); 962 - if (hours < 24) return `${hours}h ago`; 1131 + const hours = floor(minutes / 60); 1132 + if (hours < 24) return `${hours}h`; 963 1133 964 - const days = Math.floor(hours / 24); 965 - return `${days}d ago`; 1134 + const days = floor(hours / 24); 1135 + return `${days}d`; 966 1136 } 967 1137 968 1138 function formatPieceLabel(slug) { ··· 984 1154 return `${value || ""}`.replace(/\s+/g, " ").trim(); 985 1155 } 986 1156 987 - function truncate(value, max) { 1157 + function truncate(value, mx) { 988 1158 const text = `${value || ""}`; 989 - if (text.length <= max) return text; 990 - return `${text.slice(0, max - 3)}...`; 1159 + if (text.length <= mx) return text; 1160 + return `${text.slice(0, mx - 3)}...`; 991 1161 } 992 1162 993 1163 export { boot, paint, act, sim, leave, meta };