See the best posts from any Bluesky account
0
fork

Configure Feed

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

Render a dedicated page for deactivated Bluesky accounts

Catch the AppView's AccountDeactivated XRPC error in AtprotoClient.getProfile
and surface it as a typed AccountDeactivatedError, so the profile controller
can render a styled 410 page instead of bubbling the raw error message up
through the default exception handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+129 -11
+5 -1
app/controllers/profile_controller.ts
··· 3 3 import logger from '@adonisjs/core/services/logger' 4 4 import cache from '@adonisjs/cache/services/main' 5 5 import { HandleResolver, InvalidHandleError, HandleNotFoundError } from '#services/handle_resolver' 6 - import { AtprotoClient, BlueskyRateLimitedError } from '#lib/atproto/index' 6 + import { AtprotoClient, AccountDeactivatedError, BlueskyRateLimitedError } from '#lib/atproto/index' 7 7 import type { Facet, FacetLink, FacetMention } from '#lib/atproto/index' 8 8 import { ClickHouseStore } from '#lib/clickhouse/index' 9 9 import AtprotoOAuthService from '#services/atproto_oauth' ··· 241 241 handle: canonicalHandle, 242 242 message: `We can't find @${canonicalHandle} on Bluesky.`, 243 243 }) 244 + } 245 + if (err instanceof AccountDeactivatedError) { 246 + response.status(410) 247 + return view.render('pages/profile/deactivated', { handle: canonicalHandle }) 244 248 } 245 249 if (err instanceof BlueskyRateLimitedError) { 246 250 response.status(503)
+38 -7
app/lib/atproto/client.ts
··· 72 72 } 73 73 } 74 74 75 + /** 76 + * Thrown by AtprotoClient.getProfile when the AppView responds that the 77 + * account is deactivated (XRPC error "AccountDeactivated"). 78 + * 79 + * Deactivation is a reversible state — users can reactivate — so we render 80 + * a distinct page for it rather than treating it as "gone". 81 + */ 82 + export class AccountDeactivatedError extends Error { 83 + constructor( 84 + readonly did: string, 85 + readonly cause?: unknown 86 + ) { 87 + super(`Account is deactivated: ${did}`, { cause }) 88 + this.name = 'AccountDeactivatedError' 89 + } 90 + } 91 + 92 + /** XRPCError-shaped errors (from @atproto/api) carry `error` as the lexicon error name. */ 93 + function isAccountDeactivatedXrpcError(err: unknown): boolean { 94 + return err instanceof Error && (err as Error & { error?: string }).error === 'AccountDeactivated' 95 + } 96 + 75 97 // --------------------------------------------------------------------------- 76 98 // Retry / backoff helpers 77 99 // --------------------------------------------------------------------------- ··· 369 391 avatarUrl: string | null 370 392 labels: { val: string; neg?: boolean }[] 371 393 }> { 372 - const { 373 - value: response, 374 - attempts, 375 - totalLatencyMs, 376 - } = await this.withRetry('getProfile', () => 377 - this.agent.app.bsky.actor.getProfile({ actor: did }) 378 - ) 394 + let result: { 395 + value: Awaited<ReturnType<AgentLike['app']['bsky']['actor']['getProfile']>> 396 + attempts: number 397 + totalLatencyMs: number 398 + } 399 + try { 400 + result = await this.withRetry('getProfile', () => 401 + this.agent.app.bsky.actor.getProfile({ actor: did }) 402 + ) 403 + } catch (err) { 404 + if (isAccountDeactivatedXrpcError(err)) { 405 + throw new AccountDeactivatedError(did, err) 406 + } 407 + throw err 408 + } 409 + const { value: response, attempts, totalLatencyMs } = result 379 410 const headers = response.headers as Record<string, string | undefined> 380 411 this.emit({ 381 412 endpoint: 'getProfile',
+6 -1
app/lib/atproto/index.ts
··· 27 27 } from './types.js' 28 28 29 29 // Client wrapper 30 - export { AtprotoClient, BlueskyRateLimitedError, createAtprotoClient } from './client.js' 30 + export { 31 + AtprotoClient, 32 + BlueskyRateLimitedError, 33 + AccountDeactivatedError, 34 + createAtprotoClient, 35 + } from './client.js' 31 36 export type { 32 37 AgentLike, 33 38 FeedViewPost,
+25
resources/views/pages/profile/deactivated.edge
··· 1 + @component('components/layout') 2 + @slot('title') 3 + {{ '@' + handle }} is deactivated — favs.blue 4 + @endslot 5 + 6 + @slot('main') 7 + <div class="pt-20 pb-16 text-center animate-[fade-in-up_0.5s_var(--ease-out-quart)_both]"> 8 + <div class="inline-flex items-center justify-center mb-6"> 9 + <i class="ph-fill ph-moon text-indigo-400 dark:text-indigo-300 text-7xl"></i> 10 + </div> 11 + 12 + <h1 class="font-heading text-4xl font-bold tracking-tight mb-3"> 13 + {{ '@' + handle }} is taking a break 14 + </h1> 15 + 16 + <p class="text-gray-600 dark:text-gray-400 mb-8 max-w-[480px] mx-auto"> 17 + This account is deactivated on Bluesky, so their posts aren't visible right now. 18 + </p> 19 + 20 + <p> 21 + <a href="/" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">← Back home</a> 22 + </p> 23 + </div> 24 + @endslot 25 + @endcomponent
+34 -1
tests/functional/profile_controller.spec.ts
··· 13 13 import testUtils from '@adonisjs/core/services/test_utils' 14 14 import queue from '@adonisjs/queue/services/main' 15 15 import { ClickHouseStore } from '#lib/clickhouse/index' 16 - import { AtprotoClient } from '#lib/atproto/index' 16 + import { AtprotoClient, AccountDeactivatedError } from '#lib/atproto/index' 17 17 import { HandleResolver } from '#services/handle_resolver' 18 18 import AtprotoOAuthService from '#services/atproto_oauth' 19 19 import TrackedProfile from '#models/tracked_profile' ··· 308 308 const response = await client.get('/profile/dril.bsky.social/likes') 309 309 response.assertStatus(410) 310 310 assert.include(response.text(), 'no longer available') 311 + }) 312 + 313 + // Deactivated account (reversible, unlike a deleted one) renders a dedicated 314 + // page instead of bubbling the raw "Account is deactivated" XRPC message up 315 + // through the default exception handler. 316 + test('GET /profile/:handle/likes for a deactivated account renders the deactivated page', async ({ 317 + client, 318 + assert, 319 + swap, 320 + }) => { 321 + swap(HandleResolver, { 322 + normalize: (h: string) => h.toLowerCase(), 323 + async resolveToDid() { 324 + return 'did:plc:deactivated001' 325 + }, 326 + async resolve(h: string) { 327 + return { canonicalHandle: h.toLowerCase(), did: 'did:plc:deactivated001' } 328 + }, 329 + } as unknown as HandleResolver) 330 + swap(AtprotoClient, { 331 + async getProfile() { 332 + throw new AccountDeactivatedError('did:plc:deactivated001') 333 + }, 334 + } as unknown as AtprotoClient) 335 + 336 + const response = await client.get('/profile/deactivated.bsky.social/likes') 337 + response.assertStatus(410) 338 + const html = response.text() 339 + assert.include(html, 'deactivated.bsky.social') 340 + assert.include(html, 'deactivated') 341 + // Styled layout — not a raw error message 342 + assert.include(html, '<html') 343 + assert.include(html, 'Back home') 311 344 }) 312 345 313 346 // Test 15: Invalid ?days=abc returns 400
+21 -1
tests/unit/atproto/client.spec.ts
··· 1 1 import { test } from '@japa/runner' 2 - import { AtprotoClient, BlueskyRateLimitedError } from '#lib/atproto/index' 2 + import { AtprotoClient, BlueskyRateLimitedError, AccountDeactivatedError } from '#lib/atproto/index' 3 3 import { ATPROTO_USER_AGENT, createAtprotoClient } from '#lib/atproto/client' 4 4 5 5 // --------------------------------------------------------------------------- ··· 455 455 } 456 456 457 457 assert.strictEqual(caughtErr, otherError) 458 + assert.equal(agent._calls.length, 1) 459 + }) 460 + 461 + test('throws AccountDeactivatedError when XRPC returns AccountDeactivated', async ({ 462 + assert, 463 + }) => { 464 + // Shape of an @atproto/api XRPCError for a deactivated account — the 465 + // `error` field is the lexicon error name. 466 + const xrpcError = new Error('Account is deactivated') as Error & { 467 + status: number 468 + error: string 469 + } 470 + xrpcError.status = 400 471 + xrpcError.error = 'AccountDeactivated' 472 + 473 + const agent = makeMockAgent({ getProfileError: xrpcError }) 474 + const client = new AtprotoClient(agent as never, noopSleep) 475 + 476 + await assert.rejects(() => client.getProfile('did:plc:deactivated'), AccountDeactivatedError) 477 + // Not retried — non-429 458 478 assert.equal(agent._calls.length, 1) 459 479 }) 460 480 })