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.

Allow bare quote-reposts with no commentary

The quote composer's placeholder said "Optional comment…" and buildQuote
accepts empty text, but the submit button and onSubmitComposer guard both
used validateReplyText, which rejected empty input for replies and quotes
alike — so a bare quote-repost was impossible despite the UI saying the
comment was optional.

Give validateReplyText an allowEmpty option and pass it for the quote
composer: replies stay non-empty, quotes may be posted with no commentary,
and the 300-grapheme cap still applies to both.

+26 -6
+2 -1
docs/superpowers/specs/2026-06-09-social-actions-on-posts-design.md
··· 95 95 counter via the browser-native **`Intl.Segmenter`** (no new dependency; matches Bluesky's 96 96 300-grapheme rule). Plain text only in v1 — no facet/link/mention detection, so URLs in a 97 97 reply won't be clickable (acceptable for a simple composer; noted as a future addition). 98 - Submit is disabled when empty or over 300 graphemes. 98 + Submit is disabled when over 300 graphemes; a reply must also be non-empty, but a quote 99 + may be posted with no commentary (a bare quote-repost, as on Bluesky). 99 100 100 101 A small persistent note states that actions are **public and happen on Bluesky** — the 101 102 "don't surprise users" product guardrail (brief §10), the same principle the publish panel
+3 -2
src/components/PostActions.tsx
··· 134 134 if ( ! agent || ! did || ! composer || submitting ) { 135 135 return; 136 136 } 137 - if ( ! validateReplyText( text ).ok ) { 137 + if ( ! validateReplyText( text, { allowEmpty: composer === 'quote' } ).ok ) { 138 138 return; 139 139 } 140 140 setSubmitting( true ); ··· 206 206 207 207 const liked = Boolean( state?.viewerLikeUri ); 208 208 const reposted = Boolean( state?.viewerRepostUri ); 209 - const validity = validateReplyText( text ); 209 + // A quote can be posted with no commentary (bare quote-repost); a reply cannot. 210 + const validity = validateReplyText( text, { allowEmpty: composer === 'quote' } ); 210 211 const graphemes = graphemeLength( text ); 211 212 212 213 return (
+11
src/lib/social/records.test.ts
··· 123 123 it( 'rejects text over the grapheme cap', () => { 124 124 expect( validateReplyText( 'a'.repeat( MAX_REPLY_GRAPHEMES + 1 ) ).ok ).toBe( false ); 125 125 } ); 126 + 127 + it( 'accepts empty text when allowEmpty is set (a bare quote-repost)', () => { 128 + expect( validateReplyText( '', { allowEmpty: true } ).ok ).toBe( true ); 129 + expect( validateReplyText( ' ', { allowEmpty: true } ).ok ).toBe( true ); 130 + } ); 131 + 132 + it( 'still rejects over-cap text even when allowEmpty is set', () => { 133 + expect( 134 + validateReplyText( 'a'.repeat( MAX_REPLY_GRAPHEMES + 1 ), { allowEmpty: true } ).ok 135 + ).toBe( false ); 136 + } ); 126 137 } );
+10 -3
src/lib/social/records.ts
··· 124 124 graphemes: number; 125 125 } 126 126 127 - /** A reply/quote body must be non-empty and within the grapheme cap. */ 128 - export function validateReplyText( text: string ): ReplyValidation { 127 + /** 128 + * A reply/quote body must be within the grapheme cap. A reply must also be non-empty; 129 + * a quote may have no commentary (`allowEmpty`), matching Bluesky's bare quote-repost. 130 + */ 131 + export function validateReplyText( 132 + text: string, 133 + options: { allowEmpty?: boolean } = {} 134 + ): ReplyValidation { 129 135 const graphemes = graphemeLength( text ); 130 - return { ok: text.trim().length > 0 && graphemes <= MAX_REPLY_GRAPHEMES, graphemes }; 136 + const hasBody = options.allowEmpty || text.trim().length > 0; 137 + return { ok: hasBody && graphemes <= MAX_REPLY_GRAPHEMES, graphemes }; 131 138 }