Something went wrong. Try again.
FlockOff is a mass-block tool for the Atmosphere flockoff.app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574'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<string, string> = { '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<string, string> = { '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<string> { const selected = new Set<string>() 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<EngagementType, boolean> = { 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<PostData | null>(null) const [allAccounts, setAllAccounts] = useState<EngagementAccount[]>([])
// UI const [phase, setPhase] = useState<Phase>(initialUrl ? 'loading' : 'input') const [error, setError] = useState<string | null>(null) const [criteria, setCriteria] = useState<BlockCriteria>(DEFAULT_CRITERIA) const [selectedDids, setSelectedDids] = useState<Set<string>>(new Set())
// Blocking const [jobId, setJobId] = useState<string | null>(null) const [eventRkey, setEventRkey] = useState<string | null>(null) const [progress, setProgress] = useState<ProgressMessage | null>(null) const [undoLoading, setUndoLoading] = useState(false) const [retryLoading, setRetryLoading] = useState(false) const [shareLoading, setShareLoading] = useState(false) const [sharedListUrl, setSharedListUrl] = useState<string | null>(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<string | null>(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 ( <> <div className={`max-w-2xl mx-auto px-4 pt-8 flex flex-col gap-8 ${phase === 'preview' ? 'pb-28 sm:pb-16' : 'pb-16'}`}>
{/* Page header */} <div> <h1 className="font-display text-[1.75rem] font-bold tracking-[-0.02em] leading-snug"> New Block </h1> <p className="text-sm text-[var(--fo-text2)] mt-1"> Paste a post URL to preview who engaged and start blocking. </p> </div>
{/* Block a post card */} <div className="bg-card border border-border rounded-xl p-5 flex flex-col gap-4" style={{ boxShadow: 'var(--fo-shadow-card)' }}> <div> <h2 className="text-sm font-semibold">Block a post</h2> <p className="text-xs text-[var(--fo-text2)] mt-0.5">Paste a post link from any Atmosphere client — or an at:// URI — to preview who engaged.</p> </div>
{phase === 'input' ? ( <form onSubmit={handleUrlSubmit} className="flex gap-2"> <Input type="text" inputMode="url" placeholder="Post link or at:// URI" value={urlInput} onChange={(e) => setUrlInput(e.target.value)} className="flex-1 font-mono text-sm" autoFocus /> <Button type="submit" disabled={!urlInput.trim()}> Preview </Button> </form> ) : ( <div className="flex items-center gap-2"> <p className="text-xs font-mono text-[var(--fo-text3)] truncate flex-1">{urlInput}</p> <button type="button" onClick={() => { setPost(null); setAllAccounts([]); setPhase('input') }} disabled={phase === 'blocking'} className="text-xs text-[var(--fo-text2)] hover:text-foreground transition-colors shrink-0 disabled:opacity-40" > Change </button> </div> )} </div>
{/* Scope upgrade prompt */} {scopeUpgradeAction && ( <ScopeUpgradePrompt action={scopeUpgradeAction} onCancel={() => setScopeUpgradeAction(null)} /> )}
{/* Error */} {error && ( <div className="bg-destructive/10 border border-destructive/20 text-destructive text-sm rounded-lg px-4 py-3"> {error} </div> )}
{/* Block flow — post preview, criteria, accounts, progress */} {phase !== 'input' && ( <div className="flex flex-col gap-5"> {phase === 'loading' && ( <div className="bg-card border border-border rounded-xl p-4 animate-pulse h-28" /> )}
{post && phase !== 'loading' && <PostPreview post={post} />}
{phase === 'preview' && ( <> <CriteriaSelector criteria={criteria} onChange={handleCriteriaChange} counts={counts} />
{filteredAccounts.length > 0 ? ( <AccountList accounts={filteredAccounts} selectedDids={selectedDids} onToggle={handleToggle} onSelectAll={handleSelectAll} onDeselectAll={handleDeselectAll} /> ) : ( <div className="bg-card border border-border rounded-lg p-6 text-center text-sm text-muted-foreground"> No accounts match the selected criteria. </div> )}
{/* Desktop block button (mobile uses sticky bar below) */} <div className="hidden sm:flex flex-col gap-2"> {estimate && ( <p className={`text-xs font-mono text-center ${estimateIsLong ? 'text-amber-500' : 'text-[var(--fo-text3)]'}`}> {estimateIsLong ? '⚠ ' : ''}Estimated {estimate}{' '}at the network's rate limit — runs in the background. </p> )} <Button size="lg" disabled={selectedCount === 0} onClick={handleBlock} className="w-full" > {selectedCount > 0 ? `Block ${selectedCount.toLocaleString()} account${selectedCount !== 1 ? 's' : ''}` : 'Select accounts to block'} </Button> </div> </> )}
{(phase === 'blocking' || phase === 'done') && progress && eventRkey && ( <BlockProgress progress={progress} eventRkey={eventRkey} onUndo={handleUndo} onRetry={handleRetry} onShare={handleShare} undoLoading={undoLoading} retryLoading={retryLoading} shareLoading={shareLoading} sharedListUrl={sharedListUrl} /> )} </div> )}
{/* Your FlockOff Blocks — only visible in input phase */} {phase === 'input' && ( <div className="flex flex-col gap-3"> <div className="flex items-center justify-between"> <h2 className="text-sm font-semibold">Your FlockOff Blocks</h2> {initialEvents.length > 0 && ( <Link href="/events" className="text-xs text-[var(--fo-text2)] hover:text-foreground transition-colors" > View all → </Link> )} </div>
{initialEvents.length === 0 ? ( <p className="text-sm text-[var(--fo-text2)] px-1">No block events yet.</p> ) : ( <div className="flex flex-col gap-2"> {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 ( <div key={rkey} className="bg-card border border-border rounded-xl px-4 py-3 flex items-center justify-between gap-3" style={{ boxShadow: 'var(--fo-shadow-card)' }} > <div className="flex flex-col gap-0.5 min-w-0"> <div className="flex items-center gap-2 min-w-0"> <span className="text-sm font-mono truncate text-foreground min-w-0">{postUrl}</span> <Badge variant="outline" className={`shrink-0 text-xs font-mono font-medium uppercase tracking-wide ${STATUS_STYLE[status]}`} > {STATUS_LABEL[status]} </Badge> </div> <p className="text-xs text-[var(--fo-text3)]"> {date}{blocked > 0 ? ` · ${blocked.toLocaleString()} blocked` : ''} </p> </div> <Link href={`/events/${rkey}`} className="text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0" > View ↗ </Link> </div> ) })} </div> )} </div> )} </div>
{/* Mobile sticky bottom bar */} {phase === 'preview' && ( <div className="sm:hidden fixed bottom-0 left-0 right-0 bg-background/90 backdrop-blur-sm border-t border-border" style={{ paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))' }} > <div className="max-w-2xl mx-auto px-4 pt-3 pb-1 flex flex-col gap-2"> {estimate && ( <p className={`text-xs font-mono ${estimateIsLong ? 'text-amber-500' : 'text-[var(--fo-text3)]'}`}> {estimateIsLong ? '⚠ ' : ''}Estimated {estimate}{' '}at the network's rate limit. </p> )} <Button size="lg" disabled={selectedCount === 0} onClick={handleBlock} className="w-full" > {selectedCount > 0 ? `Block ${selectedCount.toLocaleString()} account${selectedCount !== 1 ? 's' : ''}` : 'Select accounts to block'} </Button> </div> </div> )} </> )}