[READ-ONLY] a fast, modern browser for the npm registry

feat: show bluesky avatar in header (#896)

authored by

Daniel Roe and committed by
GitHub
ee4d95a5 aecacb2d

+63 -4
+22 -2
app/components/Header/AccountMenu.client.vue
··· 87 87 </span> 88 88 89 89 <!-- Atmosphere avatar (second/front, overlapping) --> 90 + <img 91 + v-if="atprotoUser?.avatar" 92 + :src="atprotoUser.avatar" 93 + :alt="atprotoUser.handle" 94 + width="24" 95 + height="24" 96 + class="w-6 h-6 rounded-full ring-2 ring-bg" 97 + :class="hasBothConnections ? 'relative z-10' : ''" 98 + /> 90 99 <span 91 - v-if="atprotoUser" 100 + v-else-if="atprotoUser" 92 101 class="w-6 h-6 rounded-full bg-bg-muted ring-2 ring-bg flex items-center justify-center" 93 102 :class="hasBothConnections ? 'relative z-10' : ''" 94 103 > ··· 181 190 class="w-full px-3 py-2.5 flex items-center gap-3 hover:bg-bg-subtle transition-colors text-start" 182 191 @click="openAuthModal" 183 192 > 184 - <span class="w-8 h-8 rounded-full bg-bg-muted flex items-center justify-center"> 193 + <img 194 + v-if="atprotoUser.avatar" 195 + :src="atprotoUser.avatar" 196 + :alt="atprotoUser.handle" 197 + width="32" 198 + height="32" 199 + class="w-8 h-8 rounded-full" 200 + /> 201 + <span 202 + v-else 203 + class="w-8 h-8 rounded-full bg-bg-muted flex items-center justify-center" 204 + > 185 205 <span class="i-carbon-cloud w-4 h-4 text-fg-muted" aria-hidden="true" /> 186 206 </span> 187 207 <div class="flex-1 min-w-0">
+38 -1
server/api/auth/atproto.get.ts
··· 7 7 import { useServerSession } from '#server/utils/server-session' 8 8 import type { PublicUserSession } from '#shared/schemas/publicUserSession' 9 9 10 + interface ProfileRecord { 11 + avatar?: { 12 + $type: 'blob' 13 + ref: { $link: string } 14 + mimeType: string 15 + size: number 16 + } 17 + } 18 + 10 19 export default defineEventHandler(async event => { 11 20 const config = useRuntimeConfig(event) 12 21 if (!config.sessionPassword) { ··· 58 67 ) 59 68 if (response.ok) { 60 69 const miniDoc: PublicUserSession = await response.json() 70 + 71 + // Fetch the user's profile record to get their avatar blob reference 72 + let avatar: string | undefined 73 + const did = agent.did 74 + try { 75 + const pdsUrl = new URL(miniDoc.pds) 76 + // Only fetch from HTTPS PDS endpoints to prevent SSRF 77 + if (did && pdsUrl.protocol === 'https:') { 78 + const profileResponse = await fetch( 79 + `${pdsUrl.origin}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, 80 + { headers: { 'User-Agent': 'npmx' } }, 81 + ) 82 + if (profileResponse.ok) { 83 + const record = (await profileResponse.json()) as { value: ProfileRecord } 84 + const avatarBlob = record.value.avatar 85 + if (avatarBlob?.ref?.$link) { 86 + // Use Bluesky CDN for faster image loading 87 + avatar = `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${avatarBlob.ref.$link}@jpeg` 88 + } 89 + } 90 + } 91 + } catch { 92 + // Avatar fetch failed, continue without it 93 + } 94 + 61 95 await session.update({ 62 - public: miniDoc, 96 + public: { 97 + ...miniDoc, 98 + avatar, 99 + }, 63 100 }) 64 101 } 65 102
+2 -1
shared/schemas/publicUserSession.ts
··· 1 - import { object, string, pipe, url } from 'valibot' 1 + import { object, string, pipe, url, optional } from 'valibot' 2 2 import type { InferOutput } from 'valibot' 3 3 4 4 export const PublicUserSessionSchema = object({ ··· 6 6 did: string(), 7 7 handle: string(), 8 8 pds: pipe(string(), url()), 9 + avatar: optional(pipe(string(), url())), 9 10 }) 10 11 11 12 export type PublicUserSession = InferOutput<typeof PublicUserSessionSchema>
+1
shared/types/userSession.ts
··· 5 5 did: string 6 6 handle: string 7 7 pds: string 8 + avatar?: string 8 9 } 9 10 // Only to be used in the atproto session and state stores 10 11 // Will need to change to Record<string, T> and add a current logged in user if we ever want to support