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.

Fix @-mention autocomplete and quiet the post-length counter

Two issues surfaced by an in-browser smoke test (publish from @jeherve.com
mentioning @jeremy.herve.bzh):

1. The @ autocomplete never ran ours — every keystroke hit WordPress's built-in
user completer (GET /wp/v2/users → 404). Both completers claim the '@' trigger
and the block editor resolves a trigger to the FIRST matching completer, so
appending ours left it shadowed. Replace every '@'-triggered completer with ours
instead of appending (extracted as replaceAtMentionCompleter).

2. Selecting an option inserted nothing: getOptionCompletion returned
{ action: 'replace', value }, but 'replace' swaps the whole block (the slash-
completer contract) and dropped the inline anchor. Return the anchor element
directly so it inserts at the caret in place of the typed @query — the core
link-completer pattern.

Also: the live grapheme counter showed 'Bluesky post: N/300' at all times; only
warn when actually over the limit (hidden otherwise).

Verified end to end in the browser: autocomplete resolves jeremy.herve.bzh from
public.api.bsky.app, inserts the class+href+data-did anchor, the confirm dialog
discloses the notify target, and the published post carries the #mention facet
(correct DID + byte offsets) plus the document's flat mentions interop list.

+88 -32
+14
src/components/PublishPanel.test.tsx
··· 188 188 cleanup(); 189 189 } ); 190 190 191 + it( 'shows no counter for an in-limit new article', async () => { 192 + const { container, cleanup } = await renderPanel( { 193 + publications: [ PUB ], 194 + description: 'A short subtitle', 195 + } ); 196 + const button = Array.from( container.querySelectorAll( 'button' ) ).find( 197 + ( b ) => b.textContent === 'Publish…' 198 + )!; 199 + expect( button.disabled ).toBe( false ); 200 + // Under the limit the counter stays out of the way entirely. 201 + expect( container.textContent ).not.toContain( 'Bluesky post:' ); 202 + cleanup(); 203 + } ); 204 + 191 205 it( 'does not disable Update or show a counter when editing the same long content', async () => { 192 206 const { container, cleanup } = await renderPanel( { 193 207 editing: EDITING,
+8 -11
src/components/PublishPanel.tsx
··· 94 94 * Title + publish/update control. Publishing a NEW article targets a CHOSEN publication 95 95 * (Decision 0010) and creates a public Bluesky post, so it requires an explicit confirmation 96 96 * (brief §10) — the confirm dialog also discloses which mentioned accounts will be notified. 97 - * Publishing is blocked when the assembled post exceeds 300 graphemes (the live counter flags 98 - * it and the button is disabled). Editing updates the existing record in place, in its own 97 + * Publishing is blocked when the assembled post exceeds 300 graphemes (an over-limit warning 98 + * appears and the button is disabled; under the limit nothing is shown). Editing updates the 99 + * existing record in place, in its own 99 100 * publication, and does NOT create a new post (Decision 0008). 100 101 */ 101 102 export default function PublishPanel( { ··· 247 248 </label> 248 249 ) } 249 250 250 - { preview && ( 251 - <p 252 - className={ `publish__count${ preview.overLimit ? ' publish__count--over' : '' }` } 253 - aria-live="polite" 254 - > 255 - Bluesky post: { preview.graphemes }/300 256 - { preview.overLimit 257 - ? ' — too long to publish; shorten the subtitle or remove a mention' 258 - : '' } 251 + { /* Stay out of the way until the post is actually too long — only then warn. */ } 252 + { preview?.overLimit && ( 253 + <p className="publish__count publish__count--over" aria-live="polite"> 254 + Bluesky post: { preview.graphemes }/300 — too long to publish; shorten the 255 + subtitle or remove a mention 259 256 </p> 260 257 ) } 261 258
+28 -4
src/lib/editor/mention-autocompleter.test.ts
··· 1 1 import { describe, expect, it, vi } from 'vitest'; 2 2 import { renderToString } from 'react-dom/server'; 3 - import { createMentionCompleter } from './mention-autocompleter'; 3 + import { 4 + createMentionCompleter, 5 + replaceAtMentionCompleter, 6 + } from './mention-autocompleter'; 4 7 import type { ActorPreview } from '../landing/actor-lookup'; 5 8 6 9 const ALICE: ActorPreview = { ··· 31 34 expect( lookup ).not.toHaveBeenCalled(); 32 35 } ); 33 36 34 - it( 'inserts a mention anchor with href + data-did on completion', () => { 37 + it( 'completes to a mention anchor element (inserted at the caret, not block-replacing)', () => { 35 38 const completer = createMentionCompleter( async () => null ); 39 + // The completion is the anchor element itself, so the editor inserts it inline in 40 + // place of the typed `@query` rather than swapping out the whole block. 36 41 const completion = completer.getOptionCompletion( ALICE ); 37 - expect( completion.action ).toBe( 'replace' ); 38 - const html = renderToString( completion.value ); 42 + const html = renderToString( completion ); 39 43 expect( html ).toContain( 'href="https://bsky.app/profile/alice.bsky.social"' ); 40 44 expect( html ).toContain( 'data-did="did:plc:alice"' ); 41 45 expect( html ).toContain( '@alice.bsky.social' ); 42 46 } ); 43 47 } ); 48 + 49 + describe( 'replaceAtMentionCompleter', () => { 50 + const mention = createMentionCompleter( async () => null ); 51 + 52 + it( "drops WordPress's built-in @ user completer and appends ours", () => { 53 + const builtInUser = { name: 'user', triggerPrefix: '@' }; 54 + const blockCompleter = { name: 'block', triggerPrefix: '/' }; 55 + const result = replaceAtMentionCompleter( 56 + [ builtInUser, blockCompleter ], 57 + mention 58 + ); 59 + // The '/' completer survives; the '@' user completer is gone; ours is last. 60 + expect( result ).toEqual( [ blockCompleter, mention ] ); 61 + } ); 62 + 63 + it( 'returns only our completer when the base set is empty or not an array', () => { 64 + expect( replaceAtMentionCompleter( [], mention ) ).toEqual( [ mention ] ); 65 + expect( replaceAtMentionCompleter( undefined, mention ) ).toEqual( [ mention ] ); 66 + } ); 67 + } );
+38 -17
src/lib/editor/mention-autocompleter.ts
··· 4 4 5 5 type LookupFn = ( query: string ) => Promise< ActorPreview | null >; 6 6 7 - export interface MentionCompletion { 8 - action: 'replace'; 9 - value: ReturnType< typeof createElement >; 10 - } 7 + /** 8 + * The inline value an option completes to: a `skypress/mention` anchor element. Returning 9 + * the element directly (rather than a `{ action: 'replace' }` object) makes the block editor 10 + * insert it AT THE CARET in place of the typed `@query` — the same pattern as the core link 11 + * completer. `action: 'replace'` would instead try to swap out the whole block, dropping the 12 + * inline anchor. 13 + */ 14 + export type MentionCompletion = ReturnType< typeof createElement >; 11 15 12 16 /** 13 17 * A block-editor autocompleter for `@mentions`. `options` queries the public Bluesky ··· 56 60 ); 57 61 }, 58 62 getOptionCompletion( option: ActorPreview ): MentionCompletion { 59 - return { 60 - action: 'replace', 61 - value: createElement( 62 - 'a', 63 - { 64 - href: `https://bsky.app/profile/${ option.handle }`, 65 - className: 'skypress-mention', 66 - 'data-did': option.did, 67 - }, 68 - `@${ option.handle }` 69 - ), 70 - }; 63 + return createElement( 64 + 'a', 65 + { 66 + href: `https://bsky.app/profile/${ option.handle }`, 67 + className: 'skypress-mention', 68 + 'data-did': option.did, 69 + }, 70 + `@${ option.handle }` 71 + ); 71 72 }, 72 73 }; 73 74 } 74 75 75 76 const MENTION_FILTER_NAMESPACE = 'skypress/mention-autocompleter'; 76 77 78 + /** 79 + * Swap WordPress's built-in `@` user-mention completer for the SkyPress one. The built-in 80 + * completer queries `/wp/v2/users` (which 404s — SkyPress has no WP user table) and, because 81 + * the block editor's `Autocomplete` resolves a trigger to the FIRST completer that claims it, 82 + * appending ours leaves the built-in `@` completer shadowing it. So we drop every existing 83 + * `@`-triggered completer and add ours in their place. 84 + */ 85 + export function replaceAtMentionCompleter( 86 + completers: unknown, 87 + mention: ReturnType< typeof createMentionCompleter > 88 + ): unknown[] { 89 + const list = Array.isArray( completers ) ? completers : []; 90 + return [ 91 + ...list.filter( 92 + ( c ) => ( c as { triggerPrefix?: string } )?.triggerPrefix !== '@' 93 + ), 94 + mention, 95 + ]; 96 + } 97 + 77 98 /** Add the mention completer to every RichText instance in the editor (idempotent). */ 78 99 export function registerMentionAutocompleter(): void { 79 100 if ( hasFilter( 'editor.Autocomplete.completers', MENTION_FILTER_NAMESPACE ) ) { ··· 84 105 'editor.Autocomplete.completers', 85 106 MENTION_FILTER_NAMESPACE, 86 107 // @wordpress/hooks types the callback as (...args) => unknown; cast the first arg. 87 - ( completers: unknown ) => [ ...( completers as unknown[] ), completer ] 108 + ( completers: unknown ) => replaceAtMentionCompleter( completers, completer ) 88 109 ); 89 110 }