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.

feat: add follow hygiene/audit ui

+1308 -27
+13 -13
docs/tasks/16-follows.md
··· 24 24 25 25 #### Core 26 26 27 - - [ ] **Create `FollowHygienePanel` component** (`src/components/profile/FollowHygienePanel.tsx`). Local state via `createStore<FollowHygieneState>`. Phases: idle → scanning → ready → unfollowing → done. 28 - - [ ] **Progress bar.** Listen to `follow-hygiene:progress` Tauri events during scan. Determinate bar with animated fill. 29 - - [ ] **Flagged account list.** Scrollable list with per-row checkbox, handle, DID, status label chip. Selected rows get background tint. Use `For` (not map). 30 - - [ ] **Category filter sidebar.** Sticky sidebar with visibility toggles and select-all checkboxes per status category. Selection counter. 31 - - [ ] **Unfollow flow.** Confirmation dialog before destructive action should invoke `batch_unfollow`, remove completed rows with exit animation, show result summary. 32 - - [ ] **Entry points.** Add "Audit follows" button to the authenticated user's own profile panel. 33 - - [ ] Add secondary entry in Settings > Account section. 27 + - [x] **Create `FollowHygienePanel` component** (`src/components/profile/FollowHygienePanel.tsx`). Local state via `createStore<FollowHygieneState>`. Phases: idle → scanning → ready → unfollowing → done. 28 + - [x] **Progress bar.** Listen to `follow-hygiene:progress` Tauri events during scan. Determinate bar with animated fill. 29 + - [x] **Flagged account list.** Scrollable list with per-row checkbox, handle, DID, status label chip. Selected rows get background tint. Use `For` (not map). 30 + - [x] **Category filter sidebar.** Sticky sidebar with visibility toggles and select-all checkboxes per status category. Selection counter. 31 + - [x] **Unfollow flow.** Confirmation dialog before destructive action should invoke `batch_unfollow`, remove completed rows with exit animation, show result summary. 32 + - [x] **Entry points.** Add "Audit follows" button to the authenticated user's own profile panel. 33 + - [x] Add secondary entry in Settings > Account section. 34 34 35 35 #### Polish 36 36 37 - - [ ] Keyboard shortcuts: `Space` toggle, `Ctrl+A` select all, `Escape` close 38 - - [ ] `Motion` staggered fade-in on scan results, exit animation on unfollow 39 - - [ ] `Presence` fade-in on confirmation dialog 40 - - [ ] Skeleton/spinner states during scan 41 - - [ ] Empty state message when no flagged accounts found 42 - - [ ] Error handling: toast on scan failure, inline retry for batch unfollow failures 37 + - [x] Keyboard shortcuts: `Space` toggle, `Ctrl+A` select all, `Escape` close 38 + - [x] `Motion` staggered fade-in on scan results, exit animation on unfollow 39 + - [x] `Presence` fade-in on confirmation dialog 40 + - [x] Skeleton/spinner states during scan 41 + - [x] Empty state message when no flagged accounts found 42 + - [x] Error handling: toast on scan failure, inline retry for batch unfollow failures
+20 -1
src/components/explorer/ExplorerPanel.tsx
··· 63 63 return Object.prototype.hasOwnProperty.call(icons, collection); 64 64 } 65 65 66 + function parseExplorerTargetFromHash(hash: string) { 67 + const queryIndex = hash.indexOf("?"); 68 + if (queryIndex === -1 || queryIndex === hash.length - 1) { 69 + return null; 70 + } 71 + 72 + const params = new URLSearchParams(hash.slice(queryIndex + 1)); 73 + const value = params.get("target"); 74 + if (!value) { 75 + return null; 76 + } 77 + 78 + try { 79 + return decodeURIComponent(value); 80 + } catch { 81 + return value; 82 + } 83 + } 84 + 66 85 export function ExplorerPanel() { 67 86 const explorer = createExplorerState(); 68 87 const [clearingIconCache, setClearingIconCache] = createSignal(false); ··· 432 451 433 452 onMount(() => { 434 453 let unlisten: (() => void) | undefined; 435 - const pendingTarget = consumeQueuedExplorerTarget(); 454 + const pendingTarget = consumeQueuedExplorerTarget() ?? parseExplorerTargetFromHash(globalThis.location.hash); 436 455 437 456 void listen<ExplorerNavigation>(NAVIGATION_EVENT, (event) => { 438 457 const target = event.payload.target;
+85
src/components/profile/FollowHygeineConfirmationDialog.tsx
··· 1 + import { Show } from "solid-js"; 2 + import { Motion, Presence } from "solid-motionone"; 3 + import { Icon } from "../shared/Icon"; 4 + 5 + function ConfirmationDialogBody(props: { selectedCount: number }) { 6 + return ( 7 + <div class="grid gap-2"> 8 + <h3 class="m-0 text-lg font-semibold text-on-surface">Unfollow selected accounts?</h3> 9 + <p class="m-0 text-sm leading-relaxed text-on-surface-variant"> 10 + This will unfollow {props.selectedCount} account(s). This action cannot be undone. 11 + </p> 12 + </div> 13 + ); 14 + } 15 + 16 + function ConfirmationDialogActions(props: { pending: boolean; onCancel: () => void; onConfirm: () => void }) { 17 + return ( 18 + <div class="mt-5 flex justify-end gap-2"> 19 + <button type="button" class="ui-button-secondary" onClick={() => props.onCancel()}>Cancel</button> 20 + <button 21 + class="inline-flex min-h-10 items-center gap-2 rounded-lg border-0 bg-red-500 px-4 text-sm font-medium text-white transition hover:bg-red-600 disabled:opacity-60" 22 + disabled={props.pending} 23 + type="button" 24 + onClick={() => props.onConfirm()}> 25 + <Show when={props.pending}> 26 + <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 27 + </Show> 28 + Confirm unfollow 29 + </button> 30 + </div> 31 + ); 32 + } 33 + 34 + function ConfirmationDialogCard( 35 + props: { pending: boolean; selectedCount: number; onCancel: () => void; onConfirm: () => void }, 36 + ) { 37 + return ( 38 + <Motion.div 39 + class="w-full max-w-md rounded-3xl bg-surface-container p-5" 40 + initial={{ opacity: 0, scale: 0.95, y: 6 }} 41 + animate={{ opacity: 1, scale: 1, y: 0 }} 42 + exit={{ opacity: 0, scale: 0.95, y: 6 }} 43 + transition={{ duration: 0.18 }} 44 + onClick={(event) => event.stopPropagation()}> 45 + <ConfirmationDialogBody selectedCount={props.selectedCount} /> 46 + <ConfirmationDialogActions pending={props.pending} onCancel={props.onCancel} onConfirm={props.onConfirm} /> 47 + </Motion.div> 48 + ); 49 + } 50 + 51 + function ConfirmationDialogOverlay( 52 + props: { pending: boolean; selectedCount: number; onCancel: () => void; onConfirm: () => void }, 53 + ) { 54 + return ( 55 + <Motion.div 56 + class="fixed inset-0 z-60 flex items-center justify-center bg-surface-container-highest/70 p-4 backdrop-blur-xl" 57 + initial={{ opacity: 0 }} 58 + animate={{ opacity: 1 }} 59 + exit={{ opacity: 0 }} 60 + transition={{ duration: 0.16 }} 61 + onClick={() => props.onCancel()}> 62 + <ConfirmationDialogCard 63 + pending={props.pending} 64 + selectedCount={props.selectedCount} 65 + onCancel={props.onCancel} 66 + onConfirm={props.onConfirm} /> 67 + </Motion.div> 68 + ); 69 + } 70 + 71 + export function ConfirmationDialog( 72 + props: { isOpen: boolean; pending: boolean; selectedCount: number; onCancel: () => void; onConfirm: () => void }, 73 + ) { 74 + return ( 75 + <Presence> 76 + <Show when={props.isOpen}> 77 + <ConfirmationDialogOverlay 78 + pending={props.pending} 79 + selectedCount={props.selectedCount} 80 + onCancel={props.onCancel} 81 + onConfirm={props.onConfirm} /> 82 + </Show> 83 + </Presence> 84 + ); 85 + }
+85
src/components/profile/FollowHygieneCategories.tsx
··· 1 + import { For } from "solid-js"; 2 + import { Icon } from "../shared/Icon"; 3 + import { STATUS_CATEGORIES, type StatusCategoryKey, type StatusCategoryState } from "./types"; 4 + 5 + type CategoryRowProps = { 6 + count: number; 7 + label: string; 8 + selected: boolean; 9 + visible: boolean; 10 + onToggleSelection: () => void; 11 + onToggleVisibility: () => void; 12 + }; 13 + 14 + function CategoryRow(props: CategoryRowProps) { 15 + return ( 16 + <div class="tone-muted flex items-center gap-2 rounded-xl px-3 py-2.5"> 17 + <input 18 + aria-label={`Select ${props.label}`} 19 + checked={props.selected} 20 + class="h-4 w-4 rounded ui-outline-strong bg-transparent text-primary focus:ring-(--focus-ring)" 21 + disabled={props.count === 0} 22 + type="checkbox" 23 + onChange={() => props.onToggleSelection()} /> 24 + <span class="min-w-0 flex-1 text-sm text-on-surface">{props.label}</span> 25 + <span class="text-xs text-on-surface-variant">{props.count}</span> 26 + <button 27 + class="ui-control ui-control-hoverable flex h-7 w-7 items-center justify-center rounded-full" 28 + title={props.visible ? `Hide ${props.label}` : `Show ${props.label}`} 29 + type="button" 30 + onClick={() => props.onToggleVisibility()}> 31 + <Icon iconClass={props.visible ? "i-ri-eye-line" : "i-ri-eye-off-line"} class="text-sm" /> 32 + </button> 33 + </div> 34 + ); 35 + } 36 + 37 + export type CategorySidebarProps = { 38 + counts: Record<StatusCategoryKey, number>; 39 + filters: Record<StatusCategoryKey, StatusCategoryState>; 40 + selectedCount: number; 41 + totalCount: number; 42 + onSelectAllVisible: () => void; 43 + onToggleCategorySelection: (key: StatusCategoryKey) => void; 44 + onToggleCategoryVisibility: (key: StatusCategoryKey) => void; 45 + }; 46 + 47 + export function CategorySidebar(props: CategorySidebarProps) { 48 + return ( 49 + <aside class="grid min-h-0 gap-3 lg:sticky lg:top-0"> 50 + <div class="panel-surface grid gap-3 p-4"> 51 + <div class="flex items-center justify-between gap-2"> 52 + <h3 class="m-0 text-sm font-medium text-on-surface">Categories</h3> 53 + <span class="text-xs text-on-surface-variant">{props.selectedCount} selected</span> 54 + </div> 55 + 56 + <div class="grid gap-2"> 57 + <For each={STATUS_CATEGORIES}> 58 + {(category) => ( 59 + <CategoryRow 60 + count={props.counts[category.key]} 61 + label={category.label} 62 + selected={props.filters[category.key].selected} 63 + visible={props.filters[category.key].visible} 64 + onToggleSelection={() => props.onToggleCategorySelection(category.key)} 65 + onToggleVisibility={() => props.onToggleCategoryVisibility(category.key)} /> 66 + )} 67 + </For> 68 + </div> 69 + 70 + <button 71 + class="ui-control ui-control-hoverable inline-flex min-h-9 items-center justify-center gap-2 rounded-full px-4 text-sm text-on-surface" 72 + type="button" 73 + onClick={() => props.onSelectAllVisible()}> 74 + <Icon iconClass="i-ri-checkbox-multiple-line" class="text-base" /> 75 + Select all visible 76 + </button> 77 + </div> 78 + 79 + <div class="panel-surface grid gap-2 p-4"> 80 + <p class="m-0 text-sm text-on-surface">Selection</p> 81 + <p class="m-0 text-xs text-on-surface-variant">{props.selectedCount} of {props.totalCount} flagged follows</p> 82 + </div> 83 + </aside> 84 + ); 85 + }
+148
src/components/profile/FollowHygieneList.tsx
··· 1 + import { queueExplorerTarget } from "$/lib/explorer-navigation"; 2 + import type { FlaggedFollow } from "$/lib/types"; 3 + import { For, Show } from "solid-js"; 4 + import { Motion } from "solid-motionone"; 5 + import { Icon } from "../shared/Icon"; 6 + import { displayHandle, type FollowHygienePhase, getAtExplorerHref, getProfileHref, statusChipClass } from "./types"; 7 + 8 + export type FollowListViewportProps = { 9 + exitingUris: Set<string>; 10 + flagged: FlaggedFollow[]; 11 + focusedUri: string | null; 12 + phase: FollowHygienePhase; 13 + selectedUris: Set<string>; 14 + onFocusUri: (uri: string) => void; 15 + onSpaceToggle: (uri: string) => void; 16 + onToggle: (uri: string) => void; 17 + }; 18 + 19 + type FollowRowProps = { 20 + exiting: boolean; 21 + follow: FlaggedFollow; 22 + focused: boolean; 23 + index: number; 24 + selected: boolean; 25 + onFocus: () => void; 26 + onToggle: () => void; 27 + onToggleBySpace: () => void; 28 + }; 29 + 30 + function FollowRow(props: FollowRowProps) { 31 + return ( 32 + <Motion.article 33 + class="tone-muted rounded-2xl p-3 transition-colors duration-150" 34 + classList={{ "bg-red-500/12": props.selected, "ring-1 ring-[var(--focus-ring)]": props.focused }} 35 + animate={{ opacity: props.exiting ? 0 : 1, x: props.exiting ? 20 : 0, y: 0 }} 36 + initial={{ opacity: 0, y: 6 }} 37 + transition={{ duration: 0.18, delay: Math.min(props.index * 0.02, 0.2) }} 38 + tabIndex={0} 39 + onFocus={() => props.onFocus()} 40 + onKeyDown={(event) => { 41 + if (event.key === " ") { 42 + event.preventDefault(); 43 + props.onToggleBySpace(); 44 + } 45 + }}> 46 + <div class="flex items-start gap-3"> 47 + <input 48 + aria-label={`Select ${displayHandle(props.follow)}`} 49 + checked={props.selected} 50 + class="mt-0.5 h-4 w-4 rounded ui-outline-strong bg-transparent text-primary focus:ring-(--focus-ring)" 51 + type="checkbox" 52 + onChange={() => props.onToggle()} /> 53 + 54 + <div class="grid min-w-0 flex-1 gap-1"> 55 + <div class="flex flex-wrap items-center gap-2"> 56 + <a 57 + class="truncate text-sm font-medium text-on-surface no-underline transition hover:text-primary" 58 + href={getProfileHref(props.follow)} 59 + onClick={(event) => event.stopPropagation()}> 60 + {displayHandle(props.follow)} 61 + </a> 62 + <span class={`rounded-full px-2 py-1 text-[0.72rem] font-medium ${statusChipClass(props.follow.status)}`}> 63 + {props.follow.statusLabel} 64 + </span> 65 + </div> 66 + 67 + <a 68 + class="truncate text-xs text-on-surface-variant no-underline transition hover:text-on-surface" 69 + href={getProfileHref(props.follow)} 70 + onClick={(event) => event.stopPropagation()}> 71 + {props.follow.did} 72 + </a> 73 + </div> 74 + 75 + <a 76 + class="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-on-surface-variant no-underline transition hover:bg-surface-bright hover:text-primary" 77 + href={getAtExplorerHref(props.follow)} 78 + onClick={() => queueExplorerTarget(props.follow.followUri)}> 79 + <span>AT Explorer</span> 80 + <Icon kind="ext-link" class="text-sm" /> 81 + </a> 82 + </div> 83 + </Motion.article> 84 + ); 85 + } 86 + 87 + function FollowScanSkeleton() { 88 + return ( 89 + <div class="grid gap-2"> 90 + <For each={Array.from({ length: 6 })}> 91 + {() => ( 92 + <div class="tone-muted rounded-2xl p-3"> 93 + <div class="flex items-center gap-3"> 94 + <span class="skeleton-block h-4 w-4 rounded-sm" /> 95 + <div class="grid flex-1 gap-1.5"> 96 + <span class="skeleton-block h-3.5 w-40 rounded-full" /> 97 + <span class="skeleton-block h-3 w-64 rounded-full" /> 98 + </div> 99 + </div> 100 + </div> 101 + )} 102 + </For> 103 + </div> 104 + ); 105 + } 106 + 107 + function FollowListEmptyState(props: { phase: FollowHygienePhase }) { 108 + const message = () => props.phase === "idle" ? "Run a scan to inspect your follows." : "No flagged follows found."; 109 + const detail = () => 110 + props.phase === "idle" 111 + ? "This checks for deleted, deactivated, blocked, hidden, and self-follow accounts." 112 + : "Your following list looks clean."; 113 + 114 + return ( 115 + <div class="grid min-h-56 place-items-center p-6 text-center"> 116 + <div class="grid max-w-md gap-2"> 117 + <p class="m-0 text-base font-medium text-on-surface">{message()}</p> 118 + <p class="m-0 text-sm leading-relaxed text-on-surface-variant">{detail()}</p> 119 + </div> 120 + </div> 121 + ); 122 + } 123 + 124 + export function FollowListViewport(props: FollowListViewportProps) { 125 + return ( 126 + <div class="panel-surface min-h-0 overflow-y-auto p-3"> 127 + <Show when={props.phase !== "scanning"} fallback={<FollowScanSkeleton />}> 128 + <Show when={props.flagged.length > 0} fallback={<FollowListEmptyState phase={props.phase} />}> 129 + <div class="grid gap-2"> 130 + <For each={props.flagged}> 131 + {(follow, index) => ( 132 + <FollowRow 133 + exiting={props.exitingUris.has(follow.followUri)} 134 + follow={follow} 135 + focused={props.focusedUri === follow.followUri} 136 + index={index()} 137 + selected={props.selectedUris.has(follow.followUri)} 138 + onFocus={() => props.onFocusUri(follow.followUri)} 139 + onToggle={() => props.onToggle(follow.followUri)} 140 + onToggleBySpace={() => props.onSpaceToggle(follow.followUri)} /> 141 + )} 142 + </For> 143 + </div> 144 + </Show> 145 + </Show> 146 + </div> 147 + ); 148 + }
+552
src/components/profile/FollowHygienePanel.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 2 + import { useAppSession } from "$/contexts/app-session"; 3 + import { ProfileController } from "$/lib/api/profile"; 4 + import { FOLLOW_HYGIENE_PROGRESS_EVENT } from "$/lib/constants/events"; 5 + import { asRecord, optionalNumber } from "$/lib/type-guards"; 6 + import type { FlaggedFollow, FollowBatchResult, FollowHygieneProgress } from "$/lib/types"; 7 + import { shouldIgnoreKey } from "$/lib/utils/events"; 8 + import { normalizeError } from "$/lib/utils/text"; 9 + import { listen } from "@tauri-apps/api/event"; 10 + import * as logger from "@tauri-apps/plugin-log"; 11 + import { createMemo, onCleanup, onMount, Show } from "solid-js"; 12 + import { createStore } from "solid-js/store"; 13 + import { Motion } from "solid-motionone"; 14 + import { ConfirmationDialog } from "./FollowHygeineConfirmationDialog"; 15 + import { CategorySidebar, type CategorySidebarProps } from "./FollowHygieneCategories"; 16 + import { FollowListViewport, type FollowListViewportProps } from "./FollowHygieneList"; 17 + import { ScanToolbar, type ScanToolbarProps } from "./FollowHygieneToolbar"; 18 + import { EXIT_ANIMATION_MS, hasStatus, STATUS_CATEGORIES } from "./types"; 19 + import type { FollowHygienePhase } from "./types"; 20 + 21 + type StatusCategoryKey = "deleted" | "deactivated" | "suspended" | "blockedBy" | "blocking" | "hidden" | "selfFollow"; 22 + 23 + type StatusCategoryState = { visible: boolean; selected: boolean }; 24 + 25 + type FollowHygieneState = { 26 + confirmOpen: boolean; 27 + exitingUris: Set<string>; 28 + flagged: FlaggedFollow[]; 29 + focusedUri: string | null; 30 + phase: FollowHygienePhase; 31 + progress: FollowHygieneProgress; 32 + result: FollowBatchResult | null; 33 + scanError: string | null; 34 + selectedUris: Set<string>; 35 + unfollowError: string | null; 36 + filters: Record<StatusCategoryKey, StatusCategoryState>; 37 + }; 38 + 39 + function createDefaultFilters(): Record<StatusCategoryKey, StatusCategoryState> { 40 + return { 41 + deleted: { visible: true, selected: false }, 42 + deactivated: { visible: true, selected: false }, 43 + suspended: { visible: true, selected: false }, 44 + blockedBy: { visible: true, selected: false }, 45 + blocking: { visible: true, selected: false }, 46 + hidden: { visible: true, selected: false }, 47 + selfFollow: { visible: true, selected: false }, 48 + }; 49 + } 50 + 51 + function createInitialState(): FollowHygieneState { 52 + return { 53 + confirmOpen: false, 54 + exitingUris: new Set<string>(), 55 + flagged: [], 56 + focusedUri: null, 57 + phase: "idle", 58 + progress: { current: 0, total: 1 }, 59 + result: null, 60 + scanError: null, 61 + selectedUris: new Set<string>(), 62 + unfollowError: null, 63 + filters: createDefaultFilters(), 64 + }; 65 + } 66 + 67 + function parseProgressPayload(payload: unknown): FollowHygieneProgress | null { 68 + const record = asRecord(payload); 69 + if (!record) { 70 + return null; 71 + } 72 + 73 + const current = optionalNumber(record.current); 74 + const total = optionalNumber(record.total); 75 + if (current === null || total === null) { 76 + return null; 77 + } 78 + 79 + return { current: Math.max(0, Math.floor(current)), total: Math.max(1, Math.floor(total)) }; 80 + } 81 + 82 + function FollowHygieneHeader(props: { onClose: () => void }) { 83 + return ( 84 + <header class="flex items-center justify-between gap-3 px-5 py-4"> 85 + <div class="grid gap-1"> 86 + <p class="m-0 text-[0.68rem] uppercase tracking-[0.12em] text-on-surface-variant">Account maintenance</p> 87 + <h2 class="m-0 text-xl font-semibold tracking-[-0.02em] text-on-surface">Follow hygiene</h2> 88 + </div> 89 + <button 90 + class="ui-control ui-control-hoverable flex h-9 w-9 items-center justify-center rounded-full" 91 + type="button" 92 + onClick={() => props.onClose()}> 93 + <Icon iconClass="i-ri-close-line" class="text-base" /> 94 + </button> 95 + </header> 96 + ); 97 + } 98 + 99 + type FollowHygieneLayoutProps = { 100 + footer: FooterActionsProps; 101 + list: FollowListViewportProps; 102 + sidebar: CategorySidebarProps; 103 + toolbar: ScanToolbarProps; 104 + }; 105 + 106 + function FollowHygieneLayout(props: FollowHygieneLayoutProps) { 107 + return ( 108 + <div class="grid h-full min-h-0 gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]"> 109 + <CategorySidebar {...props.sidebar} /> 110 + 111 + <div class="grid min-h-0 grid-rows-[auto_1fr_auto] gap-3"> 112 + <ScanToolbar {...props.toolbar} /> 113 + <FollowListViewport {...props.list} /> 114 + <FooterActions {...props.footer} /> 115 + </div> 116 + </div> 117 + ); 118 + } 119 + 120 + type FooterActionsProps = { 121 + canUnfollow: boolean; 122 + failedCount: number; 123 + phase: FollowHygienePhase; 124 + result: FollowBatchResult | null; 125 + selectedCount: number; 126 + selectedVisibleCount: number; 127 + visibleCount: number; 128 + onRetryFailed: () => void; 129 + onUnfollow: () => void; 130 + }; 131 + 132 + function FooterActions(props: FooterActionsProps) { 133 + const pending = () => props.phase === "unfollowing"; 134 + 135 + return ( 136 + <section class="panel-surface grid gap-2 p-4"> 137 + <div class="flex flex-wrap items-center justify-between gap-3"> 138 + <p class="m-0 text-sm text-on-surface-variant"> 139 + {props.selectedVisibleCount} of {props.visibleCount} visible selected ({props.selectedCount} total). 140 + </p> 141 + 142 + <button 143 + class="inline-flex min-h-10 items-center gap-2 rounded-full border-0 bg-red-500/16 px-4 text-sm font-medium text-red-300 transition hover:bg-red-500/24 disabled:opacity-60" 144 + disabled={!props.canUnfollow || pending()} 145 + type="button" 146 + onClick={() => props.onUnfollow()}> 147 + <Show when={pending()} fallback={<Icon iconClass="i-ri-user-unfollow-line" class="text-base" />}> 148 + <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 149 + </Show> 150 + <span>Unfollow selected</span> 151 + </button> 152 + </div> 153 + 154 + <Show when={props.result}> 155 + {(result) => ( 156 + <div class="flex flex-wrap items-center justify-between gap-3 text-sm text-on-secondary-container"> 157 + <span>{result().deleted} unfollowed, {result().failed.length} failed.</span> 158 + <Show when={props.failedCount > 0}> 159 + <button 160 + class="ui-control ui-control-hoverable inline-flex min-h-9 items-center gap-2 rounded-full px-4 text-sm text-on-surface" 161 + type="button" 162 + onClick={() => props.onRetryFailed()}> 163 + <Icon kind="refresh" class="text-base" /> 164 + Retry failed 165 + </button> 166 + </Show> 167 + </div> 168 + )} 169 + </Show> 170 + </section> 171 + ); 172 + } 173 + 174 + export function FollowHygienePanel(props: { onClose: () => void }) { 175 + const session = useAppSession(); 176 + const [state, setState] = createStore<FollowHygieneState>(createInitialState()); 177 + let panelRef: HTMLDivElement | undefined; 178 + let requestId = 0; 179 + let exitTimer: ReturnType<typeof setTimeout> | undefined; 180 + 181 + const categoryCounts = createMemo(() => { 182 + const counts: Record<StatusCategoryKey, number> = { 183 + deleted: 0, 184 + deactivated: 0, 185 + suspended: 0, 186 + blockedBy: 0, 187 + blocking: 0, 188 + hidden: 0, 189 + selfFollow: 0, 190 + }; 191 + 192 + for (const follow of state.flagged) { 193 + for (const category of STATUS_CATEGORIES) { 194 + if (hasStatus(follow.status, category.bit)) { 195 + counts[category.key] += 1; 196 + } 197 + } 198 + } 199 + 200 + return counts; 201 + }); 202 + 203 + const visibleFlagged = createMemo(() => 204 + state.flagged.filter((follow) => 205 + STATUS_CATEGORIES.some((category) => 206 + state.filters[category.key].visible && hasStatus(follow.status, category.bit) 207 + ) 208 + ) 209 + ); 210 + 211 + const progressPercent = createMemo(() => { 212 + const total = Math.max(1, state.progress.total); 213 + const ratio = Math.min(1, state.progress.current / total); 214 + return Math.round(ratio * 100); 215 + }); 216 + 217 + const selectedCount = createMemo(() => state.selectedUris.size); 218 + const selectedVisibleCount = createMemo(() => { 219 + const selected = state.selectedUris; 220 + return visibleFlagged().reduce((count, follow) => count + (selected.has(follow.followUri) ? 1 : 0), 0); 221 + }); 222 + 223 + const canUnfollow = createMemo(() => 224 + selectedCount() > 0 && state.phase !== "scanning" && state.phase !== "unfollowing" 225 + ); 226 + 227 + const failedCount = createMemo(() => state.result?.failed.length ?? 0); 228 + const showProgress = createMemo(() => state.phase === "scanning" || state.progress.current > 0); 229 + 230 + const sidebarProps = createMemo<CategorySidebarProps>(() => ({ 231 + counts: categoryCounts(), 232 + filters: state.filters, 233 + selectedCount: selectedCount(), 234 + totalCount: state.flagged.length, 235 + onSelectAllVisible: selectAllVisible, 236 + onToggleCategorySelection: toggleCategorySelection, 237 + onToggleCategoryVisibility: toggleCategoryVisibility, 238 + })); 239 + 240 + const toolbarProps = createMemo<ScanToolbarProps>(() => ({ 241 + phase: state.phase, 242 + progress: state.progress, 243 + progressPercent: progressPercent(), 244 + scanError: state.scanError, 245 + showProgress: showProgress(), 246 + unfollowError: state.unfollowError, 247 + onScan: startScan, 248 + })); 249 + 250 + const listProps = createMemo<FollowListViewportProps>(() => ({ 251 + exitingUris: state.exitingUris, 252 + flagged: visibleFlagged(), 253 + focusedUri: state.focusedUri, 254 + phase: state.phase, 255 + selectedUris: state.selectedUris, 256 + onFocusUri: (uri) => setState("focusedUri", uri), 257 + onSpaceToggle: toggleSelection, 258 + onToggle: toggleSelection, 259 + })); 260 + 261 + const footerProps = createMemo<FooterActionsProps>(() => ({ 262 + canUnfollow: canUnfollow(), 263 + failedCount: failedCount(), 264 + phase: state.phase, 265 + result: state.result, 266 + selectedCount: selectedCount(), 267 + selectedVisibleCount: selectedVisibleCount(), 268 + visibleCount: visibleFlagged().length, 269 + onRetryFailed: handleRetryFailed, 270 + onUnfollow: openConfirmation, 271 + })); 272 + 273 + function syncCategorySelection(selectedUris: Set<string>, flagged: FlaggedFollow[]) { 274 + for (const category of STATUS_CATEGORIES) { 275 + const categoryUris = flagged.filter((follow) => hasStatus(follow.status, category.bit)).map((follow) => 276 + follow.followUri 277 + ); 278 + const selected = categoryUris.length > 0 && categoryUris.every((uri) => selectedUris.has(uri)); 279 + setState("filters", category.key, "selected", selected); 280 + } 281 + } 282 + 283 + function updateSelectedUris(nextSelected: Set<string>, flagged: FlaggedFollow[] = state.flagged) { 284 + setState("selectedUris", nextSelected); 285 + syncCategorySelection(nextSelected, flagged); 286 + } 287 + 288 + async function startScan() { 289 + if (state.phase === "scanning" || state.phase === "unfollowing") { 290 + return; 291 + } 292 + 293 + requestId += 1; 294 + const activeRequest = requestId; 295 + setState({ 296 + confirmOpen: false, 297 + exitingUris: new Set<string>(), 298 + flagged: [], 299 + focusedUri: null, 300 + phase: "scanning", 301 + progress: { current: 0, total: 1 }, 302 + result: null, 303 + scanError: null, 304 + selectedUris: new Set<string>(), 305 + unfollowError: null, 306 + filters: createDefaultFilters(), 307 + }); 308 + 309 + try { 310 + const flagged = await ProfileController.auditFollows(); 311 + if (activeRequest !== requestId) { 312 + return; 313 + } 314 + 315 + const initialSelection = new Set(flagged.map((follow) => follow.followUri)); 316 + setState("flagged", flagged); 317 + updateSelectedUris(initialSelection, flagged); 318 + setState("phase", "ready"); 319 + setState( 320 + "progress", 321 + (progress) => ({ current: Math.max(progress.current, progress.total), total: progress.total }), 322 + ); 323 + } catch (error) { 324 + if (activeRequest !== requestId) { 325 + return; 326 + } 327 + 328 + const message = "Couldn't scan your follows right now."; 329 + logger.error("follow hygiene scan failed", { keyValues: { error: normalizeError(error) } }); 330 + setState("phase", "idle"); 331 + setState("scanError", message); 332 + session.reportError(message); 333 + } 334 + } 335 + 336 + function toggleSelection(followUri: string) { 337 + const next = new Set(state.selectedUris); 338 + if (next.has(followUri)) { 339 + next.delete(followUri); 340 + } else { 341 + next.add(followUri); 342 + } 343 + updateSelectedUris(next); 344 + } 345 + 346 + function selectAllVisible() { 347 + const next = new Set(state.selectedUris); 348 + for (const follow of visibleFlagged()) { 349 + next.add(follow.followUri); 350 + } 351 + updateSelectedUris(next); 352 + } 353 + 354 + function toggleCategoryVisibility(key: StatusCategoryKey) { 355 + setState("filters", key, "visible", (visible) => !visible); 356 + } 357 + 358 + function toggleCategorySelection(key: StatusCategoryKey) { 359 + const category = STATUS_CATEGORIES.find((item) => item.key === key); 360 + if (!category) { 361 + return; 362 + } 363 + 364 + const categoryUris = state.flagged.filter((follow) => hasStatus(follow.status, category.bit)).map((follow) => 365 + follow.followUri 366 + ); 367 + if (categoryUris.length === 0) { 368 + return; 369 + } 370 + 371 + const next = new Set(state.selectedUris); 372 + const allSelected = categoryUris.every((uri) => next.has(uri)); 373 + for (const uri of categoryUris) { 374 + if (allSelected) { 375 + next.delete(uri); 376 + } else { 377 + next.add(uri); 378 + } 379 + } 380 + 381 + updateSelectedUris(next); 382 + } 383 + 384 + function openConfirmation() { 385 + if (!canUnfollow()) { 386 + return; 387 + } 388 + 389 + setState("confirmOpen", true); 390 + } 391 + 392 + function closeConfirmation() { 393 + if (state.confirmOpen) { 394 + setState("confirmOpen", false); 395 + } 396 + } 397 + 398 + function applyUnfollowResult(followUris: string[], result: FollowBatchResult) { 399 + const failed = new Set(result.failed); 400 + const successfulUris = followUris.filter((uri) => !failed.has(uri)); 401 + const nextSelected = new Set(state.selectedUris); 402 + for (const uri of successfulUris) { 403 + nextSelected.delete(uri); 404 + } 405 + 406 + setState("result", result); 407 + setState("phase", "done"); 408 + updateSelectedUris(nextSelected); 409 + 410 + if (successfulUris.length === 0) { 411 + return; 412 + } 413 + 414 + setState("exitingUris", new Set(successfulUris)); 415 + 416 + if (exitTimer) { 417 + clearTimeout(exitTimer); 418 + } 419 + 420 + exitTimer = setTimeout(() => { 421 + const filtered = state.flagged.filter((follow) => !successfulUris.includes(follow.followUri)); 422 + const selected = new Set(state.selectedUris); 423 + setState("flagged", filtered); 424 + setState("exitingUris", new Set<string>()); 425 + syncCategorySelection(selected, filtered); 426 + }, EXIT_ANIMATION_MS); 427 + } 428 + 429 + async function runUnfollow(followUris: string[]) { 430 + if (followUris.length === 0 || state.phase === "unfollowing") { 431 + return; 432 + } 433 + 434 + setState("confirmOpen", false); 435 + setState("phase", "unfollowing"); 436 + setState("unfollowError", null); 437 + 438 + try { 439 + const result = await ProfileController.batchUnfollow(followUris); 440 + applyUnfollowResult(followUris, result); 441 + } catch (error) { 442 + const message = "Couldn't unfollow selected accounts right now."; 443 + logger.error("follow hygiene unfollow failed", { keyValues: { error: normalizeError(error) } }); 444 + setState("phase", "ready"); 445 + setState("unfollowError", message); 446 + } 447 + } 448 + 449 + function handleConfirmUnfollow() { 450 + const followUris = [...state.selectedUris]; 451 + void runUnfollow(followUris); 452 + } 453 + 454 + function handleRetryFailed() { 455 + if (!state.result?.failed.length) { 456 + return; 457 + } 458 + 459 + void runUnfollow(state.result.failed); 460 + } 461 + 462 + function handleGlobalKeyDown(event: KeyboardEvent) { 463 + if (shouldIgnoreKey(event)) { 464 + return; 465 + } 466 + 467 + const key = event.key.toLowerCase(); 468 + if (key === "escape") { 469 + event.preventDefault(); 470 + if (state.confirmOpen) { 471 + setState("confirmOpen", false); 472 + } else { 473 + props.onClose(); 474 + } 475 + return; 476 + } 477 + 478 + if ((event.ctrlKey || event.metaKey) && key === "a" && !event.altKey && !event.shiftKey) { 479 + event.preventDefault(); 480 + selectAllVisible(); 481 + } 482 + } 483 + 484 + onMount(() => { 485 + queueMicrotask(() => panelRef?.focus()); 486 + globalThis.addEventListener("keydown", handleGlobalKeyDown); 487 + let unlisten: (() => void) | undefined; 488 + 489 + void listen(FOLLOW_HYGIENE_PROGRESS_EVENT, (event) => { 490 + const payload = parseProgressPayload(event.payload); 491 + if (!payload) { 492 + return; 493 + } 494 + 495 + setState("progress", payload); 496 + }).then((dispose) => { 497 + unlisten = dispose; 498 + }).catch((error) => { 499 + logger.warn("follow hygiene progress listener failed", { keyValues: { error: normalizeError(error) } }); 500 + }); 501 + 502 + onCleanup(() => { 503 + globalThis.removeEventListener("keydown", handleGlobalKeyDown); 504 + unlisten?.(); 505 + if (exitTimer) { 506 + clearTimeout(exitTimer); 507 + } 508 + }); 509 + }); 510 + 511 + return ( 512 + <> 513 + <Motion.div 514 + ref={(element) => { 515 + panelRef = element; 516 + }} 517 + aria-modal="true" 518 + class="ui-scrim fixed inset-0 z-50 flex items-stretch justify-end p-3 backdrop-blur-xl max-sm:p-0" 519 + initial={{ opacity: 0 }} 520 + animate={{ opacity: 1 }} 521 + exit={{ opacity: 0 }} 522 + role="dialog" 523 + tabIndex={-1} 524 + transition={{ duration: 0.18 }} 525 + onClick={() => props.onClose()}> 526 + <Motion.section 527 + class="grid h-full w-full max-w-[min(72rem,calc(100vw-1.5rem))] grid-rows-[auto_1fr] overflow-hidden rounded-4xl bg-surface-container-highest shadow-[0_32px_90px_rgba(0,0,0,0.3),var(--inset-shadow)] max-sm:max-w-none max-sm:rounded-none" 528 + initial={{ x: 20, opacity: 0.96 }} 529 + animate={{ x: 0, opacity: 1 }} 530 + exit={{ x: 20, opacity: 0.96 }} 531 + transition={{ duration: 0.2 }} 532 + onClick={(event) => event.stopPropagation()}> 533 + <FollowHygieneHeader onClose={props.onClose} /> 534 + <div class="min-h-0 overflow-hidden px-4 pb-4 max-sm:px-3 max-sm:pb-3"> 535 + <FollowHygieneLayout 536 + footer={footerProps()} 537 + list={listProps()} 538 + sidebar={sidebarProps()} 539 + toolbar={toolbarProps()} /> 540 + </div> 541 + </Motion.section> 542 + </Motion.div> 543 + 544 + <ConfirmationDialog 545 + isOpen={state.confirmOpen} 546 + pending={state.phase === "unfollowing"} 547 + selectedCount={selectedCount()} 548 + onCancel={closeConfirmation} 549 + onConfirm={handleConfirmUnfollow} /> 550 + </> 551 + ); 552 + }
+66
src/components/profile/FollowHygieneToolbar.tsx
··· 1 + import type { FollowHygieneProgress } from "$/lib/types"; 2 + import { Show } from "solid-js"; 3 + import { Motion } from "solid-motionone"; 4 + import { Icon } from "../shared/Icon"; 5 + import type { FollowHygienePhase } from "./types"; 6 + 7 + export type ScanToolbarProps = { 8 + phase: FollowHygienePhase; 9 + progress: FollowHygieneProgress; 10 + progressPercent: number; 11 + scanError: string | null; 12 + showProgress: boolean; 13 + unfollowError: string | null; 14 + onScan: () => void; 15 + }; 16 + 17 + function ProgressMeter(props: { current: number; total: number; percent: number }) { 18 + return ( 19 + <div class="grid gap-2"> 20 + <div class="h-2 overflow-hidden rounded-full bg-surface-container-high"> 21 + <Motion.div 22 + class="h-full rounded-full bg-primary" 23 + animate={{ width: `${props.percent}%` }} 24 + transition={{ duration: 0.25 }} /> 25 + </div> 26 + <p class="m-0 text-xs text-on-surface-variant"> 27 + Scanning batches: {Math.min(props.current, props.total)} / {props.total} 28 + </p> 29 + </div> 30 + ); 31 + } 32 + 33 + export function ScanToolbar(props: ScanToolbarProps) { 34 + const scanning = () => props.phase === "scanning"; 35 + 36 + return ( 37 + <section class="panel-surface grid gap-3 p-4"> 38 + <div class="flex flex-wrap items-center justify-between gap-3"> 39 + <div class="grid gap-1"> 40 + <h3 class="m-0 text-lg font-medium tracking-[-0.02em] text-on-surface">Flagged accounts</h3> 41 + <p class="m-0 text-sm text-on-surface-variant"> 42 + Scan follows for deleted, deactivated, blocked, and hidden accounts. 43 + </p> 44 + </div> 45 + 46 + <button 47 + class="inline-flex min-h-10 items-center gap-2 rounded-full border-0 bg-primary/15 px-4 text-sm font-medium text-primary transition hover:bg-primary/25 disabled:opacity-60" 48 + disabled={scanning()} 49 + type="button" 50 + onClick={() => props.onScan()}> 51 + <Show when={scanning()} fallback={<Icon iconClass="i-ri-radar-line" class="text-base" />}> 52 + <Icon iconClass="i-ri-loader-4-line animate-spin" class="text-base" /> 53 + </Show> 54 + <span>{scanning() ? "Scanning follows..." : "Scan follows"}</span> 55 + </button> 56 + </div> 57 + 58 + <Show when={props.showProgress}> 59 + <ProgressMeter current={props.progress.current} percent={props.progressPercent} total={props.progress.total} /> 60 + </Show> 61 + 62 + <Show when={props.scanError}>{(error) => <p class="m-0 text-sm text-red-300">{error()}</p>}</Show> 63 + <Show when={props.unfollowError}>{(error) => <p class="m-0 text-sm text-red-300">{error()}</p>}</Show> 64 + </section> 65 + ); 66 + }
+22 -9
src/components/profile/ProfileHero.tsx
··· 16 16 isSelf: boolean; 17 17 onFollow: () => void; 18 18 onMessage: () => void; 19 + onOpenFollowHygiene: () => void; 19 20 onUnfollow: () => void; 20 21 }, 21 22 ) { 22 23 return ( 23 24 <div class="flex flex-col items-end gap-2"> 24 - <Show when={!props.isSelf}> 25 - <div class="flex flex-wrap justify-end gap-2"> 26 - <MessageButton onClick={props.onMessage} /> 27 - <FollowButton 28 - isFollowing={props.isFollowing} 29 - loading={props.followLoading} 30 - onFollow={props.onFollow} 31 - onUnfollow={props.onUnfollow} /> 32 - </div> 25 + <Show 26 + when={props.isSelf} 27 + fallback={ 28 + <div class="flex flex-wrap justify-end gap-2"> 29 + <MessageButton onClick={props.onMessage} /> 30 + <FollowButton 31 + isFollowing={props.isFollowing} 32 + loading={props.followLoading} 33 + onFollow={props.onFollow} 34 + onUnfollow={props.onUnfollow} /> 35 + </div> 36 + }> 37 + <button 38 + class="tone-muted inline-flex min-h-9 items-center gap-2 rounded-full border ui-outline-subtle px-4 text-sm font-medium text-on-surface shadow-(--inset-shadow) transition duration-150 ease-out hover:bg-surface-bright" 39 + type="button" 40 + onClick={() => props.onOpenFollowHygiene()}> 41 + <Icon iconClass="i-ri-user-search-line" class="text-base" /> 42 + Audit follows 43 + </button> 33 44 </Show> 34 45 <ProfileBadgeRow badges={props.badges} isSelf={props.isSelf} /> 35 46 </div> ··· 198 209 joinedLabel: string | null; 199 210 onFollow: () => void; 200 211 onMessage: () => void; 212 + onOpenFollowHygiene: () => void; 201 213 onOpenFollowers: () => void; 202 214 onOpenFollows: () => void; 203 215 onUnfollow: () => void; ··· 247 259 isSelf={props.isSelf} 248 260 onFollow={props.onFollow} 249 261 onMessage={props.onMessage} 262 + onOpenFollowHygiene={props.onOpenFollowHygiene} 250 263 onUnfollow={props.onUnfollow} /> 251 264 </div> 252 265
+9
src/components/profile/ProfilePanel.tsx
··· 21 21 import { createStore } from "solid-js/store"; 22 22 import { Presence } from "solid-motionone"; 23 23 import { Icon } from "../shared/Icon"; 24 + import { FollowHygienePanel } from "./FollowHygienePanel"; 24 25 import { createActorListState, createFeedState, createProfilePanelState, tabLabel } from "./profile-state"; 25 26 import type { ProfilePanelState } from "./profile-state"; 26 27 import { ActorListOverlay } from "./ProfileActorList"; ··· 38 39 const postNavigation = usePostNavigation(); 39 40 const [state, setState] = createStore<ProfilePanelState>(createProfilePanelState()); 40 41 const [heroHeight, setHeroHeight] = createSignal<number | null>(null); 42 + const [followHygieneOpen, setFollowHygieneOpen] = createSignal(false); 41 43 let requestSequence = 0; 42 44 const interactions = usePostInteractions({ 43 45 onError: session.reportError, ··· 457 459 joinedLabel={joinedLabel()} 458 460 onFollow={handleFollow} 459 461 onMessage={handleMessage} 462 + onOpenFollowHygiene={() => setFollowHygieneOpen(true)} 460 463 onOpenFollowers={() => openActorList("followers")} 461 464 onOpenFollows={() => openActorList("follows")} 462 465 onUnfollow={handleUnfollow} ··· 523 526 }} 524 527 onUnfollowActor={handleActorListUnfollow} 525 528 sessionDid={session.activeDid} /> 529 + </Show> 530 + </Presence> 531 + 532 + <Presence> 533 + <Show when={followHygieneOpen()}> 534 + <FollowHygienePanel onClose={() => setFollowHygieneOpen(false)} /> 526 535 </Show> 527 536 </Presence> 528 537 </section>
+166
src/components/profile/tests/FollowHygienePanel.test.tsx
··· 1 + import { AppTestProviders } from "$/test/providers"; 2 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 3 + import { beforeEach, describe, expect, it, vi } from "vitest"; 4 + import { FollowHygienePanel } from "../FollowHygienePanel"; 5 + 6 + const auditFollowsMock = vi.hoisted(() => vi.fn()); 7 + const batchUnfollowMock = vi.hoisted(() => vi.fn()); 8 + const listenMock = vi.hoisted(() => vi.fn()); 9 + const onCloseMock = vi.hoisted(() => vi.fn()); 10 + const reportErrorMock = vi.hoisted(() => vi.fn()); 11 + 12 + vi.mock( 13 + "$/lib/api/profile", 14 + () => ({ ProfileController: { auditFollows: auditFollowsMock, batchUnfollow: batchUnfollowMock } }), 15 + ); 16 + 17 + vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); 18 + 19 + const FOLLOW_STATUS_DELETED = Math.trunc(1); 20 + const FOLLOW_STATUS_DEACTIVATED = 1 << 1; 21 + const FOLLOW_STATUS_BLOCKED_BY = 1 << 3; 22 + const FOLLOW_STATUS_BLOCKING = 1 << 4; 23 + const FOLLOW_STATUS_HIDDEN = 1 << 5; 24 + 25 + function renderPanel() { 26 + render(() => ( 27 + <AppTestProviders session={{ reportError: reportErrorMock }}> 28 + <FollowHygienePanel onClose={onCloseMock} /> 29 + </AppTestProviders> 30 + )); 31 + } 32 + 33 + function createFlagged() { 34 + return [{ 35 + did: "did:plc:ghost", 36 + followUri: "at://did:plc:alice/app.bsky.graph.follow/1", 37 + handle: "ghost.test", 38 + status: FOLLOW_STATUS_DELETED, 39 + statusLabel: "Deleted", 40 + }, { 41 + did: "did:plc:nap", 42 + followUri: "at://did:plc:alice/app.bsky.graph.follow/2", 43 + handle: "nap.test", 44 + status: FOLLOW_STATUS_DEACTIVATED | FOLLOW_STATUS_HIDDEN, 45 + statusLabel: "Deactivated, Hidden", 46 + }, { 47 + did: "did:plc:mutual", 48 + followUri: "at://did:plc:alice/app.bsky.graph.follow/3", 49 + handle: "mutual.test", 50 + status: FOLLOW_STATUS_BLOCKED_BY | FOLLOW_STATUS_BLOCKING, 51 + statusLabel: "Mutual Block", 52 + }]; 53 + } 54 + 55 + describe("FollowHygienePanel", () => { 56 + beforeEach(() => { 57 + vi.resetAllMocks(); 58 + auditFollowsMock.mockResolvedValue(createFlagged()); 59 + batchUnfollowMock.mockResolvedValue({ deleted: 0, failed: [] }); 60 + listenMock.mockResolvedValue(vi.fn()); 61 + }); 62 + 63 + it("scans, renders progress, and applies category visibility filtering", async () => { 64 + renderPanel(); 65 + 66 + fireEvent.click(screen.getByRole("button", { name: "Scan follows" })); 67 + 68 + const listener = listenMock.mock.calls[0]?.[1]; 69 + expect(listener).toBeTypeOf("function"); 70 + listener({ payload: { current: 1, total: 4 } }); 71 + 72 + expect(await screen.findByText("@ghost.test")).toBeInTheDocument(); 73 + expect(screen.getByText(/Scanning batches: [1-4] \/ 4/u)).toBeInTheDocument(); 74 + expect(screen.getByText("3 of 3 visible selected (3 total).")).toBeInTheDocument(); 75 + 76 + fireEvent.click(screen.getByRole("button", { name: "Hide Deleted" })); 77 + 78 + await waitFor(() => { 79 + expect(screen.queryByText("@ghost.test")).not.toBeInTheDocument(); 80 + expect(screen.getByText("2 of 2 visible selected (3 total).")).toBeInTheDocument(); 81 + }); 82 + }); 83 + 84 + it("supports keyboard shortcuts for space toggle and ctrl+a select all visible", async () => { 85 + renderPanel(); 86 + 87 + fireEvent.click(screen.getByRole("button", { name: "Scan follows" })); 88 + expect(await screen.findByText("@ghost.test")).toBeInTheDocument(); 89 + expect(screen.getByText("3 of 3 visible selected (3 total).")).toBeInTheDocument(); 90 + 91 + const row = screen.getByText("@ghost.test").closest("article"); 92 + expect(row).not.toBeNull(); 93 + row?.focus(); 94 + fireEvent.keyDown(row as HTMLElement, { key: " " }); 95 + 96 + await waitFor(() => { 97 + expect(screen.getByText("2 of 3 visible selected (2 total).")).toBeInTheDocument(); 98 + }); 99 + 100 + fireEvent.keyDown(document, { key: "a", ctrlKey: true }); 101 + 102 + await waitFor(() => { 103 + expect(screen.getByText("3 of 3 visible selected (3 total).")).toBeInTheDocument(); 104 + }); 105 + }); 106 + 107 + it("unfollows selected accounts, keeps failures for retry, and supports escape behavior", async () => { 108 + batchUnfollowMock.mockResolvedValueOnce({ deleted: 2, failed: ["at://did:plc:alice/app.bsky.graph.follow/3"] }) 109 + .mockResolvedValueOnce({ deleted: 1, failed: [] }); 110 + 111 + renderPanel(); 112 + 113 + fireEvent.click(screen.getByRole("button", { name: "Scan follows" })); 114 + expect(await screen.findByText("@ghost.test")).toBeInTheDocument(); 115 + 116 + fireEvent.click(screen.getByRole("button", { name: "Unfollow selected" })); 117 + expect(await screen.findByText("Unfollow selected accounts?")).toBeInTheDocument(); 118 + 119 + fireEvent.keyDown(document, { key: "Escape" }); 120 + await waitFor(() => expect(screen.queryByText("Unfollow selected accounts?")).not.toBeInTheDocument()); 121 + 122 + fireEvent.click(screen.getByRole("button", { name: "Unfollow selected" })); 123 + fireEvent.click(await screen.findByRole("button", { name: "Confirm unfollow" })); 124 + 125 + await waitFor(() => { 126 + expect(batchUnfollowMock).toHaveBeenNthCalledWith(1, [ 127 + "at://did:plc:alice/app.bsky.graph.follow/1", 128 + "at://did:plc:alice/app.bsky.graph.follow/2", 129 + "at://did:plc:alice/app.bsky.graph.follow/3", 130 + ]); 131 + expect(screen.getByText("2 unfollowed, 1 failed.")).toBeInTheDocument(); 132 + }); 133 + 134 + await waitFor(() => { 135 + expect(screen.queryByText("@ghost.test")).not.toBeInTheDocument(); 136 + expect(screen.queryByText("@nap.test")).not.toBeInTheDocument(); 137 + expect(screen.getByText("@mutual.test")).toBeInTheDocument(); 138 + }); 139 + 140 + fireEvent.click(screen.getByRole("button", { name: "Retry failed" })); 141 + 142 + await waitFor(() => { 143 + expect(batchUnfollowMock).toHaveBeenNthCalledWith(2, ["at://did:plc:alice/app.bsky.graph.follow/3"]); 144 + expect(screen.getByText("1 unfollowed, 0 failed.")).toBeInTheDocument(); 145 + }); 146 + 147 + await waitFor(() => { 148 + expect(screen.queryByText("@mutual.test")).not.toBeInTheDocument(); 149 + }); 150 + 151 + fireEvent.keyDown(document, { key: "Escape" }); 152 + expect(onCloseMock).toHaveBeenCalledTimes(1); 153 + }); 154 + 155 + it("reports a friendly scan failure", async () => { 156 + auditFollowsMock.mockRejectedValueOnce(new Error("network down")); 157 + renderPanel(); 158 + 159 + fireEvent.click(screen.getByRole("button", { name: "Scan follows" })); 160 + 161 + await waitFor(() => { 162 + expect(screen.getByText("Couldn't scan your follows right now.")).toBeInTheDocument(); 163 + expect(reportErrorMock).toHaveBeenCalledWith("Couldn't scan your follows right now."); 164 + }); 165 + }); 166 + });
+18 -1
src/components/profile/tests/ProfilePanel.test.tsx
··· 3 3 import { beforeEach, describe, expect, it, vi } from "vitest"; 4 4 import { ProfilePanel } from "../ProfilePanel"; 5 5 6 + const auditFollowsMock = vi.hoisted(() => vi.fn()); 7 + const batchUnfollowMock = vi.hoisted(() => vi.fn()); 6 8 const followActorMock = vi.hoisted(() => vi.fn()); 7 9 const getActorLikesMock = vi.hoisted(() => vi.fn()); 8 10 const getAuthorFeedMock = vi.hoisted(() => vi.fn()); ··· 27 29 "$/lib/api/profile", 28 30 () => ({ 29 31 ProfileController: { 32 + auditFollows: auditFollowsMock, 33 + batchUnfollow: batchUnfollowMock, 30 34 followActor: followActorMock, 31 35 getActorLikes: getActorLikesMock, 32 36 getAuthorFeed: getAuthorFeedMock, ··· 80 84 }; 81 85 } 82 86 83 - function renderProfilePanel(actor = "bob.test") { 87 + function renderProfilePanel(actor = "bob.test", session: Record<string, unknown> = {}) { 84 88 render(() => ( 85 89 <AppTestProviders 86 90 session={{ 87 91 activeDid: "did:plc:alice", 88 92 activeHandle: "alice.test", 89 93 activeSession: { did: "did:plc:alice", handle: "alice.test" }, 94 + ...session, 90 95 }}> 91 96 <ProfilePanel actor={actor} /> 92 97 </AppTestProviders> ··· 126 131 }); 127 132 getFollowersMock.mockResolvedValue({ actors: [], cursor: null }); 128 133 getFollowsMock.mockResolvedValue({ actors: [], cursor: null }); 134 + auditFollowsMock.mockResolvedValue([]); 135 + batchUnfollowMock.mockResolvedValue({ deleted: 0, failed: [] }); 129 136 followActorMock.mockResolvedValue({ cid: "cid-follow", uri: "at://did:plc:alice/app.bsky.graph.follow/1" }); 130 137 unfollowActorMock.mockResolvedValue(void 0); 138 + }); 139 + 140 + it("shows follow hygiene entry on the signed-in profile", async () => { 141 + renderProfilePanel("bob.test", { 142 + activeDid: "did:plc:bob", 143 + activeHandle: "bob.test", 144 + activeSession: { did: "did:plc:bob", handle: "bob.test" }, 145 + }); 146 + 147 + expect(await screen.findByRole("button", { name: "Audit follows" })).toBeInTheDocument(); 131 148 }); 132 149 133 150 it("optimistically follows and unfollows from the hero while keeping badges in sync", async () => {
+74
src/components/profile/types.ts
··· 1 + import { buildProfileRoute } from "$/lib/profile"; 2 + import type { FlaggedFollow } from "$/lib/types"; 3 + 4 + export type StatusCategoryKey = 5 + | "deleted" 6 + | "deactivated" 7 + | "suspended" 8 + | "blockedBy" 9 + | "blocking" 10 + | "hidden" 11 + | "selfFollow"; 12 + 13 + export type StatusCategoryState = { visible: boolean; selected: boolean }; 14 + 15 + export const FOLLOW_STATUS_DELETED = Math.trunc(1); 16 + export const FOLLOW_STATUS_DEACTIVATED = 1 << 1; 17 + export const FOLLOW_STATUS_SUSPENDED = 1 << 2; 18 + export const FOLLOW_STATUS_BLOCKED_BY = 1 << 3; 19 + export const FOLLOW_STATUS_BLOCKING = 1 << 4; 20 + export const FOLLOW_STATUS_HIDDEN = 1 << 5; 21 + export const FOLLOW_STATUS_SELF_FOLLOW = 1 << 6; 22 + export const EXIT_ANIMATION_MS = 220; 23 + 24 + export const STATUS_CATEGORIES: Array<{ key: StatusCategoryKey; label: string; bit: number }> = [ 25 + { key: "deleted", label: "Deleted", bit: FOLLOW_STATUS_DELETED }, 26 + { key: "deactivated", label: "Deactivated", bit: FOLLOW_STATUS_DEACTIVATED }, 27 + { key: "suspended", label: "Suspended", bit: FOLLOW_STATUS_SUSPENDED }, 28 + { key: "blockedBy", label: "Blocked by", bit: FOLLOW_STATUS_BLOCKED_BY }, 29 + { key: "blocking", label: "Blocking", bit: FOLLOW_STATUS_BLOCKING }, 30 + { key: "hidden", label: "Hidden", bit: FOLLOW_STATUS_HIDDEN }, 31 + { key: "selfFollow", label: "Self-follow", bit: FOLLOW_STATUS_SELF_FOLLOW }, 32 + ]; 33 + 34 + export function hasStatus(status: number, bit: number) { 35 + return (status & bit) !== 0; 36 + } 37 + 38 + export function statusChipClass(status: number) { 39 + if (hasStatus(status, FOLLOW_STATUS_DELETED)) { 40 + return "bg-red-400/16 text-red-300"; 41 + } 42 + if (hasStatus(status, FOLLOW_STATUS_DEACTIVATED)) { 43 + return "bg-yellow-400/16 text-yellow-300"; 44 + } 45 + if (hasStatus(status, FOLLOW_STATUS_SUSPENDED)) { 46 + return "bg-orange-400/16 text-orange-300"; 47 + } 48 + if (hasStatus(status, FOLLOW_STATUS_BLOCKED_BY) || hasStatus(status, FOLLOW_STATUS_BLOCKING)) { 49 + return "bg-violet-400/16 text-violet-300"; 50 + } 51 + if (hasStatus(status, FOLLOW_STATUS_HIDDEN)) { 52 + return "bg-pink-400/16 text-pink-300"; 53 + } 54 + return "bg-slate-400/16 text-slate-300"; 55 + } 56 + 57 + export type FollowHygienePhase = "idle" | "scanning" | "ready" | "unfollowing" | "done"; 58 + 59 + export function getAtExplorerHref(follow: FlaggedFollow) { 60 + return `#/explorer?target=${encodeURIComponent(follow.followUri)}`; 61 + } 62 + 63 + export function displayHandle(follow: FlaggedFollow) { 64 + if (follow.handle.startsWith("did:")) { 65 + return follow.handle; 66 + } 67 + 68 + return `@${follow.handle.replace(/^@/, "")}`; 69 + } 70 + 71 + export function getProfileHref(follow: FlaggedFollow) { 72 + const actor = follow.handle.startsWith("did:") ? follow.did : follow.handle.replace(/^@/, ""); 73 + return `#${buildProfileRoute(actor)}`; 74 + }
+18 -1
src/components/settings/SettingsAccount.tsx
··· 7 7 8 8 function AccountItem(props: { account: AccountSummary; active: boolean; onRemove: () => void; onSwitch: () => void }) { 9 9 return ( 10 - <div class="tone-muted flex items-center justify-between rounded-xl p-3 transition-colors hover:bg-[var(--panel-muted-hover)]"> 10 + <div class="tone-muted flex items-center justify-between rounded-xl p-3 transition-colors hover:bg-(--panel-muted-hover)"> 11 11 <div class="flex items-center gap-3"> 12 12 <div class="relative"> 13 13 <div class="h-10 w-10 overflow-hidden rounded-full"> ··· 68 68 onConfirm: () => void; 69 69 }, 70 70 ) => void; 71 + onOpenFollowHygiene: () => void; 71 72 }, 72 73 ) { 73 74 const session = useAppSession(); ··· 97 98 class="inline-flex items-center justify-center gap-2 rounded-full border-0 bg-primary px-4 py-2 text-sm font-medium text-on-primary-fixed transition hover:opacity-90"> 98 99 Add account 99 100 </button> 101 + 102 + <div class="tone-muted grid gap-3 rounded-xl p-3 shadow-(--inset-shadow)"> 103 + <div class="grid gap-1"> 104 + <p class="m-0 text-sm font-medium text-on-surface">Follow hygiene</p> 105 + <p class="m-0 text-xs leading-relaxed text-on-surface-variant"> 106 + Audit follows for deleted, deactivated, blocked, and hidden accounts. 107 + </p> 108 + </div> 109 + <button 110 + type="button" 111 + onClick={() => props.onOpenFollowHygiene()} 112 + class="ui-control ui-control-hoverable inline-flex min-h-9 items-center justify-center gap-2 rounded-full px-4 text-sm font-medium text-on-surface"> 113 + <Icon iconClass="i-ri-user-search-line" class="text-base" /> 114 + Audit follows 115 + </button> 116 + </div> 100 117 </div> 101 118 </SettingsCard> 102 119 );
+12 -1
src/components/settings/SettingsPanel.tsx
··· 17 17 import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 18 18 import { createStore } from "solid-js/store"; 19 19 import { Motion, Presence } from "solid-motionone"; 20 + import { FollowHygienePanel } from "../profile/FollowHygienePanel"; 20 21 import { Icon } from "../shared/Icon"; 21 22 import { SettingsAbout } from "./SettingsAbout"; 22 23 import { AccountControl } from "./SettingsAccount"; ··· 44 45 onConfirm: () => void; 45 46 } | null; 46 47 modalOpen: boolean; 48 + followHygieneOpen: boolean; 47 49 }; 48 50 49 51 function ConfirmationModal( ··· 168 170 const navigate = useNavigate(); 169 171 const [panel, setPanel] = createStore<SettingsPanelState>({ 170 172 cacheSize: null, 173 + followHygieneOpen: false, 171 174 logLevel: "all", 172 175 logs: [], 173 176 logsExpanded: false, ··· 289 292 <NotificationsControl settings={settings()} handleUpdateSetting={handleUpdateSetting} /> 290 293 <SettingsModeration /> 291 294 <EmbeddingsSettings /> 292 - <AccountControl openConfirmation={openConfirmation} /> 295 + <AccountControl 296 + openConfirmation={openConfirmation} 297 + onOpenFollowHygiene={() => setPanel("followHygieneOpen", true)} /> 293 298 <SettingsService settings={settings()} handleUpdateSetting={handleUpdateSetting} /> 294 299 <SettingsData 295 300 cacheSize={panel.cacheSize} ··· 327 332 panel.modalConfig?.onConfirm(); 328 333 setPanel("modalOpen", false); 329 334 }} /> 335 + 336 + <Presence> 337 + <Show when={panel.followHygieneOpen}> 338 + <FollowHygienePanel onClose={() => setPanel("followHygieneOpen", false)} /> 339 + </Show> 340 + </Presence> 330 341 </article> 331 342 ); 332 343 }
+1
src/components/settings/tests/SettingsPanel.test.tsx
··· 168 168 expect(await screen.findByText("Notifications")).toBeInTheDocument(); 169 169 expect(await screen.findByText("Moderation")).toBeInTheDocument(); 170 170 expect(await screen.findByText("Accounts")).toBeInTheDocument(); 171 + expect(await screen.findByRole("button", { name: "Audit follows" })).toBeInTheDocument(); 171 172 expect(await screen.findByText("Services")).toBeInTheDocument(); 172 173 expect(await screen.findByText("Data")).toBeInTheDocument(); 173 174 expect(await screen.findByText("Downloads")).toBeInTheDocument();
+11 -1
src/lib/api/profile.ts
··· 1 1 import { parseActorList, parseProfileFeed, parseProfileResult } from "$/lib/profile"; 2 - import type { CreateRecordResult, ProfileLookupResult } from "$/lib/types"; 2 + import type { CreateRecordResult, FlaggedFollow, FollowBatchResult, ProfileLookupResult } from "$/lib/types"; 3 3 import { invoke } from "@tauri-apps/api/core"; 4 4 5 5 async function getProfile(actor: string): Promise<ProfileLookupResult> { ··· 36 36 ); 37 37 } 38 38 39 + async function auditFollows(): Promise<FlaggedFollow[]> { 40 + return invoke("audit_follows"); 41 + } 42 + 43 + async function batchUnfollow(followUris: string[]): Promise<FollowBatchResult> { 44 + return invoke("batch_unfollow", { followUris }); 45 + } 46 + 39 47 export const ProfileController = { 48 + auditFollows, 49 + batchUnfollow, 40 50 getProfile, 41 51 getAuthorFeed, 42 52 getActorLikes,
+2
src/lib/constants/events.ts
··· 9 9 export const NOTIFICATIONS_UNREAD_COUNT_EVENT = "notifications:unread-count"; 10 10 11 11 export const NAVIGATION_EVENT = "navigation:explorer-resolved"; 12 + 13 + export const FOLLOW_HYGIENE_PROGRESS_EVENT = "follow-hygiene:progress";
+6
src/lib/types.ts
··· 138 138 139 139 export type ActorListResponse = { cursor?: string | null; actors: ProfileViewBasic[] }; 140 140 141 + export type FlaggedFollow = { did: string; followUri: string; handle: string; status: number; statusLabel: string }; 142 + 143 + export type FollowBatchResult = { deleted: number; failed: string[] }; 144 + 145 + export type FollowHygieneProgress = { current: number; total: number }; 146 + 141 147 export type FeedGeneratorView = { 142 148 uri: string; 143 149 did: string;