See the best posts from any Bluesky account
0
fork

Configure Feed

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

Fetch live Bluesky profile for avatar and linked username on profile page

Calls the Bluesky API at render time (in parallel with ClickHouse) to show
the user's current avatar and display name, with graceful fallback to DB
values. Profile header links to bsky.app as a hover group.

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

+46 -17
+26 -7
app/controllers/profile_controller.ts
··· 1 1 import { inject } from '@adonisjs/core' 2 2 import type { HttpContext } from '@adonisjs/core/http' 3 + import logger from '@adonisjs/core/services/logger' 3 4 import { HandleResolver, InvalidHandleError, HandleNotFoundError } from '#services/handle_resolver' 4 5 import { AtprotoClient, BlueskyRateLimitedError } from '#lib/atproto/index' 5 6 import { ClickHouseStore } from '#lib/clickhouse/index' ··· 259 260 return view.render('pages/profile/gone', { handle: canonicalHandle }) 260 261 } 261 262 262 - // 5. Fetch top posts from ClickHouse 263 + // 5. Fetch top posts from ClickHouse + live profile from Bluesky in parallel 263 264 let posts: Awaited<ReturnType<ClickHouseStore['getTopPosts']>> 265 + let profile: { displayName: string | null; avatarUrl: string | null } = { 266 + displayName: user.displayName, 267 + avatarUrl: user.avatarUrl, 268 + } 264 269 try { 265 - posts = await this.clickHouseStore.getTopPosts({ 266 - authorDid: user.did, 267 - kind, 268 - daysWindow, 269 - }) 270 + const [postsResult, profileResult] = await Promise.all([ 271 + this.clickHouseStore.getTopPosts({ 272 + authorDid: user.did, 273 + kind, 274 + daysWindow, 275 + }), 276 + this.atprotoClient 277 + .getProfile(user.did) 278 + .then(({ displayName, avatarUrl }) => ({ displayName, avatarUrl })) 279 + .catch((err) => { 280 + logger.error({ err, handle: canonicalHandle }, 'Failed to fetch live Bluesky profile') 281 + return null 282 + }), 283 + ]) 284 + posts = postsResult 285 + if (profileResult) { 286 + profile = profileResult 287 + } 270 288 } catch (_err) { 271 289 response.status(503) 272 290 return view.render('pages/errors/server_error', { ··· 301 319 302 320 response.header('Cache-Control', 'public, max-age=60') 303 321 return view.render('pages/profile/show', { 304 - user, 305 322 handle: canonicalHandle, 323 + displayName: profile.displayName, 324 + avatarUrl: profile.avatarUrl, 306 325 kind, 307 326 daysWindow, 308 327 posts: postsWithUrl,
+9 -3
app/lib/atproto/client.ts
··· 178 178 params: { actor: string }, 179 179 opts?: unknown 180 180 ) => Promise<{ 181 - data: { postsCount?: number; [key: string]: unknown } 181 + data: { postsCount?: number; displayName?: string; avatar?: string; [key: string]: unknown } 182 182 headers: Record<string, string | undefined> 183 183 }> 184 184 } ··· 344 344 * @returns Object with `postsCount` 345 345 * @throws BlueskyRateLimitedError on retry exhaustion 346 346 */ 347 - async getProfile(did: string): Promise<{ postsCount: number }> { 347 + async getProfile( 348 + did: string 349 + ): Promise<{ postsCount: number; displayName: string | null; avatarUrl: string | null }> { 348 350 const { 349 351 value: response, 350 352 attempts, ··· 361 363 status: 200, 362 364 headers: pickRateLimitHeaders(headers), 363 365 }) 364 - return { postsCount: response.data.postsCount ?? 0 } 366 + return { 367 + postsCount: response.data.postsCount ?? 0, 368 + displayName: response.data.displayName ?? null, 369 + avatarUrl: response.data.avatar ?? null, 370 + } 365 371 } 366 372 367 373 /**
+11 -7
resources/views/pages/profile/show.edge
··· 6 6 @slot('main') 7 7 <div class="pt-8 pb-4"> 8 8 {{-- Profile header --}} 9 - <div class="flex items-center gap-4 mb-6"> 10 - <div class="size-14 rounded-full bg-gray-300 shrink-0 flex items-center justify-center text-xl text-gray-400">@</div> 9 + <a href="https://bsky.app/profile/{{ handle }}" target="_blank" rel="noopener" class="group flex items-center gap-4 mb-6 no-underline"> 10 + @if(avatarUrl) 11 + <img src="{{ avatarUrl }}" alt="" class="size-14 rounded-full shrink-0 object-cover"> 12 + @else 13 + <div class="size-14 rounded-full bg-gray-300 shrink-0 flex items-center justify-center text-xl text-gray-400">@</div> 14 + @endif 11 15 <div> 12 - @if(user.displayName) 13 - <div class="text-lg font-semibold">{{ user.displayName }}</div> 16 + @if(displayName) 17 + <div class="text-lg font-semibold group-hover:underline">{{ displayName }}</div> 14 18 @endif 15 - <div class="text-gray-600">{{ '@' + handle }}</div> 19 + <div class="text-gray-600 group-hover:underline">{{ '@' + handle }}</div> 16 20 </div> 17 - </div> 21 + </a> 18 22 19 23 {{-- Controls --}} 20 24 <div class="flex items-center justify-between mb-6 flex-wrap gap-6"> ··· 45 49 46 50 {{-- Truncation notice --}} 47 51 @if(indexedSince) 48 - <p class="text-sm text-gray-500 mb-4"> 52 + <p class="text-sm text-gray-600 mb-4"> 49 53 This is a prolific poster! Showing posts since {{ indexedSince.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) }}. 50 54 Older posts were not indexed. 51 55 </p>