A calm place to write long-form, and publish it to the open social web.
skypress.blog/
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}