Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(anti-spam): add batch queue actions and cross-community ban warnings (M14) (#16)

- First-post queue: batch approve/reject with select-all checkbox
- First-post queue: cross-community ban warning per item
- User profile: cross-community ban warning badge
- Add bannedFromOtherCommunities to FirstPostQueueItem type and mock data

authored by

Guido X Jansen and committed by
GitHub
90c256be 4ea7ea56

+155 -3
+40
src/app/admin/moderation/page.test.tsx
··· 133 133 }) 134 134 }) 135 135 136 + it('shows batch action controls in first post queue', async () => { 137 + const user = userEvent.setup() 138 + render(<AdminModerationPage />) 139 + await user.click(screen.getByRole('tab', { name: /first post/i })) 140 + await waitFor(() => { 141 + expect(screen.getByText(/newbie\.bsky\.social/i)).toBeInTheDocument() 142 + }) 143 + // Select all checkbox 144 + const selectAll = screen.getByRole('checkbox', { name: /select all/i }) 145 + expect(selectAll).toBeInTheDocument() 146 + // Individual checkboxes for each item 147 + const itemCheckboxes = screen.getAllByRole('checkbox').filter((cb) => cb !== selectAll) 148 + expect(itemCheckboxes.length).toBe(2) 149 + }) 150 + 151 + it('shows batch approve/reject buttons when items are selected', async () => { 152 + const user = userEvent.setup() 153 + render(<AdminModerationPage />) 154 + await user.click(screen.getByRole('tab', { name: /first post/i })) 155 + await waitFor(() => { 156 + expect(screen.getByText(/newbie\.bsky\.social/i)).toBeInTheDocument() 157 + }) 158 + // Batch buttons should not be visible when nothing is selected 159 + expect(screen.queryByRole('button', { name: /approve selected/i })).not.toBeInTheDocument() 160 + // Select all items 161 + await user.click(screen.getByRole('checkbox', { name: /select all/i })) 162 + // Batch buttons should now be visible 163 + expect(screen.getByRole('button', { name: /approve selected/i })).toBeInTheDocument() 164 + expect(screen.getByRole('button', { name: /reject selected/i })).toBeInTheDocument() 165 + }) 166 + 167 + it('shows cross-community ban warning in first post queue', async () => { 168 + const user = userEvent.setup() 169 + render(<AdminModerationPage />) 170 + await user.click(screen.getByRole('tab', { name: /first post/i })) 171 + await waitFor(() => { 172 + expect(screen.getByText(/banned from 1 other community/i)).toBeInTheDocument() 173 + }) 174 + }) 175 + 136 176 it('passes axe accessibility check', async () => { 137 177 const { container } = render(<AdminModerationPage />) 138 178 await waitFor(() => {
+90 -2
src/app/admin/moderation/page.tsx
··· 149 149 function FirstPostTab({ 150 150 items, 151 151 onResolve, 152 + onBatchResolve, 152 153 }: { 153 154 items: FirstPostQueueItem[] 154 155 onResolve: (id: string, action: 'approved' | 'rejected') => void 156 + onBatchResolve: (ids: string[], action: 'approved' | 'rejected') => void 155 157 }) { 158 + const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) 159 + 160 + const allSelected = items.length > 0 && selectedIds.size === items.length 161 + 162 + const toggleSelectAll = () => { 163 + if (allSelected) { 164 + setSelectedIds(new Set()) 165 + } else { 166 + setSelectedIds(new Set(items.map((item) => item.id))) 167 + } 168 + } 169 + 170 + const toggleItem = (id: string) => { 171 + setSelectedIds((prev) => { 172 + const next = new Set(prev) 173 + if (next.has(id)) { 174 + next.delete(id) 175 + } else { 176 + next.add(id) 177 + } 178 + return next 179 + }) 180 + } 181 + 182 + const handleBatchAction = (action: 'approved' | 'rejected') => { 183 + const ids = Array.from(selectedIds) 184 + onBatchResolve(ids, action) 185 + setSelectedIds(new Set()) 186 + } 187 + 156 188 return ( 157 189 <div className="space-y-3"> 190 + {items.length > 0 && ( 191 + <div className="flex items-center justify-between"> 192 + <label className="flex items-center gap-2 text-sm text-muted-foreground"> 193 + <input 194 + type="checkbox" 195 + checked={allSelected} 196 + onChange={toggleSelectAll} 197 + className="rounded border-border" 198 + aria-label="Select all" 199 + /> 200 + Select all ({items.length}) 201 + </label> 202 + {selectedIds.size > 0 && ( 203 + <div className="flex gap-2"> 204 + <button 205 + type="button" 206 + onClick={() => handleBatchAction('approved')} 207 + className="rounded-md bg-green-600 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-green-700" 208 + > 209 + Approve selected ({selectedIds.size}) 210 + </button> 211 + <button 212 + type="button" 213 + onClick={() => handleBatchAction('rejected')} 214 + className="rounded-md bg-destructive px-3 py-1 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90" 215 + > 216 + Reject selected ({selectedIds.size}) 217 + </button> 218 + </div> 219 + )} 220 + </div> 221 + )} 158 222 {items.map((item) => ( 159 223 <article key={item.id} className="rounded-lg border border-border bg-card p-4"> 160 - <div className="flex items-start justify-between gap-3"> 224 + <div className="flex items-start gap-3"> 225 + <input 226 + type="checkbox" 227 + checked={selectedIds.has(item.id)} 228 + onChange={() => toggleItem(item.id)} 229 + className="mt-1 rounded border-border" 230 + aria-label={`Select post by ${item.authorHandle}`} 231 + /> 161 232 <div className="min-w-0 flex-1"> 162 233 <p className="text-sm font-medium text-foreground">{item.authorHandle}</p> 163 - <div className="mt-1 flex gap-2 text-xs text-muted-foreground"> 234 + <div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground"> 164 235 <span className="inline-flex items-center gap-1"> 165 236 <Clock size={12} aria-hidden="true" /> 166 237 New account, {item.accountAge} old ··· 169 240 <span className="inline-flex items-center gap-1"> 170 241 <ShieldCheck size={12} aria-hidden="true" /> 171 242 Active in {item.crossCommunityCount} other communities 243 + </span> 244 + )} 245 + {item.bannedFromOtherCommunities > 0 && ( 246 + <span className="inline-flex items-center gap-1 font-medium text-destructive"> 247 + <Prohibit size={12} aria-hidden="true" /> 248 + Banned from {item.bannedFromOtherCommunities} other{' '} 249 + {item.bannedFromOtherCommunities === 1 ? 'community' : 'communities'} 172 250 </span> 173 251 )} 174 252 </div> ··· 469 547 } 470 548 } 471 549 550 + const handleBatchResolveFirstPost = async (ids: string[], action: 'approved' | 'rejected') => { 551 + try { 552 + await Promise.all(ids.map((id) => resolveFirstPost(id, action, MOCK_TOKEN))) 553 + setFirstPostQueue((prev) => prev.filter((item) => !ids.includes(item.id))) 554 + } catch { 555 + // Silently handle 556 + } 557 + } 558 + 472 559 const handleSaveThresholds = async (updated: Partial<ModerationThresholds>) => { 473 560 try { 474 561 const result = await updateModerationThresholds(updated, MOCK_TOKEN) ··· 538 625 <FirstPostTab 539 626 items={firstPostQueue} 540 627 onResolve={(id, action) => void handleResolveFirstPost(id, action)} 628 + onBatchResolve={(ids, action) => void handleBatchResolveFirstPost(ids, action)} 541 629 /> 542 630 )} 543 631 </div>
+10
src/app/u/[handle]/page.test.tsx
··· 34 34 render(<UserProfilePage params={{ handle: 'alice.bsky.social' }} />) 35 35 expect(screen.getByText(/recent activity/i)).toBeInTheDocument() 36 36 }) 37 + 38 + it('shows cross-community ban warning when user is banned elsewhere', () => { 39 + render(<UserProfilePage params={{ handle: 'dave.bsky.social' }} />) 40 + expect(screen.getByText(/banned from.*other communit/i)).toBeInTheDocument() 41 + }) 42 + 43 + it('does not show ban warning for users with no cross-community bans', () => { 44 + render(<UserProfilePage params={{ handle: 'alice.bsky.social' }} />) 45 + expect(screen.queryByText(/banned from.*other communit/i)).not.toBeInTheDocument() 46 + }) 37 47 })
+12 -1
src/app/u/[handle]/page.tsx
··· 9 9 'use client' 10 10 11 11 import { useState, useEffect } from 'react' 12 - import { User, CalendarBlank, ChatCircle } from '@phosphor-icons/react' 12 + import { User, CalendarBlank, ChatCircle, Prohibit } from '@phosphor-icons/react' 13 13 import { ForumLayout } from '@/components/layout/forum-layout' 14 14 import { Breadcrumbs } from '@/components/breadcrumbs' 15 15 import { ReputationBadge } from '@/components/reputation-badge' ··· 42 42 } 43 43 44 44 // TODO: Fetch user profile from API when endpoint is available 45 + // Mock data: dave.bsky.social simulates a user banned from other communities 46 + const bannedFromOther = handle === 'dave.bsky.social' ? 2 : 0 45 47 const mockProfile = { 46 48 did: `did:plc:mock-${handle}`, 47 49 handle, ··· 50 52 postCount: 15, 51 53 joinedAt: '2025-06-15T00:00:00Z', 52 54 isBanned: false, 55 + bannedFromOtherCommunities: bannedFromOther, 53 56 } 54 57 55 58 const joinDate = new Date(mockProfile.joinedAt).toLocaleDateString('en-US', { ··· 91 94 <div className="mt-3"> 92 95 <BanIndicator isBanned={true} /> 93 96 </div> 97 + )} 98 + 99 + {mockProfile.bannedFromOtherCommunities > 0 && ( 100 + <p className="mt-2 inline-flex items-center gap-1 rounded-md bg-destructive/10 px-2 py-1 text-xs font-medium text-destructive"> 101 + <Prohibit size={14} aria-hidden="true" /> 102 + Banned from {mockProfile.bannedFromOtherCommunities} other{' '} 103 + {mockProfile.bannedFromOtherCommunities === 1 ? 'community' : 'communities'} 104 + </p> 94 105 )} 95 106 </div> 96 107 </div>
+1
src/lib/api/types.ts
··· 256 256 content: string 257 257 accountAge: string 258 258 crossCommunityCount: number 259 + bannedFromOtherCommunities: number 259 260 status: 'pending' | 'approved' | 'rejected' 260 261 communityDid: string 261 262 createdAt: string
+2
src/mocks/data.ts
··· 525 525 content: 'Hello everyone! I am new here and excited to join this community.', 526 526 accountAge: '2 days', 527 527 crossCommunityCount: 0, 528 + bannedFromOtherCommunities: 1, 528 529 status: 'pending', 529 530 communityDid: COMMUNITY_DID, 530 531 createdAt: NOW, ··· 539 540 content: 'Hi, I found this forum through Bluesky and wanted to introduce myself.', 540 541 accountAge: '5 days', 541 542 crossCommunityCount: 3, 543 + bannedFromOtherCommunities: 0, 542 544 status: 'pending', 543 545 communityDid: COMMUNITY_DID, 544 546 createdAt: YESTERDAY,