atproto pds in zig pds.zat.dev
pds atproto
25

Configure Feed

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

landing: show write activity signal

zzstoatzz (May 27, 2026, 9:49 PM -0500) cf53eb63 d23134ec

+100 -53
+22 -25
src/http/landing/assets.zig
··· 81 81 \\h1 { margin: 0; color: var(--text); font-size: clamp(36px, 11vw, 74px); line-height: 0.95; letter-spacing: 0; } 82 82 \\.lede { margin: 14px 0 0; max-width: 47ch; color: var(--muted); font-size: 15px; } 83 83 \\.lede a, .lede code { color: var(--green); } 84 - \\.entropy { display: flex; align-items: end; gap: 5px; width: min(100%, 260px); min-height: 54px; margin-top: 18px; padding-top: 18px; } 85 - \\.entropy a { 86 - \\ position: relative; flex: 1 1 0; min-width: 10px; height: var(--h); min-height: 12px; 87 - \\ border-radius: 999px 999px 3px 3px; text-decoration: none; background: 88 - \\ linear-gradient(180deg, var(--signal-a, var(--green)), var(--signal-b, var(--accent)) 58%, var(--signal-c, var(--pink))); 89 - \\ box-shadow: inset 0 1px 0 rgba(255,255,255,.28), 0 5px 16px color-mix(in srgb, var(--signal-a, var(--green)) 18%, transparent); 90 - \\ transform: translateY(var(--y)); opacity: .84; 91 - \\ transition: height .16s ease, opacity .16s ease, transform .16s ease, filter .16s ease; 84 + \\.entropy { width: min(100%, 300px); min-height: 64px; margin-top: 19px; display: grid; gap: 8px; } 85 + \\.entropy-label { color: var(--dim); font-size: 10px; opacity: 0; transform: translateY(3px); transition: opacity .14s ease, transform .14s ease; } 86 + \\.entropy:hover .entropy-label, .entropy:focus-within .entropy-label { opacity: 1; transform: translateY(0); } 87 + \\.entropy-bars { 88 + \\ position: relative; display: flex; align-items: end; gap: 4px; min-height: 43px; padding-bottom: 5px; 89 + \\ border-bottom: 1px solid color-mix(in srgb, var(--line) 78%, transparent); 92 90 \\} 93 - \\.entropy a::after { content: ""; position: absolute; left: 0; right: 0; bottom: -5px; height: 2px; border-radius: 999px; background: color-mix(in srgb, var(--line) 80%, transparent); } 94 - \\.entropy a span { 95 - \\ position: absolute; left: 50%; bottom: calc(100% + 8px); z-index: 2; max-width: min(68vw, 260px); 96 - \\ padding: 5px 7px; border: 1px solid var(--line); border-radius: 8px; background: color-mix(in srgb, var(--panel) 94%, transparent); 97 - \\ color: var(--text); font-size: 10px; line-height: 1.2; white-space: nowrap; transform: translate(-50%, 4px); 98 - \\ opacity: 0; pointer-events: none; box-shadow: 0 12px 28px rgba(0,0,0,.22); 99 - \\ transition: opacity .12s ease, transform .12s ease; 91 + \\.entropy-bars::before { 92 + \\ content: ""; position: absolute; left: 0; right: 0; bottom: 14px; height: 1px; 93 + \\ background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--line) 65%, transparent), transparent); 94 + \\ opacity: .8; 100 95 \\} 101 - \\.entropy a em { 102 - \\ position: absolute; left: 50%; top: calc(100% + 9px); color: var(--dim); font-size: 9px; font-style: normal; 103 - \\ transform: translateX(-50%); opacity: 0; transition: opacity .12s ease; 96 + \\.entropy-bars i { 97 + \\ position: relative; flex: 1 1 0; min-width: 5px; height: var(--h); min-height: 6px; 98 + \\ border-radius: 999px 999px 2px 2px; background: 99 + \\ linear-gradient(180deg, color-mix(in srgb, var(--signal-a, var(--green)) 92%, white 8%), var(--signal-b, var(--accent)) 58%, var(--signal-c, var(--pink))); 100 + \\ box-shadow: inset 0 1px 0 rgba(255,255,255,.35), 0 5px 18px color-mix(in srgb, var(--signal-b, var(--accent)) 18%, transparent); 101 + \\ transform: translateY(var(--y)); opacity: .78; 102 + \\ transition: opacity .16s ease, transform .16s ease, filter .16s ease; 104 103 \\} 105 - \\.entropy a:hover, .entropy a:focus-visible { opacity: 1; transform: translateY(-3px); filter: saturate(1.12); } 106 - \\.entropy a:hover span, .entropy a:focus-visible span, .entropy a:hover em, .entropy a:focus-visible em { opacity: 1; transform: translate(-50%, 0); } 107 - \\.entropy a:hover em, .entropy a:focus-visible em { transform: translateX(-50%); } 104 + \\.entropy-bars i:hover { opacity: 1; transform: translateY(-3px); filter: saturate(1.18) brightness(1.05); } 108 105 \\.signal-empty { color: var(--dim); font-size: 11px; } 109 - \\@media (prefers-reduced-motion: reduce) { .entropy a, .entropy a span, .entropy a em { transition: none; } } 106 + \\@media (prefers-reduced-motion: reduce) { .entropy-label, .entropy-bars i { transition: none; } } 110 107 \\.pulse { 111 108 \\ margin: 0 0 30px; padding: 14px; 112 109 \\ border: 1px solid var(--line); border-radius: 8px; background: ··· 213 210 \\ .icon-link, .settings { width: 32px; height: 32px; border-radius: 10px; } 214 211 \\ .logo-link::after { inset: 5px; border-radius: 8px; } 215 212 \\ .icon-link img { width: 20px; height: 20px; } 216 - \\ .entropy { width: min(100%, 220px); gap: 4px; min-height: 48px; } 217 - \\ .entropy a span { display: none; } 218 - \\ .entropy a em { top: calc(100% + 7px); } 213 + \\ .entropy { width: min(100%, 230px); min-height: 54px; } 214 + \\ .entropy-label { opacity: 1; transform: none; } 215 + \\ .entropy-bars { gap: 3px; min-height: 36px; } 219 216 \\} 220 217 ; 221 218
+22 -27
src/http/landing/entropy.zig
··· 10 10 allocator: std.mem.Allocator, 11 11 residents: []const store.Resident, 12 12 collections: []const store.CollectionSummary, 13 + activity: []const store.ActivityBucket, 13 14 ) !Signal { 14 15 var hash = std.hash.Wyhash.init(0x7a_64_73); 15 16 for (residents) |resident| { ··· 23 24 var buf: [16]u8 = undefined; 24 25 hash.update(try std.fmt.bufPrint(&buf, "{d}", .{collection.count})); 25 26 } 27 + for (activity) |bucket| { 28 + var buf: [32]u8 = undefined; 29 + hash.update(try std.fmt.bufPrint(&buf, "{d}", .{bucket.count})); 30 + } 26 31 const seed = hash.final(); 27 32 const hue_a: u16 = @intCast(seed % 360); 28 33 const hue_b: u16 = @intCast((seed >> 11) % 360); ··· 36 41 var html: std.Io.Writer.Allocating = .init(allocator); 37 42 defer html.deinit(); 38 43 39 - try html.writer.writeAll("<div class=\"entropy\" aria-label=\"record collection signal\">"); 40 - if (collections.len == 0) { 41 - try html.writer.writeAll("<span class=\"signal-empty\">waiting for records</span>"); 44 + try html.writer.writeAll("<div class=\"entropy\" aria-label=\"record write activity\">"); 45 + if (activity.len == 0) { 46 + try html.writer.writeAll("<span class=\"signal-empty\">waiting for writes</span>"); 42 47 } else { 43 - const max_count = collections[0].count; 44 - const limit = @min(collections.len, 14); 48 + var max_count: u64 = 1; 49 + var total_count: u64 = 0; 50 + for (activity) |bucket| { 51 + max_count = @max(max_count, bucket.count); 52 + total_count += bucket.count; 53 + } 54 + try html.writer.print("<span class=\"entropy-label\">{d} writes over time</span>", .{total_count}); 55 + try html.writer.writeAll("<span class=\"entropy-bars\" aria-hidden=\"true\">"); 45 56 var i: usize = 0; 46 - while (i < limit) : (i += 1) { 47 - const collection = collections[i]; 48 - const height = if (max_count == 0) 12 else 12 + (collection.count * 34 / max_count); 57 + while (i < activity.len) : (i += 1) { 58 + const bucket = activity[i]; 59 + const height = 6 + (bucket.count * 34 / max_count); 49 60 const offset = i % 3; 50 - const nsid = try escapeHtml(allocator, collection.collection); 51 61 try html.writer.print( 52 - \\<a href="https://ufos.microcosm.blue/collection/?nsid={s}" style="--h:{d}px;--y:{d}px" aria-label="{s}: {d} records"> 53 - \\ <span>{s}</span><em>{d}</em> 54 - \\</a> 55 - , .{ nsid, height, offset, nsid, collection.count, nsid, collection.count }); 62 + \\<i style="--h:{d}px;--y:{d}px" title="{d} writes"></i> 63 + , .{ height, offset, bucket.count }); 56 64 } 65 + try html.writer.writeAll("</span>"); 57 66 } 58 67 try html.writer.writeAll("</div>"); 59 68 return .{ .style = style, .html = try html.toOwnedSlice() }; 60 69 } 61 - 62 - fn escapeHtml(allocator: std.mem.Allocator, text: []const u8) ![]const u8 { 63 - var out: std.Io.Writer.Allocating = .init(allocator); 64 - defer out.deinit(); 65 - for (text) |c| switch (c) { 66 - '&' => try out.writer.writeAll("&amp;"), 67 - '<' => try out.writer.writeAll("&lt;"), 68 - '>' => try out.writer.writeAll("&gt;"), 69 - '"' => try out.writer.writeAll("&quot;"), 70 - '\'' => try out.writer.writeAll("&#39;"), 71 - else => try out.writer.writeByte(c), 72 - }; 73 - return out.toOwnedSlice(); 74 - }
+2 -1
src/http/landing/mod.zig
··· 61 61 } 62 62 63 63 const collections = try store.listCollectionSummaries(allocator, 10); 64 - const signal = try entropy.fromState(allocator, residents, collections); 64 + const activity = try store.listLandingActivity(allocator, 18); 65 + const signal = try entropy.fromState(allocator, residents, collections, activity); 65 66 var collection_items: std.Io.Writer.Allocating = .init(allocator); 66 67 defer collection_items.deinit(); 67 68 if (collections.len == 0) {
+54
src/storage/store.zig
··· 154 154 count: u64, 155 155 }; 156 156 157 + pub const ActivityBucket = struct { 158 + start_us: u64, 159 + end_us: u64, 160 + count: u64, 161 + }; 162 + 157 163 pub const RecentRecord = struct { 158 164 handle: []const u8, 159 165 did: []const u8, ··· 416 422 } 417 423 if (rows.err) |err| return err; 418 424 return summaries.toOwnedSlice(allocator); 425 + } 426 + 427 + pub fn listLandingActivity(allocator: std.mem.Allocator, bucket_count: usize) ![]ActivityBucket { 428 + db_mutex.lockUncancelable(store_io); 429 + defer db_mutex.unlock(store_io); 430 + try requireInitialized(); 431 + 432 + const actual_count = if (bucket_count == 0) 18 else @min(bucket_count, 48); 433 + var rows = try conn.rows( 434 + \\SELECT r.rev 435 + \\FROM records r 436 + \\JOIN accounts a ON a.did = r.did 437 + \\WHERE a.handle NOT LIKE '%.test' 438 + \\ORDER BY r.seq ASC 439 + , .{}); 440 + defer rows.deinit(); 441 + 442 + var timestamps: std.ArrayList(u64) = .empty; 443 + defer timestamps.deinit(allocator); 444 + while (rows.next()) |row| { 445 + const timestamp = atid.timestampMicros(row.text(0)) catch continue; 446 + try timestamps.append(allocator, timestamp); 447 + } 448 + if (rows.err) |err| return err; 449 + if (timestamps.items.len == 0) return &.{}; 450 + 451 + const first = timestamps.items[0]; 452 + const last = timestamps.items[timestamps.items.len - 1]; 453 + const span = if (last > first) last - first else 1; 454 + const width = @max(@as(u64, 1), (span + actual_count - 1) / actual_count); 455 + 456 + const buckets = try allocator.alloc(ActivityBucket, actual_count); 457 + for (buckets, 0..) |*bucket, i| { 458 + const start = first + width * i; 459 + bucket.* = .{ 460 + .start_us = start, 461 + .end_us = start + width, 462 + .count = 0, 463 + }; 464 + } 465 + 466 + for (timestamps.items) |timestamp| { 467 + const offset = if (timestamp <= first) 0 else timestamp - first; 468 + const index = @min(actual_count - 1, @as(usize, @intCast(offset / width))); 469 + buckets[index].count += 1; 470 + } 471 + buckets[actual_count - 1].end_us = last; 472 + return buckets; 419 473 } 420 474 421 475 pub fn listLandingRecentRecords(allocator: std.mem.Allocator, limit: usize) ![]RecentRecord {