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.

Add signed-out sign-in panel and signed-in account pill

+257
+47
src/components/AccountPill.test.tsx
··· 1 + import { describe, it, expect, vi } from 'vitest'; 2 + import { act, createElement } from 'react'; 3 + import { createRoot } from 'react-dom/client'; 4 + 5 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 6 + import AccountPill from './AccountPill'; 7 + 8 + function mount( props: Record< string, unknown > ) { 9 + const container = document.createElement( 'div' ); 10 + document.body.appendChild( container ); 11 + act( () => createRoot( container ).render( createElement( AccountPill, props as never ) ) ); 12 + return container; 13 + } 14 + 15 + // The pill's menu is collapsed until the trigger is clicked; open it before asserting items. 16 + const openMenu = ( container: HTMLElement ) => { 17 + const trigger = container.querySelector( '.account-pill__trigger' ) as HTMLButtonElement; 18 + act( () => trigger.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ) ); 19 + }; 20 + 21 + describe( 'AccountPill', () => { 22 + it( 'shows the handle and a Manage-publications + Profile + Sign out menu', () => { 23 + const c = mount( { 24 + displayName: 'Alice', handle: 'alice.test', avatar: null, onSignOut: vi.fn(), 25 + } ); 26 + expect( c.textContent ).toContain( 'alice.test' ); 27 + openMenu( c ); 28 + expect( c.querySelector( 'a[href="/dashboard"]' ) ).not.toBe( null ); 29 + expect( c.querySelector( 'a[href="/@alice.test"]' ) ).not.toBe( null ); 30 + expect( c.textContent?.toLowerCase() ).toContain( 'sign out' ); 31 + } ); 32 + 33 + it( 'invokes onSignOut when Sign out is clicked', () => { 34 + const onSignOut = vi.fn(); 35 + const c = mount( { displayName: 'Alice', handle: 'alice.test', avatar: null, onSignOut } ); 36 + openMenu( c ); 37 + const btn = [ ...c.querySelectorAll( 'button' ) ].find( ( b ) => /sign out/i.test( b.textContent ?? '' ) )!; 38 + act( () => btn.dispatchEvent( new MouseEvent( 'click', { bubbles: true } ) ) ); 39 + expect( onSignOut ).toHaveBeenCalledTimes( 1 ); 40 + } ); 41 + 42 + it( 'omits the Profile link when no handle is known', () => { 43 + const c = mount( { displayName: 'did:plc:me', handle: null, avatar: null, onSignOut: vi.fn() } ); 44 + openMenu( c ); 45 + expect( c.querySelector( 'a[href^="/@"]' ) ).toBe( null ); 46 + } ); 47 + } );
+86
src/components/AccountPill.tsx
··· 1 + import { useEffect, useRef, useState } from 'react'; 2 + import { authorPath } from '../lib/auth/profile'; 3 + 4 + interface Props { 5 + displayName: string; 6 + handle: string | null; 7 + avatar: string | null; 8 + onSignOut: () => void; 9 + } 10 + 11 + /** 12 + * Signed-in account pill for the writing-first page: avatar + handle, opening a menu with 13 + * Manage publications (→ existing dashboard), Profile (public author page, omitted when no 14 + * handle), and Sign out. Management lives on the dashboard by design — the editor stays the 15 + * focus here (design 2026-06-17, Q5). 16 + */ 17 + export default function AccountPill( { displayName, handle, avatar, onSignOut }: Props ) { 18 + const [ open, setOpen ] = useState( false ); 19 + const [ avatarOk, setAvatarOk ] = useState( true ); 20 + const rootRef = useRef< HTMLDivElement >( null ); 21 + 22 + useEffect( () => { 23 + if ( ! open ) { 24 + return; 25 + } 26 + function onDown( event: MouseEvent ) { 27 + if ( rootRef.current && ! rootRef.current.contains( event.target as Node ) ) { 28 + setOpen( false ); 29 + } 30 + } 31 + document.addEventListener( 'mousedown', onDown ); 32 + return () => document.removeEventListener( 'mousedown', onDown ); 33 + }, [ open ] ); 34 + 35 + const profileHref = authorPath( handle ); 36 + 37 + return ( 38 + <div className="account-pill" ref={ rootRef }> 39 + <button 40 + type="button" 41 + className="account-pill__trigger" 42 + aria-haspopup="menu" 43 + aria-expanded={ open } 44 + onClick={ () => setOpen( ( v ) => ! v ) } 45 + > 46 + { avatar && avatarOk ? ( 47 + <img 48 + className="account-pill__avatar" 49 + src={ avatar } 50 + alt="" 51 + width={ 28 } 52 + height={ 28 } 53 + onError={ () => setAvatarOk( false ) } 54 + /> 55 + ) : ( 56 + <span className="account-pill__avatar account-pill__avatar--fallback" aria-hidden="true"> 57 + { displayName.charAt( 0 ).toUpperCase() } 58 + </span> 59 + ) } 60 + <span className="account-pill__handle"> 61 + { handle ? `@${ handle }` : displayName } 62 + </span> 63 + </button> 64 + { open && ( 65 + <div className="account-pill__menu" role="menu"> 66 + <a className="account-pill__item" role="menuitem" href="/dashboard"> 67 + Manage publications 68 + </a> 69 + { profileHref && ( 70 + <a className="account-pill__item" role="menuitem" href={ profileHref }> 71 + Profile 72 + </a> 73 + ) } 74 + <button 75 + type="button" 76 + className="account-pill__item account-pill__item--button" 77 + role="menuitem" 78 + onClick={ onSignOut } 79 + > 80 + Sign out 81 + </button> 82 + </div> 83 + ) } 84 + </div> 85 + ); 86 + }
+51
src/components/SignInPanel.test.tsx
··· 1 + import { describe, it, expect, vi } from 'vitest'; 2 + import { act, createElement } from 'react'; 3 + import { createRoot } from 'react-dom/client'; 4 + 5 + ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; 6 + import SignInPanel from './SignInPanel'; 7 + 8 + function mount( props: Record< string, unknown > ) { 9 + const container = document.createElement( 'div' ); 10 + document.body.appendChild( container ); 11 + act( () => createRoot( container ).render( createElement( SignInPanel, props as never ) ) ); 12 + return container; 13 + } 14 + 15 + // React 18 tracks a controlled input's value via a private setter; assigning `.value` 16 + // directly then dispatching `input` is invisible to React and onChange never fires. Set 17 + // through the native prototype setter so React's tracker sees the change (standard idiom). 18 + const setInputValue = ( input: HTMLInputElement, value: string ) => { 19 + const setter = Object.getOwnPropertyDescriptor( 20 + window.HTMLInputElement.prototype, 21 + 'value' 22 + )!.set!; 23 + setter.call( input, value ); 24 + }; 25 + 26 + describe( 'SignInPanel', () => { 27 + it( 'for-publish variant frames the CTA around publishing and links out to signup', () => { 28 + const c = mount( { forPublish: true, error: null, onSubmit: vi.fn() } ); 29 + expect( c.textContent?.toLowerCase() ).toContain( 'publish' ); 30 + const signup = c.querySelector( 'a[href*="bsky.app"]' ); 31 + expect( signup ).not.toBe( null ); 32 + } ); 33 + 34 + it( 'submits the typed handle', () => { 35 + const onSubmit = vi.fn(); 36 + const c = mount( { forPublish: false, error: null, onSubmit } ); 37 + const input = c.querySelector( 'input' )!; 38 + const form = c.querySelector( 'form' )!; 39 + act( () => { 40 + setInputValue( input as HTMLInputElement, 'alice.bsky.social' ); 41 + input.dispatchEvent( new Event( 'input', { bubbles: true } ) ); 42 + } ); 43 + act( () => form.dispatchEvent( new Event( 'submit', { bubbles: true, cancelable: true } ) ) ); 44 + expect( onSubmit ).toHaveBeenCalledWith( 'alice.bsky.social' ); 45 + } ); 46 + 47 + it( 'shows an error when provided', () => { 48 + const c = mount( { forPublish: false, error: 'Bad handle', onSubmit: vi.fn() } ); 49 + expect( c.textContent ).toContain( 'Bad handle' ); 50 + } ); 51 + } );
+73
src/components/SignInPanel.tsx
··· 1 + import { useState } from 'react'; 2 + 3 + interface Props { 4 + /** True when opened from "Publish" — the copy promises a publish on return. */ 5 + forPublish: boolean; 6 + error: string | null; 7 + onSubmit: ( value: string ) => void; 8 + onCancel?: () => void; 9 + } 10 + 11 + /** 12 + * Signed-out handle entry for the writing-first flow. OAuth is sign-in only, so this never 13 + * creates an account — it links out to Bluesky's hosted signup instead (brief guardrail). The 14 + * caller persists the draft + sets publish intent before the redirect. 15 + */ 16 + export default function SignInPanel( { forPublish, error, onSubmit, onCancel }: Props ) { 17 + const [ value, setValue ] = useState( '' ); 18 + 19 + return ( 20 + <form 21 + className="signin-panel" 22 + onSubmit={ ( event ) => { 23 + event.preventDefault(); 24 + onSubmit( value.trim() ); 25 + } } 26 + > 27 + <h2 className="signin-panel__title"> 28 + { forPublish ? 'Sign in to publish' : 'Sign in' } 29 + </h2> 30 + <p className="signin-panel__lede"> 31 + { forPublish 32 + ? "Your draft is saved. Sign in and we'll pick up right where you left off and publish it." 33 + : 'Use your existing Bluesky / AT Protocol identity. Your work stays in your own account.' } 34 + </p> 35 + <label className="signin-panel__label" htmlFor="write-handle"> 36 + Your handle, DID, or PDS URL 37 + </label> 38 + <input 39 + id="write-handle" 40 + className="signin-panel__input" 41 + name="handle" 42 + autoComplete="username" 43 + autoCapitalize="none" 44 + autoCorrect="off" 45 + spellCheck={ false } 46 + placeholder="alice.bsky.social" 47 + value={ value } 48 + onChange={ ( event ) => setValue( event.target.value ) } 49 + /> 50 + <div className="signin-panel__actions"> 51 + <button className="signin-panel__submit" type="submit"> 52 + Sign in with AT Protocol 53 + </button> 54 + { onCancel && ( 55 + <button className="signin-panel__cancel" type="button" onClick={ onCancel }> 56 + Cancel 57 + </button> 58 + ) } 59 + </div> 60 + { error && ( 61 + <p className="signin-panel__error" role="alert"> 62 + { error } 63 + </p> 64 + ) } 65 + <p className="signin-panel__signup"> 66 + Need an account?{ ' ' } 67 + <a href="https://bsky.app" target="_blank" rel="noreferrer noopener"> 68 + Create one on Bluesky → 69 + </a> 70 + </p> 71 + </form> 72 + ); 73 + }