See the best posts from any Bluesky account
0
fork

Configure Feed

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

Replace profile link with avatar dropdown in header

Add an avatar-triggered dropdown menu containing "My profile" and "Log
out" so authenticated users can sign out from any page — previously the
sign-out button only existed on the profile page itself, which was
inaccessible during first-lookup backfill. Avatar is resolved via
AtprotoClient and cached in memory for 1h per user.

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

+224 -26
+48
app/middleware/auth_avatar_middleware.ts
··· 1 + import { type HttpContext } from '@adonisjs/core/http' 2 + import { type NextFn } from '@adonisjs/core/types/http' 3 + import { inject } from '@adonisjs/core' 4 + import logger from '@adonisjs/core/services/logger' 5 + import cache from '@adonisjs/cache/services/main' 6 + import { AtprotoClient } from '#lib/atproto/index' 7 + 8 + /** 9 + * Resolves the logged-in user's avatar URL and shares it with views as 10 + * `authAvatarUrl`. Result is cached in memory (1h TTL) so the AppView is hit 11 + * at most once per hour per signed-in user, not on every page render. 12 + * 13 + * Runs on every routed request — cheap cache hit when the user is logged in, 14 + * no-op when they aren't. 15 + */ 16 + @inject() 17 + export default class AuthAvatarMiddleware { 18 + constructor(private readonly atprotoClient: AtprotoClient) {} 19 + 20 + async handle(ctx: HttpContext, next: NextFn) { 21 + let authAvatarUrl: string | null = null 22 + 23 + try { 24 + const isAuthed = await ctx.auth.use('web').check() 25 + if (isAuthed) { 26 + const user = ctx.auth.use('web').user! 27 + authAvatarUrl = await cache.getOrSet({ 28 + key: `authAvatar:${user.did}`, 29 + ttl: '1h', 30 + factory: async () => { 31 + const profile = await this.atprotoClient.getProfile(user.did) 32 + return profile.avatarUrl 33 + }, 34 + }) 35 + } 36 + } catch (error) { 37 + logger.warn({ error }, 'Failed to resolve auth avatar for header') 38 + } 39 + 40 + if ('view' in ctx) { 41 + ;( 42 + ctx as HttpContext & { view: { share: (data: Record<string, unknown>) => void } } 43 + ).view.share({ authAvatarUrl }) 44 + } 45 + 46 + return next() 47 + } 48 + }
+12
resources/js/app.js
··· 37 37 } 38 38 }) 39 39 40 + Alpine.data('userMenu', function () { 41 + return { 42 + open: false, 43 + toggle() { 44 + this.open = !this.open 45 + }, 46 + close() { 47 + this.open = false 48 + }, 49 + } 50 + }) 51 + 40 52 Alpine.data('alert', function () { 41 53 return { 42 54 isVisible: false,
+1 -1
resources/views/pages/landing.edge
··· 11 11 @endslot 12 12 @slot('main') 13 13 <div class="pt-16 pb-12"> 14 - <div class="flex items-center justify-between mb-2 animate-[fade-in-up_0.5s_var(--ease-out-quart)_both]"> 14 + <div class="flex items-center justify-between mb-2 relative z-20 animate-[fade-in-up_0.5s_var(--ease-out-quart)_both]"> 15 15 <h1 class="font-heading text-4xl font-bold flex items-center gap-2 tracking-tight"> 16 16 <i class="ph-fill ph-heart text-red-500 inline-block animate-[heart-pulse_2s_var(--ease-out-quart)_1.2s_both]"></i> 17 17 favs.blue
+13 -21
resources/views/pages/profile/show.edge
··· 16 16 @slot('main') 17 17 <div class="pt-8 pb-4"> 18 18 {{-- Profile header --}} 19 - <div class="flex items-center justify-between mb-6"> 20 - <a href="https://bsky.app/profile/{{ handle }}" target="_blank" rel="noopener" class="group flex items-center gap-4 no-underline"> 21 - @if(avatarUrl) 22 - <img src="{{ avatarUrl }}" alt="{{ '@' + handle }} avatar" class="size-14 rounded-full shrink-0 object-cover"> 19 + <a href="https://bsky.app/profile/{{ handle }}" target="_blank" rel="noopener" class="group flex items-center gap-4 no-underline mb-6"> 20 + @if(avatarUrl) 21 + <img src="{{ avatarUrl }}" alt="{{ '@' + handle }} avatar" class="size-14 rounded-full shrink-0 object-cover"> 22 + @else 23 + <div class="size-14 rounded-full bg-gray-300 dark:bg-gray-700 shrink-0 flex items-center justify-center text-xl text-gray-400">@</div> 24 + @endif 25 + <div> 26 + @if(displayName) 27 + <h1 class="font-heading text-xl font-bold group-hover:underline leading-tight">{{ displayName }}</h1> 28 + <div class="text-gray-600 dark:text-gray-400 group-hover:underline">{{ '@' + handle }}</div> 23 29 @else 24 - <div class="size-14 rounded-full bg-gray-300 dark:bg-gray-700 shrink-0 flex items-center justify-center text-xl text-gray-400">@</div> 30 + <h1 class="font-heading text-xl font-bold group-hover:underline leading-tight">{{ '@' + handle }}</h1> 25 31 @endif 26 - <div> 27 - @if(displayName) 28 - <h1 class="font-heading text-xl font-bold group-hover:underline leading-tight">{{ displayName }}</h1> 29 - <div class="text-gray-600 dark:text-gray-400 group-hover:underline">{{ '@' + handle }}</div> 30 - @else 31 - <h1 class="font-heading text-xl font-bold group-hover:underline leading-tight">{{ '@' + handle }}</h1> 32 - @endif 33 - </div> 34 - </a> 35 - @if(auth.isAuthenticated && auth.user.handle === handle) 36 - <form action="/oauth/logout" method="POST"> 37 - {{ csrfField() }} 38 - <button type="submit" class="text-sm text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors duration-200 cursor-pointer">Sign out</button> 39 - </form> 40 - @endif 41 - </div> 32 + </div> 33 + </a> 42 34 43 35 {{-- Controls --}} 44 36 <div class="flex flex-wrap items-center gap-x-3 gap-y-2 mb-6">
+53 -4
resources/views/partials/auth_buttons.edge
··· 1 1 @eval(auth && await auth.check()) 2 2 @if(auth && auth.isAuthenticated) 3 - <a 4 - href="/profile/{{ auth.user.handle }}/likes" 5 - class="text-sm text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200" 6 - >My profile</a> 3 + <div 4 + x-data="userMenu" 5 + x-on:keydown.escape="close" 6 + class="relative z-40" 7 + > 8 + <button 9 + id="user-menu-button" 10 + type="button" 11 + x-on:click="toggle" 12 + x-bind:aria-expanded="open" 13 + aria-haspopup="menu" 14 + aria-controls="user-menu" 15 + aria-label="Account menu for {{ '@' + auth.user.handle }}" 16 + class="inline-flex items-center justify-center size-8 rounded-full overflow-hidden ring-1 ring-gray-200 dark:ring-gray-800 hover:ring-blue-500 dark:hover:ring-blue-400 transition-colors duration-200 cursor-pointer" 17 + > 18 + @if(authAvatarUrl) 19 + <img 20 + src="{{ authAvatarUrl }}" 21 + alt="" 22 + class="size-8 rounded-full object-cover" 23 + referrerpolicy="no-referrer" 24 + > 25 + @else 26 + <span 27 + class="flex items-center justify-center size-8 rounded-full bg-gray-200 dark:bg-gray-800 text-gray-500 dark:text-gray-400 text-sm" 28 + aria-hidden="true" 29 + >@</span> 30 + @endif 31 + </button> 32 + <div 33 + id="user-menu" 34 + role="menu" 35 + aria-labelledby="user-menu-button" 36 + x-show="open" 37 + x-cloak 38 + x-on:click.outside="close" 39 + class="absolute right-0 mt-2 w-44 rounded-md border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 shadow-lg py-1 z-50 overflow-hidden" 40 + > 41 + <a 42 + href="/profile/{{ auth.user.handle }}/likes" 43 + role="menuitem" 44 + class="block px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 no-underline" 45 + >My profile</a> 46 + <form action="/oauth/logout" method="POST" role="none"> 47 + {{ csrfField() }} 48 + <button 49 + type="submit" 50 + role="menuitem" 51 + class="w-full text-left block px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer" 52 + >Log out</button> 53 + </form> 54 + </div> 55 + </div> 7 56 @else 8 57 <a 9 58 href="/oauth/login"
+1
start/kernel.ts
··· 40 40 () => import('@adonisjs/session/session_middleware'), 41 41 () => import('@adonisjs/shield/shield_middleware'), 42 42 () => import('@adonisjs/auth/initialize_auth_middleware'), 43 + () => import('#middleware/auth_avatar_middleware'), 43 44 ]) 44 45 45 46 /**
+96
tests/functional/profile_controller.spec.ts
··· 16 16 import { AtprotoClient } from '#lib/atproto/index' 17 17 import { HandleResolver } from '#services/handle_resolver' 18 18 import TrackedProfile from '#models/tracked_profile' 19 + import Account from '#models/account' 19 20 20 21 // --------------------------------------------------------------------------- 21 22 // ClickHouse helpers (mirror clickhouse_store.spec.ts) ··· 554 555 assert.include(html, 'A popular repost') 555 556 }).skip(async () => !(await isClickHouseAvailable()), 'ClickHouse not available') 556 557 }) 558 + 559 + // --------------------------------------------------------------------------- 560 + // Header user menu (auth_buttons partial) 561 + // --------------------------------------------------------------------------- 562 + 563 + test.group('Header user menu', (group) => { 564 + group.each.setup(() => testUtils.db().withGlobalTransaction()) 565 + 566 + test('GET / when authenticated with an avatar renders an avatar trigger and dropdown menu', async ({ 567 + client, 568 + assert, 569 + swap, 570 + }) => { 571 + swap(AtprotoClient, { 572 + async getProfile() { 573 + return { 574 + postsCount: 0, 575 + displayName: null, 576 + avatarUrl: 'https://cdn.bsky.app/img/avatar/plain/did:plc:menuuser/bafyrei-test@jpeg', 577 + } 578 + }, 579 + } as unknown as AtprotoClient) 580 + 581 + const account = await Account.create({ 582 + did: 'did:plc:menuuser', 583 + handle: 'menuuser.bsky.social', 584 + sessionData: '{}', 585 + createdAt: Date.now(), 586 + updatedAt: Date.now(), 587 + }) 588 + 589 + const response = await client.get('/').loginAs(account) 590 + response.assertStatus(200) 591 + const html = response.text() 592 + 593 + // Dropdown Alpine component should be mounted in the header 594 + assert.include(html, 'x-data="userMenu"') 595 + 596 + // Trigger is an avatar image, not a text handle 597 + assert.match( 598 + html, 599 + /<img[^>]+src="https:\/\/cdn\.bsky\.app\/img\/avatar\/plain\/did:plc:menuuser\/bafyrei-test@jpeg"/ 600 + ) 601 + // Button is still labelled for screen readers 602 + assert.match(html, /aria-label="[^"]*@menuuser\.bsky\.social[^"]*"/) 603 + 604 + // Menu items: link to the user's own profile, and a POST form for logout 605 + assert.include(html, 'href="/profile/menuuser.bsky.social/likes"') 606 + assert.match(html, /<form[^>]+action="\/oauth\/logout"[^>]+method="POST"/i) 607 + }) 608 + 609 + test('GET / when the avatar lookup fails renders a fallback placeholder trigger', async ({ 610 + client, 611 + assert, 612 + swap, 613 + }) => { 614 + swap(AtprotoClient, { 615 + async getProfile() { 616 + throw new Error('profile lookup failed') 617 + }, 618 + } as unknown as AtprotoClient) 619 + 620 + const account = await Account.create({ 621 + did: 'did:plc:noavatar', 622 + handle: 'noavatar.bsky.social', 623 + sessionData: '{}', 624 + createdAt: Date.now(), 625 + updatedAt: Date.now(), 626 + }) 627 + 628 + const response = await client.get('/').loginAs(account) 629 + response.assertStatus(200) 630 + const html = response.text() 631 + 632 + // No avatar image in the header for this user 633 + assert.notMatch(html, /<img[^>]+src="[^"]*noavatar[^"]*"/) 634 + // Button still labelled with the handle so the user knows who they are signed in as 635 + assert.match(html, /aria-label="[^"]*@noavatar\.bsky\.social[^"]*"/) 636 + // Menu still works 637 + assert.match(html, /<form[^>]+action="\/oauth\/logout"[^>]+method="POST"/i) 638 + }) 639 + 640 + test('GET / when unauthenticated renders a Sign in link and no user menu', async ({ 641 + client, 642 + assert, 643 + }) => { 644 + const response = await client.get('/') 645 + response.assertStatus(200) 646 + const html = response.text() 647 + 648 + assert.include(html, 'href="/oauth/login"') 649 + assert.notInclude(html, 'x-data="userMenu"') 650 + assert.notInclude(html, 'action="/oauth/logout"') 651 + }) 652 + })