mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

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

docs: follow hygiene plan

+336
+227
docs/specs/phase-8.md
··· 1 + --- 2 + title: Phase 8 Spec 3 + updated: 2026-04-11 4 + --- 5 + 6 + ## Follow Hygiene — Detect & Remove Inactive/Problematic Follows 7 + 8 + Audit the authenticated user's follow list to surface accounts that are 9 + deleted, deactivated, suspended, blocking/blocked-by, hidden by moderation, or 10 + the user's own DID. Present a filterable, selectable list and batch-unfollow in 11 + a single action. 12 + 13 + ### Why 14 + 15 + Users accumulate dead follows over time — accounts get suspended, deactivated, 16 + or start blocking. Bluesky provides no built-in way to audit this. The existing 17 + profile action layer handles individual follow/unfollow, but there is no 18 + batch-audit or batch-unfollow capability. 19 + 20 + ### Data Flow 21 + 22 + ```text 23 + User taps "Clean Follows" 24 + → Paginate com.atproto.repo.listRecords(collection: app.bsky.graph.follow) 25 + → Collect all follow record URIs + subject DIDs 26 + → Batch-hydrate via app.bsky.actor.getProfiles (25 per batch) 27 + → For each batch, classify: 28 + - Missing from response → resolve DID individually → deleted/deactivated/suspended 29 + - Present but viewer.blockedBy → blocked-by (or mutual block if also blocking) 30 + - Present but viewer.blocking/blockingByList → blocking 31 + - Present but labels contain "!hide" → hidden by moderation 32 + - subject DID == own DID → self-follow 33 + → Display categorized results 34 + → User selects accounts → batch delete via com.atproto.repo.applyWrites 35 + ``` 36 + 37 + ### Account Classification 38 + 39 + Reuse the existing `_hydrateProfiles` pattern from `ProfileContextRepository` 40 + — batch `getProfiles`, then per-DID fallback for missing entries. Extend with 41 + follow-specific status classification: 42 + 43 + | Status | Detection | 44 + | ------------- | ------------------------------------------------------------- | 45 + | Deleted | `getProfile` returns "not found" error | 46 + | Deactivated | `getProfile` returns "deactivated" error | 47 + | Suspended | `getProfile` returns "suspended" error | 48 + | Blocked By | `viewer.blockedBy == true` on profile response | 49 + | Blocking | `viewer.blocking != null` or `viewer.blockingByList != null` | 50 + | Mutual Block | Both `blockedBy` and `blocking` are true | 51 + | Hidden | Profile labels contain `val == "!hide"` | 52 + | Self-follow | Subject DID matches authenticated user's DID | 53 + 54 + ### Batch Unfollow 55 + 56 + AT Protocol's `com.atproto.repo.applyWrites` accepts up to 200 operations per 57 + call. The `bluesky` Dart package exposes this as 58 + `atproto.repo.applyWrites(writes: [...])`. 59 + 60 + Each delete operation: 61 + 62 + ```dart 63 + { 64 + '$type': 'com.atproto.repo.applyWrites#delete', 65 + 'collection': 'app.bsky.graph.follow', 66 + 'rkey': rkey, // extracted from the follow record URI 67 + } 68 + ``` 69 + 70 + Chunk selected records into batches of 200 and execute sequentially. Update 71 + local state after each successful batch. 72 + 73 + ### Repository Layer 74 + 75 + **`FollowAuditRepository`** — new file in `lib/features/profile/data/`. 76 + 77 + Depends on the authenticated `Bluesky` client (same injection pattern as 78 + `ProfileActionRepository` and `ProfileContextRepository`). 79 + 80 + **Methods:** 81 + 82 + - `fetchAllFollows()` → paginate `atproto.repo.listRecords(repo: did, 83 + collection: 'app.bsky.graph.follow', limit: 100)` with cursor. Returns 84 + `List<FollowRecord>` (uri, rkey, subjectDid). 85 + - `classifyFollows(List<FollowRecord>, String ownDid)` → batch 86 + `getProfiles` (25/batch, 2 concurrent batches, 500ms delay between groups), 87 + per-DID fallback for missing, return `List<ClassifiedFollow>`. 88 + - `batchUnfollow(List<ClassifiedFollow>)` → `applyWrites` in chunks of 200. 89 + 90 + **Models:** 91 + 92 + ```dart 93 + enum FollowStatus { 94 + deleted, 95 + deactivated, 96 + suspended, 97 + blockedBy, 98 + blocking, 99 + mutualBlock, 100 + hidden, 101 + selfFollow, 102 + } 103 + 104 + class FollowRecord { 105 + final String uri; 106 + final String rkey; 107 + final String subjectDid; 108 + } 109 + 110 + class ClassifiedFollow { 111 + final FollowRecord record; 112 + final String? handle; 113 + final FollowStatus status; 114 + final String statusLabel; 115 + bool selected; 116 + } 117 + ``` 118 + 119 + ### Rate Limiting 120 + 121 + The `bluesky` package's `getProfiles` accepts up to 25 actors. Strategy: 122 + 123 + - Batch size: 25 (API max) 124 + - Concurrent batches: 2 125 + - Inter-group delay: 500ms 126 + - On rate-limit (429) or network error: retry with exponential backoff (1s, 2s, 127 + 4s), max 3 retries per batch, then skip and count as failed 128 + 129 + ### Cubit 130 + 131 + **`FollowAuditCubit`** — new file in `lib/features/profile/cubit/`. 132 + 133 + **States:** 134 + 135 + ```dart 136 + enum FollowAuditStatus { 137 + initial, 138 + fetching, // paginating follow records 139 + classifying, // hydrating + classifying profiles 140 + ready, // results displayed 141 + unfollowing, // batch delete in progress 142 + complete, // unfollow finished 143 + error, 144 + } 145 + 146 + class FollowAuditState { 147 + final FollowAuditStatus status; 148 + final List<ClassifiedFollow> results; 149 + final int totalFollows; 150 + final int progress; // records processed so far 151 + final int failedProfiles; // profiles that couldn't be fetched 152 + final int unfollowedCount; // after batch delete 153 + final String? errorMessage; 154 + final Set<FollowStatus> visibleStatuses; // filter toggles 155 + } 156 + ``` 157 + 158 + **Methods:** 159 + 160 + - `audit()` — fetch all follows, classify, transition through 161 + fetching → classifying → ready. 162 + - `toggleSelection(int index)` — toggle `selected` on a single result. 163 + - `selectAllByStatus(FollowStatus)` / `deselectAllByStatus(FollowStatus)` — 164 + bulk select/deselect by category. 165 + - `toggleVisibility(FollowStatus)` — show/hide a category in the list. 166 + - `confirmUnfollow()` — batch-delete selected, transition to 167 + unfollowing → complete. 168 + 169 + Progress is reported as `progress / totalFollows` during both fetching and 170 + classifying phases. 171 + 172 + ### UI 173 + 174 + **Entry point:** New item in the settings screen under a "Follows" or 175 + "Account Maintenance" section. Also accessible from the profile screen's 176 + overflow menu (three-dot) for the user's own profile. 177 + 178 + **Screen: `FollowAuditScreen`** 179 + 180 + Layout (top to bottom): 181 + 182 + 1. **Header** — "Clean Follows" title, subtitle with follow count once loaded. 183 + 2. **Action bar** — "Scan" button (initial state) → "Unfollow Selected (N)" 184 + button (ready state). Disabled during fetching/classifying/unfollowing. 185 + 3. **Progress indicator** — linear progress bar during fetch/classify. Shows 186 + "Fetching follows: 142/1200" or "Classifying: 300/1200". If failed profiles 187 + > 0, show amber warning text. 188 + 4. **Filter sidebar / chip row** — one toggle per `FollowStatus` category. 189 + Each toggle shows the category label and count. Visibility toggle 190 + (show/hide in list) + "Select All" checkbox per category. On narrow 191 + screens, render as a horizontal scrollable chip row above the list; on 192 + wider screens, render as a sticky sidebar. 193 + 5. **Results list** — each row: checkbox, handle (tappable → profile), DID 194 + (truncated, tappable → copy), status badge. Selected rows have a 195 + destructive-red background tint. Rows hidden by visibility filter are 196 + excluded from the list entirely. 197 + 6. **Summary footer** — "Selected: 12/47" count. After unfollow: 198 + "Unfollowed 12 accounts". 199 + 7. **Empty/complete states** — "No problematic follows found" or 200 + "Unfollowed N account(s)". 201 + 202 + **Styling:** Follows the UI refactor spec — square geometry, uppercase labels, 203 + `outlineVariant` borders, `surfaceContainerLowest` card backgrounds. 204 + 205 + ### Error Handling 206 + 207 + - Network failure during fetch: show error state with retry button. 208 + - Partial profile hydration failure: continue with available data, show count 209 + of failed profiles as a warning (not a blocker). 210 + - `applyWrites` failure on a batch: stop, show error with count of successful 211 + unfollows so far, allow retry for remaining. 212 + - Account not authenticated: guard entry points behind auth state (already 213 + handled by app shell). 214 + 215 + ### Limitations & Future Work 216 + 217 + - **No "inactive" detection.** We'll only detects hard states (deleted, 218 + suspended, blocking). Detecting genuinely inactive accounts (no posts in N 219 + months) would require fetching each account's feed — prohibitively expensive 220 + for large follow lists. Could be added as an opt-in deep scan later. 221 + - **No undo.** Unfollow is destructive. A future version could cache unfollowed 222 + DIDs locally and offer a "re-follow" list for a limited time. 223 + - **Single-account.** Runs against the active account only. Multi-account batch 224 + audit is out of scope. 225 + - **No background execution.** The audit runs in the foreground. For users with 226 + 10k+ follows, this could take 1-2 minutes. A future version could use a 227 + background isolate with notification on completion.
+109
docs/tasks/phase-8.md
··· 1 + --- 2 + title: Phase 8 Task Breakdown 3 + updated: 2026-04-11 4 + --- 5 + 6 + ## M27 - Follow Hygiene: Detect & Remove Inactive/Problematic Follows 7 + 8 + ### Core 9 + 10 + #### Models 11 + 12 + - [ ] `FollowStatus` enum — `deleted`, `deactivated`, `suspended`, `blockedBy`, `blocking`, `mutualBlock`, `hidden`, `selfFollow` 13 + - [ ] `FollowRecord` model — `uri`, `rkey`, `subjectDid`; extracted from `com.atproto.repo.listRecords` response 14 + - [ ] `ClassifiedFollow` model — `record` (FollowRecord), `handle`, `status` (FollowStatus), `statusLabel`, `selected` (mutable); `Equatable` for state comparison (excluding `selected`) 15 + 16 + #### Repository 17 + 18 + - [ ] `FollowAuditRepository` — new file `lib/features/profile/data/follow_audit_repository.dart`, depends on authenticated `Bluesky` client 19 + - [ ] `fetchAllFollows(String did)` — paginate `atproto.repo.listRecords(repo: did, collection: 'app.bsky.graph.follow', limit: 100)` with cursor until exhausted, return `List<FollowRecord>` 20 + - [ ] `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)` 21 + - [ ] `batchUnfollow(List<ClassifiedFollow>)` — extract rkeys, build `applyWrites#delete` operations, chunk into batches of 200, execute sequentially, return count of successfully deleted records 22 + - [ ] Retry logic — on 429 or network error during `getProfiles`/`getProfile`, exponential backoff (1s/2s/4s), max 3 retries per batch 23 + 24 + #### Cubit 25 + 26 + - [ ] `FollowAuditState` — `status` (initial/fetching/classifying/ready/unfollowing/complete/error), `results`, `totalFollows`, `progress`, `failedProfiles`, `unfollowedCount`, `errorMessage`, `visibleStatuses` 27 + - [ ] `FollowAuditCubit` — depends on `FollowAuditRepository`, authenticated DID 28 + - [ ] `audit()` — orchestrates fetch → classify → ready, emits progress updates during each phase 29 + - [ ] `toggleSelection(int index)` — toggle individual record selection 30 + - [ ] `selectAllByStatus(FollowStatus)` / `deselectAllByStatus(FollowStatus)` — bulk select/deselect by category 31 + - [ ] `toggleVisibility(FollowStatus)` — show/hide category in results list 32 + - [ ] `confirmUnfollow()` — call `batchUnfollow` with selected records, emit unfollowing → complete, clear unfollowed records from results 33 + 34 + ### UI 35 + 36 + #### Follow Audit Screen 37 + 38 + - [ ] `FollowAuditScreen` — new file `lib/features/profile/presentation/follow_audit_screen.dart` 39 + - [ ] Header — "Clean Follows" title, subtitle with total follow count 40 + - [ ] Action bar — "Scan" button (initial) → "Unfollow Selected (N)" button (ready), disabled during loading states 41 + - [ ] Linear progress bar — during fetch/classify, shows "Fetching follows: X/Y" or "Classifying: X/Y" 42 + - [ ] Failed profiles warning — amber text below progress bar when `failedProfiles > 0` 43 + - [ ] Results list — checkbox, handle (tappable → navigate to profile via GoRouter), truncated DID, status badge chip. Selected rows get destructive-red background tint 44 + - [ ] Empty state — "No problematic follows found" when audit completes with 0 results 45 + - [ ] Complete state — "Unfollowed N account(s)" after successful batch delete 46 + - [ ] Error state — error message with "Retry" button 47 + 48 + #### Filter Controls 49 + 50 + - [ ] Responsive layout — horizontal scrollable chip row on narrow screens (`< 600px`), sticky sidebar on wider screens 51 + - [ ] Per-status filter tile — visibility toggle (show/hide rows of that status in list) + "Select All" checkbox 52 + - [ ] Category count badges — show count of results per status category 53 + - [ ] Summary line — "Selected: N/M" count, always visible 54 + 55 + #### Navigation & Entry Points 56 + 57 + - [ ] Settings screen — new "Account Maintenance" section with "Clean Follows" tile, navigates to `FollowAuditScreen` 58 + - [ ] Profile screen overflow menu — add "Clean Follows" option when viewing own profile, navigates to `FollowAuditScreen` 59 + - [ ] GoRouter route — `/settings/clean-follows` 60 + 61 + ### Tests 62 + 63 + #### Unit Tests — Models 64 + 65 + - [ ] `FollowRecord` — construction, rkey extraction from AT URI 66 + - [ ] `ClassifiedFollow` — construction, statusLabel mapping for each `FollowStatus` value 67 + - [ ] `FollowStatus` — verify all enum values exist and labels are correct 68 + 69 + #### Unit Tests — Repository 70 + 71 + - [ ] `fetchAllFollows` — single page (< 100 records), multi-page pagination (cursor handling), empty follows list 72 + - [ ] `classifyFollows` — deleted account (getProfile returns "not found"), deactivated account, suspended account 73 + - [ ] `classifyFollows` — blocked-by (viewer.blockedBy), blocking (viewer.blocking), mutual block (both), hidden (!hide label), self-follow 74 + - [ ] `classifyFollows` — batch hydration: profiles returned in getProfiles are classified correctly, missing profiles fall through to per-DID lookup 75 + - [ ] `classifyFollows` — partial failure: some batches fail, returns results for successful batches + failedCount 76 + - [ ] `classifyFollows` — rate limit retry: mock 429 response, verify retry with backoff 77 + - [ ] `batchUnfollow` — single batch (< 200 records), multi-batch chunking, empty selection (no-op) 78 + - [ ] `batchUnfollow` — partial failure: first batch succeeds, second fails, returns partial count 79 + 80 + #### Unit Tests — Cubit 81 + 82 + - [ ] `audit()` — state transitions: initial → fetching → classifying → ready 83 + - [ ] `audit()` — progress updates emitted during fetch and classify phases 84 + - [ ] `audit()` — error during fetch: initial → fetching → error 85 + - [ ] `audit()` — empty results: transitions to ready with empty list 86 + - [ ] `toggleSelection` — toggles selected flag on correct index, emits new state 87 + - [ ] `selectAllByStatus` / `deselectAllByStatus` — selects/deselects all records matching status 88 + - [ ] `toggleVisibility` — adds/removes status from visibleStatuses set 89 + - [ ] `confirmUnfollow` — state transitions: ready → unfollowing → complete, unfollowed records removed from results 90 + - [ ] `confirmUnfollow` — error during unfollow: ready → unfollowing → error with partial count 91 + 92 + #### Widget Tests (FollowAuditScreen) 93 + 94 + - [ ] initial state renders "Scan" button 95 + - [ ] fetching state shows progress bar with count text 96 + - [ ] ready state renders results list with correct status badges 97 + - [ ] selecting a record changes row background to red tint 98 + - [ ] "Unfollow Selected" button shows correct count and is disabled when nothing selected 99 + - [ ] filter toggles hide/show rows by status 100 + - [ ] "Select All" per category selects all visible records of that status 101 + - [ ] complete state shows "Unfollowed N account(s)" 102 + - [ ] error state shows message and retry button 103 + - [ ] empty results shows "No problematic follows found" 104 + - [ ] tapping handle navigates to profile screen 105 + - [ ] responsive layout: chips on narrow, sidebar on wide 106 + 107 + #### Integration Tests 108 + 109 + - [ ] End-to-end: scan follows → results displayed → select records → confirm unfollow → success state