diff --git a/docs/tasks/16-follows.md b/docs/tasks/16-follows.md index 1920189..41e1358 100644 --- a/docs/tasks/16-follows.md +++ b/docs/tasks/16-follows.md @@ -24,19 +24,19 @@ Spec: [follow-hygiene.md](../specs/follow-hygiene.md) #### Core -- [ ] **Create `FollowHygienePanel` component** (`src/components/profile/FollowHygienePanel.tsx`). Local state via `createStore`. Phases: idle → scanning → ready → unfollowing → done. -- [ ] **Progress bar.** Listen to `follow-hygiene:progress` Tauri events during scan. Determinate bar with animated fill. -- [ ] **Flagged account list.** Scrollable list with per-row checkbox, handle, DID, status label chip. Selected rows get background tint. Use `For` (not map). -- [ ] **Category filter sidebar.** Sticky sidebar with visibility toggles and select-all checkboxes per status category. Selection counter. -- [ ] **Unfollow flow.** Confirmation dialog before destructive action should invoke `batch_unfollow`, remove completed rows with exit animation, show result summary. -- [ ] **Entry points.** Add "Audit follows" button to the authenticated user's own profile panel. - - [ ] Add secondary entry in Settings > Account section. +- [x] **Create `FollowHygienePanel` component** (`src/components/profile/FollowHygienePanel.tsx`). Local state via `createStore`. Phases: idle → scanning → ready → unfollowing → done. +- [x] **Progress bar.** Listen to `follow-hygiene:progress` Tauri events during scan. Determinate bar with animated fill. +- [x] **Flagged account list.** Scrollable list with per-row checkbox, handle, DID, status label chip. Selected rows get background tint. Use `For` (not map). +- [x] **Category filter sidebar.** Sticky sidebar with visibility toggles and select-all checkboxes per status category. Selection counter. +- [x] **Unfollow flow.** Confirmation dialog before destructive action should invoke `batch_unfollow`, remove completed rows with exit animation, show result summary. +- [x] **Entry points.** Add "Audit follows" button to the authenticated user's own profile panel. + - [x] Add secondary entry in Settings > Account section. #### Polish -- [ ] Keyboard shortcuts: `Space` toggle, `Ctrl+A` select all, `Escape` close -- [ ] `Motion` staggered fade-in on scan results, exit animation on unfollow -- [ ] `Presence` fade-in on confirmation dialog -- [ ] Skeleton/spinner states during scan -- [ ] Empty state message when no flagged accounts found -- [ ] Error handling: toast on scan failure, inline retry for batch unfollow failures +- [x] Keyboard shortcuts: `Space` toggle, `Ctrl+A` select all, `Escape` close +- [x] `Motion` staggered fade-in on scan results, exit animation on unfollow +- [x] `Presence` fade-in on confirmation dialog +- [x] Skeleton/spinner states during scan +- [x] Empty state message when no flagged accounts found +- [x] Error handling: toast on scan failure, inline retry for batch unfollow failures diff --git a/src/components/explorer/ExplorerPanel.tsx b/src/components/explorer/ExplorerPanel.tsx index 002eb9d..c42a580 100644 --- a/src/components/explorer/ExplorerPanel.tsx +++ b/src/components/explorer/ExplorerPanel.tsx @@ -63,6 +63,25 @@ function hasCachedLexiconIcon(icons: Record, collection: return Object.prototype.hasOwnProperty.call(icons, collection); } +function parseExplorerTargetFromHash(hash: string) { + const queryIndex = hash.indexOf("?"); + if (queryIndex === -1 || queryIndex === hash.length - 1) { + return null; + } + + const params = new URLSearchParams(hash.slice(queryIndex + 1)); + const value = params.get("target"); + if (!value) { + return null; + } + + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + export function ExplorerPanel() { const explorer = createExplorerState(); const [clearingIconCache, setClearingIconCache] = createSignal(false); @@ -432,7 +451,7 @@ export function ExplorerPanel() { onMount(() => { let unlisten: (() => void) | undefined; - const pendingTarget = consumeQueuedExplorerTarget(); + const pendingTarget = consumeQueuedExplorerTarget() ?? parseExplorerTargetFromHash(globalThis.location.hash); void listen(NAVIGATION_EVENT, (event) => { const target = event.payload.target; diff --git a/src/components/profile/FollowHygeineConfirmationDialog.tsx b/src/components/profile/FollowHygeineConfirmationDialog.tsx new file mode 100644 index 0000000..60ebd5d --- /dev/null +++ b/src/components/profile/FollowHygeineConfirmationDialog.tsx @@ -0,0 +1,85 @@ +import { Show } from "solid-js"; +import { Motion, Presence } from "solid-motionone"; +import { Icon } from "../shared/Icon"; + +function ConfirmationDialogBody(props: { selectedCount: number }) { + return ( +
+

Unfollow selected accounts?

+

+ This will unfollow {props.selectedCount} account(s). This action cannot be undone. +

