ReviewOS

also looking at this

stacks/postline

UI polish, real queue + scheduling, Bluesky threads, live analytics, blog target

#8
Merged glennmichael123 wants to merge feat/ui-polish into main
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.

app/Actions/Postline/CrosspostPublishAction.tsmodified+12-1
Changes to app/Actions/Postline/CrosspostPublishAction.ts
@@ -35,8 +35,19 @@ export default new Action({
3535 ? [{ url: imageUrl, altText: imageAlt || undefined }]
3636 : undefined
3737
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[] = []
3841 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 })
4051 // Surface a partial-failure (some targets failed) as a 207-style payload
4152 // while still returning 200 so the client can report per-provider state.
4253 const allFailed = data.results.length > 0 && data.results.every(result => !result.ok)
app/Services/Social/BlueskyService.tsmodified+3-0
Changes to app/Services/Social/BlueskyService.ts
@@ -186,8 +186,10 @@ export class BlueskyService {
186186 const createdAt = now()
187187 const publishInput = { text: post.body } as Parameters<BlueskyDriver['publish']>[1] & {
188188 external?: { uri: string, title: string, description?: string }
189 reply?: { root: { uri: string, cid: string }, parent: { uri: string, cid: string } }
189190 }
190191 if (content?.external) publishInput.external = content.external
192 if (content?.reply) publishInput.reply = content.reply
191193
192194 await database.insertInto('post_targets').values({
193195 uuid: targetUuid,
@@ -230,6 +232,7 @@ export class BlueskyService {
230232 ok: true,
231233 url: published.url,
232234 uri: published.uri,
235 cid: published.cid,
233236 targetId: Number(target.id),
234237 }
235238 }
app/Services/Social/CrosspostService.tsmodified+85-0
Changes to app/Services/Social/CrosspostService.ts
@@ -80,6 +80,91 @@ export class CrosspostService {
8080 return { postId: Number(post.id), results }
8181 }
8282
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
83168 /**
84169 * Publish an existing `posts` row to the given providers. Used by the
85170 * fresh-publish path above and by the queue when a scheduled or drafted
app/Support/Social/types.tsmodified+6-0
Changes to app/Support/Social/types.ts
@@ -60,6 +60,11 @@ export interface PublishContent {
6060 url: string
6161 altText?: string
6262 }>
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 }
6368}
6469
6570/** Outcome of publishing one post to a single provider during a crosspost. */
@@ -68,6 +73,7 @@ export interface CrosspostTargetResult {
6873 ok: boolean
6974 url?: string
7075 uri?: string
76 cid?: string
7177 targetId?: number
7278 error?: string
7379}
resources/assets/scripts/postline-social.tsmodified+4-0
Changes to resources/assets/scripts/postline-social.ts
@@ -24,6 +24,8 @@ export interface CrosspostInput {
2424 providers: string[]
2525 external?: { uri: string, title: string, description?: string }
2626 image?: { url: string, altText?: string }
27 /** Multi-segment thread; reply-chained on providers that support it. */
28 thread?: string[]
2729}
2830
2931const PROVIDER_LABELS: Record<string, string> = {
@@ -54,6 +56,8 @@ export async function publishCrosspost(input: CrosspostInput): Promise<{ postId:
5456 const body = new FormData()
5557 body.set('text', input.text)
5658 body.set('providers', input.providers.join(','))
59 if (input.thread && input.thread.length > 1)
60 body.set('thread', JSON.stringify(input.thread))
5761 if (input.external?.uri && input.external?.title) {
5862 body.set('external_uri', input.external.uri)
5963 body.set('external_title', input.external.title)
resources/views/index.stxmodified+24-7
Changes to resources/views/index.stx
@@ -606,8 +606,7 @@
606606 let lastUrl
607607 const failures = []
608608 let successCount = 0
609 for (const [index, post] of targets.entries()) {
610 const data = await publishOne(post, targetProviders)
609 const collect = (data) => {
611610 for (const result of data.results || []) {
612611 if (result.ok) {
613612 successCount += 1
@@ -617,11 +616,29 @@
617616 failures.push(`${providerLabel(result.provider)}: ${result.error || 'failed'}`)
618617 }
619618 }
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))
625642 }
626643
627644 if (activeMode === 'thread') {
storage/framework/core/socials/src/drivers/bluesky.tsmodified+1-0
Changes to storage/framework/core/socials/src/drivers/bluesky.ts
@@ -110,6 +110,7 @@ export class BlueskyPublishingDriver implements SocialPublishingDriver {
110110 }
111111
112112 if (post.langs?.length) record.langs = post.langs
113 if (post.reply) record.reply = post.reply
113114 if (post.external) {
114115 record.embed = {
115116 $type: 'app.bsky.embed.external',
storage/framework/core/socials/src/types.tsmodified+8-0
Changes to storage/framework/core/socials/src/types.ts
@@ -103,6 +103,14 @@ export interface PublishPostInput {
103103 title: string
104104 description?: string
105105 }
106 /**
107 * Reply references for thread chaining (ATProto shape). `root` is the
108 * first post of the thread, `parent` the one directly above.
109 */
110 reply?: {
111 root: { uri: string, cid: string }
112 parent: { uri: string, cid: string }
113 }
106114 /**
107115 * Attached media. Required by media-only providers such as Instagram, and
108116 * ignored by text-first providers (Bluesky, LinkedIn) for now.