diff --git a/src-tauri/src/feed.rs b/src-tauri/src/feed.rs index 7ffa61c..0a9504e 100644 --- a/src-tauri/src/feed.rs +++ b/src-tauri/src/feed.rs @@ -377,6 +377,7 @@ pub struct BatchResult { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct FollowHygieneProgress { + batch_size: usize, current: usize, total: usize, } @@ -1241,7 +1242,11 @@ async fn resolve_follow_statuses( completed += 1; app.emit( FOLLOW_HYGIENE_PROGRESS_EVENT, - FollowHygieneProgress { current: completed, total: total_batches }, + FollowHygieneProgress { + batch_size: FOLLOW_AUDIT_PROFILE_BATCH_SIZE, + current: completed, + total: total_batches, + }, )?; } diff --git a/src/components/profile/FollowHygieneList.tsx b/src/components/profile/FollowHygieneList.tsx index 8d74b3e..c09afcb 100644 --- a/src/components/profile/FollowHygieneList.tsx +++ b/src/components/profile/FollowHygieneList.tsx @@ -33,8 +33,7 @@ function FollowRow(props: FollowRowProps) { class="tone-muted rounded-2xl p-3 transition-colors duration-150" classList={{ "bg-red-500/12": props.selected, "ring-1 ring-[var(--focus-ring)]": props.focused }} animate={{ opacity: props.exiting ? 0 : 1, x: props.exiting ? 20 : 0, y: 0 }} - initial={{ opacity: 0, y: 6 }} - transition={{ duration: 0.18, delay: Math.min(props.index * 0.02, 0.2) }} + transition={{ duration: 0.18 }} tabIndex={0} onFocus={() => props.onFocus()} onKeyDown={(event) => { diff --git a/src/components/profile/FollowHygienePanel.tsx b/src/components/profile/FollowHygienePanel.tsx index b1cb443..430b155 100644 --- a/src/components/profile/FollowHygienePanel.tsx +++ b/src/components/profile/FollowHygienePanel.tsx @@ -8,6 +8,7 @@ import { shouldIgnoreKey } from "$/lib/utils/events"; import { normalizeError } from "$/lib/utils/text"; import { listen } from "@tauri-apps/api/event"; import * as logger from "@tauri-apps/plugin-log"; +import { openUrl } from "@tauri-apps/plugin-opener"; import { createMemo, onCleanup, onMount, Show } from "solid-js"; import { createStore } from "solid-js/store"; import { Motion } from "solid-motionone"; @@ -55,7 +56,7 @@ function createInitialState(): FollowHygieneState { flagged: [], focusedUri: null, phase: "idle", - progress: { current: 0, total: 1 }, + progress: { batchSize: 0, current: 0, total: 1 }, result: null, scanError: null, selectedUris: new Set(), @@ -70,13 +71,33 @@ function parseProgressPayload(payload: unknown): FollowHygieneProgress | null { return null; } + const batchSize = optionalNumber(record.batchSize); const current = optionalNumber(record.current); const total = optionalNumber(record.total); if (current === null || total === null) { return null; } - return { current: Math.max(0, Math.floor(current)), total: Math.max(1, Math.floor(total)) }; + return { + batchSize: batchSize === null ? 0 : Math.max(1, Math.floor(batchSize)), + current: Math.max(0, Math.floor(current)), + total: Math.max(1, Math.floor(total)), + }; +} + +function deriveFilters( + selectedUris: Set, flagged: FlaggedFollow[], filters: Record, +) { + const nextFilters = { ...filters }; + for (const category of STATUS_CATEGORIES) { + const categoryUris = flagged.filter((follow) => hasStatus(follow.status, category.bit)).map((follow) => + follow.followUri + ); + const selected = categoryUris.length > 0 && categoryUris.every((uri) => selectedUris.has(uri)); + nextFilters[category.key] = { ...filters[category.key], selected }; + } + + return nextFilters; } function FollowHygieneHeader(props: { onClose: () => void }) { @@ -84,14 +105,23 @@ function FollowHygieneHeader(props: { onClose: () => void }) {

Account maintenance

-

Follow hygiene

+

Follow Audit

+
+
+ +
-
); } @@ -270,19 +300,11 @@ export function FollowHygienePanel(props: { onClose: () => void }) { onUnfollow: openConfirmation, })); - function syncCategorySelection(selectedUris: Set, flagged: FlaggedFollow[]) { - for (const category of STATUS_CATEGORIES) { - const categoryUris = flagged.filter((follow) => hasStatus(follow.status, category.bit)).map((follow) => - follow.followUri - ); - const selected = categoryUris.length > 0 && categoryUris.every((uri) => selectedUris.has(uri)); - setState("filters", category.key, "selected", selected); - } - } - function updateSelectedUris(nextSelected: Set, flagged: FlaggedFollow[] = state.flagged) { - setState("selectedUris", nextSelected); - syncCategorySelection(nextSelected, flagged); + setState((current) => ({ + filters: deriveFilters(nextSelected, flagged, current.filters), + selectedUris: nextSelected, + })); } async function startScan() { @@ -292,13 +314,19 @@ export function FollowHygienePanel(props: { onClose: () => void }) { requestId += 1; const activeRequest = requestId; + + if (exitTimer) { + clearTimeout(exitTimer); + exitTimer = undefined; + } + setState({ confirmOpen: false, exitingUris: new Set(), flagged: [], focusedUri: null, phase: "scanning", - progress: { current: 0, total: 1 }, + progress: { batchSize: 0, current: 0, total: 1 }, result: null, scanError: null, selectedUris: new Set(), @@ -313,13 +341,17 @@ export function FollowHygienePanel(props: { onClose: () => void }) { } const initialSelection = new Set(flagged.map((follow) => follow.followUri)); - setState("flagged", flagged); - updateSelectedUris(initialSelection, flagged); - setState("phase", "ready"); - setState( - "progress", - (progress) => ({ current: Math.max(progress.current, progress.total), total: progress.total }), - ); + setState((current) => ({ + filters: deriveFilters(initialSelection, flagged, current.filters), + flagged, + phase: "ready", + progress: { + batchSize: current.progress.batchSize, + current: Math.max(current.progress.current, current.progress.total), + total: current.progress.total, + }, + selectedUris: initialSelection, + })); } catch (error) { if (activeRequest !== requestId) { return; @@ -419,10 +451,12 @@ export function FollowHygienePanel(props: { onClose: () => void }) { exitTimer = setTimeout(() => { const filtered = state.flagged.filter((follow) => !successfulUris.includes(follow.followUri)); - const selected = new Set(state.selectedUris); - setState("flagged", filtered); - setState("exitingUris", new Set()); - syncCategorySelection(selected, filtered); + setState((current) => ({ + exitingUris: new Set(), + filters: deriveFilters(current.selectedUris, filtered, current.filters), + flagged: filtered, + })); + exitTimer = undefined; }, EXIT_ANIMATION_MS); } @@ -550,3 +584,4 @@ export function FollowHygienePanel(props: { onClose: () => void }) { ); } +const FOLLOW_AUDIT_INSPIRATION_URL = "https://cleanfollow-bsky.pages.dev/"; diff --git a/src/components/profile/FollowHygieneToolbar.tsx b/src/components/profile/FollowHygieneToolbar.tsx index 1763963..1b2ef82 100644 --- a/src/components/profile/FollowHygieneToolbar.tsx +++ b/src/components/profile/FollowHygieneToolbar.tsx @@ -14,7 +14,7 @@ export type ScanToolbarProps = { onScan: () => void; }; -function ProgressMeter(props: { current: number; total: number; percent: number }) { +function ProgressMeter(props: { batchSize: number; current: number; total: number; percent: number }) { return (
@@ -25,6 +25,7 @@ function ProgressMeter(props: { current: number; total: number; percent: number

Scanning batches: {Math.min(props.current, props.total)} / {props.total} + 0}> ({props.batchSize} per batch)

); @@ -56,7 +57,11 @@ export function ScanToolbar(props: ScanToolbarProps) { - + {(error) =>

{error()}

}
diff --git a/src/components/profile/tests/FollowHygienePanel.test.tsx b/src/components/profile/tests/FollowHygienePanel.test.tsx index c3a8dbe..b915fa1 100644 --- a/src/components/profile/tests/FollowHygienePanel.test.tsx +++ b/src/components/profile/tests/FollowHygienePanel.test.tsx @@ -67,10 +67,10 @@ describe("FollowHygienePanel", () => { const listener = listenMock.mock.calls[0]?.[1]; expect(listener).toBeTypeOf("function"); - listener({ payload: { current: 1, total: 4 } }); + listener({ payload: { batchSize: 25, current: 1, total: 4 } }); expect(await screen.findByText("@ghost.test")).toBeInTheDocument(); - expect(screen.getByText(/Scanning batches: [1-4] \/ 4/u)).toBeInTheDocument(); + expect(screen.getByText(/Scanning batches: [1-4] \/ 4 \(25 per batch\)/u)).toBeInTheDocument(); expect(screen.getByText("3 of 3 visible selected (3 total).")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Hide Deleted" })); diff --git a/src/lib/types.ts b/src/lib/types.ts index f656bfb..719531f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -142,7 +142,7 @@ export type FlaggedFollow = { did: string; followUri: string; handle: string; st export type FollowBatchResult = { deleted: number; failed: string[] }; -export type FollowHygieneProgress = { current: number; total: number }; +export type FollowHygieneProgress = { batchSize: number; current: number; total: number }; export type FeedGeneratorView = { uri: string;