A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

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

at trunk 4.7 kB View raw
1import { useEffect, useRef, useState } from 'react'; 2import { Agent } from '@atproto/api'; 3import { createOAuthClient } from '../lib/auth/oauth'; 4import { 5 fetchViewerProfile, 6 displayNameFor, 7 accountMenuItems, 8 type ViewerProfile, 9} from '../lib/auth/profile'; 10 11/** 12 * Signed-in account menu for the home masthead. Restores the browser OAuth 13 * session and, when signed in, renders an avatar + name + handle trigger with a 14 * Dashboard / Write / Profile dropdown. Renders nothing when signed out or if 15 * anything fails — it is non-critical chrome (the static "Write" link in the 16 * masthead remains), so it never surfaces errors. While signed in it sets 17 * `data-signed-in` on `.masthead__right` so CSS hides that static link. 18 */ 19export default function AccountMenu() { 20 const [ profile, setProfile ] = useState< ViewerProfile | null >( null ); 21 const [ open, setOpen ] = useState( false ); 22 const [ avatarOk, setAvatarOk ] = useState( true ); 23 const [ canHover, setCanHover ] = useState( false ); 24 const rootRef = useRef< HTMLDivElement >( null ); 25 const triggerRef = useRef< HTMLButtonElement >( null ); 26 27 // Hover is a desktop-only enhancement. On hover-capable devices the trigger 28 // opens on pointer enter (and closes on leave), so its click only ever opens — 29 // a toggle would close the menu the hover just opened. On touch there is no 30 // hover, so the click acts as a tap toggle instead. 31 useEffect( () => { 32 setCanHover( window.matchMedia?.( '(hover: hover)' ).matches ?? false ); 33 }, [] ); 34 35 // Restore the OAuth session and fetch the viewer's profile. 36 useEffect( () => { 37 let cancelled = false; 38 ( async () => { 39 try { 40 const client = await createOAuthClient(); 41 const result = await client.init(); 42 if ( cancelled || ! result?.session ) { 43 return; 44 } 45 const next = await fetchViewerProfile( new Agent( result.session ), result.session.did ); 46 if ( ! cancelled ) { 47 setProfile( next ); 48 } 49 } catch { 50 // Non-critical chrome: stay hidden on any failure. 51 } 52 } )(); 53 return () => { 54 cancelled = true; 55 }; 56 }, [] ); 57 58 // While signed in, mark the masthead so the static "Write" link is hidden. 59 useEffect( () => { 60 const right = rootRef.current?.closest( '.masthead__right' ); 61 if ( ! right ) { 62 return; 63 } 64 right.setAttribute( 'data-signed-in', '' ); 65 return () => right.removeAttribute( 'data-signed-in' ); 66 }, [ profile ] ); 67 68 // Close the dropdown on outside click and on Escape. 69 useEffect( () => { 70 if ( ! open ) { 71 return; 72 } 73 function onDown( event: MouseEvent ) { 74 if ( rootRef.current && ! rootRef.current.contains( event.target as Node ) ) { 75 setOpen( false ); 76 } 77 } 78 function onKey( event: KeyboardEvent ) { 79 if ( event.key === 'Escape' ) { 80 setOpen( false ); 81 triggerRef.current?.focus(); 82 } 83 } 84 document.addEventListener( 'mousedown', onDown ); 85 document.addEventListener( 'keydown', onKey ); 86 return () => { 87 document.removeEventListener( 'mousedown', onDown ); 88 document.removeEventListener( 'keydown', onKey ); 89 }; 90 }, [ open ] ); 91 92 if ( ! profile ) { 93 return null; 94 } 95 96 const name = displayNameFor( profile ); 97 const items = accountMenuItems( profile ); 98 99 return ( 100 <div 101 className="account-menu" 102 ref={ rootRef } 103 onMouseEnter={ canHover ? () => setOpen( true ) : undefined } 104 onMouseLeave={ canHover ? () => setOpen( false ) : undefined } 105 onBlur={ ( event ) => { 106 if ( ! rootRef.current?.contains( event.relatedTarget as Node ) ) { 107 setOpen( false ); 108 } 109 } } 110 > 111 <button 112 ref={ triggerRef } 113 type="button" 114 className="account-menu__trigger" 115 aria-haspopup="menu" 116 aria-expanded={ open } 117 aria-controls="account-menu-dropdown" 118 onClick={ () => setOpen( ( value ) => ( canHover ? true : ! value ) ) } 119 > 120 { profile.avatar && avatarOk ? ( 121 <img 122 className="account-menu__avatar" 123 src={ profile.avatar } 124 alt="" 125 width={ 30 } 126 height={ 30 } 127 onError={ () => setAvatarOk( false ) } 128 /> 129 ) : ( 130 <span className="account-menu__avatar account-menu__avatar--fallback" aria-hidden="true"> 131 { name.charAt( 0 ).toUpperCase() } 132 </span> 133 ) } 134 <span className="account-menu__who"> 135 <span className="account-menu__name">{ name }</span> 136 { profile.handle && <span className="account-menu__handle">@{ profile.handle }</span> } 137 </span> 138 </button> 139 { open && ( 140 <div id="account-menu-dropdown" className="account-menu__dropdown" role="menu"> 141 { items.map( ( item ) => ( 142 <a key={ item.href } className="account-menu__item" role="menuitem" href={ item.href }> 143 { item.label } 144 </a> 145 ) ) } 146 </div> 147 ) } 148 </div> 149 ); 150}