BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

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

feat: diagnostics commands and constellation client

+1432 -38
+525
docs/designs/social-diagnostics.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Social Diagnostics - Lazurite</title> 7 + <script src="https://cdn.tailwindcss.com"></script> 8 + <link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&display=swap" rel="stylesheet"> 9 + <style> 10 + :root { 11 + --surface-container-lowest: #000000; 12 + --surface: #0e0e0e; 13 + --surface-container: #191919; 14 + --surface-container-high: #1f1f1f; 15 + --surface-container-highest: rgba(36, 36, 36, 0.7); 16 + --surface-bright: rgba(255, 255, 255, 0.05); 17 + --primary: #7dafff; 18 + --primary-dim: #0073de; 19 + --on-primary-fixed: #05080f; 20 + --on-surface: #f4f6fb; 21 + --on-surface-variant: #ababab; 22 + --on-secondary-container: #c9d1dd; 23 + --outline-variant: rgba(255, 255, 255, 0.08); 24 + } 25 + 26 + * { box-sizing: border-box; } 27 + 28 + html { scroll-behavior: smooth; } 29 + 30 + body { 31 + margin: 0; 32 + min-height: 100vh; 33 + font-family: "Google Sans", "Segoe UI", sans-serif; 34 + background: var(--surface-container-lowest); 35 + color: var(--on-surface); 36 + } 37 + 38 + .app-rail { 39 + width: 64px; 40 + background: var(--surface-container-lowest); 41 + } 42 + 43 + .rail-icon { 44 + width: 40px; 45 + height: 40px; 46 + display: flex; 47 + align-items: center; 48 + justify-content: center; 49 + border-radius: 12px; 50 + transition: all 0.15s ease; 51 + color: var(--on-surface-variant); 52 + } 53 + 54 + .rail-icon.active { 55 + color: var(--primary); 56 + background: rgba(125, 175, 255, 0.1); 57 + } 58 + 59 + .rail-icon:not(.active):hover { 60 + color: var(--on-surface); 61 + background: rgba(255, 255, 255, 0.05); 62 + } 63 + 64 + .surface { 65 + background: var(--surface); 66 + } 67 + 68 + .panel { 69 + background: var(--surface-container); 70 + } 71 + 72 + .panel-high { 73 + background: var(--surface-container-high); 74 + } 75 + 76 + .panel-highest { 77 + background: var(--surface-container-highest); 78 + backdrop-filter: blur(20px); 79 + } 80 + 81 + .soft-outline { 82 + box-shadow: 0 0 0 1px var(--outline-variant); 83 + } 84 + 85 + .tab-btn { 86 + color: var(--on-surface-variant); 87 + background: transparent; 88 + border: 0; 89 + border-radius: 999px; 90 + padding: 0.7rem 0.95rem; 91 + font-size: 0.875rem; 92 + font-weight: 500; 93 + cursor: default; 94 + transition: background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; 95 + white-space: nowrap; 96 + } 97 + 98 + .tab-btn.active { 99 + color: var(--on-surface); 100 + background: rgba(255, 255, 255, 0.06); 101 + box-shadow: 0 0 0 1px rgba(125, 175, 255, 0.18) inset; 102 + } 103 + 104 + .chip { 105 + display: inline-flex; 106 + align-items: center; 107 + gap: 0.35rem; 108 + padding: 0.4rem 0.65rem; 109 + border-radius: 999px; 110 + background: rgba(255, 255, 255, 0.05); 111 + color: var(--on-secondary-container); 112 + font-size: 0.75rem; 113 + font-weight: 500; 114 + } 115 + 116 + .mini-chip { 117 + display: inline-flex; 118 + align-items: center; 119 + justify-content: center; 120 + padding: 0.22rem 0.5rem; 121 + border-radius: 999px; 122 + background: rgba(125, 175, 255, 0.12); 123 + color: var(--primary); 124 + font-size: 0.72rem; 125 + font-weight: 500; 126 + white-space: nowrap; 127 + } 128 + 129 + .card { 130 + background: rgba(255, 255, 255, 0.03); 131 + border-radius: 1rem; 132 + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.03) inset; 133 + } 134 + 135 + .list-card:hover, 136 + .preview-card:hover { 137 + background: rgba(255, 255, 255, 0.045); 138 + } 139 + 140 + .keycap { 141 + padding: 0.25rem 0.45rem; 142 + border-radius: 0.5rem; 143 + background: rgba(255, 255, 255, 0.08); 144 + color: var(--on-surface-variant); 145 + font-size: 0.72rem; 146 + font-weight: 500; 147 + } 148 + 149 + .metric { 150 + padding: 1rem; 151 + border-radius: 1rem; 152 + background: rgba(255, 255, 255, 0.03); 153 + } 154 + 155 + .muted { 156 + color: var(--on-surface-variant); 157 + } 158 + 159 + .title-tight { 160 + letter-spacing: -0.02em; 161 + } 162 + 163 + .sticky-shell { 164 + position: sticky; 165 + top: 0; 166 + z-index: 30; 167 + } 168 + </style> 169 + </head> 170 + <body class="flex"> 171 + <aside class="app-rail fixed left-0 top-0 h-full flex flex-col items-center py-4 z-50"> 172 + <div class="mb-6"> 173 + <svg width="40" height="40" viewBox="0 0 512 512" style="color: #7dafff;"> 174 + <path fill="currentColor" d="M128 16v99.3l119 118.9V120.1zm256 0L265 120.1v114.1l119-119zM16 128l104 119h114.2L115.3 128zm380.8 0l-119 119h114.1l104-119zM120 265L16 384h99.2l119-119zm157.8 0l119 119h99.1l-104-119zM247 277.8l-119 119V496l119-104.1zm18 0v114.1L384 496v-99.2z"/> 175 + </svg> 176 + </div> 177 + 178 + <nav class="flex flex-col gap-1 flex-1"> 179 + <button class="rail-icon" title="Timeline"> 180 + <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 181 + <rect x="3" y="3" width="7" height="7" rx="1"/> 182 + <rect x="14" y="3" width="7" height="7" rx="1"/> 183 + <rect x="14" y="14" width="7" height="7" rx="1"/> 184 + <rect x="3" y="14" width="7" height="7" rx="1"/> 185 + </svg> 186 + </button> 187 + 188 + <button class="rail-icon" title="Search"> 189 + <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 190 + <circle cx="11" cy="11" r="8"/> 191 + <path d="m21 21-4.35-4.35"/> 192 + </svg> 193 + </button> 194 + 195 + <button class="rail-icon" title="Notifications"> 196 + <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 197 + <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/> 198 + <path d="M13.73 21a2 2 0 0 1-3.46 0"/> 199 + </svg> 200 + </button> 201 + 202 + <button class="rail-icon active" title="AT Explorer"> 203 + <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 204 + <circle cx="12" cy="12" r="3"/> 205 + <path d="M12 1v6m0 6v6m4.22-10.22l4.24-4.24M6.34 6.34L2.1 2.1m17.9 10h-6m-6 0H2.1m16.12 4.24l4.24 4.24M6.34 17.66l-4.24 4.24"/> 206 + </svg> 207 + </button> 208 + </nav> 209 + 210 + <div class="flex flex-col gap-2"> 211 + <button class="w-10 h-10 rounded-full overflow-hidden transition-colors" style="box-shadow: 0 0 0 1px rgba(255,255,255,0.08);"> 212 + <img src="https://placehold.co/40x40/7dafff/05080f?text=U" alt="Current account" class="w-full h-full object-cover"> 213 + </button> 214 + </div> 215 + </aside> 216 + 217 + <main class="flex-1 ml-16 surface min-h-screen"> 218 + <header class="sticky-shell panel-highest"> 219 + <div class="px-6 py-4 flex items-start justify-between gap-6"> 220 + <div> 221 + <p class="text-xs uppercase tracking-[0.22em] muted mb-2">Documentation wireframe</p> 222 + <h1 class="text-2xl font-medium title-tight mb-2">Social Diagnostics</h1> 223 + <p class="text-sm leading-relaxed max-w-3xl muted"> 224 + A deliberate context panel for public AT Protocol artifacts. The design keeps counts secondary, uses progressive disclosure for sensitive sections, and frames self-diagnostics as a first-class entry point. 225 + </p> 226 + </div> 227 + <div class="flex flex-wrap items-center gap-2 justify-end text-xs"> 228 + <span class="keycap">CMD/CTRL + 1-5</span> 229 + <span class="keycap">Esc</span> 230 + <span class="chip">Opened from profile or AT Explorer</span> 231 + </div> 232 + </div> 233 + </header> 234 + 235 + <div class="px-6 py-8 space-y-8"> 236 + <section class="grid lg:grid-cols-[minmax(0,1fr)_320px] gap-6 items-start"> 237 + <div class="space-y-4"> 238 + <div class="panel-high rounded-3xl p-5 soft-outline"> 239 + <div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> 240 + <div class="flex items-start gap-4 min-w-0"> 241 + <div class="w-14 h-14 rounded-2xl overflow-hidden shrink-0" style="box-shadow: 0 0 0 1px rgba(255,255,255,0.06);"> 242 + <img src="https://placehold.co/56x56/7dafff/05080f?text=AC" alt="Account avatar" class="w-full h-full object-cover"> 243 + </div> 244 + <div class="min-w-0"> 245 + <p class="text-xs muted mb-1">Viewing</p> 246 + <h2 class="text-lg font-medium title-tight truncate">Alice Chen</h2> 247 + <p class="text-sm muted truncate">@alice.bsky.social · did:plc:abc123</p> 248 + </div> 249 + </div> 250 + 251 + <div class="flex flex-wrap items-center gap-2"> 252 + <span class="chip">Self diagnostics</span> 253 + <span class="chip">Public protocol data</span> 254 + <button class="px-4 py-2 rounded-full text-sm font-medium" style="background: rgba(125,175,255,0.12); color: var(--primary);">Switch account</button> 255 + <button class="px-4 py-2 rounded-full text-sm font-medium" style="background: rgba(255,255,255,0.05); color: var(--on-surface);">Open in AT Explorer</button> 256 + </div> 257 + </div> 258 + 259 + <div class="mt-5 grid sm:grid-cols-3 gap-3"> 260 + <div class="metric"> 261 + <p class="text-xs uppercase tracking-[0.18em] muted mb-2">Focus</p> 262 + <p class="text-sm">Context before action</p> 263 + </div> 264 + <div class="metric"> 265 + <p class="text-xs uppercase tracking-[0.18em] muted mb-2">Disclosure</p> 266 + <p class="text-sm">Summary first, details on demand</p> 267 + </div> 268 + <div class="metric"> 269 + <p class="text-xs uppercase tracking-[0.18em] muted mb-2">Tone</p> 270 + <p class="text-sm">Neutral, factual, non-judgmental</p> 271 + </div> 272 + </div> 273 + </div> 274 + 275 + <div class="panel rounded-3xl p-4 soft-outline"> 276 + <div class="flex items-center gap-2 overflow-x-auto pb-1"> 277 + <button class="tab-btn active">Lists</button> 278 + <button class="tab-btn">Labels</button> 279 + <button class="tab-btn">Blocks</button> 280 + <button class="tab-btn">Starter Packs</button> 281 + <button class="tab-btn">Backlinks</button> 282 + </div> 283 + </div> 284 + 285 + <div class="panel rounded-3xl p-5 soft-outline space-y-5"> 286 + <div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3"> 287 + <div> 288 + <p class="text-xs uppercase tracking-[0.18em] muted mb-2">Lists tab</p> 289 + <h3 class="text-xl font-medium title-tight">Where this account appears</h3> 290 + </div> 291 + <p class="text-sm muted max-w-md sm:text-right"> 292 + Lists are treated as ordinary social structure. The view prioritizes purpose, owner context, and membership details over aggregate counts. 293 + </p> 294 + </div> 295 + 296 + <div class="grid gap-3"> 297 + <div class="card p-4 list-card transition-colors"> 298 + <div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> 299 + <div class="min-w-0"> 300 + <div class="flex flex-wrap items-center gap-2 mb-2"> 301 + <h4 class="text-sm font-medium">Design systems people</h4> 302 + <span class="mini-chip">Curation</span> 303 + </div> 304 + <p class="text-sm muted leading-relaxed">A curated list of builders and designers working on interface systems, motion, and product craft.</p> 305 + <p class="text-xs muted mt-3">Owner: @mira.studio · 412 members</p> 306 + </div> 307 + <button class="px-4 py-2 rounded-full text-sm" style="background: rgba(255,255,255,0.05); color: var(--on-surface);">Open list</button> 308 + </div> 309 + </div> 310 + 311 + <div class="card p-4 list-card transition-colors"> 312 + <div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> 313 + <div class="min-w-0"> 314 + <div class="flex flex-wrap items-center gap-2 mb-2"> 315 + <h4 class="text-sm font-medium">Moderation boundary set</h4> 316 + <span class="mini-chip">Moderation</span> 317 + </div> 318 + <p class="text-sm muted leading-relaxed">A public moderation list used by a service to describe account-level boundaries. Presented without alarm or rank.</p> 319 + <p class="text-xs muted mt-3">Owner: @safety.service · 18,204 members</p> 320 + </div> 321 + <button class="px-4 py-2 rounded-full text-sm" style="background: rgba(255,255,255,0.05); color: var(--on-surface);">Open list</button> 322 + </div> 323 + </div> 324 + 325 + <div class="card p-4 list-card transition-colors"> 326 + <div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> 327 + <div class="min-w-0"> 328 + <div class="flex flex-wrap items-center gap-2 mb-2"> 329 + <h4 class="text-sm font-medium">Reference: AT builders</h4> 330 + <span class="mini-chip">Reference</span> 331 + </div> 332 + <p class="text-sm muted leading-relaxed">A reference list for people and projects related to the AT ecosystem. Useful as context, not judgment.</p> 333 + <p class="text-xs muted mt-3">Owner: @atlas.lab · 91 members</p> 334 + </div> 335 + <button class="px-4 py-2 rounded-full text-sm" style="background: rgba(255,255,255,0.05); color: var(--on-surface);">Open list</button> 336 + </div> 337 + </div> 338 + </div> 339 + </div> 340 + </div> 341 + 342 + <aside class="space-y-4 lg:sticky lg:top-24"> 343 + <div class="panel-high rounded-3xl p-5 soft-outline space-y-4"> 344 + <div> 345 + <p class="text-xs uppercase tracking-[0.18em] muted mb-2">How to read it</p> 346 + <h3 class="text-lg font-medium title-tight">The panel teaches context, not conclusions</h3> 347 + </div> 348 + <div class="space-y-3 text-sm leading-relaxed muted"> 349 + <p>• Counts stay in the summary row. Specific records appear only after a deliberate action.</p> 350 + <p>• Labels use uniform chips and source attribution instead of severity colors.</p> 351 + <p>• Blocks open with count-only defaults and a plain-language explanation before details.</p> 352 + </div> 353 + </div> 354 + 355 + <div class="panel rounded-3xl p-5 soft-outline space-y-4"> 356 + <div class="flex items-start justify-between gap-3"> 357 + <div> 358 + <p class="text-xs uppercase tracking-[0.18em] muted mb-2">Summary</p> 359 + <h3 class="text-lg font-medium title-tight">Tab model</h3> 360 + </div> 361 + <span class="chip">5 tabs</span> 362 + </div> 363 + 364 + <div class="grid grid-cols-2 gap-3 text-sm"> 365 + <div class="metric"> 366 + <p class="muted text-xs mb-1">Lists</p> 367 + <p>Curated, moderation, reference</p> 368 + </div> 369 + <div class="metric"> 370 + <p class="muted text-xs mb-1">Labels</p> 371 + <p>Service, definition, effect</p> 372 + </div> 373 + <div class="metric"> 374 + <p class="muted text-xs mb-1">Blocks</p> 375 + <p>Counts first, details on request</p> 376 + </div> 377 + <div class="metric"> 378 + <p class="muted text-xs mb-1">Starter packs</p> 379 + <p>Discovery context</p> 380 + </div> 381 + </div> 382 + </div> 383 + 384 + <div class="panel rounded-3xl p-5 soft-outline space-y-3"> 385 + <p class="text-xs uppercase tracking-[0.18em] muted mb-1">Keyboard</p> 386 + <div class="space-y-2 text-sm muted"> 387 + <div class="flex items-center justify-between gap-4"><span>Switch tabs</span><span class="keycap">1-5</span></div> 388 + <div class="flex items-center justify-between gap-4"><span>Close panel</span><span class="keycap">Esc</span></div> 389 + </div> 390 + </div> 391 + </aside> 392 + </section> 393 + 394 + <section class="space-y-4"> 395 + <div class="flex items-end justify-between gap-4"> 396 + <div> 397 + <p class="text-xs uppercase tracking-[0.18em] muted mb-2">Tab previews</p> 398 + <h3 class="text-xl font-medium title-tight">Secondary states for implementation</h3> 399 + </div> 400 + <p class="text-sm muted max-w-xl text-right">These compact frames document the content patterns for the remaining tabs so the panel can be built without inventing new UI language.</p> 401 + </div> 402 + 403 + <div class="grid xl:grid-cols-2 gap-4"> 404 + <article class="panel rounded-3xl p-5 soft-outline space-y-4"> 405 + <div class="flex items-center justify-between gap-3"> 406 + <div> 407 + <p class="text-xs uppercase tracking-[0.18em] muted mb-1">Labels</p> 408 + <h4 class="text-lg font-medium title-tight">Moderation metadata</h4> 409 + </div> 410 + <span class="chip">Muted chips</span> 411 + </div> 412 + <div class="flex flex-wrap gap-2"> 413 + <span class="mini-chip" style="background: rgba(255,255,255,0.08); color: var(--on-secondary-container);">!no-unauthenticated</span> 414 + <span class="mini-chip" style="background: rgba(255,255,255,0.08); color: var(--on-secondary-container);">!hide-replies</span> 415 + <span class="mini-chip" style="background: rgba(255,255,255,0.08); color: var(--on-secondary-container);">labeler: safety.service</span> 416 + </div> 417 + <p class="text-sm muted leading-relaxed">Tooltip copy should explain the label definition, the labeling service, and the visibility effect. The chip palette stays uniform and neutral.</p> 418 + </article> 419 + 420 + <article class="panel rounded-3xl p-5 soft-outline space-y-4"> 421 + <div class="flex items-center justify-between gap-3"> 422 + <div> 423 + <p class="text-xs uppercase tracking-[0.18em] muted mb-1">Blocks</p> 424 + <h4 class="text-lg font-medium title-tight">Summary first</h4> 425 + </div> 426 + <span class="chip">Counts only</span> 427 + </div> 428 + <div class="grid sm:grid-cols-2 gap-3"> 429 + <div class="metric"> 430 + <p class="muted text-xs mb-1">Blocked by</p> 431 + <p class="text-2xl font-medium title-tight">18</p> 432 + </div> 433 + <div class="metric"> 434 + <p class="muted text-xs mb-1">Blocking</p> 435 + <p class="text-2xl font-medium title-tight">2</p> 436 + </div> 437 + </div> 438 + <div class="card p-4 space-y-3"> 439 + <div class="flex items-center justify-between gap-3"> 440 + <p class="text-sm">Blocks are a normal part of social media. This data is public on the AT Protocol.</p> 441 + <button class="px-4 py-2 rounded-full text-sm" style="background: rgba(255,255,255,0.05); color: var(--on-surface);">Show details</button> 442 + </div> 443 + <p class="text-sm muted">Expanded details would reveal profile cards only after a deliberate click, with no warning styling.</p> 444 + </div> 445 + </article> 446 + 447 + <article class="panel rounded-3xl p-5 soft-outline space-y-4"> 448 + <div class="flex items-center justify-between gap-3"> 449 + <div> 450 + <p class="text-xs uppercase tracking-[0.18em] muted mb-1">Starter packs</p> 451 + <h4 class="text-lg font-medium title-tight">Discovery context</h4> 452 + </div> 453 + <span class="chip">Compact cards</span> 454 + </div> 455 + <div class="space-y-3"> 456 + <div class="card p-4 preview-card transition-colors"> 457 + <div class="flex items-start justify-between gap-3"> 458 + <div class="min-w-0"> 459 + <p class="text-sm font-medium truncate">AT Protocol newcomers</p> 460 + <p class="text-xs muted mt-1 truncate">by @starterpack.co · 54 members</p> 461 + </div> 462 + <button class="px-3 py-1.5 rounded-full text-xs" style="background: rgba(255,255,255,0.05); color: var(--on-surface);">AT Explorer</button> 463 + </div> 464 + </div> 465 + <div class="card p-4 preview-card transition-colors"> 466 + <div class="flex items-start justify-between gap-3"> 467 + <div class="min-w-0"> 468 + <p class="text-sm font-medium truncate">Indie dev circles</p> 469 + <p class="text-xs muted mt-1 truncate">by @buildclub · 118 members</p> 470 + </div> 471 + <button class="px-3 py-1.5 rounded-full text-xs" style="background: rgba(255,255,255,0.05); color: var(--on-surface);">AT Explorer</button> 472 + </div> 473 + </div> 474 + </div> 475 + </article> 476 + 477 + <article class="panel rounded-3xl p-5 soft-outline space-y-4"> 478 + <div class="flex items-center justify-between gap-3"> 479 + <div> 480 + <p class="text-xs uppercase tracking-[0.18em] muted mb-1">Backlinks</p> 481 + <h4 class="text-lg font-medium title-tight">Record context</h4> 482 + </div> 483 + <span class="chip">Grouped by type</span> 484 + </div> 485 + <div class="space-y-2"> 486 + <div class="card p-4 preview-card transition-colors flex items-center justify-between gap-3"> 487 + <div> 488 + <p class="text-sm font-medium">Replies</p> 489 + <p class="text-xs muted mt-1">Direct replies to this record</p> 490 + </div> 491 + <span class="text-sm muted">12</span> 492 + </div> 493 + <div class="card p-4 preview-card transition-colors flex items-center justify-between gap-3"> 494 + <div> 495 + <p class="text-sm font-medium">Quotes</p> 496 + <p class="text-xs muted mt-1">Records that embed this URI</p> 497 + </div> 498 + <span class="text-sm muted">8</span> 499 + </div> 500 + </div> 501 + </article> 502 + </div> 503 + </section> 504 + 505 + <section class="grid lg:grid-cols-3 gap-4 pb-8"> 506 + <div class="panel rounded-3xl p-5 soft-outline"> 507 + <p class="text-xs uppercase tracking-[0.18em] muted mb-2">Profile view</p> 508 + <h3 class="text-lg font-medium title-tight mb-3">Context tab, not default</h3> 509 + <p class="text-sm muted leading-relaxed">Placed alongside Posts, Replies, Media, and Likes. It should be discoverable without adding unsolicited account warnings anywhere else in the app.</p> 510 + </div> 511 + <div class="panel rounded-3xl p-5 soft-outline"> 512 + <p class="text-xs uppercase tracking-[0.18em] muted mb-2">AT Explorer record view</p> 513 + <h3 class="text-lg font-medium title-tight mb-3">Supplementary backlinks panel</h3> 514 + <p class="text-sm muted leading-relaxed">Use engagement backlinks only here. Moderation-related data stays inside the dedicated diagnostics panel.</p> 515 + </div> 516 + <div class="panel rounded-3xl p-5 soft-outline"> 517 + <p class="text-xs uppercase tracking-[0.18em] muted mb-2">Repo view</p> 518 + <h3 class="text-lg font-medium title-tight mb-3">Avoid overloading summaries</h3> 519 + <p class="text-sm muted leading-relaxed">Follower and following counts can appear in repository context, but block counts should stay in this dedicated panel only.</p> 520 + </div> 521 + </section> 522 + </div> 523 + </main> 524 + </body> 525 + </html>
+15 -15
docs/tasks/12-social-diagnostics.md
··· 6 6 7 7 ### Backend - Constellation Client (`src-tauri/src/constellation.rs`) 8 8 9 - - [ ] Constellation HTTP client struct with configurable base URL (default: `https://constellation.microcosm.blue`) 10 - - [ ] `get_backlinks_count(subject: String, source: String)` - `blue.microcosm.links.getBacklinksCount` 11 - - [ ] `get_backlinks(subject: String, source: String, limit: Option<u32>)` - `blue.microcosm.links.getBacklinks` 12 - - [ ] `get_distinct_dids(subject: String, source: String, limit: Option<u32>, cursor: Option<String>)` - `blue.microcosm.links.getDistinct` 13 - - [ ] `get_many_to_many_counts(subject: String, source: String, path_to_other: String)` - `blue.microcosm.links.getManyToManyCounts` 14 - - [ ] `get_many_to_many(subject: String, source: String, path_to_other: String, limit: Option<u32>)` - `blue.microcosm.links.getManyToMany` 9 + - [x] Constellation HTTP client struct with configurable base URL (default: `https://constellation.microcosm.blue`) 10 + - [x] `get_backlinks_count(subject: String, source: String)` - `blue.microcosm.links.getBacklinksCount` 11 + - [x] `get_backlinks(subject: String, source: String, limit: Option<u32>)` - `blue.microcosm.links.getBacklinks` 12 + - [x] `get_distinct_dids(subject: String, source: String, limit: Option<u32>, cursor: Option<String>)` - `blue.microcosm.links.getDistinct` 13 + - [x] `get_many_to_many_counts(subject: String, source: String, path_to_other: String)` - `blue.microcosm.links.getManyToManyCounts` 14 + - [x] `get_many_to_many(subject: String, source: String, path_to_other: String, limit: Option<u32>)` - `blue.microcosm.links.getManyToMany` 15 15 16 - ### Backend - Diagnostics Commands (`src-tauri/src/diagnostics.rs` & `src-tauri/src/commands/diagnostics.rs`) 16 + ### Backend - Diagnostics Commands (`src-tauri/src/commands/diagnostics.rs`) 17 17 18 - - [ ] `get_account_lists(did: String)` - query Constellation for `app.bsky.graph.listitem:subject` backlinks, extract list URIs, hydrate via `app.bsky.graph.getList` 19 - - [ ] `get_account_labels(did: String)` - query `com.atproto.label.queryLabels` (Bluesky API) 20 - - [ ] `get_account_blocked_by(did: String, limit: Option<u32>, cursor: Option<String>)` - Constellation `getDistinct` for `app.bsky.graph.block:subject` 21 - - [ ] `get_account_blocking(did: String, cursor: Option<String>)` - `com.atproto.repo.listRecords` on target's `app.bsky.graph.block` collection 22 - - [ ] `get_account_starter_packs(did: String)` - Constellation backlinks from starter pack collections 23 - - [ ] `get_record_backlinks(uri: String)` - Constellation backlinks grouped by interaction type (likes, reposts, replies, quotes) 18 + - [x] `get_account_lists(did: String)` - query Constellation for `app.bsky.graph.listitem:subject` backlinks, extract list URIs, hydrate via `app.bsky.graph.getList` 19 + - [x] `get_account_labels(did: String)` - query `com.atproto.label.queryLabels` (Bluesky API) 20 + - [x] `get_account_blocked_by(did: String, limit: Option<u32>, cursor: Option<String>)` - Constellation `getDistinct` for `app.bsky.graph.block:subject` 21 + - [x] `get_account_blocking(did: String, cursor: Option<String>)` - `com.atproto.repo.listRecords` on target's `app.bsky.graph.block` collection 22 + - [x] `get_account_starter_packs(did: String)` - Constellation backlinks from starter pack collections 23 + - [x] `get_record_backlinks(uri: String)` - Constellation backlinks grouped by interaction type (likes, reposts, replies, quotes) 24 24 25 25 ### Backend - Settings 26 26 27 - - [ ] `constellation_url` field in settings table (default: `https://constellation.microcosm.blue`) 28 - - [ ] `set_constellation_url(url: String)` / `get_constellation_url()` commands 27 + - [x] `constellation_url` field in settings table (default: `https://constellation.microcosm.blue`) 28 + - [x] `set_constellation_url(url: String)` / `get_constellation_url()` commands 29 29 30 30 ### Frontend - Diagnostics Panel 31 31
+46
src-tauri/src/commands/diagnostics.rs
··· 1 + #![allow(clippy::needless_pass_by_value)] 2 + 3 + use crate::diagnostics; 4 + use crate::error::AppError; 5 + use crate::state::AppState; 6 + use tauri::State; 7 + 8 + #[tauri::command] 9 + pub async fn get_account_lists( 10 + did: String, state: State<'_, AppState>, 11 + ) -> Result<diagnostics::AccountListsResult, AppError> { 12 + diagnostics::get_account_lists(did, &state).await 13 + } 14 + 15 + #[tauri::command] 16 + pub async fn get_account_labels(did: String) -> Result<diagnostics::AccountLabelsResult, AppError> { 17 + diagnostics::get_account_labels(did).await 18 + } 19 + 20 + #[tauri::command] 21 + pub async fn get_account_blocked_by( 22 + did: String, limit: Option<u32>, cursor: Option<String>, state: State<'_, AppState>, 23 + ) -> Result<diagnostics::AccountBlockedByResult, AppError> { 24 + diagnostics::get_account_blocked_by(did, limit, cursor, &state).await 25 + } 26 + 27 + #[tauri::command] 28 + pub async fn get_account_blocking( 29 + did: String, cursor: Option<String>, 30 + ) -> Result<diagnostics::AccountBlockingResult, AppError> { 31 + diagnostics::get_account_blocking(did, cursor).await 32 + } 33 + 34 + #[tauri::command] 35 + pub async fn get_account_starter_packs( 36 + did: String, state: State<'_, AppState>, 37 + ) -> Result<diagnostics::AccountStarterPacksResult, AppError> { 38 + diagnostics::get_account_starter_packs(did, &state).await 39 + } 40 + 41 + #[tauri::command] 42 + pub async fn get_record_backlinks( 43 + uri: String, state: State<'_, AppState>, 44 + ) -> Result<diagnostics::RecordBacklinksResult, AppError> { 45 + diagnostics::get_record_backlinks(uri, &state).await 46 + }
+1
src-tauri/src/commands/mod.rs
··· 1 1 #![allow(clippy::needless_pass_by_value)] 2 2 3 3 pub mod columns; 4 + pub mod diagnostics; 4 5 pub mod explorer; 5 6 pub mod search; 6 7 pub mod settings;
+10
src-tauri/src/commands/settings.rs
··· 11 11 } 12 12 13 13 #[tauri::command] 14 + pub fn get_constellation_url(state: State<'_, AppState>) -> Result<String, AppError> { 15 + settings::get_constellation_url(&state) 16 + } 17 + 18 + #[tauri::command] 14 19 pub fn update_setting(key: String, value: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), AppError> { 15 20 settings::update_setting(&key, &value, &state, &app) 21 + } 22 + 23 + #[tauri::command] 24 + pub fn set_constellation_url(url: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), AppError> { 25 + settings::set_constellation_url(&url, &state, &app) 16 26 } 17 27 18 28 #[tauri::command]
+226
src-tauri/src/constellation.rs
··· 1 + use crate::error::{AppError, Result}; 2 + use reqwest::{Client, StatusCode, Url}; 3 + use serde::de::DeserializeOwned; 4 + use serde::{Deserialize, Serialize}; 5 + use tauri_plugin_log::log; 6 + 7 + const DEFAULT_TIMEOUT_SECS: u64 = 10; 8 + const USER_AGENT: &str = "lazurite-desktop"; 9 + const GET_BACKLINKS_COUNT_NSID: &str = "blue.microcosm.links.getBacklinksCount"; 10 + const GET_BACKLINKS_NSID: &str = "blue.microcosm.links.getBacklinks"; 11 + const GET_DISTINCT_NSID: &str = "blue.microcosm.links.getDistinct"; 12 + const GET_BACKLINK_DIDS_NSID: &str = "blue.microcosm.links.getBacklinkDids"; 13 + const GET_MANY_TO_MANY_COUNTS_NSID: &str = "blue.microcosm.links.getManyToManyCounts"; 14 + const GET_MANY_TO_MANY_NSID: &str = "blue.microcosm.links.getManyToMany"; 15 + 16 + #[derive(Debug, Clone)] 17 + pub struct ConstellationClient { 18 + base_url: Url, 19 + http: Client, 20 + } 21 + 22 + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] 23 + pub struct BacklinksCountResponse { 24 + pub total: u64, 25 + } 26 + 27 + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] 28 + #[serde(rename_all = "camelCase")] 29 + pub struct ConstellationLinkRecord { 30 + pub did: String, 31 + pub collection: String, 32 + pub rkey: String, 33 + } 34 + 35 + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] 36 + pub struct BacklinksResponse { 37 + pub total: u64, 38 + #[serde(default)] 39 + pub records: Vec<ConstellationLinkRecord>, 40 + pub cursor: Option<String>, 41 + } 42 + 43 + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] 44 + pub struct DistinctDidsResponse { 45 + pub total: u64, 46 + #[serde(default, alias = "linking_dids", alias = "linkingDids")] 47 + pub dids: Vec<String>, 48 + pub cursor: Option<String>, 49 + } 50 + 51 + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] 52 + pub struct ManyToManyCountsResponse { 53 + #[serde(default)] 54 + pub counts_by_other_subject: Vec<ManyToManyCount>, 55 + } 56 + 57 + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] 58 + pub struct ManyToManyCount { 59 + #[serde(alias = "otherSubject")] 60 + pub subject: String, 61 + pub total: u64, 62 + pub distinct: u64, 63 + } 64 + 65 + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] 66 + pub struct ManyToManyResponse { 67 + #[serde(default)] 68 + pub items: Vec<ManyToManyItem>, 69 + pub cursor: Option<String>, 70 + } 71 + 72 + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] 73 + #[serde(rename_all = "camelCase")] 74 + pub struct ManyToManyItem { 75 + pub link_record: ConstellationLinkRecord, 76 + pub other_subject: String, 77 + } 78 + 79 + impl ConstellationClient { 80 + pub fn new(base_url: &str) -> Result<Self> { 81 + let parsed = Url::parse(base_url) 82 + .map_err(|error| AppError::validation(format!("invalid Constellation URL: {error}")))?; 83 + 84 + match parsed.scheme() { 85 + "http" | "https" => {} 86 + _ => return Err(AppError::validation("Constellation URL must use http or https")), 87 + } 88 + 89 + if parsed.host_str().is_none() { 90 + return Err(AppError::validation("Constellation URL must include a host")); 91 + } 92 + 93 + let http = Client::builder() 94 + .timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS)) 95 + .user_agent(USER_AGENT) 96 + .build()?; 97 + 98 + Ok(Self { base_url: parsed, http }) 99 + } 100 + 101 + pub async fn get_backlinks_count(&self, subject: String, source: String) -> Result<BacklinksCountResponse> { 102 + self.get_json(GET_BACKLINKS_COUNT_NSID, &[("subject", subject), ("source", source)]) 103 + .await 104 + } 105 + 106 + pub async fn get_backlinks( 107 + &self, subject: String, source: String, limit: Option<u32>, cursor: Option<String>, 108 + ) -> Result<BacklinksResponse> { 109 + let mut query = vec![("subject", subject), ("source", source)]; 110 + if let Some(limit) = limit { 111 + query.push(("limit", limit.to_string())); 112 + } 113 + if let Some(cursor) = cursor { 114 + query.push(("cursor", cursor)); 115 + } 116 + 117 + self.get_json(GET_BACKLINKS_NSID, &query).await 118 + } 119 + 120 + pub async fn get_distinct_dids( 121 + &self, subject: String, source: String, limit: Option<u32>, cursor: Option<String>, 122 + ) -> Result<DistinctDidsResponse> { 123 + let mut query = vec![("subject", subject.clone()), ("source", source.clone())]; 124 + if let Some(limit) = limit { 125 + query.push(("limit", limit.to_string())); 126 + } 127 + if let Some(cursor) = cursor.clone() { 128 + query.push(("cursor", cursor)); 129 + } 130 + 131 + let response = self.send(GET_DISTINCT_NSID, &query).await?; 132 + if response.status() == StatusCode::NOT_FOUND { 133 + log::warn!( 134 + "Constellation {} returned 404; falling back to {}", 135 + GET_DISTINCT_NSID, 136 + GET_BACKLINK_DIDS_NSID 137 + ); 138 + return self.get_json(GET_BACKLINK_DIDS_NSID, &query).await; 139 + } 140 + 141 + Self::decode_json(response, GET_DISTINCT_NSID).await 142 + } 143 + 144 + pub async fn get_many_to_many_counts( 145 + &self, subject: String, source: String, path_to_other: String, 146 + ) -> Result<ManyToManyCountsResponse> { 147 + self.get_json( 148 + GET_MANY_TO_MANY_COUNTS_NSID, 149 + &[("subject", subject), ("source", source), ("pathToOther", path_to_other)], 150 + ) 151 + .await 152 + } 153 + 154 + pub async fn get_many_to_many( 155 + &self, subject: String, source: String, path_to_other: String, limit: Option<u32>, cursor: Option<String>, 156 + ) -> Result<ManyToManyResponse> { 157 + let mut query = vec![("subject", subject), ("source", source), ("pathToOther", path_to_other)]; 158 + if let Some(limit) = limit { 159 + query.push(("limit", limit.to_string())); 160 + } 161 + if let Some(cursor) = cursor { 162 + query.push(("cursor", cursor)); 163 + } 164 + 165 + self.get_json(GET_MANY_TO_MANY_NSID, &query).await 166 + } 167 + 168 + async fn get_json<T>(&self, endpoint: &str, query: &[(&str, String)]) -> Result<T> 169 + where 170 + T: DeserializeOwned, 171 + { 172 + let response = self.send(endpoint, query).await?; 173 + Self::decode_json(response, endpoint).await 174 + } 175 + 176 + async fn send(&self, endpoint: &str, query: &[(&str, String)]) -> Result<reqwest::Response> { 177 + let mut url = self.base_url.clone(); 178 + url.set_path(&format!("/xrpc/{endpoint}")); 179 + 180 + self.http.get(url).query(query).send().await.map_err(AppError::from) 181 + } 182 + 183 + async fn decode_json<T>(response: reqwest::Response, endpoint: &str) -> Result<T> 184 + where 185 + T: DeserializeOwned, 186 + { 187 + let status = response.status(); 188 + if !status.is_success() { 189 + let body = response.text().await.unwrap_or_default(); 190 + log::warn!("Constellation {endpoint} failed with {status}: {body}"); 191 + return Err(AppError::validation( 192 + "The diagnostics service returned an unexpected response.", 193 + )); 194 + } 195 + 196 + response.json::<T>().await.map_err(|error| { 197 + log::error!("failed to decode Constellation {endpoint} response: {error}"); 198 + AppError::validation("The diagnostics service returned data Lazurite could not read.") 199 + }) 200 + } 201 + } 202 + 203 + #[cfg(test)] 204 + mod tests { 205 + use super::{DistinctDidsResponse, ManyToManyCountsResponse}; 206 + 207 + #[test] 208 + fn distinct_response_accepts_backlink_dids_shape() { 209 + let parsed: DistinctDidsResponse = 210 + serde_json::from_str(r#"{"total":2,"linking_dids":["did:plc:one","did:plc:two"],"cursor":"abc"}"#) 211 + .expect("backlink dids response should deserialize"); 212 + 213 + assert_eq!(parsed.dids, vec!["did:plc:one", "did:plc:two"]); 214 + assert_eq!(parsed.cursor.as_deref(), Some("abc")); 215 + } 216 + 217 + #[test] 218 + fn many_to_many_counts_accepts_subject_field() { 219 + let parsed: ManyToManyCountsResponse = serde_json::from_str( 220 + r#"{"counts_by_other_subject":[{"subject":"at://did/list/1","total":1,"distinct":1}]}"#, 221 + ) 222 + .expect("many-to-many counts should deserialize"); 223 + 224 + assert_eq!(parsed.counts_by_other_subject[0].subject, "at://did/list/1"); 225 + } 226 + }
+10 -13
src-tauri/src/conversations.rs
··· 60 60 async fn ensure_chat_scope(state: &AppState) -> Result<()> { 61 61 let did = active_did(state)?; 62 62 let parsed_did = Did::new(&did).map_err(|_| AppError::validation("invalid active account DID"))?; 63 - let account = state 64 - .auth_store 65 - .get_account(&did)? 66 - .ok_or_else(|| { 67 - log::error!("active account missing from auth store"); 68 - AppError::validation("no active account") 69 - })?; 63 + let account = state.auth_store.get_account(&did)?.ok_or_else(|| { 64 + log::error!("active account missing from auth store"); 65 + AppError::validation("no active account") 66 + })?; 70 67 let session_id = account.session_id.ok_or_else(|| { 71 68 log::error!("active account missing session id"); 72 69 AppError::validation("no active account session") ··· 97 94 CallOptions { atproto_proxy: Some(CHAT_PROXY.into()), ..Default::default() } 98 95 } 99 96 100 - fn map_chat_error(error: ClientError, default_message: &'static str, context: &'static str) -> AppError { 97 + fn map_chat_error(error: &ClientError, default_message: &'static str, context: &'static str) -> AppError { 101 98 if let ClientErrorKind::Http { status } = error.kind() { 102 99 if *status == StatusCode::FORBIDDEN { 103 100 log::warn!("{context} forbidden, likely missing DM scope"); ··· 120 117 let output = session 121 118 .send_with_opts(req.build(), chat_opts()) 122 119 .await 123 - .map_err(|error| map_chat_error(error, "Could not load conversations.", "listConvos error"))? 120 + .map_err(|error| map_chat_error(&error, "Could not load conversations.", "listConvos error"))? 124 121 .into_output() 125 122 .map_err(|error| { 126 123 log::error!("listConvos output error: {error}"); ··· 150 147 let output = session 151 148 .send_with_opts(req, chat_opts()) 152 149 .await 153 - .map_err(|error| map_chat_error(error, "Could not open this conversation.", "getConvoForMembers error"))? 150 + .map_err(|error| map_chat_error(&error, "Could not open this conversation.", "getConvoForMembers error"))? 154 151 .into_output() 155 152 .map_err(|error| { 156 153 log::error!("getConvoForMembers output error: {error}"); ··· 179 176 let output = session 180 177 .send_with_opts(req.build(), chat_opts()) 181 178 .await 182 - .map_err(|error| map_chat_error(error, "Could not load messages.", "getMessages error"))? 179 + .map_err(|error| map_chat_error(&error, "Could not load messages.", "getMessages error"))? 183 180 .into_output() 184 181 .map_err(|error| { 185 182 log::error!("getMessages output error: {error}"); ··· 205 202 let output = session 206 203 .send_with_opts(req, chat_opts()) 207 204 .await 208 - .map_err(|error| map_chat_error(error, "Could not send this message.", "sendMessage error"))? 205 + .map_err(|error| map_chat_error(&error, "Could not send this message.", "sendMessage error"))? 209 206 .into_output() 210 207 .map_err(|error| { 211 208 log::error!("sendMessage output error: {error}"); ··· 233 230 .await 234 231 .map_err(|error| { 235 232 map_chat_error( 236 - error, 233 + &error, 237 234 "Could not update the read status for this conversation.", 238 235 "updateRead error", 239 236 )
+564
src-tauri/src/diagnostics.rs
··· 1 + use crate::constellation::{BacklinksResponse, ConstellationClient, ConstellationLinkRecord}; 2 + use crate::error::{AppError, Result}; 3 + use crate::explorer; 4 + use crate::settings; 5 + use crate::state::AppState; 6 + use jacquard::api::app_bsky::actor::get_profiles::GetProfiles; 7 + use jacquard::api::app_bsky::graph::get_list::GetList; 8 + use jacquard::api::app_bsky::graph::get_starter_packs::GetStarterPacks; 9 + use jacquard::api::com_atproto::label::query_labels::QueryLabels; 10 + use jacquard::client::{Agent, UnauthenticatedSession}; 11 + use jacquard::identity::JacquardResolver; 12 + use jacquard::types::aturi::AtUri; 13 + use jacquard::types::did::Did; 14 + use jacquard::types::ident::AtIdentifier; 15 + use jacquard::xrpc::XrpcClient; 16 + use jacquard::IntoStatic; 17 + use serde::{Deserialize, Serialize}; 18 + use serde_json::Value; 19 + use std::collections::{BTreeMap, BTreeSet, HashMap}; 20 + use tauri_plugin_log::log; 21 + 22 + const LIST_MEMBERSHIP_SOURCE: &str = "app.bsky.graph.listitem:subject"; 23 + const LIST_MEMBERSHIP_PATH_TO_OTHER: &str = "list"; 24 + const BLOCK_SOURCE: &str = "app.bsky.graph.block:subject"; 25 + const STARTER_PACK_SOURCE: &str = "app.bsky.graph.starterpack:listItemsSample[].subject"; 26 + const BLOCK_COLLECTION: &str = "app.bsky.graph.block"; 27 + const LIKES_SOURCE: &str = "app.bsky.feed.like:subject.uri"; 28 + const REPOSTS_SOURCE: &str = "app.bsky.feed.repost:subject.uri"; 29 + const REPLIES_SOURCE: &str = "app.bsky.feed.post:reply.parent.uri"; 30 + const QUOTES_SOURCE: &str = "app.bsky.feed.post:embed.record.uri"; 31 + const PUBLIC_BATCH_LIMIT: usize = 25; 32 + const ACCOUNT_LIST_PAGE_LIMIT: u32 = 100; 33 + const ACCOUNT_LIST_MAX_ITEMS: usize = 200; 34 + const STARTER_PACK_LIMIT: u32 = 100; 35 + const STARTER_PACK_MAX_ITEMS: usize = 200; 36 + const BACKLINK_PREVIEW_LIMIT: u32 = 25; 37 + const BLOCK_PREVIEW_LIMIT: u32 = 50; 38 + const LABEL_LIMIT: i64 = 100; 39 + 40 + type PublicClient = Agent<UnauthenticatedSession<JacquardResolver>>; 41 + 42 + #[derive(Debug, Clone, Serialize)] 43 + #[serde(rename_all = "camelCase")] 44 + pub struct AccountListsResult { 45 + pub total: usize, 46 + pub lists: Vec<Value>, 47 + pub truncated: bool, 48 + } 49 + 50 + #[derive(Debug, Clone, Serialize)] 51 + #[serde(rename_all = "camelCase")] 52 + pub struct AccountLabelsResult { 53 + pub labels: Vec<Value>, 54 + pub source_profiles: BTreeMap<String, Value>, 55 + pub cursor: Option<String>, 56 + } 57 + 58 + #[derive(Debug, Clone, Serialize)] 59 + #[serde(rename_all = "camelCase")] 60 + pub struct DidProfileItem { 61 + pub did: String, 62 + pub profile: Option<Value>, 63 + } 64 + 65 + #[derive(Debug, Clone, Serialize)] 66 + #[serde(rename_all = "camelCase")] 67 + pub struct AccountBlockedByResult { 68 + pub total: u64, 69 + pub items: Vec<DidProfileItem>, 70 + pub cursor: Option<String>, 71 + } 72 + 73 + #[derive(Debug, Clone, Serialize)] 74 + #[serde(rename_all = "camelCase")] 75 + pub struct AccountBlockingResult { 76 + pub items: Vec<AccountBlockingItem>, 77 + pub cursor: Option<String>, 78 + } 79 + 80 + #[derive(Debug, Clone, Serialize)] 81 + #[serde(rename_all = "camelCase")] 82 + pub struct AccountBlockingItem { 83 + pub uri: String, 84 + pub cid: String, 85 + pub subject_did: String, 86 + pub created_at: Option<String>, 87 + pub value: Value, 88 + pub profile: Option<Value>, 89 + } 90 + 91 + #[derive(Debug, Clone, Serialize)] 92 + #[serde(rename_all = "camelCase")] 93 + pub struct AccountStarterPacksResult { 94 + pub total: u64, 95 + pub starter_packs: Vec<Value>, 96 + pub truncated: bool, 97 + } 98 + 99 + #[derive(Debug, Clone, Serialize)] 100 + #[serde(rename_all = "camelCase")] 101 + pub struct RecordBacklinksResult { 102 + pub likes: BacklinkGroup, 103 + pub reposts: BacklinkGroup, 104 + pub replies: BacklinkGroup, 105 + pub quotes: BacklinkGroup, 106 + } 107 + 108 + #[derive(Debug, Clone, Serialize)] 109 + #[serde(rename_all = "camelCase")] 110 + pub struct BacklinkGroup { 111 + pub total: u64, 112 + pub records: Vec<BacklinkRecordItem>, 113 + pub cursor: Option<String>, 114 + } 115 + 116 + #[derive(Debug, Clone, Serialize)] 117 + #[serde(rename_all = "camelCase")] 118 + pub struct BacklinkRecordItem { 119 + pub uri: String, 120 + pub did: String, 121 + pub collection: String, 122 + pub rkey: String, 123 + pub profile: Option<Value>, 124 + } 125 + 126 + #[derive(Debug, Clone, Deserialize)] 127 + #[serde(rename_all = "camelCase")] 128 + struct RepoListRecordsOutput { 129 + cursor: Option<String>, 130 + #[serde(default)] 131 + records: Vec<RepoRecord>, 132 + } 133 + 134 + #[derive(Debug, Clone, Deserialize)] 135 + #[serde(rename_all = "camelCase")] 136 + struct RepoRecord { 137 + uri: String, 138 + cid: String, 139 + value: Value, 140 + } 141 + 142 + pub async fn get_account_lists(did: String, state: &AppState) -> Result<AccountListsResult> { 143 + let normalized_did = normalize_did(&did)?; 144 + let client = constellation_client(state)?; 145 + let counts = client 146 + .get_many_to_many_counts( 147 + normalized_did.clone(), 148 + LIST_MEMBERSHIP_SOURCE.to_string(), 149 + LIST_MEMBERSHIP_PATH_TO_OTHER.to_string(), 150 + ) 151 + .await 152 + .map_err(|error| diagnostics_error("Couldn't load lists for this account.", error))?; 153 + 154 + let mut list_uris = Vec::new(); 155 + let mut cursor = None; 156 + let mut truncated = false; 157 + 158 + while list_uris.len() < ACCOUNT_LIST_MAX_ITEMS { 159 + let response = client 160 + .get_many_to_many( 161 + normalized_did.clone(), 162 + LIST_MEMBERSHIP_SOURCE.to_string(), 163 + LIST_MEMBERSHIP_PATH_TO_OTHER.to_string(), 164 + Some(ACCOUNT_LIST_PAGE_LIMIT), 165 + cursor.clone(), 166 + ) 167 + .await 168 + .map_err(|error| diagnostics_error("Couldn't load lists for this account.", error))?; 169 + 170 + if response.items.is_empty() { 171 + break; 172 + } 173 + 174 + for item in response.items { 175 + if list_uris.len() >= ACCOUNT_LIST_MAX_ITEMS { 176 + truncated = true; 177 + break; 178 + } 179 + list_uris.push(item.other_subject); 180 + } 181 + 182 + match response.cursor { 183 + Some(next_cursor) if list_uris.len() < ACCOUNT_LIST_MAX_ITEMS => cursor = Some(next_cursor), 184 + Some(_) => { 185 + truncated = true; 186 + break; 187 + } 188 + None => break, 189 + } 190 + } 191 + 192 + let unique_list_uris = dedupe_preserve_order(list_uris); 193 + let lists = fetch_lists(&unique_list_uris).await?; 194 + 195 + Ok(AccountListsResult { total: counts.counts_by_other_subject.len(), lists, truncated }) 196 + } 197 + 198 + pub async fn get_account_labels(did: String) -> Result<AccountLabelsResult> { 199 + let normalized_did = normalize_did(&did)?; 200 + let client = public_client(); 201 + let output = client 202 + .send( 203 + QueryLabels::new() 204 + .uri_patterns(vec![normalized_did.clone().into()]) 205 + .limit(LABEL_LIMIT) 206 + .build(), 207 + ) 208 + .await 209 + .map_err(|error| diagnostics_error("Couldn't load labels for this account.", error))? 210 + .into_output() 211 + .map_err(|error| diagnostics_error("Couldn't read labels for this account.", error))? 212 + .into_static(); 213 + 214 + let labels = output 215 + .labels 216 + .iter() 217 + .map(serde_json::to_value) 218 + .collect::<std::result::Result<Vec<_>, _>>()?; 219 + 220 + let source_dids = output 221 + .labels 222 + .iter() 223 + .map(|label| label.src.to_string()) 224 + .collect::<Vec<_>>(); 225 + let source_profiles = fetch_profiles_map(&source_dids).await?; 226 + 227 + Ok(AccountLabelsResult { labels, source_profiles, cursor: output.cursor.map(|cursor| cursor.to_string()) }) 228 + } 229 + 230 + pub async fn get_account_blocked_by( 231 + did: String, limit: Option<u32>, cursor: Option<String>, state: &AppState, 232 + ) -> Result<AccountBlockedByResult> { 233 + let normalized_did = normalize_did(&did)?; 234 + let client = constellation_client(state)?; 235 + let response = client 236 + .get_distinct_dids( 237 + normalized_did, 238 + BLOCK_SOURCE.to_string(), 239 + limit.or(Some(BLOCK_PREVIEW_LIMIT)), 240 + cursor, 241 + ) 242 + .await 243 + .map_err(|error| diagnostics_error("Couldn't load the accounts blocking this profile.", error))?; 244 + 245 + let profiles = fetch_profiles_map(&response.dids).await?; 246 + let items = response 247 + .dids 248 + .into_iter() 249 + .map(|entry_did| DidProfileItem { profile: profiles.get(&entry_did).cloned(), did: entry_did }) 250 + .collect(); 251 + 252 + Ok(AccountBlockedByResult { total: response.total, items, cursor: response.cursor }) 253 + } 254 + 255 + pub async fn get_account_blocking(did: String, cursor: Option<String>) -> Result<AccountBlockingResult> { 256 + let normalized_did = normalize_did(&did)?; 257 + let output = explorer::list_records(normalized_did.clone(), BLOCK_COLLECTION.to_string(), cursor) 258 + .await 259 + .map_err(|error| diagnostics_error("Couldn't load this account's block records.", error))?; 260 + let parsed: RepoListRecordsOutput = serde_json::from_value(output).map_err(|error| { 261 + log::error!("failed to decode block listRecords output: {error}"); 262 + AppError::validation("Lazurite couldn't read this account's block records.") 263 + })?; 264 + 265 + let subject_dids = parsed 266 + .records 267 + .iter() 268 + .filter_map(|record| extract_subject_did(&record.value)) 269 + .collect::<Vec<_>>(); 270 + let profiles = fetch_profiles_map(&subject_dids).await?; 271 + 272 + let items = parsed 273 + .records 274 + .into_iter() 275 + .filter_map(|record| { 276 + let subject_did = extract_subject_did(&record.value)?; 277 + Some(AccountBlockingItem { 278 + created_at: extract_created_at(&record.value), 279 + profile: profiles.get(&subject_did).cloned(), 280 + uri: record.uri, 281 + cid: record.cid, 282 + subject_did, 283 + value: record.value, 284 + }) 285 + }) 286 + .collect(); 287 + 288 + Ok(AccountBlockingResult { items, cursor: parsed.cursor }) 289 + } 290 + 291 + pub async fn get_account_starter_packs(did: String, state: &AppState) -> Result<AccountStarterPacksResult> { 292 + let normalized_did = normalize_did(&did)?; 293 + let client = constellation_client(state)?; 294 + let count = client 295 + .get_backlinks_count(normalized_did.clone(), STARTER_PACK_SOURCE.to_string()) 296 + .await 297 + .map_err(|error| diagnostics_error("Couldn't load starter packs for this account.", error))?; 298 + 299 + let mut pack_uris = Vec::new(); 300 + let mut cursor = None; 301 + let mut truncated = false; 302 + 303 + while pack_uris.len() < STARTER_PACK_MAX_ITEMS { 304 + let response = client 305 + .get_backlinks( 306 + normalized_did.clone(), 307 + STARTER_PACK_SOURCE.to_string(), 308 + Some(STARTER_PACK_LIMIT), 309 + cursor.clone(), 310 + ) 311 + .await 312 + .map_err(|error| diagnostics_error("Couldn't load starter packs for this account.", error))?; 313 + 314 + if response.records.is_empty() { 315 + break; 316 + } 317 + 318 + for record in response.records { 319 + if pack_uris.len() >= STARTER_PACK_MAX_ITEMS { 320 + truncated = true; 321 + break; 322 + } 323 + pack_uris.push(link_record_uri(&record)); 324 + } 325 + 326 + match response.cursor { 327 + Some(next_cursor) if pack_uris.len() < STARTER_PACK_MAX_ITEMS => cursor = Some(next_cursor), 328 + Some(_) => { 329 + truncated = true; 330 + break; 331 + } 332 + None => break, 333 + } 334 + } 335 + 336 + let starter_packs = fetch_starter_packs(&dedupe_preserve_order(pack_uris)).await?; 337 + Ok(AccountStarterPacksResult { total: count.total, starter_packs, truncated }) 338 + } 339 + 340 + pub async fn get_record_backlinks(uri: String, state: &AppState) -> Result<RecordBacklinksResult> { 341 + let normalized_uri = normalize_at_uri(&uri)?; 342 + let client = constellation_client(state)?; 343 + 344 + let likes = fetch_backlink_group(&client, &normalized_uri, LIKES_SOURCE).await?; 345 + let reposts = fetch_backlink_group(&client, &normalized_uri, REPOSTS_SOURCE).await?; 346 + let replies = fetch_backlink_group(&client, &normalized_uri, REPLIES_SOURCE).await?; 347 + let quotes = fetch_backlink_group(&client, &normalized_uri, QUOTES_SOURCE).await?; 348 + 349 + Ok(RecordBacklinksResult { likes, reposts, replies, quotes }) 350 + } 351 + 352 + fn constellation_client(state: &AppState) -> Result<ConstellationClient> { 353 + ConstellationClient::new(&settings::get_constellation_url(state)?) 354 + } 355 + 356 + fn public_client() -> PublicClient { 357 + Agent::new(UnauthenticatedSession::new_public()) 358 + } 359 + 360 + fn normalize_did(input: &str) -> Result<String> { 361 + let trimmed = input.trim(); 362 + if trimmed.is_empty() { 363 + return Err(AppError::validation("A DID is required.")); 364 + } 365 + 366 + Did::new(trimmed) 367 + .map(|did| did.to_string()) 368 + .map_err(|_| AppError::validation("Enter a valid DID.")) 369 + } 370 + 371 + fn normalize_at_uri(input: &str) -> Result<String> { 372 + let trimmed = input.trim(); 373 + if trimmed.is_empty() { 374 + return Err(AppError::validation("A record URI is required.")); 375 + } 376 + 377 + AtUri::new(trimmed) 378 + .map(|uri| uri.to_string()) 379 + .map_err(|_| AppError::validation("Enter a valid AT-URI.")) 380 + } 381 + 382 + fn diagnostics_error(message: &'static str, error: impl std::fmt::Display) -> AppError { 383 + log::error!("{message} {error}"); 384 + AppError::validation(message) 385 + } 386 + 387 + fn link_record_uri(record: &ConstellationLinkRecord) -> String { 388 + format!("at://{}/{}/{}", record.did, record.collection, record.rkey) 389 + } 390 + 391 + fn dedupe_preserve_order(values: Vec<String>) -> Vec<String> { 392 + let mut seen = BTreeSet::new(); 393 + let mut deduped = Vec::new(); 394 + 395 + for value in values { 396 + if seen.insert(value.clone()) { 397 + deduped.push(value); 398 + } 399 + } 400 + 401 + deduped 402 + } 403 + 404 + fn did_identifier(did: &str) -> Result<AtIdentifier<'static>> { 405 + Ok(AtIdentifier::Did(Did::new(did)?.into_static())) 406 + } 407 + 408 + async fn fetch_profiles_map(dids: &[String]) -> Result<BTreeMap<String, Value>> { 409 + let unique_dids = dedupe_preserve_order(dids.to_vec()); 410 + if unique_dids.is_empty() { 411 + return Ok(BTreeMap::new()); 412 + } 413 + 414 + let client = public_client(); 415 + let mut profiles = BTreeMap::new(); 416 + 417 + for chunk in unique_dids.chunks(PUBLIC_BATCH_LIMIT) { 418 + let actors = chunk 419 + .iter() 420 + .map(|did| did_identifier(did)) 421 + .collect::<Result<Vec<_>>>()?; 422 + let output = client 423 + .send(GetProfiles::new().actors(actors).build()) 424 + .await 425 + .map_err(|error| diagnostics_error("Couldn't load account profiles.", error))? 426 + .into_output() 427 + .map_err(|error| diagnostics_error("Couldn't read account profiles.", error))? 428 + .into_static(); 429 + 430 + for profile in output.profiles { 431 + profiles.insert(profile.did.to_string(), serde_json::to_value(profile)?); 432 + } 433 + } 434 + 435 + Ok(profiles) 436 + } 437 + 438 + async fn fetch_lists(list_uris: &[String]) -> Result<Vec<Value>> { 439 + let client = public_client(); 440 + let mut lists = Vec::new(); 441 + 442 + for list_uri in list_uris { 443 + let parsed_uri = AtUri::new(list_uri).map_err(|_| AppError::validation("A list URI was invalid."))?; 444 + let output = client 445 + .send(GetList::new().list(parsed_uri.into_static()).limit(1).build()) 446 + .await 447 + .map_err(|error| diagnostics_error("Couldn't load one of the matching lists.", error))? 448 + .into_output() 449 + .map_err(|error| diagnostics_error("Couldn't read one of the matching lists.", error))? 450 + .into_static(); 451 + lists.push(serde_json::to_value(output.list)?); 452 + } 453 + 454 + Ok(lists) 455 + } 456 + 457 + async fn fetch_starter_packs(uris: &[String]) -> Result<Vec<Value>> { 458 + if uris.is_empty() { 459 + return Ok(Vec::new()); 460 + } 461 + 462 + let client = public_client(); 463 + let mut starter_packs = Vec::new(); 464 + 465 + for chunk in uris.chunks(PUBLIC_BATCH_LIMIT) { 466 + let parsed_uris = chunk 467 + .iter() 468 + .map(|uri| { 469 + AtUri::new(uri) 470 + .map(IntoStatic::into_static) 471 + .map_err(|_| AppError::validation("A starter pack URI was invalid.")) 472 + }) 473 + .collect::<Result<Vec<_>>>()?; 474 + let output = client 475 + .send(GetStarterPacks::new().uris(parsed_uris).build()) 476 + .await 477 + .map_err(|error| diagnostics_error("Couldn't load starter packs for this account.", error))? 478 + .into_output() 479 + .map_err(|error| diagnostics_error("Couldn't read starter pack details.", error))? 480 + .into_static(); 481 + 482 + for starter_pack in output.starter_packs { 483 + starter_packs.push(serde_json::to_value(starter_pack)?); 484 + } 485 + } 486 + 487 + Ok(starter_packs) 488 + } 489 + 490 + async fn fetch_backlink_group(client: &ConstellationClient, subject: &str, source: &str) -> Result<BacklinkGroup> { 491 + let response = client 492 + .get_backlinks( 493 + subject.to_string(), 494 + source.to_string(), 495 + Some(BACKLINK_PREVIEW_LIMIT), 496 + None, 497 + ) 498 + .await 499 + .map_err(|error| diagnostics_error("Couldn't load record backlinks right now.", error))?; 500 + 501 + build_backlink_group(response).await 502 + } 503 + 504 + async fn build_backlink_group(response: BacklinksResponse) -> Result<BacklinkGroup> { 505 + let dids = response 506 + .records 507 + .iter() 508 + .map(|record| record.did.clone()) 509 + .collect::<Vec<_>>(); 510 + let profiles = fetch_profiles_lookup(&dids).await?; 511 + 512 + let records = response 513 + .records 514 + .into_iter() 515 + .map(|record| BacklinkRecordItem { 516 + uri: link_record_uri(&record), 517 + profile: profiles.get(&record.did).cloned(), 518 + did: record.did, 519 + collection: record.collection, 520 + rkey: record.rkey, 521 + }) 522 + .collect(); 523 + 524 + Ok(BacklinkGroup { total: response.total, records, cursor: response.cursor }) 525 + } 526 + 527 + async fn fetch_profiles_lookup(dids: &[String]) -> Result<HashMap<String, Value>> { 528 + Ok(fetch_profiles_map(dids).await?.into_iter().collect()) 529 + } 530 + 531 + fn extract_subject_did(value: &Value) -> Option<String> { 532 + value.get("subject").and_then(Value::as_str).map(str::to_string) 533 + } 534 + 535 + fn extract_created_at(value: &Value) -> Option<String> { 536 + value.get("createdAt").and_then(Value::as_str).map(str::to_string) 537 + } 538 + 539 + #[cfg(test)] 540 + mod tests { 541 + use super::{dedupe_preserve_order, extract_created_at, extract_subject_did}; 542 + use serde_json::json; 543 + 544 + #[test] 545 + fn dedupe_preserve_order_keeps_first_occurrence() { 546 + let values = vec!["at://one".to_string(), "at://two".to_string(), "at://one".to_string()]; 547 + 548 + assert_eq!( 549 + dedupe_preserve_order(values), 550 + vec!["at://one".to_string(), "at://two".to_string()] 551 + ); 552 + } 553 + 554 + #[test] 555 + fn extract_subject_and_created_at_from_block_value() { 556 + let value = json!({ 557 + "subject": "did:plc:blocked", 558 + "createdAt": "2025-01-01T00:00:00Z" 559 + }); 560 + 561 + assert_eq!(extract_subject_did(&value).as_deref(), Some("did:plc:blocked")); 562 + assert_eq!(extract_created_at(&value).as_deref(), Some("2025-01-01T00:00:00Z")); 563 + } 564 + }
+17 -10
src-tauri/src/feed.rs
··· 717 717 let session = get_session(state).await?; 718 718 let active_did = active_did(state)?; 719 719 720 - let follow = Follow::new().created_at(Datetime::now()).subject(Did::new(&did)?).build(); 720 + let follow = Follow::new() 721 + .created_at(Datetime::now()) 722 + .subject(Did::new(&did)?) 723 + .build(); 721 724 722 725 let record_json = serde_json::to_value(&follow)?; 723 - let record_data = 724 - Data::from_json_owned(record_json).map_err(|_| AppError::validation("serialize follow"))?; 726 + let record_data = Data::from_json_owned(record_json).map_err(|_| AppError::validation("serialize follow"))?; 725 727 726 728 let repo = AtIdentifier::Did(Did::new(&active_did)?); 727 729 let collection = Nsid::new("app.bsky.graph.follow").map_err(|_| AppError::validation("nsid"))?; 728 730 729 731 let output = session 730 - .send(CreateRecord::new().repo(repo).collection(collection).record(record_data).build()) 732 + .send( 733 + CreateRecord::new() 734 + .repo(repo) 735 + .collection(collection) 736 + .record(record_data) 737 + .build(), 738 + ) 731 739 .await 732 740 .map_err(|error| { 733 741 log::error!("createRecord (follow) error: {error}"); ··· 746 754 let session = get_session(state).await?; 747 755 let did = active_did(state)?; 748 756 749 - let at_uri = 750 - AtUri::new(&follow_uri).map_err(|_| AppError::validation("invalid follow URI"))?; 751 - let RecordKey(rkey) = 752 - at_uri.rkey().ok_or_else(|| AppError::Validation("follow URI has no rkey".into()))?; 757 + let at_uri = AtUri::new(&follow_uri).map_err(|_| AppError::validation("invalid follow URI"))?; 758 + let RecordKey(rkey) = at_uri 759 + .rkey() 760 + .ok_or_else(|| AppError::Validation("follow URI has no rkey".into()))?; 753 761 let rkey_str = rkey.to_string(); 754 762 755 763 let repo = AtIdentifier::Did(Did::new(&did)?); 756 - let collection = 757 - Nsid::new("app.bsky.graph.follow").map_err(|_| AppError::validation("nsid"))?; 764 + let collection = Nsid::new("app.bsky.graph.follow").map_err(|_| AppError::validation("nsid"))?; 758 765 let rkey = RecordKey::any(&rkey_str).map_err(|_| AppError::validation("invalid rkey"))?; 759 766 760 767 session
+10
src-tauri/src/lib.rs
··· 1 1 mod auth; 2 2 mod columns; 3 3 mod commands; 4 + mod constellation; 4 5 mod conversations; 5 6 mod db; 7 + mod diagnostics; 6 8 mod error; 7 9 mod explorer; 8 10 mod feed; ··· 122 124 cmd::search::get_embeddings_config, 123 125 cmd::search::prepare_embeddings_model, 124 126 cmd::settings::get_settings, 127 + cmd::settings::get_constellation_url, 125 128 cmd::settings::update_setting, 129 + cmd::settings::set_constellation_url, 126 130 cmd::settings::get_cache_size, 127 131 cmd::settings::clear_cache, 128 132 cmd::settings::export_data, 129 133 cmd::settings::reset_app, 130 134 cmd::settings::get_log_entries, 135 + cmd::diagnostics::get_account_lists, 136 + cmd::diagnostics::get_account_labels, 137 + cmd::diagnostics::get_account_blocked_by, 138 + cmd::diagnostics::get_account_blocking, 139 + cmd::diagnostics::get_account_starter_packs, 140 + cmd::diagnostics::get_record_backlinks, 131 141 cmd::columns::get_columns, 132 142 cmd::columns::add_column, 133 143 cmd::columns::remove_column,
+8
src-tauri/src/settings.rs
··· 556 556 db_get_all_settings(&conn) 557 557 } 558 558 559 + pub fn get_constellation_url(state: &AppState) -> Result<String> { 560 + Ok(get_settings(state)?.constellation_url) 561 + } 562 + 559 563 pub fn update_setting(key: &str, value: &str, state: &AppState, app: &AppHandle) -> Result<()> { 560 564 let valid_key = match SettingsKey::from_str(key) { 561 565 Some(valid_key) if SettingsKey::valid_keys().contains(&valid_key) => valid_key, ··· 573 577 574 578 apply_post_persist_side_effects(valid_key, &normalized_value, app); 575 579 Ok(()) 580 + } 581 + 582 + pub fn set_constellation_url(url: &str, state: &AppState, app: &AppHandle) -> Result<()> { 583 + update_setting(SettingsKey::ConstellationUrl.as_str(), url, state, app) 576 584 } 577 585 578 586 pub fn get_cache_size(state: &AppState) -> Result<CacheSize> {