+
+ ); +} + +function ConfirmationDialogActions(props: { pending: boolean; onCancel: () => void; onConfirm: () => void }) { + return ( +
+ + +
+ ); +} + +function ConfirmationDialogCard( + props: { pending: boolean; selectedCount: number; onCancel: () => void; onConfirm: () => void }, +) { + return ( + event.stopPropagation()}> + + + + ); +} + +function ConfirmationDialogOverlay( + props: { pending: boolean; selectedCount: number; onCancel: () => void; onConfirm: () => void }, +) { + return ( + props.onCancel()}> + + + ); +} + +export function ConfirmationDialog( + props: { isOpen: boolean; pending: boolean; selectedCount: number; onCancel: () => void; onConfirm: () => void }, +) { + return ( + + + + + + ); +} diff --git a/src/components/profile/FollowHygieneCategories.tsx b/src/components/profile/FollowHygieneCategories.tsx new file mode 100644 index 0000000..e33d182 --- /dev/null +++ b/src/components/profile/FollowHygieneCategories.tsx @@ -0,0 +1,85 @@ +import { For } from "solid-js"; +import { Icon } from "../shared/Icon"; +import { STATUS_CATEGORIES, type StatusCategoryKey, type StatusCategoryState } from "./types"; + +type CategoryRowProps = { + count: number; + label: string; + selected: boolean; + visible: boolean; + onToggleSelection: () => void; + onToggleVisibility: () => void; +}; + +function CategoryRow(props: CategoryRowProps) { + return ( +
+ props.onToggleSelection()} /> + {props.label} + {props.count} + +
+ ); +} + +export type CategorySidebarProps = { + counts: Record; + filters: Record; + selectedCount: number; + totalCount: number; + onSelectAllVisible: () => void; + onToggleCategorySelection: (key: StatusCategoryKey) => void; + onToggleCategoryVisibility: (key: StatusCategoryKey) => void; +}; + +export function CategorySidebar(props: CategorySidebarProps) { + return ( + + ); +} diff --git a/src/components/profile/FollowHygieneList.tsx b/src/components/profile/FollowHygieneList.tsx new file mode 100644 index 0000000..8d74b3e --- /dev/null +++ b/src/components/profile/FollowHygieneList.tsx @@ -0,0 +1,148 @@ +import { queueExplorerTarget } from "$/lib/explorer-navigation"; +import type { FlaggedFollow } from "$/lib/types"; +import { For, Show } from "solid-js"; +import { Motion } from "solid-motionone"; +import { Icon } from "../shared/Icon"; +import { displayHandle, type FollowHygienePhase, getAtExplorerHref, getProfileHref, statusChipClass } from "./types"; + +export type FollowListViewportProps = { + exitingUris: Set; + flagged: FlaggedFollow[]; + focusedUri: string | null; + phase: FollowHygienePhase; + selectedUris: Set; + onFocusUri: (uri: string) => void; + onSpaceToggle: (uri: string) => void; + onToggle: (uri: string) => void; +}; + +type FollowRowProps = { + exiting: boolean; + follow: FlaggedFollow; + focused: boolean; + index: number; + selected: boolean; + onFocus: () => void; + onToggle: () => void; + onToggleBySpace: () => void; +}; + +function FollowRow(props: FollowRowProps) { + return ( + props.onFocus()} + onKeyDown={(event) => { + if (event.key === " ") { + event.preventDefault(); + props.onToggleBySpace(); + } + }}> + + + ); +} + +function FollowScanSkeleton() { + return ( +
+ + {() => ( +
+
+ +
+ + +
+
+
+ )} +
+
+ ); +} + +function FollowListEmptyState(props: { phase: FollowHygienePhase }) { + const message = () => props.phase === "idle" ? "Run a scan to inspect your follows." : "No flagged follows found."; + const detail = () => + props.phase === "idle" + ? "This checks for deleted, deactivated, blocked, hidden, and self-follow accounts." + : "Your following list looks clean."; + + return ( +
+
+

{message()}

+

{detail()}

+
+
+ ); +} + +export function FollowListViewport(props: FollowListViewportProps) { + return ( +
+ }> + 0} fallback={}> +
+ + {(follow, index) => ( + props.onFocusUri(follow.followUri)} + onToggle={() => props.onToggle(follow.followUri)} + onToggleBySpace={() => props.onSpaceToggle(follow.followUri)} /> + )} + +
+
+
+
+ ); +} diff --git a/src/components/profile/FollowHygienePanel.tsx b/src/components/profile/FollowHygienePanel.tsx new file mode 100644 index 0000000..b1cb443 --- /dev/null +++ b/src/components/profile/FollowHygienePanel.tsx @@ -0,0 +1,552 @@ +import { Icon } from "$/components/shared/Icon"; +import { useAppSession } from "$/contexts/app-session"; +import { ProfileController } from "$/lib/api/profile"; +import { FOLLOW_HYGIENE_PROGRESS_EVENT } from "$/lib/constants/events"; +import { asRecord, optionalNumber } from "$/lib/type-guards"; +import type { FlaggedFollow, FollowBatchResult, FollowHygieneProgress } from "$/lib/types"; +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 { createMemo, onCleanup, onMount, Show } from "solid-js"; +import { createStore } from "solid-js/store"; +import { Motion } from "solid-motionone"; +import { ConfirmationDialog } from "./FollowHygeineConfirmationDialog"; +import { CategorySidebar, type CategorySidebarProps } from "./FollowHygieneCategories"; +import { FollowListViewport, type FollowListViewportProps } from "./FollowHygieneList"; +import { ScanToolbar, type ScanToolbarProps } from "./FollowHygieneToolbar"; +import { EXIT_ANIMATION_MS, hasStatus, STATUS_CATEGORIES } from "./types"; +import type { FollowHygienePhase } from "./types"; + +type StatusCategoryKey = "deleted" | "deactivated" | "suspended" | "blockedBy" | "blocking" | "hidden" | "selfFollow"; + +type StatusCategoryState = { visible: boolean; selected: boolean }; + +type FollowHygieneState = { + confirmOpen: boolean; + exitingUris: Set; + flagged: FlaggedFollow[]; + focusedUri: string | null; + phase: FollowHygienePhase; + progress: FollowHygieneProgress; + result: FollowBatchResult | null; + scanError: string | null; + selectedUris: Set; + unfollowError: string | null; + filters: Record; +}; + +function createDefaultFilters(): Record { + return { + deleted: { visible: true, selected: false }, + deactivated: { visible: true, selected: false }, + suspended: { visible: true, selected: false }, + blockedBy: { visible: true, selected: false }, + blocking: { visible: true, selected: false }, + hidden: { visible: true, selected: false }, + selfFollow: { visible: true, selected: false }, + }; +} + +function createInitialState(): FollowHygieneState { + return { + confirmOpen: false, + exitingUris: new Set(), + flagged: [], + focusedUri: null, + phase: "idle", + progress: { current: 0, total: 1 }, + result: null, + scanError: null, + selectedUris: new Set(), + unfollowError: null, + filters: createDefaultFilters(), + }; +} + +function parseProgressPayload(payload: unknown): FollowHygieneProgress | null { + const record = asRecord(payload); + if (!record) { + return null; + } + + 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)) }; +} + +function FollowHygieneHeader(props: { onClose: () => void }) { + return ( +
+
+

Account maintenance

+

Follow hygiene

+
+ +
+ ); +} + +type FollowHygieneLayoutProps = { + footer: FooterActionsProps; + list: FollowListViewportProps; + sidebar: CategorySidebarProps; + toolbar: ScanToolbarProps; +}; + +function FollowHygieneLayout(props: FollowHygieneLayoutProps) { + return ( +
+ + +
+ + + +
+
+ ); +} + +type FooterActionsProps = { + canUnfollow: boolean; + failedCount: number; + phase: FollowHygienePhase; + result: FollowBatchResult | null; + selectedCount: number; + selectedVisibleCount: number; + visibleCount: number; + onRetryFailed: () => void; + onUnfollow: () => void; +}; + +function FooterActions(props: FooterActionsProps) { + const pending = () => props.phase === "unfollowing"; + + return ( +
+
+

+ {props.selectedVisibleCount} of {props.visibleCount} visible selected ({props.selectedCount} total). +

+ + +
+ + + {(result) => ( +
+ {result().deleted} unfollowed, {result().failed.length} failed. + 0}> + + +
+ )} +
+
+ ); +} + +export function FollowHygienePanel(props: { onClose: () => void }) { + const session = useAppSession(); + const [state, setState] = createStore(createInitialState()); + let panelRef: HTMLDivElement | undefined; + let requestId = 0; + let exitTimer: ReturnType | undefined; + + const categoryCounts = createMemo(() => { + const counts: Record = { + deleted: 0, + deactivated: 0, + suspended: 0, + blockedBy: 0, + blocking: 0, + hidden: 0, + selfFollow: 0, + }; + + for (const follow of state.flagged) { + for (const category of STATUS_CATEGORIES) { + if (hasStatus(follow.status, category.bit)) { + counts[category.key] += 1; + } + } + } + + return counts; + }); + + const visibleFlagged = createMemo(() => + state.flagged.filter((follow) => + STATUS_CATEGORIES.some((category) => + state.filters[category.key].visible && hasStatus(follow.status, category.bit) + ) + ) + ); + + const progressPercent = createMemo(() => { + const total = Math.max(1, state.progress.total); + const ratio = Math.min(1, state.progress.current / total); + return Math.round(ratio * 100); + }); + + const selectedCount = createMemo(() => state.selectedUris.size); + const selectedVisibleCount = createMemo(() => { + const selected = state.selectedUris; + return visibleFlagged().reduce((count, follow) => count + (selected.has(follow.followUri) ? 1 : 0), 0); + }); + + const canUnfollow = createMemo(() => + selectedCount() > 0 && state.phase !== "scanning" && state.phase !== "unfollowing" + ); + + const failedCount = createMemo(() => state.result?.failed.length ?? 0); + const showProgress = createMemo(() => state.phase === "scanning" || state.progress.current > 0); + + const sidebarProps = createMemo(() => ({ + counts: categoryCounts(), + filters: state.filters, + selectedCount: selectedCount(), + totalCount: state.flagged.length, + onSelectAllVisible: selectAllVisible, + onToggleCategorySelection: toggleCategorySelection, + onToggleCategoryVisibility: toggleCategoryVisibility, + })); + + const toolbarProps = createMemo(() => ({ + phase: state.phase, + progress: state.progress, + progressPercent: progressPercent(), + scanError: state.scanError, + showProgress: showProgress(), + unfollowError: state.unfollowError, + onScan: startScan, + })); + + const listProps = createMemo(() => ({ + exitingUris: state.exitingUris, + flagged: visibleFlagged(), + focusedUri: state.focusedUri, + phase: state.phase, + selectedUris: state.selectedUris, + onFocusUri: (uri) => setState("focusedUri", uri), + onSpaceToggle: toggleSelection, + onToggle: toggleSelection, + })); + + const footerProps = createMemo(() => ({ + canUnfollow: canUnfollow(), + failedCount: failedCount(), + phase: state.phase, + result: state.result, + selectedCount: selectedCount(), + selectedVisibleCount: selectedVisibleCount(), + visibleCount: visibleFlagged().length, + onRetryFailed: handleRetryFailed, + 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); + } + + async function startScan() { + if (state.phase === "scanning" || state.phase === "unfollowing") { + return; + } + + requestId += 1; + const activeRequest = requestId; + setState({ + confirmOpen: false, + exitingUris: new Set(), + flagged: [], + focusedUri: null, + phase: "scanning", + progress: { current: 0, total: 1 }, + result: null, + scanError: null, + selectedUris: new Set(), + unfollowError: null, + filters: createDefaultFilters(), + }); + + try { + const flagged = await ProfileController.auditFollows(); + if (activeRequest !== requestId) { + return; + } + + 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 }), + ); + } catch (error) { + if (activeRequest !== requestId) { + return; + } + + const message = "Couldn't scan your follows right now."; + logger.error("follow hygiene scan failed", { keyValues: { error: normalizeError(error) } }); + setState("phase", "idle"); + setState("scanError", message); + session.reportError(message); + } + } + + function toggleSelection(followUri: string) { + const next = new Set(state.selectedUris); + if (next.has(followUri)) { + next.delete(followUri); + } else { + next.add(followUri); + } + updateSelectedUris(next); + } + + function selectAllVisible() { + const next = new Set(state.selectedUris); + for (const follow of visibleFlagged()) { + next.add(follow.followUri); + } + updateSelectedUris(next); + } + + function toggleCategoryVisibility(key: StatusCategoryKey) { + setState("filters", key, "visible", (visible) => !visible); + } + + function toggleCategorySelection(key: StatusCategoryKey) { + const category = STATUS_CATEGORIES.find((item) => item.key === key); + if (!category) { + return; + } + + const categoryUris = state.flagged.filter((follow) => hasStatus(follow.status, category.bit)).map((follow) => + follow.followUri + ); + if (categoryUris.length === 0) { + return; + } + + const next = new Set(state.selectedUris); + const allSelected = categoryUris.every((uri) => next.has(uri)); + for (const uri of categoryUris) { + if (allSelected) { + next.delete(uri); + } else { + next.add(uri); + } + } + + updateSelectedUris(next); + } + + function openConfirmation() { + if (!canUnfollow()) { + return; + } + + setState("confirmOpen", true); + } + + function closeConfirmation() { + if (state.confirmOpen) { + setState("confirmOpen", false); + } + } + + function applyUnfollowResult(followUris: string[], result: FollowBatchResult) { + const failed = new Set(result.failed); + const successfulUris = followUris.filter((uri) => !failed.has(uri)); + const nextSelected = new Set(state.selectedUris); + for (const uri of successfulUris) { + nextSelected.delete(uri); + } + + setState("result", result); + setState("phase", "done"); + updateSelectedUris(nextSelected); + + if (successfulUris.length === 0) { + return; + } + + setState("exitingUris", new Set(successfulUris)); + + if (exitTimer) { + clearTimeout(exitTimer); + } + + 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); + }, EXIT_ANIMATION_MS); + } + + async function runUnfollow(followUris: string[]) { + if (followUris.length === 0 || state.phase === "unfollowing") { + return; + } + + setState("confirmOpen", false); + setState("phase", "unfollowing"); + setState("unfollowError", null); + + try { + const result = await ProfileController.batchUnfollow(followUris); + applyUnfollowResult(followUris, result); + } catch (error) { + const message = "Couldn't unfollow selected accounts right now."; + logger.error("follow hygiene unfollow failed", { keyValues: { error: normalizeError(error) } }); + setState("phase", "ready"); + setState("unfollowError", message); + } + } + + function handleConfirmUnfollow() { + const followUris = [...state.selectedUris]; + void runUnfollow(followUris); + } + + function handleRetryFailed() { + if (!state.result?.failed.length) { + return; + } + + void runUnfollow(state.result.failed); + } + + function handleGlobalKeyDown(event: KeyboardEvent) { + if (shouldIgnoreKey(event)) { + return; + } + + const key = event.key.toLowerCase(); + if (key === "escape") { + event.preventDefault(); + if (state.confirmOpen) { + setState("confirmOpen", false); + } else { + props.onClose(); + } + return; + } + + if ((event.ctrlKey || event.metaKey) && key === "a" && !event.altKey && !event.shiftKey) { + event.preventDefault(); + selectAllVisible(); + } + } + + onMount(() => { + queueMicrotask(() => panelRef?.focus()); + globalThis.addEventListener("keydown", handleGlobalKeyDown); + let unlisten: (() => void) | undefined; + + void listen(FOLLOW_HYGIENE_PROGRESS_EVENT, (event) => { + const payload = parseProgressPayload(event.payload); + if (!payload) { + return; + } + + setState("progress", payload); + }).then((dispose) => { + unlisten = dispose; + }).catch((error) => { + logger.warn("follow hygiene progress listener failed", { keyValues: { error: normalizeError(error) } }); + }); + + onCleanup(() => { + globalThis.removeEventListener("keydown", handleGlobalKeyDown); + unlisten?.(); + if (exitTimer) { + clearTimeout(exitTimer); + } + }); + }); + + return ( + <> + { + panelRef = element; + }} + aria-modal="true" + class="ui-scrim fixed inset-0 z-50 flex items-stretch justify-end p-3 backdrop-blur-xl max-sm:p-0" + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + role="dialog" + tabIndex={-1} + transition={{ duration: 0.18 }} + onClick={() => props.onClose()}> + event.stopPropagation()}> + +
+ +
+
+
+ + + + ); +} diff --git a/src/components/profile/FollowHygieneToolbar.tsx b/src/components/profile/FollowHygieneToolbar.tsx new file mode 100644 index 0000000..1763963 --- /dev/null +++ b/src/components/profile/FollowHygieneToolbar.tsx @@ -0,0 +1,66 @@ +import type { FollowHygieneProgress } from "$/lib/types"; +import { Show } from "solid-js"; +import { Motion } from "solid-motionone"; +import { Icon } from "../shared/Icon"; +import type { FollowHygienePhase } from "./types"; + +export type ScanToolbarProps = { + phase: FollowHygienePhase; + progress: FollowHygieneProgress; + progressPercent: number; + scanError: string | null; + showProgress: boolean; + unfollowError: string | null; + onScan: () => void; +}; + +function ProgressMeter(props: { current: number; total: number; percent: number }) { + return ( +
+
+ +
+

+ Scanning batches: {Math.min(props.current, props.total)} / {props.total} +

+
+ ); +} + +export function ScanToolbar(props: ScanToolbarProps) { + const scanning = () => props.phase === "scanning"; + + return ( +
+
+
+

Flagged accounts

+

+ Scan follows for deleted, deactivated, blocked, and hidden accounts. +

+
+ + +
+ + + + + + {(error) =>

{error()}

}
+ {(error) =>

{error()}

}
+
+ ); +} diff --git a/src/components/profile/ProfileHero.tsx b/src/components/profile/ProfileHero.tsx index 541afec..4ecb7e5 100644 --- a/src/components/profile/ProfileHero.tsx +++ b/src/components/profile/ProfileHero.tsx @@ -16,20 +16,31 @@ function ProfileHeroActions( isSelf: boolean; onFollow: () => void; onMessage: () => void; + onOpenFollowHygiene: () => void; onUnfollow: () => void; }, ) { return (
- -
- - -
+ + + +
+ }> + @@ -198,6 +209,7 @@ export function ProfileHero( joinedLabel: string | null; onFollow: () => void; onMessage: () => void; + onOpenFollowHygiene: () => void; onOpenFollowers: () => void; onOpenFollows: () => void; onUnfollow: () => void; @@ -247,6 +259,7 @@ export function ProfileHero( isSelf={props.isSelf} onFollow={props.onFollow} onMessage={props.onMessage} + onOpenFollowHygiene={props.onOpenFollowHygiene} onUnfollow={props.onUnfollow} /> diff --git a/src/components/profile/ProfilePanel.tsx b/src/components/profile/ProfilePanel.tsx index afcb65e..828b1b7 100644 --- a/src/components/profile/ProfilePanel.tsx +++ b/src/components/profile/ProfilePanel.tsx @@ -21,6 +21,7 @@ import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import { createStore } from "solid-js/store"; import { Presence } from "solid-motionone"; import { Icon } from "../shared/Icon"; +import { FollowHygienePanel } from "./FollowHygienePanel"; import { createActorListState, createFeedState, createProfilePanelState, tabLabel } from "./profile-state"; import type { ProfilePanelState } from "./profile-state"; import { ActorListOverlay } from "./ProfileActorList"; @@ -38,6 +39,7 @@ export function ProfilePanel(props: { actor: string | null; embedded?: boolean } const postNavigation = usePostNavigation(); const [state, setState] = createStore(createProfilePanelState()); const [heroHeight, setHeroHeight] = createSignal(null); + const [followHygieneOpen, setFollowHygieneOpen] = createSignal(false); let requestSequence = 0; const interactions = usePostInteractions({ onError: session.reportError, @@ -457,6 +459,7 @@ export function ProfilePanel(props: { actor: string | null; embedded?: boolean } joinedLabel={joinedLabel()} onFollow={handleFollow} onMessage={handleMessage} + onOpenFollowHygiene={() => setFollowHygieneOpen(true)} onOpenFollowers={() => openActorList("followers")} onOpenFollows={() => openActorList("follows")} onUnfollow={handleUnfollow} @@ -525,6 +528,12 @@ export function ProfilePanel(props: { actor: string | null; embedded?: boolean } sessionDid={session.activeDid} /> + + + + setFollowHygieneOpen(false)} /> + + ); } diff --git a/src/components/profile/tests/FollowHygienePanel.test.tsx b/src/components/profile/tests/FollowHygienePanel.test.tsx new file mode 100644 index 0000000..c3a8dbe --- /dev/null +++ b/src/components/profile/tests/FollowHygienePanel.test.tsx @@ -0,0 +1,166 @@ +import { AppTestProviders } from "$/test/providers"; +import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { FollowHygienePanel } from "../FollowHygienePanel"; + +const auditFollowsMock = vi.hoisted(() => vi.fn()); +const batchUnfollowMock = vi.hoisted(() => vi.fn()); +const listenMock = vi.hoisted(() => vi.fn()); +const onCloseMock = vi.hoisted(() => vi.fn()); +const reportErrorMock = vi.hoisted(() => vi.fn()); + +vi.mock( + "$/lib/api/profile", + () => ({ ProfileController: { auditFollows: auditFollowsMock, batchUnfollow: batchUnfollowMock } }), +); + +vi.mock("@tauri-apps/api/event", () => ({ listen: listenMock })); + +const FOLLOW_STATUS_DELETED = Math.trunc(1); +const FOLLOW_STATUS_DEACTIVATED = 1 << 1; +const FOLLOW_STATUS_BLOCKED_BY = 1 << 3; +const FOLLOW_STATUS_BLOCKING = 1 << 4; +const FOLLOW_STATUS_HIDDEN = 1 << 5; + +function renderPanel() { + render(() => ( + + + + )); +} + +function createFlagged() { + return [{ + did: "did:plc:ghost", + followUri: "at://did:plc:alice/app.bsky.graph.follow/1", + handle: "ghost.test", + status: FOLLOW_STATUS_DELETED, + statusLabel: "Deleted", + }, { + did: "did:plc:nap", + followUri: "at://did:plc:alice/app.bsky.graph.follow/2", + handle: "nap.test", + status: FOLLOW_STATUS_DEACTIVATED | FOLLOW_STATUS_HIDDEN, + statusLabel: "Deactivated, Hidden", + }, { + did: "did:plc:mutual", + followUri: "at://did:plc:alice/app.bsky.graph.follow/3", + handle: "mutual.test", + status: FOLLOW_STATUS_BLOCKED_BY | FOLLOW_STATUS_BLOCKING, + statusLabel: "Mutual Block", + }]; +} + +describe("FollowHygienePanel", () => { + beforeEach(() => { + vi.resetAllMocks(); + auditFollowsMock.mockResolvedValue(createFlagged()); + batchUnfollowMock.mockResolvedValue({ deleted: 0, failed: [] }); + listenMock.mockResolvedValue(vi.fn()); + }); + + it("scans, renders progress, and applies category visibility filtering", async () => { + renderPanel(); + + fireEvent.click(screen.getByRole("button", { name: "Scan follows" })); + + const listener = listenMock.mock.calls[0]?.[1]; + expect(listener).toBeTypeOf("function"); + listener({ payload: { current: 1, total: 4 } }); + + expect(await screen.findByText("@ghost.test")).toBeInTheDocument(); + expect(screen.getByText(/Scanning batches: [1-4] \/ 4/u)).toBeInTheDocument(); + expect(screen.getByText("3 of 3 visible selected (3 total).")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Hide Deleted" })); + + await waitFor(() => { + expect(screen.queryByText("@ghost.test")).not.toBeInTheDocument(); + expect(screen.getByText("2 of 2 visible selected (3 total).")).toBeInTheDocument(); + }); + }); + + it("supports keyboard shortcuts for space toggle and ctrl+a select all visible", async () => { + renderPanel(); + + fireEvent.click(screen.getByRole("button", { name: "Scan follows" })); + expect(await screen.findByText("@ghost.test")).toBeInTheDocument(); + expect(screen.getByText("3 of 3 visible selected (3 total).")).toBeInTheDocument(); + + const row = screen.getByText("@ghost.test").closest("article"); + expect(row).not.toBeNull(); + row?.focus(); + fireEvent.keyDown(row as HTMLElement, { key: " " }); + + await waitFor(() => { + expect(screen.getByText("2 of 3 visible selected (2 total).")).toBeInTheDocument(); + }); + + fireEvent.keyDown(document, { key: "a", ctrlKey: true }); + + await waitFor(() => { + expect(screen.getByText("3 of 3 visible selected (3 total).")).toBeInTheDocument(); + }); + }); + + it("unfollows selected accounts, keeps failures for retry, and supports escape behavior", async () => { + batchUnfollowMock.mockResolvedValueOnce({ deleted: 2, failed: ["at://did:plc:alice/app.bsky.graph.follow/3"] }) + .mockResolvedValueOnce({ deleted: 1, failed: [] }); + + renderPanel(); + + fireEvent.click(screen.getByRole("button", { name: "Scan follows" })); + expect(await screen.findByText("@ghost.test")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Unfollow selected" })); + expect(await screen.findByText("Unfollow selected accounts?")).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => expect(screen.queryByText("Unfollow selected accounts?")).not.toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "Unfollow selected" })); + fireEvent.click(await screen.findByRole("button", { name: "Confirm unfollow" })); + + await waitFor(() => { + expect(batchUnfollowMock).toHaveBeenNthCalledWith(1, [ + "at://did:plc:alice/app.bsky.graph.follow/1", + "at://did:plc:alice/app.bsky.graph.follow/2", + "at://did:plc:alice/app.bsky.graph.follow/3", + ]); + expect(screen.getByText("2 unfollowed, 1 failed.")).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.queryByText("@ghost.test")).not.toBeInTheDocument(); + expect(screen.queryByText("@nap.test")).not.toBeInTheDocument(); + expect(screen.getByText("@mutual.test")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Retry failed" })); + + await waitFor(() => { + expect(batchUnfollowMock).toHaveBeenNthCalledWith(2, ["at://did:plc:alice/app.bsky.graph.follow/3"]); + expect(screen.getByText("1 unfollowed, 0 failed.")).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.queryByText("@mutual.test")).not.toBeInTheDocument(); + }); + + fireEvent.keyDown(document, { key: "Escape" }); + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); + + it("reports a friendly scan failure", async () => { + auditFollowsMock.mockRejectedValueOnce(new Error("network down")); + renderPanel(); + + fireEvent.click(screen.getByRole("button", { name: "Scan follows" })); + + await waitFor(() => { + expect(screen.getByText("Couldn't scan your follows right now.")).toBeInTheDocument(); + expect(reportErrorMock).toHaveBeenCalledWith("Couldn't scan your follows right now."); + }); + }); +}); diff --git a/src/components/profile/tests/ProfilePanel.test.tsx b/src/components/profile/tests/ProfilePanel.test.tsx index c15d3d5..a22b4cf 100644 --- a/src/components/profile/tests/ProfilePanel.test.tsx +++ b/src/components/profile/tests/ProfilePanel.test.tsx @@ -3,6 +3,8 @@ import { fireEvent, render, screen, waitFor, within } from "@solidjs/testing-lib import { beforeEach, describe, expect, it, vi } from "vitest"; import { ProfilePanel } from "../ProfilePanel"; +const auditFollowsMock = vi.hoisted(() => vi.fn()); +const batchUnfollowMock = vi.hoisted(() => vi.fn()); const followActorMock = vi.hoisted(() => vi.fn()); const getActorLikesMock = vi.hoisted(() => vi.fn()); const getAuthorFeedMock = vi.hoisted(() => vi.fn()); @@ -27,6 +29,8 @@ vi.mock( "$/lib/api/profile", () => ({ ProfileController: { + auditFollows: auditFollowsMock, + batchUnfollow: batchUnfollowMock, followActor: followActorMock, getActorLikes: getActorLikesMock, getAuthorFeed: getAuthorFeedMock, @@ -80,13 +84,14 @@ function createProfile() { }; } -function renderProfilePanel(actor = "bob.test") { +function renderProfilePanel(actor = "bob.test", session: Record = {}) { render(() => ( @@ -126,10 +131,22 @@ describe("ProfilePanel", () => { }); getFollowersMock.mockResolvedValue({ actors: [], cursor: null }); getFollowsMock.mockResolvedValue({ actors: [], cursor: null }); + auditFollowsMock.mockResolvedValue([]); + batchUnfollowMock.mockResolvedValue({ deleted: 0, failed: [] }); followActorMock.mockResolvedValue({ cid: "cid-follow", uri: "at://did:plc:alice/app.bsky.graph.follow/1" }); unfollowActorMock.mockResolvedValue(void 0); }); + it("shows follow hygiene entry on the signed-in profile", async () => { + renderProfilePanel("bob.test", { + activeDid: "did:plc:bob", + activeHandle: "bob.test", + activeSession: { did: "did:plc:bob", handle: "bob.test" }, + }); + + expect(await screen.findByRole("button", { name: "Audit follows" })).toBeInTheDocument(); + }); + it("optimistically follows and unfollows from the hero while keeping badges in sync", async () => { const followRequest = deferred<{ cid: string; uri: string }>(); followActorMock.mockReturnValueOnce(followRequest.promise); diff --git a/src/components/profile/types.ts b/src/components/profile/types.ts new file mode 100644 index 0000000..3b3beb7 --- /dev/null +++ b/src/components/profile/types.ts @@ -0,0 +1,74 @@ +import { buildProfileRoute } from "$/lib/profile"; +import type { FlaggedFollow } from "$/lib/types"; + +export type StatusCategoryKey = + | "deleted" + | "deactivated" + | "suspended" + | "blockedBy" + | "blocking" + | "hidden" + | "selfFollow"; + +export type StatusCategoryState = { visible: boolean; selected: boolean }; + +export const FOLLOW_STATUS_DELETED = Math.trunc(1); +export const FOLLOW_STATUS_DEACTIVATED = 1 << 1; +export const FOLLOW_STATUS_SUSPENDED = 1 << 2; +export const FOLLOW_STATUS_BLOCKED_BY = 1 << 3; +export const FOLLOW_STATUS_BLOCKING = 1 << 4; +export const FOLLOW_STATUS_HIDDEN = 1 << 5; +export const FOLLOW_STATUS_SELF_FOLLOW = 1 << 6; +export const EXIT_ANIMATION_MS = 220; + +export const STATUS_CATEGORIES: Array<{ key: StatusCategoryKey; label: string; bit: number }> = [ + { key: "deleted", label: "Deleted", bit: FOLLOW_STATUS_DELETED }, + { key: "deactivated", label: "Deactivated", bit: FOLLOW_STATUS_DEACTIVATED }, + { key: "suspended", label: "Suspended", bit: FOLLOW_STATUS_SUSPENDED }, + { key: "blockedBy", label: "Blocked by", bit: FOLLOW_STATUS_BLOCKED_BY }, + { key: "blocking", label: "Blocking", bit: FOLLOW_STATUS_BLOCKING }, + { key: "hidden", label: "Hidden", bit: FOLLOW_STATUS_HIDDEN }, + { key: "selfFollow", label: "Self-follow", bit: FOLLOW_STATUS_SELF_FOLLOW }, +]; + +export function hasStatus(status: number, bit: number) { + return (status & bit) !== 0; +} + +export function statusChipClass(status: number) { + if (hasStatus(status, FOLLOW_STATUS_DELETED)) { + return "bg-red-400/16 text-red-300"; + } + if (hasStatus(status, FOLLOW_STATUS_DEACTIVATED)) { + return "bg-yellow-400/16 text-yellow-300"; + } + if (hasStatus(status, FOLLOW_STATUS_SUSPENDED)) { + return "bg-orange-400/16 text-orange-300"; + } + if (hasStatus(status, FOLLOW_STATUS_BLOCKED_BY) || hasStatus(status, FOLLOW_STATUS_BLOCKING)) { + return "bg-violet-400/16 text-violet-300"; + } + if (hasStatus(status, FOLLOW_STATUS_HIDDEN)) { + return "bg-pink-400/16 text-pink-300"; + } + return "bg-slate-400/16 text-slate-300"; +} + +export type FollowHygienePhase = "idle" | "scanning" | "ready" | "unfollowing" | "done"; + +export function getAtExplorerHref(follow: FlaggedFollow) { + return `#/explorer?target=${encodeURIComponent(follow.followUri)}`; +} + +export function displayHandle(follow: FlaggedFollow) { + if (follow.handle.startsWith("did:")) { + return follow.handle; + } + + return `@${follow.handle.replace(/^@/, "")}`; +} + +export function getProfileHref(follow: FlaggedFollow) { + const actor = follow.handle.startsWith("did:") ? follow.did : follow.handle.replace(/^@/, ""); + return `#${buildProfileRoute(actor)}`; +} diff --git a/src/components/settings/SettingsAccount.tsx b/src/components/settings/SettingsAccount.tsx index dd94969..ef25ff1 100644 --- a/src/components/settings/SettingsAccount.tsx +++ b/src/components/settings/SettingsAccount.tsx @@ -7,7 +7,7 @@ import { SettingsCard } from "./SettingsCard"; function AccountItem(props: { account: AccountSummary; active: boolean; onRemove: () => void; onSwitch: () => void }) { return ( -
+
@@ -68,6 +68,7 @@ export function AccountControl( onConfirm: () => void; }, ) => void; + onOpenFollowHygiene: () => void; }, ) { const session = useAppSession(); @@ -97,6 +98,22 @@ export function AccountControl( 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"> Add account + +
+
+

Follow hygiene

+

+ Audit follows for deleted, deactivated, blocked, and hidden accounts. +

+
+ +
); diff --git a/src/components/settings/SettingsPanel.tsx b/src/components/settings/SettingsPanel.tsx index 0711c1e..c917a1a 100644 --- a/src/components/settings/SettingsPanel.tsx +++ b/src/components/settings/SettingsPanel.tsx @@ -17,6 +17,7 @@ import * as logger from "@tauri-apps/plugin-log"; import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; import { createStore } from "solid-js/store"; import { Motion, Presence } from "solid-motionone"; +import { FollowHygienePanel } from "../profile/FollowHygienePanel"; import { Icon } from "../shared/Icon"; import { SettingsAbout } from "./SettingsAbout"; import { AccountControl } from "./SettingsAccount"; @@ -44,6 +45,7 @@ type SettingsPanelState = { onConfirm: () => void; } | null; modalOpen: boolean; + followHygieneOpen: boolean; }; function ConfirmationModal( @@ -168,6 +170,7 @@ export function SettingsPanel() { const navigate = useNavigate(); const [panel, setPanel] = createStore({ cacheSize: null, + followHygieneOpen: false, logLevel: "all", logs: [], logsExpanded: false, @@ -289,7 +292,9 @@ export function SettingsPanel() { - + setPanel("followHygieneOpen", true)} /> + + + + setPanel("followHygieneOpen", false)} /> + + ); } diff --git a/src/components/settings/tests/SettingsPanel.test.tsx b/src/components/settings/tests/SettingsPanel.test.tsx index 29343c5..e6b8b03 100644 --- a/src/components/settings/tests/SettingsPanel.test.tsx +++ b/src/components/settings/tests/SettingsPanel.test.tsx @@ -168,6 +168,7 @@ describe("SettingsPanel", () => { expect(await screen.findByText("Notifications")).toBeInTheDocument(); expect(await screen.findByText("Moderation")).toBeInTheDocument(); expect(await screen.findByText("Accounts")).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: "Audit follows" })).toBeInTheDocument(); expect(await screen.findByText("Services")).toBeInTheDocument(); expect(await screen.findByText("Data")).toBeInTheDocument(); expect(await screen.findByText("Downloads")).toBeInTheDocument(); diff --git a/src/lib/api/profile.ts b/src/lib/api/profile.ts index 064f85f..dca23ee 100644 --- a/src/lib/api/profile.ts +++ b/src/lib/api/profile.ts @@ -1,5 +1,5 @@ import { parseActorList, parseProfileFeed, parseProfileResult } from "$/lib/profile"; -import type { CreateRecordResult, ProfileLookupResult } from "$/lib/types"; +import type { CreateRecordResult, FlaggedFollow, FollowBatchResult, ProfileLookupResult } from "$/lib/types"; import { invoke } from "@tauri-apps/api/core"; async function getProfile(actor: string): Promise { @@ -36,7 +36,17 @@ async function getFollows(actor: string, cursor?: string | null, limit?: number) ); } +async function auditFollows(): Promise { + return invoke("audit_follows"); +} + +async function batchUnfollow(followUris: string[]): Promise { + return invoke("batch_unfollow", { followUris }); +} + export const ProfileController = { + auditFollows, + batchUnfollow, getProfile, getAuthorFeed, getActorLikes, diff --git a/src/lib/constants/events.ts b/src/lib/constants/events.ts index 17cf99d..b67fa55 100644 --- a/src/lib/constants/events.ts +++ b/src/lib/constants/events.ts @@ -9,3 +9,5 @@ export const ACCOUNT_SWITCH_EVENT = "auth:account-switched"; export const NOTIFICATIONS_UNREAD_COUNT_EVENT = "notifications:unread-count"; export const NAVIGATION_EVENT = "navigation:explorer-resolved"; + +export const FOLLOW_HYGIENE_PROGRESS_EVENT = "follow-hygiene:progress"; diff --git a/src/lib/types.ts b/src/lib/types.ts index dad9db5..f656bfb 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -138,6 +138,12 @@ export type ProfileLookupResult = ProfileLookupAvailable | ProfileLookupUnavaila export type ActorListResponse = { cursor?: string | null; actors: ProfileViewBasic[] }; +export type FlaggedFollow = { did: string; followUri: string; handle: string; status: number; statusLabel: string }; + +export type FollowBatchResult = { deleted: number; failed: string[] }; + +export type FollowHygieneProgress = { current: number; total: number }; + export type FeedGeneratorView = { uri: string; did: string;