Barazo default frontend barazo.forum
2
fork

Configure Feed

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

feat(topic-view): display tombstone page for deleted topics (#122)

* feat(topic-view): display tombstone page for deleted topics

Render a proper tombstone when visiting a deleted topic via direct URL.
The API returns isAuthorDeleted/isModDeleted flags (added in barazo-api
PR #115), so the frontend now detects them and shows a muted placeholder
instead of empty content.

Changes:
- Add isAuthorDeleted/isModDeleted to Topic interface
- TopicView early-returns a tombstone card for deleted topics
- Topic detail page skips JSON-LD and uses noindex for deleted topics
- 12 new tests covering both author and moderator deletion variants
- All 742 tests pass

Fixes barazo-forum/barazo-workspace#63

* style: fix prettier formatting in topic-view tests

authored by

Guido X Jansen and committed by
GitHub
8c030046 092e96c3

+218 -3
+8
src/app/sitemap.test.ts
··· 67 67 cid: 'bafyabc', 68 68 replyCount: 5, 69 69 reactionCount: 3, 70 + isAuthorDeleted: false, 71 + isModDeleted: false, 70 72 categoryMaturityRating: 'safe' as const, 71 73 lastActivityAt: '2025-06-15T12:00:00Z', 72 74 createdAt: '2025-06-01T00:00:00Z', ··· 85 87 cid: 'bafydef', 86 88 replyCount: 0, 87 89 reactionCount: 1, 90 + isAuthorDeleted: false, 91 + isModDeleted: false, 88 92 categoryMaturityRating: 'safe' as const, 89 93 lastActivityAt: '2025-06-10T08:00:00Z', 90 94 createdAt: '2025-06-10T00:00:00Z', ··· 218 222 cid: 'bafysafe', 219 223 replyCount: 0, 220 224 reactionCount: 0, 225 + isAuthorDeleted: false, 226 + isModDeleted: false, 221 227 categoryMaturityRating: 'safe' as const, 222 228 lastActivityAt: '2025-06-15T12:00:00Z', 223 229 createdAt: '2025-06-01T00:00:00Z', ··· 236 242 cid: 'bafyadult', 237 243 replyCount: 0, 238 244 reactionCount: 0, 245 + isAuthorDeleted: false, 246 + isModDeleted: false, 239 247 categoryMaturityRating: 'adult' as const, 240 248 lastActivityAt: '2025-06-10T08:00:00Z', 241 249 createdAt: '2025-06-10T00:00:00Z',
+39 -2
src/app/t/[slug]/[rkey]/page.tsx
··· 43 43 getTopicByRkey(rkey), 44 44 getPublicSettings().catch(() => null), 45 45 ]) 46 + 47 + // Deleted topics: noindex, no OG, generic title 48 + if (topic.isAuthorDeleted || topic.isModDeleted) { 49 + return { 50 + title: 'Topic Removed', 51 + robots: { index: false, follow: false }, 52 + } 53 + } 54 + 46 55 const description = 47 56 topic.content.length > 160 ? topic.content.slice(0, 157) + '...' : topic.content 48 57 ··· 89 98 throw error 90 99 } 91 100 101 + const isDeleted = topic.isAuthorDeleted || topic.isModDeleted 102 + 92 103 // Fetch community settings for maturity context 93 104 const publicSettings = await getPublicSettings().catch(() => null) 105 + const communityName = publicSettings?.communityName ?? '' 106 + 107 + // Deleted topics: minimal page with tombstone, no replies/JSON-LD 108 + if (isDeleted) { 109 + let categoriesResult: CategoriesResponse = { categories: [] } 110 + try { 111 + categoriesResult = await getCategories() 112 + } catch { 113 + // Non-critical 114 + } 115 + 116 + return ( 117 + <ForumLayout 118 + communityName={communityName} 119 + sidebar={ 120 + categoriesResult.categories.length > 0 ? ( 121 + <CategoryNav categories={categoriesResult.categories} /> 122 + ) : undefined 123 + } 124 + > 125 + <Breadcrumbs items={[{ label: 'Home', href: '/' }, { label: 'Topic Removed' }]} /> 126 + <div className="mt-4"> 127 + <TopicView topic={topic} /> 128 + </div> 129 + </ForumLayout> 130 + ) 131 + } 132 + 94 133 const communityRating = publicSettings?.maturityRating ?? 'safe' 95 134 const effectiveMaturity = getEffectiveMaturity(communityRating, topic.categoryMaturityRating) 96 135 ··· 121 160 122 161 const categoryName = 123 162 findCategoryName(categoriesResult.categories, topic.category) ?? topic.category 124 - 125 - const communityName = publicSettings?.communityName ?? '' 126 163 127 164 const breadcrumbItems = [ 128 165 { label: 'Home', href: '/' },
+85 -1
src/components/topic-view.test.tsx
··· 7 7 import userEvent from '@testing-library/user-event' 8 8 import { axe } from 'vitest-axe' 9 9 import { TopicView } from './topic-view' 10 - import { mockTopics, mockUsers } from '@/mocks/data' 10 + import { mockTopics, mockUsers, mockAuthorDeletedTopic, mockModDeletedTopic } from '@/mocks/data' 11 11 12 12 const topic = mockTopics[0]! 13 13 ··· 106 106 render(<TopicView topic={topic} reactions={mockReactions} onReactionToggle={onToggle} />) 107 107 await user.click(screen.getByRole('button', { name: /like/i })) 108 108 expect(onToggle).toHaveBeenCalledWith('like') 109 + }) 110 + 111 + describe('tombstone: author-deleted topics', () => { 112 + it('shows author-deleted placeholder text', () => { 113 + render(<TopicView topic={mockAuthorDeletedTopic} />) 114 + expect(screen.getByText('This topic was removed by the author.')).toBeInTheDocument() 115 + }) 116 + 117 + it('does not render topic content for author-deleted topics', () => { 118 + const { container } = render(<TopicView topic={mockAuthorDeletedTopic} />) 119 + expect(container.querySelector('.prose')).not.toBeInTheDocument() 120 + }) 121 + 122 + it('does not render the API placeholder title for author-deleted topics', () => { 123 + render(<TopicView topic={mockAuthorDeletedTopic} />) 124 + expect(screen.queryByText('[Deleted by author]')).not.toBeInTheDocument() 125 + }) 126 + 127 + it('shows [deleted] instead of author identity for author-deleted topics', () => { 128 + render(<TopicView topic={mockAuthorDeletedTopic} />) 129 + expect(screen.getByText('[deleted]')).toBeInTheDocument() 130 + }) 131 + 132 + it('does not render category or tags for author-deleted topics', () => { 133 + render(<TopicView topic={mockAuthorDeletedTopic} />) 134 + expect(screen.queryByText(mockAuthorDeletedTopic.category)).not.toBeInTheDocument() 135 + }) 136 + 137 + it('does not render reactions footer for author-deleted topics', () => { 138 + render( 139 + <TopicView 140 + topic={mockAuthorDeletedTopic} 141 + reactions={[{ type: 'like', count: 3, reacted: true }]} 142 + onReactionToggle={vi.fn()} 143 + /> 144 + ) 145 + expect(screen.queryByRole('group', { name: 'Reactions' })).not.toBeInTheDocument() 146 + }) 147 + 148 + it('does not render report button for author-deleted topics', () => { 149 + render(<TopicView topic={mockAuthorDeletedTopic} canReport={true} onReport={vi.fn()} />) 150 + expect(screen.queryByRole('button', { name: /report/i })).not.toBeInTheDocument() 151 + }) 152 + 153 + it('uses muted styling for author-deleted topics', () => { 154 + const { container } = render(<TopicView topic={mockAuthorDeletedTopic} />) 155 + const article = container.querySelector('article') 156 + expect(article?.className).toContain('bg-muted/50') 157 + }) 158 + 159 + it('passes axe accessibility check for author-deleted topics', async () => { 160 + const { container } = render(<TopicView topic={mockAuthorDeletedTopic} />) 161 + const results = await axe(container) 162 + expect(results).toHaveNoViolations() 163 + }) 164 + }) 165 + 166 + describe('tombstone: moderator-deleted topics', () => { 167 + it('shows moderator-deleted placeholder text', () => { 168 + render(<TopicView topic={mockModDeletedTopic} />) 169 + expect(screen.getByText('This topic was removed by a moderator.')).toBeInTheDocument() 170 + }) 171 + 172 + it('does not render the API placeholder title for mod-deleted topics', () => { 173 + render(<TopicView topic={mockModDeletedTopic} />) 174 + expect(screen.queryByText('[Removed by moderator]')).not.toBeInTheDocument() 175 + }) 176 + 177 + it('shows [deleted] instead of author identity for mod-deleted topics', () => { 178 + render(<TopicView topic={mockModDeletedTopic} />) 179 + expect(screen.getByText('[deleted]')).toBeInTheDocument() 180 + }) 181 + 182 + it('uses muted styling for mod-deleted topics', () => { 183 + const { container } = render(<TopicView topic={mockModDeletedTopic} />) 184 + const article = container.querySelector('article') 185 + expect(article?.className).toContain('bg-muted/50') 186 + }) 187 + 188 + it('passes axe accessibility check for mod-deleted topics', async () => { 189 + const { container } = render(<TopicView topic={mockModDeletedTopic} />) 190 + const results = await axe(container) 191 + expect(results).toHaveNoViolations() 192 + }) 109 193 }) 110 194 })
+30
src/components/topic-view.tsx
··· 50 50 className, 51 51 }: TopicViewProps) { 52 52 const headingId = `topic-heading-${topic.rkey}` 53 + const isDeleted = topic.isAuthorDeleted || topic.isModDeleted 54 + 55 + if (isDeleted) { 56 + const tombstoneText = topic.isModDeleted 57 + ? 'This topic was removed by a moderator.' 58 + : 'This topic was removed by the author.' 59 + 60 + return ( 61 + <article 62 + id="post-1" 63 + className={cn('rounded-lg border border-border bg-muted/50', className)} 64 + aria-labelledby={headingId} 65 + > 66 + <div className="p-4 sm:p-6"> 67 + <div className="flex items-center gap-2 text-sm"> 68 + <span 69 + className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground" 70 + aria-hidden="true" 71 + > 72 + ? 73 + </span> 74 + <h2 id={headingId} className="font-medium text-muted-foreground"> 75 + [deleted] 76 + </h2> 77 + </div> 78 + <p className="mt-4 text-sm italic text-muted-foreground">{tombstoneText}</p> 79 + </div> 80 + </article> 81 + ) 82 + } 53 83 54 84 return ( 55 85 <article
+2
src/lib/api/types.ts
··· 74 74 cid: string 75 75 replyCount: number 76 76 reactionCount: number 77 + isAuthorDeleted: boolean 78 + isModDeleted: boolean 77 79 categoryMaturityRating: MaturityRating 78 80 lastActivityAt: string 79 81 createdAt: string
+54
src/mocks/data.ts
··· 193 193 cid: 'bafyreib1', 194 194 replyCount: 5, 195 195 reactionCount: 12, 196 + isAuthorDeleted: false, 197 + isModDeleted: false, 196 198 categoryMaturityRating: 'safe', 197 199 lastActivityAt: NOW, 198 200 createdAt: TWO_DAYS_AGO, ··· 212 214 cid: 'bafyreib2', 213 215 replyCount: 8, 214 216 reactionCount: 23, 217 + isAuthorDeleted: false, 218 + isModDeleted: false, 215 219 categoryMaturityRating: 'safe', 216 220 lastActivityAt: YESTERDAY, 217 221 createdAt: TWO_DAYS_AGO, ··· 231 235 cid: 'bafyreib3', 232 236 replyCount: 3, 233 237 reactionCount: 7, 238 + isAuthorDeleted: false, 239 + isModDeleted: false, 234 240 categoryMaturityRating: 'safe', 235 241 lastActivityAt: YESTERDAY, 236 242 createdAt: YESTERDAY, ··· 250 256 cid: 'bafyreib4', 251 257 replyCount: 15, 252 258 reactionCount: 31, 259 + isAuthorDeleted: false, 260 + isModDeleted: false, 253 261 categoryMaturityRating: 'safe', 254 262 lastActivityAt: NOW, 255 263 createdAt: YESTERDAY, ··· 269 277 cid: 'bafyreib5', 270 278 replyCount: 2, 271 279 reactionCount: 9, 280 + isAuthorDeleted: false, 281 + isModDeleted: false, 272 282 categoryMaturityRating: 'safe', 273 283 lastActivityAt: NOW, 274 284 createdAt: NOW, 275 285 indexedAt: NOW, 276 286 }, 277 287 ] 288 + 289 + // --- Tombstone Topics (deleted) --- 290 + 291 + export const mockAuthorDeletedTopic: Topic = { 292 + uri: `at://${mockUsers[2]!.did}/forum.barazo.topic.post/3kf6del`, 293 + rkey: '3kf6del', 294 + authorDid: mockUsers[2]!.did, 295 + title: '[Deleted by author]', 296 + content: '', 297 + contentFormat: null, 298 + category: 'general', 299 + tags: null, 300 + communityDid: COMMUNITY_DID, 301 + cid: 'bafyreib6', 302 + replyCount: 0, 303 + reactionCount: 0, 304 + isAuthorDeleted: true, 305 + isModDeleted: false, 306 + categoryMaturityRating: 'safe', 307 + lastActivityAt: YESTERDAY, 308 + createdAt: YESTERDAY, 309 + indexedAt: YESTERDAY, 310 + } 311 + 312 + export const mockModDeletedTopic: Topic = { 313 + uri: `at://${mockUsers[3]!.did}/forum.barazo.topic.post/3kf7del`, 314 + rkey: '3kf7del', 315 + authorDid: mockUsers[3]!.did, 316 + title: '[Removed by moderator]', 317 + content: '', 318 + contentFormat: null, 319 + category: 'general', 320 + tags: null, 321 + communityDid: COMMUNITY_DID, 322 + cid: 'bafyreib7', 323 + replyCount: 0, 324 + reactionCount: 0, 325 + isAuthorDeleted: false, 326 + isModDeleted: true, 327 + categoryMaturityRating: 'safe', 328 + lastActivityAt: NOW, 329 + createdAt: NOW, 330 + indexedAt: NOW, 331 + } 278 332 279 333 // --- Replies --- 280 334