···11+---
22+title: Phase 8 Spec
33+updated: 2026-04-11
44+---
55+66+## Follow Hygiene — Detect & Remove Inactive/Problematic Follows
77+88+Audit the authenticated user's follow list to surface accounts that are
99+deleted, deactivated, suspended, blocking/blocked-by, hidden by moderation, or
1010+the user's own DID. Present a filterable, selectable list and batch-unfollow in
1111+a single action.
1212+1313+### Why
1414+1515+Users accumulate dead follows over time — accounts get suspended, deactivated,
1616+or start blocking. Bluesky provides no built-in way to audit this. The existing
1717+profile action layer handles individual follow/unfollow, but there is no
1818+batch-audit or batch-unfollow capability.
1919+2020+### Data Flow
2121+2222+```text
2323+User taps "Clean Follows"
2424+ → Paginate com.atproto.repo.listRecords(collection: app.bsky.graph.follow)
2525+ → Collect all follow record URIs + subject DIDs
2626+ → Batch-hydrate via app.bsky.actor.getProfiles (25 per batch)
2727+ → For each batch, classify:
2828+ - Missing from response → resolve DID individually → deleted/deactivated/suspended
2929+ - Present but viewer.blockedBy → blocked-by (or mutual block if also blocking)
3030+ - Present but viewer.blocking/blockingByList → blocking
3131+ - Present but labels contain "!hide" → hidden by moderation
3232+ - subject DID == own DID → self-follow
3333+ → Display categorized results
3434+ → User selects accounts → batch delete via com.atproto.repo.applyWrites
3535+```
3636+3737+### Account Classification
3838+3939+Reuse the existing `_hydrateProfiles` pattern from `ProfileContextRepository`
4040+— batch `getProfiles`, then per-DID fallback for missing entries. Extend with
4141+follow-specific status classification:
4242+4343+| Status | Detection |
4444+| ------------- | ------------------------------------------------------------- |
4545+| Deleted | `getProfile` returns "not found" error |
4646+| Deactivated | `getProfile` returns "deactivated" error |
4747+| Suspended | `getProfile` returns "suspended" error |
4848+| Blocked By | `viewer.blockedBy == true` on profile response |
4949+| Blocking | `viewer.blocking != null` or `viewer.blockingByList != null` |
5050+| Mutual Block | Both `blockedBy` and `blocking` are true |
5151+| Hidden | Profile labels contain `val == "!hide"` |
5252+| Self-follow | Subject DID matches authenticated user's DID |
5353+5454+### Batch Unfollow
5555+5656+AT Protocol's `com.atproto.repo.applyWrites` accepts up to 200 operations per
5757+call. The `bluesky` Dart package exposes this as
5858+`atproto.repo.applyWrites(writes: [...])`.
5959+6060+Each delete operation:
6161+6262+```dart
6363+{
6464+ '$type': 'com.atproto.repo.applyWrites#delete',
6565+ 'collection': 'app.bsky.graph.follow',
6666+ 'rkey': rkey, // extracted from the follow record URI
6767+}
6868+```
6969+7070+Chunk selected records into batches of 200 and execute sequentially. Update
7171+local state after each successful batch.
7272+7373+### Repository Layer
7474+7575+**`FollowAuditRepository`** — new file in `lib/features/profile/data/`.
7676+7777+Depends on the authenticated `Bluesky` client (same injection pattern as
7878+`ProfileActionRepository` and `ProfileContextRepository`).
7979+8080+**Methods:**
8181+8282+- `fetchAllFollows()` → paginate `atproto.repo.listRecords(repo: did,
8383+ collection: 'app.bsky.graph.follow', limit: 100)` with cursor. Returns
8484+ `List<FollowRecord>` (uri, rkey, subjectDid).
8585+- `classifyFollows(List<FollowRecord>, String ownDid)` → batch
8686+ `getProfiles` (25/batch, 2 concurrent batches, 500ms delay between groups),
8787+ per-DID fallback for missing, return `List<ClassifiedFollow>`.
8888+- `batchUnfollow(List<ClassifiedFollow>)` → `applyWrites` in chunks of 200.
8989+9090+**Models:**
9191+9292+```dart
9393+enum FollowStatus {
9494+ deleted,
9595+ deactivated,
9696+ suspended,
9797+ blockedBy,
9898+ blocking,
9999+ mutualBlock,
100100+ hidden,
101101+ selfFollow,
102102+}
103103+104104+class FollowRecord {
105105+ final String uri;
106106+ final String rkey;
107107+ final String subjectDid;
108108+}
109109+110110+class ClassifiedFollow {
111111+ final FollowRecord record;
112112+ final String? handle;
113113+ final FollowStatus status;
114114+ final String statusLabel;
115115+ bool selected;
116116+}
117117+```
118118+119119+### Rate Limiting
120120+121121+The `bluesky` package's `getProfiles` accepts up to 25 actors. Strategy:
122122+123123+- Batch size: 25 (API max)
124124+- Concurrent batches: 2
125125+- Inter-group delay: 500ms
126126+- On rate-limit (429) or network error: retry with exponential backoff (1s, 2s,
127127+ 4s), max 3 retries per batch, then skip and count as failed
128128+129129+### Cubit
130130+131131+**`FollowAuditCubit`** — new file in `lib/features/profile/cubit/`.
132132+133133+**States:**
134134+135135+```dart
136136+enum FollowAuditStatus {
137137+ initial,
138138+ fetching, // paginating follow records
139139+ classifying, // hydrating + classifying profiles
140140+ ready, // results displayed
141141+ unfollowing, // batch delete in progress
142142+ complete, // unfollow finished
143143+ error,
144144+}
145145+146146+class FollowAuditState {
147147+ final FollowAuditStatus status;
148148+ final List<ClassifiedFollow> results;
149149+ final int totalFollows;
150150+ final int progress; // records processed so far
151151+ final int failedProfiles; // profiles that couldn't be fetched
152152+ final int unfollowedCount; // after batch delete
153153+ final String? errorMessage;
154154+ final Set<FollowStatus> visibleStatuses; // filter toggles
155155+}
156156+```
157157+158158+**Methods:**
159159+160160+- `audit()` — fetch all follows, classify, transition through
161161+ fetching → classifying → ready.
162162+- `toggleSelection(int index)` — toggle `selected` on a single result.
163163+- `selectAllByStatus(FollowStatus)` / `deselectAllByStatus(FollowStatus)` —
164164+ bulk select/deselect by category.
165165+- `toggleVisibility(FollowStatus)` — show/hide a category in the list.
166166+- `confirmUnfollow()` — batch-delete selected, transition to
167167+ unfollowing → complete.
168168+169169+Progress is reported as `progress / totalFollows` during both fetching and
170170+classifying phases.
171171+172172+### UI
173173+174174+**Entry point:** New item in the settings screen under a "Follows" or
175175+"Account Maintenance" section. Also accessible from the profile screen's
176176+overflow menu (three-dot) for the user's own profile.
177177+178178+**Screen: `FollowAuditScreen`**
179179+180180+Layout (top to bottom):
181181+182182+1. **Header** — "Clean Follows" title, subtitle with follow count once loaded.
183183+2. **Action bar** — "Scan" button (initial state) → "Unfollow Selected (N)"
184184+ button (ready state). Disabled during fetching/classifying/unfollowing.
185185+3. **Progress indicator** — linear progress bar during fetch/classify. Shows
186186+ "Fetching follows: 142/1200" or "Classifying: 300/1200". If failed profiles
187187+ > 0, show amber warning text.
188188+4. **Filter sidebar / chip row** — one toggle per `FollowStatus` category.
189189+ Each toggle shows the category label and count. Visibility toggle
190190+ (show/hide in list) + "Select All" checkbox per category. On narrow
191191+ screens, render as a horizontal scrollable chip row above the list; on
192192+ wider screens, render as a sticky sidebar.
193193+5. **Results list** — each row: checkbox, handle (tappable → profile), DID
194194+ (truncated, tappable → copy), status badge. Selected rows have a
195195+ destructive-red background tint. Rows hidden by visibility filter are
196196+ excluded from the list entirely.
197197+6. **Summary footer** — "Selected: 12/47" count. After unfollow:
198198+ "Unfollowed 12 accounts".
199199+7. **Empty/complete states** — "No problematic follows found" or
200200+ "Unfollowed N account(s)".
201201+202202+**Styling:** Follows the UI refactor spec — square geometry, uppercase labels,
203203+`outlineVariant` borders, `surfaceContainerLowest` card backgrounds.
204204+205205+### Error Handling
206206+207207+- Network failure during fetch: show error state with retry button.
208208+- Partial profile hydration failure: continue with available data, show count
209209+ of failed profiles as a warning (not a blocker).
210210+- `applyWrites` failure on a batch: stop, show error with count of successful
211211+ unfollows so far, allow retry for remaining.
212212+- Account not authenticated: guard entry points behind auth state (already
213213+ handled by app shell).
214214+215215+### Limitations & Future Work
216216+217217+- **No "inactive" detection.** We'll only detects hard states (deleted,
218218+ suspended, blocking). Detecting genuinely inactive accounts (no posts in N
219219+ months) would require fetching each account's feed — prohibitively expensive
220220+ for large follow lists. Could be added as an opt-in deep scan later.
221221+- **No undo.** Unfollow is destructive. A future version could cache unfollowed
222222+ DIDs locally and offer a "re-follow" list for a limited time.
223223+- **Single-account.** Runs against the active account only. Multi-account batch
224224+ audit is out of scope.
225225+- **No background execution.** The audit runs in the foreground. For users with
226226+ 10k+ follows, this could take 1-2 minutes. A future version could use a
227227+ background isolate with notification on completion.
+109
docs/tasks/phase-8.md
···11+---
22+title: Phase 8 Task Breakdown
33+updated: 2026-04-11
44+---
55+66+## M27 - Follow Hygiene: Detect & Remove Inactive/Problematic Follows
77+88+### Core
99+1010+#### Models
1111+1212+- [ ] `FollowStatus` enum — `deleted`, `deactivated`, `suspended`, `blockedBy`, `blocking`, `mutualBlock`, `hidden`, `selfFollow`
1313+- [ ] `FollowRecord` model — `uri`, `rkey`, `subjectDid`; extracted from `com.atproto.repo.listRecords` response
1414+- [ ] `ClassifiedFollow` model — `record` (FollowRecord), `handle`, `status` (FollowStatus), `statusLabel`, `selected` (mutable); `Equatable` for state comparison (excluding `selected`)
1515+1616+#### Repository
1717+1818+- [ ] `FollowAuditRepository` — new file `lib/features/profile/data/follow_audit_repository.dart`, depends on authenticated `Bluesky` client
1919+- [ ] `fetchAllFollows(String did)` — paginate `atproto.repo.listRecords(repo: did, collection: 'app.bsky.graph.follow', limit: 100)` with cursor until exhausted, return `List<FollowRecord>`
2020+- [ ] `classifyFollows(List<FollowRecord>, String ownDid)` — batch `actor.getProfiles` (25/batch, 2 concurrent, 500ms inter-group delay), per-DID `getProfile` fallback for missing entries, classify each by `FollowStatus`, return `(List<ClassifiedFollow> results, int failedCount)`
2121+- [ ] `batchUnfollow(List<ClassifiedFollow>)` — extract rkeys, build `applyWrites#delete` operations, chunk into batches of 200, execute sequentially, return count of successfully deleted records
2222+- [ ] Retry logic — on 429 or network error during `getProfiles`/`getProfile`, exponential backoff (1s/2s/4s), max 3 retries per batch
2323+2424+#### Cubit
2525+2626+- [ ] `FollowAuditState` — `status` (initial/fetching/classifying/ready/unfollowing/complete/error), `results`, `totalFollows`, `progress`, `failedProfiles`, `unfollowedCount`, `errorMessage`, `visibleStatuses`
2727+- [ ] `FollowAuditCubit` — depends on `FollowAuditRepository`, authenticated DID
2828+- [ ] `audit()` — orchestrates fetch → classify → ready, emits progress updates during each phase
2929+- [ ] `toggleSelection(int index)` — toggle individual record selection
3030+- [ ] `selectAllByStatus(FollowStatus)` / `deselectAllByStatus(FollowStatus)` — bulk select/deselect by category
3131+- [ ] `toggleVisibility(FollowStatus)` — show/hide category in results list
3232+- [ ] `confirmUnfollow()` — call `batchUnfollow` with selected records, emit unfollowing → complete, clear unfollowed records from results
3333+3434+### UI
3535+3636+#### Follow Audit Screen
3737+3838+- [ ] `FollowAuditScreen` — new file `lib/features/profile/presentation/follow_audit_screen.dart`
3939+- [ ] Header — "Clean Follows" title, subtitle with total follow count
4040+- [ ] Action bar — "Scan" button (initial) → "Unfollow Selected (N)" button (ready), disabled during loading states
4141+- [ ] Linear progress bar — during fetch/classify, shows "Fetching follows: X/Y" or "Classifying: X/Y"
4242+- [ ] Failed profiles warning — amber text below progress bar when `failedProfiles > 0`
4343+- [ ] Results list — checkbox, handle (tappable → navigate to profile via GoRouter), truncated DID, status badge chip. Selected rows get destructive-red background tint
4444+- [ ] Empty state — "No problematic follows found" when audit completes with 0 results
4545+- [ ] Complete state — "Unfollowed N account(s)" after successful batch delete
4646+- [ ] Error state — error message with "Retry" button
4747+4848+#### Filter Controls
4949+5050+- [ ] Responsive layout — horizontal scrollable chip row on narrow screens (`< 600px`), sticky sidebar on wider screens
5151+- [ ] Per-status filter tile — visibility toggle (show/hide rows of that status in list) + "Select All" checkbox
5252+- [ ] Category count badges — show count of results per status category
5353+- [ ] Summary line — "Selected: N/M" count, always visible
5454+5555+#### Navigation & Entry Points
5656+5757+- [ ] Settings screen — new "Account Maintenance" section with "Clean Follows" tile, navigates to `FollowAuditScreen`
5858+- [ ] Profile screen overflow menu — add "Clean Follows" option when viewing own profile, navigates to `FollowAuditScreen`
5959+- [ ] GoRouter route — `/settings/clean-follows`
6060+6161+### Tests
6262+6363+#### Unit Tests — Models
6464+6565+- [ ] `FollowRecord` — construction, rkey extraction from AT URI
6666+- [ ] `ClassifiedFollow` — construction, statusLabel mapping for each `FollowStatus` value
6767+- [ ] `FollowStatus` — verify all enum values exist and labels are correct
6868+6969+#### Unit Tests — Repository
7070+7171+- [ ] `fetchAllFollows` — single page (< 100 records), multi-page pagination (cursor handling), empty follows list
7272+- [ ] `classifyFollows` — deleted account (getProfile returns "not found"), deactivated account, suspended account
7373+- [ ] `classifyFollows` — blocked-by (viewer.blockedBy), blocking (viewer.blocking), mutual block (both), hidden (!hide label), self-follow
7474+- [ ] `classifyFollows` — batch hydration: profiles returned in getProfiles are classified correctly, missing profiles fall through to per-DID lookup
7575+- [ ] `classifyFollows` — partial failure: some batches fail, returns results for successful batches + failedCount
7676+- [ ] `classifyFollows` — rate limit retry: mock 429 response, verify retry with backoff
7777+- [ ] `batchUnfollow` — single batch (< 200 records), multi-batch chunking, empty selection (no-op)
7878+- [ ] `batchUnfollow` — partial failure: first batch succeeds, second fails, returns partial count
7979+8080+#### Unit Tests — Cubit
8181+8282+- [ ] `audit()` — state transitions: initial → fetching → classifying → ready
8383+- [ ] `audit()` — progress updates emitted during fetch and classify phases
8484+- [ ] `audit()` — error during fetch: initial → fetching → error
8585+- [ ] `audit()` — empty results: transitions to ready with empty list
8686+- [ ] `toggleSelection` — toggles selected flag on correct index, emits new state
8787+- [ ] `selectAllByStatus` / `deselectAllByStatus` — selects/deselects all records matching status
8888+- [ ] `toggleVisibility` — adds/removes status from visibleStatuses set
8989+- [ ] `confirmUnfollow` — state transitions: ready → unfollowing → complete, unfollowed records removed from results
9090+- [ ] `confirmUnfollow` — error during unfollow: ready → unfollowing → error with partial count
9191+9292+#### Widget Tests (FollowAuditScreen)
9393+9494+- [ ] initial state renders "Scan" button
9595+- [ ] fetching state shows progress bar with count text
9696+- [ ] ready state renders results list with correct status badges
9797+- [ ] selecting a record changes row background to red tint
9898+- [ ] "Unfollow Selected" button shows correct count and is disabled when nothing selected
9999+- [ ] filter toggles hide/show rows by status
100100+- [ ] "Select All" per category selects all visible records of that status
101101+- [ ] complete state shows "Unfollowed N account(s)"
102102+- [ ] error state shows message and retry button
103103+- [ ] empty results shows "No problematic follows found"
104104+- [ ] tapping handle navigates to profile screen
105105+- [ ] responsive layout: chips on narrow, sidebar on wide
106106+107107+#### Integration Tests
108108+109109+- [ ] End-to-end: scan follows → results displayed → select records → confirm unfollow → success state