'use client' import { useState, useEffect, useMemo, useCallback } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { PostPreview } from '@/components/PostPreview' import { ScopeUpgradePrompt, isInsufficientScope } from '@/components/ScopeUpgradePrompt' import { CriteriaSelector } from '@/components/CriteriaSelector' import { AccountList } from '@/components/AccountList' import { BlockProgress } from '@/components/BlockProgress' import type { EngagementAccount, PostData, BlockCriteria, ProgressMessage, EngagementType, BlockEventSummary, BlockEventRecord, } from '@/types' type Phase = 'input' | 'loading' | 'preview' | 'blocking' | 'done' interface BlockClientProps { initialUrl: string initialEvents: BlockEventSummary[] } const DEFAULT_CRITERIA: BlockCriteria = { blockOp: true, blockLikes: true, blockReposts: true, blockQuotes: false, blockReplies: false, } const STATUS_STYLE: Record = { 'in-progress': 'border-sky-500/50 bg-sky-500/10 text-sky-600 dark:text-sky-400', partial: 'border-amber-500/50 bg-amber-500/10 text-amber-600 dark:text-amber-400', complete: 'border-green-500/50 bg-green-500/10 text-green-600 dark:text-green-400', undone: 'border-violet-500/50 bg-violet-500/10 text-violet-600 dark:text-violet-400', empty: 'border-border bg-muted text-[var(--fo-text3)]', } const STATUS_LABEL: Record = { 'in-progress': 'In Progress', partial: 'Partial', complete: 'Complete', undone: 'Undone', empty: 'Empty', } function getEventStatus(record: BlockEventRecord): string { const { stats, blockedDids, blockedRkeys, failedDids, pendingDids } = record const total = stats?.total ?? blockedDids.length + failedDids.length + pendingDids.length const completed = stats?.completed ?? blockedDids.length const failed = stats?.failed ?? failedDids.length const pending = pendingDids.length const isUndone = !!record.undoneAt || (total > 0 && completed === 0 && failed === 0 && pending === 0 && blockedRkeys.length === 0) if (isUndone) return 'undone' if (pending > 0) return 'in-progress' if (failed > 0) return 'partial' if (completed > 0) return 'complete' return 'empty' } function formatDate(iso: string): string { try { return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }) } catch { return '' } } function defaultSelected(accounts: EngagementAccount[]): Set { const selected = new Set() for (const a of accounts) { if (a.relationship === 'mutual' || a.relationship === 'following') continue selected.add(a.profile.did) } return selected } function meetsCriteria(account: EngagementAccount, criteria: BlockCriteria): boolean { const typeMap: Record = { op: criteria.blockOp, like: criteria.blockLikes, repost: criteria.blockReposts, quote: criteria.blockQuotes, reply: criteria.blockReplies, } return account.engagementTypes.some((t) => typeMap[t]) } export function BlockClient({ initialUrl, initialEvents }: BlockClientProps) { const router = useRouter() // Form const [urlInput, setUrlInput] = useState(initialUrl) // Data const [post, setPost] = useState(null) const [allAccounts, setAllAccounts] = useState([]) // UI const [phase, setPhase] = useState(initialUrl ? 'loading' : 'input') const [error, setError] = useState(null) const [criteria, setCriteria] = useState(DEFAULT_CRITERIA) const [selectedDids, setSelectedDids] = useState>(new Set()) // Blocking const [jobId, setJobId] = useState(null) const [eventRkey, setEventRkey] = useState(null) const [progress, setProgress] = useState(null) const [undoLoading, setUndoLoading] = useState(false) const [retryLoading, setRetryLoading] = useState(false) const [shareLoading, setShareLoading] = useState(false) const [sharedListUrl, setSharedListUrl] = useState(null) // Scope upgrade prompt — set to a description of the attempted action when // an API route returns insufficient_scope (needs the list OAuth scopes) const [scopeUpgradeAction, setScopeUpgradeAction] = useState(null) // Derived: accounts visible under current criteria const filteredAccounts = useMemo( () => allAccounts.filter((a) => meetsCriteria(a, criteria)), [allAccounts, criteria] ) // Count by engagement type (for criteria labels) const counts = useMemo(() => { const c = { op: 0, like: 0, repost: 0, quote: 0, reply: 0 } for (const a of allAccounts) { for (const t of a.engagementTypes) { c[t as keyof typeof c]++ } } return c }, [allAccounts]) // ── Fetch engagement on mount / URL change ────────────────────────────────── useEffect(() => { if (!initialUrl) return fetchPost(initialUrl) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) async function fetchPost(url: string) { setPhase('loading') setError(null) setPost(null) setAllAccounts([]) setSelectedDids(new Set()) try { const res = await fetch(`/api/post?url=${encodeURIComponent(url)}`) const data = await res.json() if (!res.ok) throw new Error(data.error ?? 'Failed to fetch post') setPost(data.post) setAllAccounts(data.accounts) setSelectedDids(defaultSelected(data.accounts)) setPhase('preview') } catch (err) { setError(err instanceof Error ? err.message : 'Something went wrong') setPhase('input') } } function handleUrlSubmit(e: React.FormEvent) { e.preventDefault() const url = urlInput.trim() if (!url) return router.push(`/block?url=${encodeURIComponent(url)}`) fetchPost(url) } // ── Criteria changes: preserve existing selections, add defaults for new ──── function handleCriteriaChange(newCriteria: BlockCriteria) { const currentlyVisible = new Set(filteredAccounts.map((a) => a.profile.did)) const newlyVisible = allAccounts.filter( (a) => !currentlyVisible.has(a.profile.did) && meetsCriteria(a, newCriteria) ) setSelectedDids((prev) => { const next = new Set(prev) for (const a of newlyVisible) { if (a.relationship !== 'mutual' && a.relationship !== 'following') { next.add(a.profile.did) } } return next }) setCriteria(newCriteria) } // ── Account toggle / select all ───────────────────────────────────────────── const handleToggle = useCallback((did: string) => { setSelectedDids((prev) => { const next = new Set(prev) if (next.has(did)) next.delete(did) else next.add(did) return next }) }, []) const handleSelectAll = useCallback(() => { setSelectedDids(new Set(filteredAccounts.map((a) => a.profile.did))) }, [filteredAccounts]) const handleDeselectAll = useCallback(() => { setSelectedDids((prev) => { const next = new Set(prev) for (const a of filteredAccounts) { if (a.relationship === 'mutual' || a.relationship === 'following') { next.delete(a.profile.did) } } return next }) }, [filteredAccounts]) // ── Start blocking ─────────────────────────────────────────────────────────── async function handleBlock() { if (!post) return const dids = filteredAccounts .filter((a) => selectedDids.has(a.profile.did)) .map((a) => a.profile.did) if (dids.length === 0) return try { const res = await fetch('/api/blocks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ postUri: post.uri, postUrl: post.url, dids, criteria }), }) const data = await res.json() if (!res.ok) throw new Error(data.error ?? 'Failed to start blocking') setJobId(data.jobId) setEventRkey(data.eventRkey) setPhase('blocking') } catch (err) { setError(err instanceof Error ? err.message : 'Failed to start blocking') } } // ── SSE progress ───────────────────────────────────────────────────────────── useEffect(() => { if (!jobId) return const source = new EventSource(`/api/blocks/status?jobId=${jobId}`) source.onmessage = (event) => { const msg = JSON.parse(event.data) as ProgressMessage setProgress(msg) if (msg.status !== 'running') { setPhase('done') source.close() } } source.onerror = () => source.close() return () => source.close() }, [jobId]) // ── Undo ───────────────────────────────────────────────────────────────────── async function handleUndo() { if (!eventRkey) return setUndoLoading(true) try { await fetch('/api/blocks/undo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ eventRkey }), }) } finally { setUndoLoading(false) } } // ── Share as block list ─────────────────────────────────────────────────────── async function handleShare() { if (!eventRkey) return setShareLoading(true) try { const res = await fetch('/api/blocks/share', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ eventRkey }), }) const data = await res.json() if (isInsufficientScope(data)) { setScopeUpgradeAction('share this block event as a list') return } if (!res.ok) throw new Error(data.error ?? 'Failed to create list') setSharedListUrl(data.listUrl) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create list') } finally { setShareLoading(false) } } // ── Retry failed ───────────────────────────────────────────────────────────── async function handleRetry() { if (!eventRkey) return setRetryLoading(true) try { const res = await fetch('/api/blocks/retry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ eventRkey }), }) const data = await res.json() if (isInsufficientScope(data)) { setScopeUpgradeAction('retry these blocks (they also get added to your shared block list)') return } if (!res.ok) throw new Error(data.error) setJobId(data.jobId) setPhase('blocking') } finally { setRetryLoading(false) } } // ── Render ─────────────────────────────────────────────────────────────────── const selectedCount = filteredAccounts.filter((a) => selectedDids.has(a.profile.did)).length function blockTimeEstimate(count: number): string | null { if (count < 10) return null const totalSeconds = count if (totalSeconds < 60) return `~${totalSeconds} sec` const minutes = Math.ceil(totalSeconds / 60) return `~${minutes} min` } const estimate = blockTimeEstimate(selectedCount) const estimateIsLong = selectedCount >= 300 return ( <>
{/* Page header */}

New Block

Paste a post URL to preview who engaged and start blocking.

{/* Block a post card */}

Block a post

Paste a post link from any Atmosphere client — or an at:// URI — to preview who engaged.

{phase === 'input' ? (
setUrlInput(e.target.value)} className="flex-1 font-mono text-sm" autoFocus />
) : (

{urlInput}

)}
{/* Scope upgrade prompt */} {scopeUpgradeAction && ( setScopeUpgradeAction(null)} /> )} {/* Error */} {error && (
{error}
)} {/* Block flow — post preview, criteria, accounts, progress */} {phase !== 'input' && (
{phase === 'loading' && (
)} {post && phase !== 'loading' && } {phase === 'preview' && ( <> {filteredAccounts.length > 0 ? ( ) : (
No accounts match the selected criteria.
)} {/* Desktop block button (mobile uses sticky bar below) */}
{estimate && (

{estimateIsLong ? '⚠ ' : ''}Estimated {estimate}{' '}at the network's rate limit — runs in the background.

)}
)} {(phase === 'blocking' || phase === 'done') && progress && eventRkey && ( )}
)} {/* Your FlockOff Blocks — only visible in input phase */} {phase === 'input' && (

Your FlockOff Blocks

{initialEvents.length > 0 && ( View all → )}
{initialEvents.length === 0 ? (

No block events yet.

) : (
{initialEvents.slice(0, 5).map(({ rkey, record }) => { const status = getEventStatus(record) const blocked = record.stats?.completed ?? record.blockedDids.length const date = formatDate(record.createdAt) const postUrl = record.sourcePostUrl ?? record.sourcePostUri return (
{postUrl} {STATUS_LABEL[status]}

{date}{blocked > 0 ? ` · ${blocked.toLocaleString()} blocked` : ''}

View ↗
) })}
)}
)}
{/* Mobile sticky bottom bar */} {phase === 'preview' && (
{estimate && (

{estimateIsLong ? '⚠ ' : ''}Estimated {estimate}{' '}at the network's rate limit.

)}
)} ) }