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 Bluesky post grapheme counter + mention disclosure to PublishPanel

+111 -3
+38 -1
src/components/PublishPanel.test.tsx
··· 26 26 // Mock the publish layer so we can assert what PublishPanel forwards. 27 27 vi.mock( '../lib/publish/publisher', () => ( { publish, updateDocument } ) ); 28 28 29 - import PublishPanel from './PublishPanel'; 29 + import PublishPanel, { computePostPreview } from './PublishPanel'; 30 + import type { BlockNode } from '../lib/blocks/render'; 31 + 32 + function mentionPara( handle: string, did: string ): BlockNode { 33 + return { 34 + name: 'core/paragraph', 35 + attributes: { 36 + content: `Hi <a class="skypress-mention" href="https://bsky.app/profile/${ handle }" data-did="${ did }">@${ handle }</a>`, 37 + }, 38 + innerBlocks: [], 39 + }; 40 + } 30 41 31 42 const EDITING = { 32 43 rkey: '3kdoc', ··· 125 136 } ); 126 137 container.remove(); 127 138 } 139 + 140 + describe( 'computePostPreview', () => { 141 + it( 'reports the mentioned handles and a grapheme count', () => { 142 + const preview = computePostPreview( { 143 + title: 'Hello', 144 + lede: 'A lede', 145 + blocks: [ mentionPara( 'alice.bsky.social', 'did:plc:alice' ) ], 146 + handle: 'me.bsky.social', 147 + slug: 'pub', 148 + } ); 149 + expect( preview.handles ).toEqual( [ '@alice.bsky.social' ] ); 150 + expect( preview.graphemes ).toBeGreaterThan( 0 ); 151 + expect( preview.overLimit ).toBe( false ); 152 + } ); 153 + 154 + it( 'flags overLimit when the assembled post exceeds 300 graphemes', () => { 155 + const preview = computePostPreview( { 156 + title: 'Hello', 157 + lede: 'x'.repeat( 320 ), 158 + blocks: [], 159 + handle: 'me.bsky.social', 160 + slug: 'pub', 161 + } ); 162 + expect( preview.overLimit ).toBe( true ); 163 + } ); 164 + } ); 128 165 129 166 describe( 'PublishPanel', () => { 130 167 it( 'forwards the lede to updateDocument as description', async () => {
+73 -2
src/components/PublishPanel.tsx
··· 7 7 type Identity, 8 8 } from '../lib/publish/publisher'; 9 9 import type { Publication } from '../lib/publish/publications'; 10 - import { normalizeBlocks, type StrongRef } from '../lib/publish/records'; 10 + import { 11 + canonicalArticleUrl, 12 + normalizeBlocks, 13 + type StrongRef, 14 + } from '../lib/publish/records'; 15 + import { collectMentions } from '../lib/publish/mentions'; 16 + import { assemblePostText } from '../lib/publish/post-text'; 17 + import { graphemeLength } from '../lib/publish/grapheme'; 18 + import type { BlockNode } from '../lib/blocks/render'; 11 19 import { attachBlobRefs } from '../lib/media/blob'; 12 20 import type { BlobRegistry } from '../lib/media/mediaUpload'; 13 21 import type { BlobRefJson } from '../lib/media/blob'; ··· 49 57 onComplete?: ( result: { articleUrl: string; isEditing: boolean } ) => void; 50 58 } 51 59 60 + const POST_GRAPHEME_LIMIT = 300; 61 + const RKEY_PLACEHOLDER = 'aaaaaaaaaaaaa'; // 13 chars, TID length 62 + 63 + export interface PostPreview { 64 + graphemes: number; 65 + overLimit: boolean; 66 + handles: string[]; 67 + } 68 + 69 + /** Live preview of the companion Bluesky post: its grapheme count and who gets cc'd. */ 70 + export function computePostPreview( input: { 71 + title: string; 72 + lede: string; 73 + blocks: BlockNode[]; 74 + handle: string; 75 + slug: string; 76 + } ): PostPreview { 77 + const mentions = collectMentions( input.blocks ); 78 + const articleUrl = canonicalArticleUrl( input.handle, input.slug, RKEY_PLACEHOLDER ); 79 + const { text } = assemblePostText( { 80 + title: input.title, 81 + articleUrl, 82 + bodyLede: input.lede, 83 + mentions, 84 + } ); 85 + const graphemes = graphemeLength( text ); 86 + return { 87 + graphemes, 88 + overLimit: graphemes > POST_GRAPHEME_LIMIT, 89 + handles: mentions.map( ( m ) => `@${ m.handle }` ), 90 + }; 91 + } 92 + 52 93 /** 53 94 * Title + publish/update control. Publishing a NEW article targets a CHOSEN publication 54 95 * (Decision 0010) and creates a public Bluesky post, so it requires an explicit confirmation ··· 81 122 const editingPubName = 82 123 isEditing && pubs.find( ( pub ) => pub.uri === editing!.siteUri )?.name; 83 124 const hasTarget = Boolean( target ); 84 - const canSubmit = title.trim().length > 0 && blocks.length > 0 && hasTarget; 125 + 126 + const previewHandle = identity.handle ?? identity.did; 127 + const preview = target 128 + ? computePostPreview( { 129 + title, 130 + lede: description, 131 + blocks: normalizeBlocks( blocks ), 132 + handle: previewHandle, 133 + slug: target.slug, 134 + } ) 135 + : null; 136 + 137 + const canSubmit = 138 + title.trim().length > 0 && blocks.length > 0 && hasTarget && ! preview?.overLimit; 85 139 86 140 // `publications` arrives asynchronously after mount, so the initial selection may not match 87 141 // any loaded publication. Lock onto the first one once they load (and recover if the current ··· 187 241 </label> 188 242 ) } 189 243 244 + { preview && ( 245 + <p 246 + className={ `publish__count${ preview.overLimit ? ' publish__count--over' : '' }` } 247 + aria-live="polite" 248 + > 249 + Bluesky post: { preview.graphemes }/300 250 + { preview.overLimit ? ' — shorten the subtitle to publish' : '' } 251 + </p> 252 + ) } 253 + 190 254 { ( phase === 'idle' || phase === 'error' || phase === 'done' ) && ( 191 255 <button 192 256 className="publish__button" ··· 204 268 ⚠️ Publishing saves this article to <strong>your PDS</strong> and also 205 269 creates a <strong>public Bluesky post</strong> linking to it. Everyone 206 270 following you will see it. 271 + { preview && preview.handles.length > 0 && ( 272 + <> 273 + { ' ' } 274 + It will notify{ ' ' } 275 + <strong>{ preview.handles.join( ', ' ) }</strong>. 276 + </> 277 + ) } 207 278 </p> 208 279 <div className="publish__actions"> 209 280 <button className="publish__button" type="button" onClick={ run }>