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.

docs: follow hygiene plan

+811 -1
+606
docs/designs/follow-mgmt.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>Follows Audit - 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 + --status-deleted: #ff6b6b; 25 + --status-deactivated: #ffd93d; 26 + --status-suspended: #ff8c42; 27 + --status-blocked: #c084fc; 28 + --status-hiding: #f472b6; 29 + --status-self: #94a3b8; 30 + } 31 + 32 + * { box-sizing: border-box; } 33 + 34 + html { scroll-behavior: smooth; } 35 + 36 + body { 37 + margin: 0; 38 + min-height: 100vh; 39 + font-family: "Google Sans", "Segoe UI", sans-serif; 40 + background: var(--surface-container-lowest); 41 + color: var(--on-surface); 42 + } 43 + 44 + .app-rail { 45 + width: 64px; 46 + background: var(--surface-container-lowest); 47 + } 48 + 49 + .rail-icon { 50 + width: 40px; 51 + height: 40px; 52 + display: flex; 53 + align-items: center; 54 + justify-content: center; 55 + border-radius: 12px; 56 + transition: all 0.15s ease; 57 + color: var(--on-surface-variant); 58 + } 59 + 60 + .rail-icon.active { 61 + color: var(--primary); 62 + background: rgba(125, 175, 255, 0.1); 63 + } 64 + 65 + .rail-icon:not(.active):hover { 66 + color: var(--on-surface); 67 + background: rgba(255, 255, 255, 0.05); 68 + } 69 + 70 + .surface { 71 + background: var(--surface); 72 + } 73 + 74 + .panel { 75 + background: var(--surface-container); 76 + } 77 + 78 + .panel-high { 79 + background: var(--surface-container-high); 80 + } 81 + 82 + .panel-highest { 83 + background: var(--surface-container-highest); 84 + backdrop-filter: blur(20px); 85 + } 86 + 87 + .soft-outline { 88 + box-shadow: 0 0 0 1px var(--outline-variant); 89 + } 90 + 91 + .chip { 92 + display: inline-flex; 93 + align-items: center; 94 + gap: 0.35rem; 95 + padding: 0.4rem 0.65rem; 96 + border-radius: 999px; 97 + background: rgba(255, 255, 255, 0.05); 98 + color: var(--on-secondary-container); 99 + font-size: 0.75rem; 100 + font-weight: 500; 101 + } 102 + 103 + .status-chip { 104 + display: inline-flex; 105 + align-items: center; 106 + gap: 0.35rem; 107 + padding: 0.3rem 0.6rem; 108 + border-radius: 999px; 109 + font-size: 0.72rem; 110 + font-weight: 500; 111 + } 112 + 113 + .status-deleted { background: rgba(255, 107, 107, 0.15); color: var(--status-deleted); } 114 + .status-deactivated { background: rgba(255, 217, 61, 0.15); color: var(--status-deactivated); } 115 + .status-suspended { background: rgba(255, 140, 66, 0.15); color: var(--status-suspended); } 116 + .status-blocked { background: rgba(192, 132, 252, 0.15); color: var(--status-blocked); } 117 + .status-hiding { background: rgba(244, 114, 182, 0.15); color: var(--status-hiding); } 118 + .status-self { background: rgba(148, 163, 184, 0.15); color: var(--status-self); } 119 + 120 + .card { 121 + background: rgba(255, 255, 255, 0.03); 122 + border-radius: 1rem; 123 + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.03) inset; 124 + } 125 + 126 + .account-row { 127 + background: rgba(255, 255, 255, 0.02); 128 + border-radius: 0.75rem; 129 + transition: background 0.15s ease; 130 + } 131 + 132 + .account-row:hover { 133 + background: rgba(255, 255, 255, 0.04); 134 + } 135 + 136 + .account-row.selected { 137 + background: rgba(255, 107, 107, 0.08); 138 + } 139 + 140 + .checkbox-custom { 141 + width: 18px; 142 + height: 18px; 143 + border-radius: 5px; 144 + border: 2px solid var(--on-surface-variant); 145 + transition: all 0.15s ease; 146 + } 147 + 148 + .checkbox-custom.checked { 149 + background: var(--primary); 150 + border-color: var(--primary); 151 + } 152 + 153 + .progress-bar { 154 + height: 6px; 155 + border-radius: 999px; 156 + background: rgba(255, 255, 255, 0.08); 157 + overflow: hidden; 158 + } 159 + 160 + .progress-fill { 161 + height: 100%; 162 + border-radius: 999px; 163 + background: var(--primary); 164 + transition: width 0.3s ease; 165 + } 166 + 167 + .keycap { 168 + padding: 0.25rem 0.45rem; 169 + border-radius: 0.5rem; 170 + background: rgba(255, 255, 255, 0.08); 171 + color: var(--on-surface-variant); 172 + font-size: 0.72rem; 173 + font-weight: 500; 174 + } 175 + 176 + .btn-primary { 177 + background: rgba(125, 175, 255, 0.12); 178 + color: var(--primary); 179 + padding: 0.6rem 1.2rem; 180 + border-radius: 999px; 181 + font-size: 0.875rem; 182 + font-weight: 500; 183 + transition: all 0.15s ease; 184 + border: 0; 185 + cursor: pointer; 186 + } 187 + 188 + .btn-primary:hover:not(:disabled) { 189 + background: rgba(125, 175, 255, 0.18); 190 + } 191 + 192 + .btn-primary:disabled { 193 + opacity: 0.5; 194 + cursor: not-allowed; 195 + } 196 + 197 + .btn-danger { 198 + background: rgba(255, 107, 107, 0.15); 199 + color: var(--status-deleted); 200 + padding: 0.6rem 1.2rem; 201 + border-radius: 999px; 202 + font-size: 0.875rem; 203 + font-weight: 500; 204 + transition: all 0.15s ease; 205 + border: 0; 206 + cursor: pointer; 207 + } 208 + 209 + .btn-danger:hover:not(:disabled) { 210 + background: rgba(255, 107, 107, 0.22); 211 + } 212 + 213 + .btn-danger:disabled { 214 + opacity: 0.5; 215 + cursor: not-allowed; 216 + } 217 + 218 + .metric { 219 + padding: 1rem; 220 + border-radius: 1rem; 221 + background: rgba(255, 255, 255, 0.03); 222 + } 223 + 224 + .muted { 225 + color: var(--on-surface-variant); 226 + } 227 + 228 + .title-tight { 229 + letter-spacing: -0.02em; 230 + } 231 + 232 + .sticky-shell { 233 + position: sticky; 234 + top: 0; 235 + z-index: 30; 236 + } 237 + 238 + .spinner { 239 + width: 20px; 240 + height: 20px; 241 + border: 2px solid transparent; 242 + border-top-color: currentColor; 243 + border-radius: 50%; 244 + animation: spin 0.8s linear infinite; 245 + } 246 + 247 + @keyframes spin { 248 + to { transform: rotate(360deg); } 249 + } 250 + 251 + @keyframes fadeIn { 252 + from { opacity: 0; transform: translateY(8px); } 253 + to { opacity: 1; transform: translateY(0); } 254 + } 255 + 256 + .fade-in { 257 + animation: fadeIn 0.2s ease forwards; 258 + } 259 + </style> 260 + </head> 261 + <body class="flex"> 262 + <aside class="app-rail fixed left-0 top-0 h-full flex flex-col items-center py-4 z-50"> 263 + <div class="mb-6"> 264 + <svg width="40" height="40" viewBox="0 0 512 512" style="color: #7dafff;"> 265 + <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"/> 266 + </svg> 267 + </div> 268 + 269 + <nav class="flex flex-col gap-1 flex-1"> 270 + <button class="rail-icon" title="Timeline"> 271 + <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 272 + <rect x="3" y="3" width="7" height="7" rx="1"/> 273 + <rect x="14" y="3" width="7" height="7" rx="1"/> 274 + <rect x="14" y="14" width="7" height="7" rx="1"/> 275 + <rect x="3" y="14" width="7" height="7" rx="1"/> 276 + </svg> 277 + </button> 278 + 279 + <button class="rail-icon" title="Search"> 280 + <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 281 + <circle cx="11" cy="11" r="8"/> 282 + <path d="m21 21-4.35-4.35"/> 283 + </svg> 284 + </button> 285 + 286 + <button class="rail-icon" title="Notifications"> 287 + <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 288 + <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/> 289 + <path d="M13.73 21a2 2 0 0 1-3.46 0"/> 290 + </svg> 291 + </button> 292 + 293 + <button class="rail-icon active" title="Profile"> 294 + <svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 295 + <circle cx="12" cy="8" r="5"/> 296 + <path d="M20 21a8 8 0 1 0-16 0"/> 297 + </svg> 298 + </button> 299 + </nav> 300 + 301 + <div class="flex flex-col gap-2"> 302 + <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);"> 303 + <img src="https://placehold.co/40x40/7dafff/05080f?text=U" alt="Current account" class="w-full h-full object-cover"> 304 + </button> 305 + </div> 306 + </aside> 307 + 308 + <main class="flex-1 ml-16 surface min-h-screen"> 309 + <header class="sticky-shell panel-highest"> 310 + <div class="px-6 py-4 flex items-start justify-between gap-6"> 311 + <div> 312 + <p class="text-xs uppercase tracking-[0.22em] muted mb-2">Lazurite Wireframe</p> 313 + <h1 class="text-2xl font-medium title-tight mb-2">Follows Audit</h1> 314 + <p class="text-sm leading-relaxed max-w-3xl muted"> 315 + Audit your following list for dead weight and batch unfollow accounts that are deleted, deactivated, blocked, or otherwise unreachable. 316 + </p> 317 + </div> 318 + <div class="flex flex-wrap items-center gap-2 justify-end text-xs"> 319 + <span class="keycap">Space</span> 320 + <span class="keycap">Ctrl+A</span> 321 + <span class="keycap">Esc</span> 322 + <span class="chip">Slide-over panel</span> 323 + </div> 324 + </div> 325 + </header> 326 + 327 + <div class="px-6 py-8"> 328 + <section class="grid lg:grid-cols-[280px_minmax(0,1fr)] gap-6 items-start"> 329 + <aside class="space-y-4 lg:sticky lg:top-24"> 330 + <div class="panel rounded-3xl p-5 soft-outline space-y-4"> 331 + <div class="flex items-center justify-between gap-3"> 332 + <h3 class="text-base font-medium title-tight">Categories</h3> 333 + <span class="text-xs muted">12 selected</span> 334 + </div> 335 + 336 + <div class="space-y-3"> 337 + <div class="flex items-center justify-between gap-3"> 338 + <div class="flex items-center gap-2"> 339 + <div class="checkbox-custom checked"></div> 340 + <span class="text-sm">Deleted</span> 341 + </div> 342 + <span class="text-xs muted">4</span> 343 + </div> 344 + <div class="flex items-center justify-between gap-3"> 345 + <div class="flex items-center gap-2"> 346 + <div class="checkbox-custom checked"></div> 347 + <span class="text-sm">Deactivated</span> 348 + </div> 349 + <span class="text-xs muted">2</span> 350 + </div> 351 + <div class="flex items-center justify-between gap-3"> 352 + <div class="flex items-center gap-2"> 353 + <div class="checkbox-custom"></div> 354 + <span class="text-sm">Suspended</span> 355 + </div> 356 + <span class="text-xs muted">0</span> 357 + </div> 358 + <div class="flex items-center justify-between gap-3"> 359 + <div class="flex items-center gap-2"> 360 + <div class="checkbox-custom checked"></div> 361 + <span class="text-sm">Blocked by</span> 362 + </div> 363 + <span class="text-xs muted">3</span> 364 + </div> 365 + <div class="flex items-center justify-between gap-3"> 366 + <div class="flex items-center gap-2"> 367 + <div class="checkbox-custom"></div> 368 + <span class="text-sm">Blocking</span> 369 + </div> 370 + <span class="text-xs muted">1</span> 371 + </div> 372 + <div class="flex items-center justify-between gap-3"> 373 + <div class="flex items-center gap-2"> 374 + <div class="checkbox-custom checked"></div> 375 + <span class="text-sm">Hidden</span> 376 + </div> 377 + <span class="text-xs muted">2</span> 378 + </div> 379 + <div class="flex items-center justify-between gap-3"> 380 + <div class="flex items-center gap-2"> 381 + <div class="checkbox-custom"></div> 382 + <span class="text-sm">Self-follow</span> 383 + </div> 384 + <span class="text-xs muted">0</span> 385 + </div> 386 + </div> 387 + 388 + <div class="pt-3 border-t" style="border-color: var(--outline-variant);"> 389 + <button class="text-sm muted hover:text-on-surface transition-colors">Select all visible</button> 390 + </div> 391 + </div> 392 + 393 + <div class="panel-high rounded-3xl p-5 soft-outline"> 394 + <div class="flex items-center justify-between gap-3 mb-4"> 395 + <span class="text-sm">Selection</span> 396 + <span class="text-sm font-medium">12 / 15</span> 397 + </div> 398 + <button class="btn-danger w-full"> 399 + Unfollow selected 400 + </button> 401 + </div> 402 + </aside> 403 + 404 + <div class="space-y-4"> 405 + <div class="panel rounded-3xl p-5 soft-outline"> 406 + <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> 407 + <div> 408 + <h2 class="text-lg font-medium title-tight">Flagged accounts</h2> 409 + <p class="text-sm muted mt-1">15 accounts need attention across 6 categories</p> 410 + </div> 411 + <button class="btn-primary"> 412 + <span class="flex items-center gap-2"> 413 + <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 414 + <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/> 415 + <path d="M21 3v5h-5"/> 416 + <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/> 417 + <path d="M8 16H3v5"/> 418 + </svg> 419 + Scan follows 420 + </span> 421 + </button> 422 + </div> 423 + 424 + <div class="mt-4"> 425 + <div class="progress-bar"> 426 + <div class="progress-fill" style="width: 67%;"></div> 427 + </div> 428 + <p class="text-xs muted mt-2">Scanning batch 4 of 6...</p> 429 + </div> 430 + </div> 431 + 432 + <div class="panel rounded-3xl p-4 soft-outline"> 433 + <div class="flex items-center gap-3 mb-4"> 434 + <div class="flex-1"> 435 + <div class="relative"> 436 + <svg class="absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 437 + <circle cx="11" cy="11" r="8"/> 438 + <path d="m21 21-4.35-4.35"/> 439 + </svg> 440 + <input type="text" placeholder="Search by handle or DID..." class="w-full bg-[rgba(255,255,255,0.04)] rounded-full py-2.5 pl-10 pr-4 text-sm outline-none border border-transparent focus:border-primary transition-colors"> 441 + </div> 442 + </div> 443 + <span class="text-xs muted whitespace-nowrap">12 of 15 shown</span> 444 + </div> 445 + 446 + <div class="space-y-2"> 447 + <div class="account-row selected p-4 fade-in"> 448 + <div class="flex items-start gap-3"> 449 + <div class="checkbox-custom checked mt-1"></div> 450 + <div class="flex-1 min-w-0"> 451 + <div class="flex flex-wrap items-center gap-2 mb-1"> 452 + <span class="text-sm font-medium truncate">@ghost.bsky.social</span> 453 + <span class="status-chip status-deleted">Deleted</span> 454 + </div> 455 + <p class="text-xs muted truncate">did:plc:def456</p> 456 + </div> 457 + <a href="#" class="text-xs muted hover:text-primary transition-colors">AT Explorer</a> 458 + </div> 459 + </div> 460 + 461 + <div class="account-row selected p-4 fade-in" style="animation-delay: 0.02s;"> 462 + <div class="flex items-start gap-3"> 463 + <div class="checkbox-custom checked mt-1"></div> 464 + <div class="flex-1 min-w-0"> 465 + <div class="flex flex-wrap items-center gap-2 mb-1"> 466 + <span class="text-sm font-medium truncate">@vacation.zone</span> 467 + <span class="status-chip status-deactivated">Deactivated</span> 468 + </div> 469 + <p class="text-xs muted truncate">did:plc:ghi789</p> 470 + </div> 471 + <a href="#" class="text-xs muted hover:text-primary transition-colors">AT Explorer</a> 472 + </div> 473 + </div> 474 + 475 + <div class="account-row p-4 fade-in" style="animation-delay: 0.04s;"> 476 + <div class="flex items-start gap-3"> 477 + <div class="checkbox-custom mt-1"></div> 478 + <div class="flex-1 min-w-0"> 479 + <div class="flex flex-wrap items-center gap-2 mb-1"> 480 + <span class="text-sm font-medium truncate">@suspended.example</span> 481 + <span class="status-chip status-suspended">Suspended</span> 482 + </div> 483 + <p class="text-xs muted truncate">did:plc:abc111</p> 484 + </div> 485 + <a href="#" class="text-xs muted hover:text-primary transition-colors">AT Explorer</a> 486 + </div> 487 + </div> 488 + 489 + <div class="account-row selected p-4 fade-in" style="animation-delay: 0.06s;"> 490 + <div class="flex items-start gap-3"> 491 + <div class="checkbox-custom checked mt-1"></div> 492 + <div class="flex-1 min-w-0"> 493 + <div class="flex flex-wrap items-center gap-2 mb-1"> 494 + <span class="text-sm font-medium truncate">@blocked.user</span> 495 + <span class="status-chip status-blocked">Blocked by</span> 496 + </div> 497 + <p class="text-xs muted truncate">did:plc:jkl012</p> 498 + </div> 499 + <a href="#" class="text-xs muted hover:text-primary transition-colors">AT Explorer</a> 500 + </div> 501 + </div> 502 + 503 + <div class="account-row p-4 fade-in" style="animation-delay: 0.08s;"> 504 + <div class="flex items-start gap-3"> 505 + <div class="checkbox-custom mt-1"></div> 506 + <div class="flex-1 min-w-0"> 507 + <div class="flex flex-wrap items-center gap-2 mb-1"> 508 + <span class="text-sm font-medium truncate">@mutual.block</span> 509 + <span class="status-chip status-blocked" style="background: rgba(192, 132, 252, 0.2);">Mutual block</span> 510 + </div> 511 + <p class="text-xs muted truncate">did:plc:mno345</p> 512 + </div> 513 + <a href="#" class="text-xs muted hover:text-primary transition-colors">AT Explorer</a> 514 + </div> 515 + </div> 516 + 517 + <div class="account-row selected p-4 fade-in" style="animation-delay: 0.1s;"> 518 + <div class="flex items-start gap-3"> 519 + <div class="checkbox-custom checked mt-1"></div> 520 + <div class="flex-1 min-w-0"> 521 + <div class="flex flex-wrap items-center gap-2 mb-1"> 522 + <span class="text-sm font-medium truncate">@hidden.account</span> 523 + <span class="status-chip status-hiding">Hidden</span> 524 + </div> 525 + <p class="text-xs muted truncate">did:plc:pqr678</p> 526 + </div> 527 + <a href="#" class="text-xs muted hover:text-primary transition-colors">AT Explorer</a> 528 + </div> 529 + </div> 530 + </div> 531 + </div> 532 + </div> 533 + </section> 534 + 535 + <section class="mt-8 grid lg:grid-cols-3 gap-4"> 536 + <div class="panel rounded-3xl p-5 soft-outline"> 537 + <p class="text-xs uppercase tracking-[0.18em] muted mb-2">Entry points</p> 538 + <h3 class="text-lg font-medium title-tight mb-3">Profile + Settings</h3> 539 + <p class="text-sm muted leading-relaxed">Primary entry from own profile panel ("Audit follows" button). Secondary entry in Settings &gt; Account section.</p> 540 + </div> 541 + <div class="panel rounded-3xl p-5 soft-outline"> 542 + <p class="text-xs uppercase tracking-[0.18em] muted mb-2">Batch operations</p> 543 + <h3 class="text-lg font-medium title-tight mb-3">ApplyWrites via PDS</h3> 544 + <p class="text-sm muted leading-relaxed">Unfollows chunked to 200 max per call. Failed URIs returned for retry. Rate-limit handling with inter-batch delays.</p> 545 + </div> 546 + <div class="panel rounded-3xl p-5 soft-outline"> 547 + <p class="text-xs uppercase tracking-[0.18em] muted mb-2">Animations</p> 548 + <h3 class="text-lg font-medium title-tight mb-3">Motion + Presence</h3> 549 + <p class="text-sm muted leading-relaxed">Staggered fade-in on results. Exit animation on removed rows.</p> 550 + </div> 551 + </section> 552 + 553 + <section class="mt-8 panel rounded-3xl p-5 soft-outline"> 554 + <div class="flex items-center justify-between gap-4 mb-4"> 555 + <div> 556 + <p class="text-xs uppercase tracking-[0.18em] muted mb-2">Component states</p> 557 + <h3 class="text-lg font-medium title-tight">Follows Audit Panel</h3> 558 + </div> 559 + <span class="chip">5 phases</span> 560 + </div> 561 + 562 + <div class="grid sm:grid-cols-5 gap-3"> 563 + <div class="metric text-center"> 564 + <p class="muted text-xs mb-2">Idle</p> 565 + <div class="w-10 h-10 mx-auto rounded-full flex items-center justify-center" style="background: rgba(255,255,255,0.05);"> 566 + <svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" class="muted"> 567 + <circle cx="12" cy="12" r="10"/> 568 + <path d="M12 6v6l4 2"/> 569 + </svg> 570 + </div> 571 + </div> 572 + <div class="metric text-center"> 573 + <p class="muted text-xs mb-2">Scanning</p> 574 + <div class="w-10 h-10 mx-auto rounded-full flex items-center justify-center" style="background: rgba(125,175,255,0.12);"> 575 + <div class="spinner" style="color: var(--primary);"></div> 576 + </div> 577 + </div> 578 + <div class="metric text-center"> 579 + <p class="muted text-xs mb-2">Ready</p> 580 + <div class="w-10 h-10 mx-auto rounded-full flex items-center justify-center" style="background: rgba(255,255,255,0.05);"> 581 + <svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" class="muted"> 582 + <path d="M5 12h14M12 5l7 7-7 7"/> 583 + </svg> 584 + </div> 585 + </div> 586 + <div class="metric text-center"> 587 + <p class="muted text-xs mb-2">Unfollowing</p> 588 + <div class="w-10 h-10 mx-auto rounded-full flex items-center justify-center" style="background: rgba(255,107,107,0.12);"> 589 + <div class="spinner" style="color: var(--status-deleted);"></div> 590 + </div> 591 + </div> 592 + <div class="metric text-center"> 593 + <p class="muted text-xs mb-2">Done</p> 594 + <div class="w-10 h-10 mx-auto rounded-full flex items-center justify-center" style="background: rgba(74,222,128,0.12);"> 595 + <svg width="18" height="18" fill="none" stroke="#4ade80" stroke-width="2" viewBox="0 0 24 24"> 596 + <path d="M20 6L9 17l-5-5"/> 597 + </svg> 598 + </div> 599 + </div> 600 + </div> 601 + </section> 602 + </div> 603 + </main> 604 + 605 + </body> 606 + </html>
+160
docs/specs/follow-hygiene.md
··· 1 + # Follow Hygiene 2 + 3 + Surfaces inactive, blocked, or otherwise unreachable accounts in the user's following list and provides batch unfollow. Inspired by [cleanfollow-bsky](https://github.com/notjuliet/cleanfollow-bsky). 4 + 5 + ## Motivation 6 + 7 + Following lists accumulate dead weight over time: deleted accounts, deactivated users, mutual blocks, suspended accounts. These inflate follow counts, pollute feed algorithms, and create a false sense of network size. Follow Hygiene gives users a tool to audit and prune their following list without manually checking each account. 8 + 9 + ## Account Statuses 10 + 11 + Each followed account is classified by querying the appview. Statuses are bitflags to support compound states (e.g., mutual block). 12 + 13 + | Status | Detection Method | 14 + | ----------- | -------------------------------------------------------------------------------- | 15 + | Deleted | `getProfiles` omits DID; fallback `getProfile` returns `"not found"` | 16 + | Deactivated | Fallback `getProfile` returns `"deactivated"` | 17 + | Suspended | Fallback `getProfile` returns `"suspended"` | 18 + | Blocked By | `viewer.blockedBy` is `true` | 19 + | Blocking | `viewer.blocking` or `viewer.blockingByList` is set | 20 + | Hidden | Account has a `!hide` label from a moderation service | 21 + | Self-Follow | Followed DID matches the authenticated user's DID | 22 + 23 + Compound: **Mutual Block** = `BlockedBy | Blocking`. 24 + 25 + Accounts that are reachable and have no issues are not surfaced — only problematic follows appear. 26 + 27 + ## Backend (Rust) 28 + 29 + ### Follow Enumeration 30 + 31 + New function in the feed/graph module: 32 + 33 + 1. Paginate `com.atproto.repo.listRecords` for `app.bsky.graph.follow` (page size 100) 34 + 2. Batch-resolve profiles via `app.bsky.actor.getProfiles` (max 25 per call) 35 + 3. For DIDs missing from the batch response, individually query `app.bsky.actor.getProfile` to distinguish deleted/deactivated/suspended 36 + 4. Resolve handles for missing DIDs via DID document (`plc.directory` or `did:web`) 37 + 5. Return only accounts with a non-zero status — healthy follows are filtered out 38 + 39 + Concurrency: process profile batches with bounded concurrency (2-3 concurrent requests) and inter-batch delays to respect rate limits. Use `tokio::sync::Semaphore` or similar. 40 + 41 + **Tauri command:** `audit_follows() -> Vec<FlaggedFollow>` 42 + 43 + ```rust 44 + struct FlaggedFollow { 45 + did: String, 46 + handle: String, 47 + follow_uri: String, // at:// URI of the follow record 48 + status: u8, // bitflag 49 + status_label: String, // human-readable 50 + } 51 + ``` 52 + 53 + Progress reporting via Tauri events: emit `follow-hygiene:progress` with `{ current: usize, total: usize }` as each batch completes so the frontend can render a progress bar without polling. 54 + 55 + ### Batch Unfollow 56 + 57 + Use `com.atproto.repo.applyWrites` to delete multiple follow records in a single transaction. The PDS enforces a max of 200 writes per call — chunk accordingly. 58 + 59 + **Tauri command:** `batch_unfollow(follow_uris: Vec<String>) -> BatchResult` 60 + 61 + ```rust 62 + struct BatchResult { 63 + deleted: usize, 64 + failed: Vec<String>, // URIs that failed 65 + } 66 + ``` 67 + 68 + Each write is a `Delete` operation on `app.bsky.graph.follow` with the rkey extracted from the follow URI. 69 + 70 + ## Frontend (SolidJS) 71 + 72 + ### Entry Point 73 + 74 + Accessible from two locations: 75 + 76 + 1. **Profile panel** — button in the user's own profile (not visible on other users' profiles). Naturally fits as a self-diagnostic action alongside follower/following lists. 77 + 2. **Settings > Account** — secondary entry point for users who think of this as account maintenance. 78 + 79 + Both open the same `FollowHygienePanel` component, rendered as a slide-over panel or routed view (consistent with how Social Diagnostics panels work). 80 + 81 + ### State 82 + 83 + ```ts 84 + type FollowHygieneState = { 85 + phase: "idle" | "scanning" | "ready" | "unfollowing" | "done"; 86 + progress: { current: number; total: number }; 87 + flagged: FlaggedFollow[]; 88 + selectedUris: Set<string>; 89 + filters: Record<StatusCategory, { visible: boolean; selected: boolean }>; 90 + result: { deleted: number; failed: string[] } | null; 91 + }; 92 + ``` 93 + 94 + Use `createStore` for local component state — this is a self-contained tool, not shared state that needs context. 95 + 96 + ### Scan Flow 97 + 98 + 1. User clicks "Scan follows" 99 + 2. Frontend invokes `audit_follows`, transitions to `scanning` phase 100 + 3. Progress bar updates via Tauri event listener (`follow-hygiene:progress`) 101 + 4. On completion, `flagged` array populates, phase becomes `ready` 102 + 5. If no flagged accounts found: show a brief "All clear" message 103 + 104 + ### Selection & Filtering 105 + 106 + - **Category toggles**: visibility toggles per status category (show/hide deleted, deactivated, etc.) 107 + - **Category select-all**: checkbox per category to batch-select/deselect all accounts of that type 108 + - **Individual selection**: per-account checkbox 109 + - **Selection counter**: `{selected} / {total}` in the action bar 110 + 111 + ### Unfollow Flow 112 + 113 + 1. User reviews selection, clicks "Unfollow selected" 114 + 2. Confirmation step: "Unfollow {n} account(s)?" — destructive action, requires deliberate confirmation 115 + 3. Frontend invokes `batch_unfollow` with selected URIs 116 + 4. On completion, remove unfollowed accounts from the list, show result summary 117 + 5. If any failures, show count with option to retry failed 118 + 119 + ### Layout 120 + 121 + Left sidebar (sticky): category filters with toggles and select-all checkboxes, selection counter. 122 + Main area: scrollable list of flagged accounts. 123 + 124 + Each account row: 125 + 126 + - Checkbox for selection 127 + - Handle (if resolvable) with external link to Bluesky profile 128 + - DID with external link to AT Explorer 129 + - Status label chip 130 + 131 + Selected rows get a subtle background tint to indicate pending deletion. 132 + 133 + ## UX Polish 134 + 135 + - Scan button: disabled with spinner while scanning 136 + - Progress bar: determinate bar based on `current/total` with animated fill 137 + - Account list: `Motion` staggered fade-in on scan completion 138 + - Row selection: immediate background tint transition 139 + - Unfollow completion: `Motion` exit animation on removed rows, counter animates down 140 + - Confirmation dialog: `Presence` fade-in overlay 141 + - Empty state (no flagged accounts): brief, positive message — not a dramatic "all clear" celebration 142 + 143 + ## Keyboard Shortcuts 144 + 145 + | Key | Action | 146 + | --------- | ----------------------------------- | 147 + | `Space` | Toggle selection on focused account | 148 + | `Ctrl+A` | Select all visible accounts | 149 + | `Escape` | Close panel / cancel confirmation | 150 + 151 + ## Relationship to Social Diagnostics 152 + 153 + Follow Hygiene is complementary to Social Diagnostics but distinct in purpose: 154 + 155 + - **Social Diagnostics** answers "what does the network say about this account?" (read-only inspection) 156 + - **Follow Hygiene** answers "which of my follows are dead weight?" (actionable cleanup) 157 + 158 + The Blocks & Boundaries tab in Social Diagnostics surfaces block relationships for any account. Follow Hygiene uses similar detection but is scoped to the authenticated user's following list and provides write actions (unfollow). 159 + 160 + Data from `audit_follows` could inform the Social Diagnostics self-view in the future, but the two features should remain separate panels with separate entry points.
+1
docs/specs/mvp.md
··· 62 62 - [Search & Embeddings](./search.md) 63 63 - [Social Diagnostics](./social-diagnostics.md) 64 64 - [Multicolumn Views](./multicolumn.md) 65 + - [Follow Hygiene](./follow-hygiene.md)
+39
docs/tasks/16-follows.md
··· 1 + # Milestone 16: Follow Hygiene 2 + 3 + Spec: [follow-hygiene.md](../specs/follow-hygiene.md) 4 + 5 + ## Dependencies 6 + 7 + - Milestone 02 (Auth) — active OAuth session required 8 + - Milestone 09 (Profile) — profile panel hosts the primary entry point 9 + 10 + ## Tasks 11 + 12 + ### Backend 13 + 14 + - [ ] **Add `FlaggedFollow` type** to `src-tauri/src/feed.rs` (or a new `graph.rs` module if feed.rs is getting large). 15 + Bitflag status field matching the spec's status table. 16 + - [ ] **Implement `audit_follows` command.** Paginate `com.atproto.repo.listRecords` for the follow collection, batch-resolve via `getProfiles` (25/batch, bounded concurrency via semaphore), individually resolve missing DIDs via `getProfile` + DID document handle resolution. 17 + Emit `follow-hygiene:progress` events per batch. Return only accounts with non-zero status. 18 + - [ ] **Implement `batch_unfollow` command.** Accept a `Vec<String>` of follow AT-URIs. 19 + Extract rkeys, build `Delete` operations, chunk into groups of 200, send via `applyWrites`. Return `BatchResult` with deleted count and any failed URIs. 20 + - [ ] **Rate-limit handling.** Add inter-batch delays and respect `429` / `Retry-After` headers in the audit scan. 21 + Log warnings on rate-limit hits. 22 + 23 + ### Frontend 24 + 25 + - [ ] **Create `FollowHygienePanel` component** (`src/components/profile/FollowHygienePanel.tsx`). Local state via `createStore<FollowHygieneState>`. Phases: idle → scanning → ready → unfollowing → done. 26 + - [ ] **Progress bar.** Listen to `follow-hygiene:progress` Tauri events during scan. Determinate bar with animated fill. 27 + - [ ] **Flagged account list.** Scrollable list with per-row checkbox, handle, DID, status label chip. Selected rows get background tint. Use `For` (not map). 28 + - [ ] **Category filter sidebar.** Sticky sidebar with visibility toggles and select-all checkboxes per status category. Selection counter. 29 + - [ ] **Unfollow flow.** Confirmation dialog before destructive action. Invoke `batch_unfollow`, remove completed rows with exit animation, show result summary. 30 + - [ ] **Entry points.** Add "Audit follows" button to the authenticated user's own profile panel. Add secondary entry in Settings > Account section. 31 + 32 + ### Polish 33 + 34 + - [ ] Keyboard shortcuts: `Space` toggle, `Ctrl+A` select all, `Escape` close 35 + - [ ] `Motion` staggered fade-in on scan results, exit animation on unfollow 36 + - [ ] `Presence` fade-in on confirmation dialog 37 + - [ ] Skeleton/spinner states during scan 38 + - [ ] Empty state message when no flagged accounts found 39 + - [ ] Error handling: toast on scan failure, inline retry for batch unfollow failures
+5 -1
docs/tasks/mvp.md
··· 29 29 - [Spacedust](./11-spacedust.md) - Real-time backlink notifications via microcosm Spacedust 30 30 - [Social Diagnostics](./12-social-diagnostics.md) - Constellation-powered lists, labels, blocks, starter packs, backlinks (depends on Spacedust for live engagement) 31 31 32 - ## Phase 6: Release 32 + ## Phase 6: Maintenance Tools 33 + 34 + - [Follow Hygiene](./16-follows.md) - Audit and batch-unfollow dead/blocked/inactive accounts 35 + 36 + ## Phase 7: Release 33 37 34 38 - [Release](./13-release.md) - Cross-platform build (macOS, Windows, Linux), code signing, auto-update, CI/CD