also looking at this
UI polish, real queue + scheduling, Bluesky threads, live analytics, blog target
#8
54 files
+2036
-589
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -35,8 +35,19 @@ export default new Action({ | ||
| 35 | 35 | ? [{ url: imageUrl, altText: imageAlt || undefined }] |
| 36 | 36 | : undefined |
| 37 | 37 | |
| 38 | // Optional multi-segment thread: JSON array of post texts. Segments are | |
| 39 | // reply-chained on providers that support it (Bluesky). | |
| 40 | let thread: string[] = [] | |
| 38 | 41 | try { |
| 39 | const data = await crosspost.publish(text, providers, { external, media }) | |
| 42 | const parsed = JSON.parse(String(request.get('thread') || '[]')) | |
| 43 | if (Array.isArray(parsed)) thread = parsed.map(value => String(value).trim()).filter(Boolean) | |
| 44 | } | |
| 45 | catch {} | |
| 46 | ||
| 47 | try { | |
| 48 | const data = thread.length > 1 | |
| 49 | ? await crosspost.publishThread(thread, providers, { external, media }) | |
| 50 | : await crosspost.publish(thread[0] || text, providers, { external, media }) | |
| 40 | 51 | // Surface a partial-failure (some targets failed) as a 207-style payload |
| 41 | 52 | // while still returning 200 so the client can report per-provider state. |
| 42 | 53 | const allFailed = data.results.length > 0 && data.results.every(result => !result.ok) |
| @@ -186,8 +186,10 @@ export class BlueskyService { | ||
| 186 | 186 | const createdAt = now() |
| 187 | 187 | const publishInput = { text: post.body } as Parameters<BlueskyDriver['publish']>[1] & { |
| 188 | 188 | external?: { uri: string, title: string, description?: string } |
| 189 | reply?: { root: { uri: string, cid: string }, parent: { uri: string, cid: string } } | |
| 189 | 190 | } |
| 190 | 191 | if (content?.external) publishInput.external = content.external |
| 192 | if (content?.reply) publishInput.reply = content.reply | |
| 191 | 193 | |
| 192 | 194 | await database.insertInto('post_targets').values({ |
| 193 | 195 | uuid: targetUuid, |
| @@ -230,6 +232,7 @@ export class BlueskyService { | ||
| 230 | 232 | ok: true, |
| 231 | 233 | url: published.url, |
| 232 | 234 | uri: published.uri, |
| 235 | cid: published.cid, | |
| 233 | 236 | targetId: Number(target.id), |
| 234 | 237 | } |
| 235 | 238 | } |
| @@ -80,6 +80,91 @@ export class CrosspostService { | ||
| 80 | 80 | return { postId: Number(post.id), results } |
| 81 | 81 | } |
| 82 | 82 | |
| 83 | /** | |
| 84 | * Publish a multi-post thread. Each segment gets its own `posts` row | |
| 85 | * (grouped by `thread_key`). Providers that support reply chains | |
| 86 | * (Bluesky) receive root/parent refs so segments render as an actual | |
| 87 | * thread; other providers publish each segment independently. | |
| 88 | */ | |
| 89 | async publishThread( | |
| 90 | texts: string[], | |
| 91 | providers: SocialProvider[], | |
| 92 | content?: PublishContent, | |
| 93 | ): Promise<{ postIds: number[], results: CrosspostTargetResult[] }> { | |
| 94 | const segments = texts.map(text => text.trim()).filter(Boolean) | |
| 95 | if (segments.length === 0) throw new Error('Post text is required.') | |
| 96 | ||
| 97 | const selected = providers.filter(provider => publishers[provider]) | |
| 98 | if (selected.length === 0) { | |
| 99 | throw new Error('Select at least one connected provider to publish.') | |
| 100 | } | |
| 101 | ||
| 102 | const accountId = await ensureAccount() | |
| 103 | const threadKey = uuid() | |
| 104 | const postIds: number[] = [] | |
| 105 | const results: CrosspostTargetResult[] = [] | |
| 106 | // Per-provider chain refs; only populated for providers whose results | |
| 107 | // carry a cid (Bluesky). | |
| 108 | const chains = new Map<SocialProvider, { root: { uri: string, cid: string }, parent: { uri: string, cid: string } }>() | |
| 109 | ||
| 110 | for (const [index, body] of segments.entries()) { | |
| 111 | const postUuid = uuid() | |
| 112 | const createdAt = now() | |
| 113 | ||
| 114 | await database.insertInto('posts').values({ | |
| 115 | uuid: postUuid, | |
| 116 | title: body.slice(0, 80), | |
| 117 | body, | |
| 118 | status: 'publishing', | |
| 119 | timezone: env.TZ || 'America/Los_Angeles', | |
| 120 | source: 'composer', | |
| 121 | thread_key: threadKey, | |
| 122 | account_id: accountId, | |
| 123 | created_at: createdAt, | |
| 124 | updated_at: createdAt, | |
| 125 | }).execute() | |
| 126 | ||
| 127 | const post = await database | |
| 128 | .selectFrom('posts') | |
| 129 | .selectAll() | |
| 130 | .where('uuid', '=', postUuid) | |
| 131 | .executeTakeFirstOrThrow() | |
| 132 | postIds.push(Number(post.id)) | |
| 133 | ||
| 134 | const segmentResults: CrosspostTargetResult[] = [] | |
| 135 | for (const provider of selected) { | |
| 136 | const publisher = publishers[provider]! | |
| 137 | const chain = chains.get(provider) | |
| 138 | // Link previews/media only make sense on the first segment. | |
| 139 | const segmentContent: PublishContent | undefined = index === 0 | |
| 140 | ? content | |
| 141 | : chain ? { reply: chain } : undefined | |
| 142 | ||
| 143 | const result = await publisher.publishToPost({ id: Number(post.id), body }, segmentContent) | |
| 144 | segmentResults.push(result) | |
| 145 | ||
| 146 | if (result.ok && result.uri && result.cid) { | |
| 147 | const existing = chains.get(provider) | |
| 148 | chains.set(provider, { | |
| 149 | root: existing?.root || { uri: result.uri, cid: result.cid }, | |
| 150 | parent: { uri: result.uri, cid: result.cid }, | |
| 151 | }) | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | results.push(...segmentResults) | |
| 156 | const anyOk = segmentResults.some(result => result.ok) | |
| 157 | const finishedAt = now() | |
| 158 | await database.updateTable('posts').set({ | |
| 159 | status: anyOk ? 'published' : 'failed', | |
| 160 | ...(anyOk ? { published_at: finishedAt } : {}), | |
| 161 | updated_at: finishedAt, | |
| 162 | }).where('id', '=', post.id).execute() | |
| 163 | } | |
| 164 | ||
| 165 | return { postIds, results } | |
| 166 | } | |
| 167 | ||
| 83 | 168 | /** |
| 84 | 169 | * Publish an existing `posts` row to the given providers. Used by the |
| 85 | 170 | * fresh-publish path above and by the queue when a scheduled or drafted |
| @@ -60,6 +60,11 @@ export interface PublishContent { | ||
| 60 | 60 | url: string |
| 61 | 61 | altText?: string |
| 62 | 62 | }> |
| 63 | /** Thread chaining refs — only honored by providers that support replies. */ | |
| 64 | reply?: { | |
| 65 | root: { uri: string, cid: string } | |
| 66 | parent: { uri: string, cid: string } | |
| 67 | } | |
| 63 | 68 | } |
| 64 | 69 | |
| 65 | 70 | /** Outcome of publishing one post to a single provider during a crosspost. */ |
| @@ -68,6 +73,7 @@ export interface CrosspostTargetResult { | ||
| 68 | 73 | ok: boolean |
| 69 | 74 | url?: string |
| 70 | 75 | uri?: string |
| 76 | cid?: string | |
| 71 | 77 | targetId?: number |
| 72 | 78 | error?: string |
| 73 | 79 | } |
| @@ -606,8 +606,7 @@ | ||
| 606 | 606 | let lastUrl |
| 607 | 607 | const failures = [] |
| 608 | 608 | let successCount = 0 |
| 609 | for (const [index, post] of targets.entries()) { | |
| 610 | const data = await publishOne(post, targetProviders) | |
| 609 | const collect = (data) => { | |
| 611 | 610 | for (const result of data.results || []) { |
| 612 | 611 | if (result.ok) { |
| 613 | 612 | successCount += 1 |
| @@ -617,11 +616,29 @@ | ||
| 617 | 616 | failures.push(`${providerLabel(result.provider)}: ${result.error || 'failed'}`) |
| 618 | 617 | } |
| 619 | 618 | } |
| 620 | if (targets.length > 1) { | |
| 621 | textareaFor(post).value = '' | |
| 622 | renderLinkPreview(post, null) | |
| 623 | } | |
| 624 | if (index < targets.length - 1) await new Promise(resolve => setTimeout(resolve, 400)) | |
| 619 | } | |
| 620 | ||
| 621 | if (targets.length > 1) { | |
| 622 | // Threads go up in one request so the server can reply-chain the | |
| 623 | // segments on providers that support it (Bluesky). | |
| 624 | const social = window.postlineSocial | |
| 625 | if (!social) throw new Error('Publishing is still loading. Try again in a moment.') | |
| 626 | const first = targets[0] | |
| 627 | const external = (first?.dataset.externalUri && first.dataset.externalTitle) | |
| 628 | ? { uri: first.dataset.externalUri, title: first.dataset.externalTitle, description: first.dataset.externalDescription || '' } | |
| 629 | : undefined | |
| 630 | const imageUrl = composeImage?.value.trim() | |
| 631 | const thread = targets.map(post => textareaFor(post)?.value.trim() || '') | |
| 632 | collect(await social.publishCrosspost({ | |
| 633 | text: thread[0], | |
| 634 | thread, | |
| 635 | providers: targetProviders, | |
| 636 | external, | |
| 637 | image: imageUrl ? { url: imageUrl } : undefined, | |
| 638 | })) | |
| 639 | } | |
| 640 | else { | |
| 641 | collect(await publishOne(targets[0], targetProviders)) | |
| 625 | 642 | } |
| 626 | 643 | |
| 627 | 644 | if (activeMode === 'thread') { |