Your calm window into the Atmosphere. morgen.blue
rss atproto
3
fork

Configure Feed

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

feat(layout): show avatar, display name, handle in account dropdown

Surface the signed-in user at the top of the window-chrome account
dropdown. Avatar + displayName fetched live from the PDS via a deferred
Inertia prop, cached per DID for an hour.

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

+142 -19
+6
app/Http/Middleware/HandleInertiaRequests.php
··· 2 2 3 3 namespace App\Http\Middleware; 4 4 5 + use App\Services\PdsProfileService; 5 6 use Illuminate\Http\Request; 7 + use Inertia\Inertia; 6 8 use Inertia\Middleware; 7 9 8 10 class HandleInertiaRequests extends Middleware ··· 20 22 'auth' => [ 21 23 'user' => $request->user(), 22 24 'handle' => $request->session()->get('atproto.handle'), 25 + 'profile' => Inertia::defer(fn () => $request->user() 26 + ? app(PdsProfileService::class)->for($request->user()) 27 + : null 28 + ), 23 29 ], 24 30 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 25 31 ];
+46
app/Services/PdsProfileService.php
··· 1 + <?php 2 + 3 + namespace App\Services; 4 + 5 + use App\Models\User; 6 + use Illuminate\Support\Facades\Cache; 7 + use Revolution\Bluesky\Facades\Bluesky; 8 + use Throwable; 9 + 10 + class PdsProfileService 11 + { 12 + /** 13 + * @return array{avatar: ?string, displayName: ?string}|null 14 + */ 15 + public function for(User $user): ?array 16 + { 17 + return Cache::remember( 18 + "pds-profile:{$user->did}", 19 + now()->addHour(), 20 + fn () => $this->fetch($user->did), 21 + ); 22 + } 23 + 24 + /** 25 + * @return array{avatar: ?string, displayName: ?string}|null 26 + */ 27 + private function fetch(string $did): ?array 28 + { 29 + try { 30 + $profile = Bluesky::public()->getProfile(actor: $did)->json(); 31 + } catch (Throwable $e) { 32 + report($e); 33 + 34 + return null; 35 + } 36 + 37 + if (! is_array($profile)) { 38 + return null; 39 + } 40 + 41 + return [ 42 + 'avatar' => $profile['avatar'] ?? null, 43 + 'displayName' => $profile['displayName'] ?? null, 44 + ]; 45 + } 46 + }
+1 -16
resources/js/components/user-info.tsx
··· 1 1 import { Avatar, AvatarFallback } from '@/components/ui/avatar'; 2 - 3 - function initialsFromHandle(handle: string | null, did: string): string { 4 - const source = handle ?? did.replace(/^did:[a-z]+:/, ''); 5 - 6 - return source.slice(0, 2).toUpperCase(); 7 - } 8 - 9 - function truncateDid(did: string): string { 10 - const suffix = did.replace(/^did:[a-z]+:/, ''); 11 - 12 - if (suffix.length <= 10) { 13 - return did; 14 - } 15 - 16 - return `${did.slice(0, 12)}…${suffix.slice(-4)}`; 17 - } 2 + import { initialsFromHandle, truncateDid } from '@/lib/handle'; 18 3 19 4 export function UserInfo({ 20 5 handle,
+68 -3
resources/js/components/window-chrome.tsx
··· 5 5 UserCircleIcon, 6 6 } from '@hugeicons/core-free-icons'; 7 7 import { HugeiconsIcon } from '@hugeicons/react'; 8 - import { Link, router, usePage } from '@inertiajs/react'; 8 + import { Deferred, Link, router, usePage } from '@inertiajs/react'; 9 9 10 + import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; 10 11 import { Button } from '@/components/ui/button'; 11 12 import { 12 13 DropdownMenu, 13 14 DropdownMenuContent, 14 15 DropdownMenuItem, 16 + DropdownMenuSeparator, 15 17 DropdownMenuTrigger, 16 18 } from '@/components/ui/dropdown-menu'; 19 + import { Skeleton } from '@/components/ui/skeleton'; 20 + import { initialsFromHandle, truncateDid } from '@/lib/handle'; 17 21 import { cn } from '@/lib/utils'; 18 22 import { consume, create, discover, logout } from '@/routes'; 19 23 import { edit as editAppearance } from '@/routes/appearance'; 24 + import type { Profile } from '@/types/auth'; 20 25 21 26 type Tab = { 22 27 label: string; ··· 30 35 ]; 31 36 32 37 export function WindowChrome() { 33 - const { url } = usePage(); 38 + const { url, props } = usePage(); 39 + const auth = props.auth; 34 40 35 41 const handleLogout = () => { 36 42 router.flushAll(); ··· 85 91 className="size-5" 86 92 /> 87 93 </DropdownMenuTrigger> 88 - <DropdownMenuContent align="end" className="w-44"> 94 + <DropdownMenuContent align="end" className="w-56"> 95 + {auth.user && ( 96 + <> 97 + <Deferred 98 + data="auth.profile" 99 + fallback={<UserHeaderSkeleton />} 100 + > 101 + <UserHeader 102 + handle={auth.handle} 103 + did={auth.user.did} 104 + profile={auth.profile ?? null} 105 + /> 106 + </Deferred> 107 + <DropdownMenuSeparator /> 108 + </> 109 + )} 89 110 <DropdownMenuItem 90 111 render={<Link href={editAppearance().url} />} 91 112 > ··· 102 123 </header> 103 124 ); 104 125 } 126 + 127 + function UserHeader({ 128 + handle, 129 + did, 130 + profile, 131 + }: { 132 + handle: string | null; 133 + did: string; 134 + profile: Profile | null; 135 + }) { 136 + const handleLine = handle ? `@${handle}` : truncateDid(did); 137 + const displayName = profile?.displayName?.trim() || handleLine; 138 + 139 + return ( 140 + <div className="flex items-center gap-2 px-2 py-1.5 transition-opacity duration-200"> 141 + <Avatar className="size-8 shrink-0"> 142 + {profile?.avatar && <AvatarImage src={profile.avatar} alt="" />} 143 + <AvatarFallback className="bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"> 144 + {initialsFromHandle(handle, did)} 145 + </AvatarFallback> 146 + </Avatar> 147 + <div className="flex min-w-0 flex-col leading-tight"> 148 + <span className="truncate text-sm font-medium text-foreground"> 149 + {displayName} 150 + </span> 151 + <span className="truncate text-xs font-light text-muted-foreground"> 152 + {handleLine} 153 + </span> 154 + </div> 155 + </div> 156 + ); 157 + } 158 + 159 + function UserHeaderSkeleton() { 160 + return ( 161 + <div className="flex items-center gap-2 px-2 py-1.5"> 162 + <Skeleton className="size-8 shrink-0 animate-none rounded-full" /> 163 + <div className="flex min-w-0 flex-col gap-1.5"> 164 + <Skeleton className="h-3.5 w-24 animate-none" /> 165 + <Skeleton className="h-3 w-16 animate-none" /> 166 + </div> 167 + </div> 168 + ); 169 + }
+15
resources/js/lib/handle.ts
··· 1 + export function initialsFromHandle(handle: string | null, did: string): string { 2 + const source = handle ?? did.replace(/^did:[a-z]+:/, ''); 3 + 4 + return source.slice(0, 2).toUpperCase(); 5 + } 6 + 7 + export function truncateDid(did: string): string { 8 + const suffix = did.replace(/^did:[a-z]+:/, ''); 9 + 10 + if (suffix.length <= 10) { 11 + return did; 12 + } 13 + 14 + return `${did.slice(0, 12)}…${suffix.slice(-4)}`; 15 + }
+6
resources/js/types/auth.ts
··· 5 5 updated_at: string; 6 6 }; 7 7 8 + export type Profile = { 9 + avatar: string | null; 10 + displayName: string | null; 11 + }; 12 + 8 13 export type Auth = { 9 14 user: User | null; 10 15 handle: string | null; 16 + profile?: Profile | null; 11 17 };