···11+# Follow Hygiene
22+33+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).
44+55+## Motivation
66+77+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.
88+99+## Account Statuses
1010+1111+Each followed account is classified by querying the appview. Statuses are bitflags to support compound states (e.g., mutual block).
1212+1313+| Status | Detection Method |
1414+| ----------- | -------------------------------------------------------------------------------- |
1515+| Deleted | `getProfiles` omits DID; fallback `getProfile` returns `"not found"` |
1616+| Deactivated | Fallback `getProfile` returns `"deactivated"` |
1717+| Suspended | Fallback `getProfile` returns `"suspended"` |
1818+| Blocked By | `viewer.blockedBy` is `true` |
1919+| Blocking | `viewer.blocking` or `viewer.blockingByList` is set |
2020+| Hidden | Account has a `!hide` label from a moderation service |
2121+| Self-Follow | Followed DID matches the authenticated user's DID |
2222+2323+Compound: **Mutual Block** = `BlockedBy | Blocking`.
2424+2525+Accounts that are reachable and have no issues are not surfaced — only problematic follows appear.
2626+2727+## Backend (Rust)
2828+2929+### Follow Enumeration
3030+3131+New function in the feed/graph module:
3232+3333+1. Paginate `com.atproto.repo.listRecords` for `app.bsky.graph.follow` (page size 100)
3434+2. Batch-resolve profiles via `app.bsky.actor.getProfiles` (max 25 per call)
3535+3. For DIDs missing from the batch response, individually query `app.bsky.actor.getProfile` to distinguish deleted/deactivated/suspended
3636+4. Resolve handles for missing DIDs via DID document (`plc.directory` or `did:web`)
3737+5. Return only accounts with a non-zero status — healthy follows are filtered out
3838+3939+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.
4040+4141+**Tauri command:** `audit_follows() -> Vec<FlaggedFollow>`
4242+4343+```rust
4444+struct FlaggedFollow {
4545+ did: String,
4646+ handle: String,
4747+ follow_uri: String, // at:// URI of the follow record
4848+ status: u8, // bitflag
4949+ status_label: String, // human-readable
5050+}
5151+```
5252+5353+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.
5454+5555+### Batch Unfollow
5656+5757+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.
5858+5959+**Tauri command:** `batch_unfollow(follow_uris: Vec<String>) -> BatchResult`
6060+6161+```rust
6262+struct BatchResult {
6363+ deleted: usize,
6464+ failed: Vec<String>, // URIs that failed
6565+}
6666+```
6767+6868+Each write is a `Delete` operation on `app.bsky.graph.follow` with the rkey extracted from the follow URI.
6969+7070+## Frontend (SolidJS)
7171+7272+### Entry Point
7373+7474+Accessible from two locations:
7575+7676+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.
7777+2. **Settings > Account** — secondary entry point for users who think of this as account maintenance.
7878+7979+Both open the same `FollowHygienePanel` component, rendered as a slide-over panel or routed view (consistent with how Social Diagnostics panels work).
8080+8181+### State
8282+8383+```ts
8484+type FollowHygieneState = {
8585+ phase: "idle" | "scanning" | "ready" | "unfollowing" | "done";
8686+ progress: { current: number; total: number };
8787+ flagged: FlaggedFollow[];
8888+ selectedUris: Set<string>;
8989+ filters: Record<StatusCategory, { visible: boolean; selected: boolean }>;
9090+ result: { deleted: number; failed: string[] } | null;
9191+};
9292+```
9393+9494+Use `createStore` for local component state — this is a self-contained tool, not shared state that needs context.
9595+9696+### Scan Flow
9797+9898+1. User clicks "Scan follows"
9999+2. Frontend invokes `audit_follows`, transitions to `scanning` phase
100100+3. Progress bar updates via Tauri event listener (`follow-hygiene:progress`)
101101+4. On completion, `flagged` array populates, phase becomes `ready`
102102+5. If no flagged accounts found: show a brief "All clear" message
103103+104104+### Selection & Filtering
105105+106106+- **Category toggles**: visibility toggles per status category (show/hide deleted, deactivated, etc.)
107107+- **Category select-all**: checkbox per category to batch-select/deselect all accounts of that type
108108+- **Individual selection**: per-account checkbox
109109+- **Selection counter**: `{selected} / {total}` in the action bar
110110+111111+### Unfollow Flow
112112+113113+1. User reviews selection, clicks "Unfollow selected"
114114+2. Confirmation step: "Unfollow {n} account(s)?" — destructive action, requires deliberate confirmation
115115+3. Frontend invokes `batch_unfollow` with selected URIs
116116+4. On completion, remove unfollowed accounts from the list, show result summary
117117+5. If any failures, show count with option to retry failed
118118+119119+### Layout
120120+121121+Left sidebar (sticky): category filters with toggles and select-all checkboxes, selection counter.
122122+Main area: scrollable list of flagged accounts.
123123+124124+Each account row:
125125+126126+- Checkbox for selection
127127+- Handle (if resolvable) with external link to Bluesky profile
128128+- DID with external link to AT Explorer
129129+- Status label chip
130130+131131+Selected rows get a subtle background tint to indicate pending deletion.
132132+133133+## UX Polish
134134+135135+- Scan button: disabled with spinner while scanning
136136+- Progress bar: determinate bar based on `current/total` with animated fill
137137+- Account list: `Motion` staggered fade-in on scan completion
138138+- Row selection: immediate background tint transition
139139+- Unfollow completion: `Motion` exit animation on removed rows, counter animates down
140140+- Confirmation dialog: `Presence` fade-in overlay
141141+- Empty state (no flagged accounts): brief, positive message — not a dramatic "all clear" celebration
142142+143143+## Keyboard Shortcuts
144144+145145+| Key | Action |
146146+| --------- | ----------------------------------- |
147147+| `Space` | Toggle selection on focused account |
148148+| `Ctrl+A` | Select all visible accounts |
149149+| `Escape` | Close panel / cancel confirmation |
150150+151151+## Relationship to Social Diagnostics
152152+153153+Follow Hygiene is complementary to Social Diagnostics but distinct in purpose:
154154+155155+- **Social Diagnostics** answers "what does the network say about this account?" (read-only inspection)
156156+- **Follow Hygiene** answers "which of my follows are dead weight?" (actionable cleanup)
157157+158158+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).
159159+160160+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.