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 @ mention autocompleter (actor-lookup backed)

+133
+43
src/lib/editor/mention-autocompleter.test.ts
··· 1 + import { describe, expect, it, vi } from 'vitest'; 2 + import { renderToString } from 'react-dom/server'; 3 + import { createMentionCompleter } from './mention-autocompleter'; 4 + import type { ActorPreview } from '../landing/actor-lookup'; 5 + 6 + const ALICE: ActorPreview = { 7 + did: 'did:plc:alice', 8 + handle: 'alice.bsky.social', 9 + displayName: 'Alice', 10 + avatar: null, 11 + }; 12 + 13 + describe( 'mention autocompleter', () => { 14 + it( 'has an @ trigger prefix', () => { 15 + const completer = createMentionCompleter( async () => null ); 16 + expect( completer.triggerPrefix ).toBe( '@' ); 17 + } ); 18 + 19 + it( 'returns the looked-up actor as the only option', async () => { 20 + const lookup = vi.fn( async () => ALICE ); 21 + const completer = createMentionCompleter( lookup ); 22 + const options = await completer.options( 'alice' ); 23 + expect( options ).toEqual( [ ALICE ] ); 24 + expect( lookup ).toHaveBeenCalledWith( 'alice' ); 25 + } ); 26 + 27 + it( 'returns no options for an empty query without calling lookup', async () => { 28 + const lookup = vi.fn( async () => ALICE ); 29 + const completer = createMentionCompleter( lookup ); 30 + expect( await completer.options( '' ) ).toEqual( [] ); 31 + expect( lookup ).not.toHaveBeenCalled(); 32 + } ); 33 + 34 + it( 'inserts a mention anchor with href + data-did on completion', () => { 35 + const completer = createMentionCompleter( async () => null ); 36 + const completion = completer.getOptionCompletion( ALICE ); 37 + expect( completion.action ).toBe( 'replace' ); 38 + const html = renderToString( completion.value ); 39 + expect( html ).toContain( 'href="https://bsky.app/profile/alice.bsky.social"' ); 40 + expect( html ).toContain( 'data-did="did:plc:alice"' ); 41 + expect( html ).toContain( '@alice.bsky.social' ); 42 + } ); 43 + } );
+90
src/lib/editor/mention-autocompleter.ts
··· 1 + import { createElement } from '@wordpress/element'; 2 + import { addFilter } from '@wordpress/hooks'; 3 + import { lookupActor, type ActorPreview } from '../landing/actor-lookup'; 4 + 5 + type LookupFn = ( query: string ) => Promise< ActorPreview | null >; 6 + 7 + export interface MentionCompletion { 8 + action: 'replace'; 9 + value: ReturnType< typeof createElement >; 10 + } 11 + 12 + /** 13 + * A block-editor autocompleter for `@mentions`. `options` queries the public Bluesky 14 + * AppView (reusing `actor-lookup`); selecting an account inserts the `skypress/mention` 15 + * anchor (class + href + data-did), with the DID resolved once, here, at pick time. 16 + */ 17 + export function createMentionCompleter( lookup: LookupFn = lookupActor ) { 18 + return { 19 + name: 'skypress/mention', 20 + triggerPrefix: '@', 21 + async options( query: string ): Promise< ActorPreview[] > { 22 + const trimmed = query.trim(); 23 + if ( ! trimmed ) { 24 + return []; 25 + } 26 + const found = await lookup( trimmed ); 27 + return found ? [ found ] : []; 28 + }, 29 + getOptionKeywords( option: ActorPreview ): string[] { 30 + return [ option.handle, option.displayName ?? '' ].filter( Boolean ); 31 + }, 32 + getOptionLabel( option: ActorPreview ) { 33 + return createElement( 34 + 'span', 35 + { className: 'skypress-mention-option' }, 36 + option.avatar 37 + ? createElement( 'img', { 38 + src: option.avatar, 39 + alt: '', 40 + width: 20, 41 + height: 20, 42 + className: 'skypress-mention-option__avatar', 43 + } ) 44 + : null, 45 + createElement( 46 + 'span', 47 + { className: 'skypress-mention-option__name' }, 48 + option.displayName ?? option.handle 49 + ), 50 + createElement( 51 + 'span', 52 + { className: 'skypress-mention-option__handle' }, 53 + `@${ option.handle }` 54 + ) 55 + ); 56 + }, 57 + getOptionCompletion( option: ActorPreview ): MentionCompletion { 58 + return { 59 + action: 'replace', 60 + value: createElement( 61 + 'a', 62 + { 63 + href: `https://bsky.app/profile/${ option.handle }`, 64 + className: 'skypress-mention', 65 + 'data-did': option.did, 66 + }, 67 + `@${ option.handle }` 68 + ), 69 + }; 70 + }, 71 + }; 72 + } 73 + 74 + let registered = false; 75 + 76 + /** Add the mention completer to every RichText instance in the editor. */ 77 + export function registerMentionAutocompleter(): void { 78 + if ( registered ) { 79 + return; 80 + } 81 + registered = true; 82 + const completer = createMentionCompleter(); 83 + addFilter( 84 + 'editor.Autocomplete.completers', 85 + 'skypress/mention-autocompleter', 86 + // The local `addFilter` type signs the callback as `( ...args: unknown[] )`, 87 + // so accept `unknown` and narrow to the completers array here. 88 + ( completers: unknown ) => [ ...( completers as unknown[] ), completer ] 89 + ); 90 + }