ReviewOS

also looking at this

stacks/postline

Bluesky publishing v2: rich-text facets + image upload

#9
Merged glennmichael123 wants to merge feat/bluesky-publishing-v2 into main
8 files +347 -12
app/Actions/Postline/CrosspostPublishAction.tsmodified+15-5
Changes to app/Actions/Postline/CrosspostPublishAction.ts
@@ -31,9 +31,18 @@ export default new Action({
3131
3232 const imageUrl = String(request.get('image_url') || '').trim()
3333 const imageAlt = String(request.get('image_alt') || '').trim()
34 const media = imageUrl
35 ? [{ url: imageUrl, altText: imageAlt || undefined }]
36 : undefined
34
35 const media: NonNullable<Parameters<typeof crosspost.publish>[2]>['media'] = []
36 const uploaded = request.file?.('image')
37 if (uploaded?.buffer && uploaded.buffer.byteLength > 0) {
38 media.push({
39 bytes: new Uint8Array(uploaded.buffer),
40 mimeType: uploaded.mimetype || 'image/jpeg',
41 altText: imageAlt || undefined,
42 })
43 }
44 if (imageUrl)
45 media.push({ url: imageUrl, altText: imageAlt || undefined })
3746
3847 // Optional multi-segment thread: JSON array of post texts. Segments are
3948 // reply-chained on providers that support it (Bluesky).
@@ -45,9 +54,10 @@ export default new Action({
4554 catch {}
4655
4756 try {
57 const content = { external, media: media.length ? media : undefined }
4858 const data = thread.length > 1
49 ? await crosspost.publishThread(thread, providers, { external, media })
50 : await crosspost.publish(thread[0] || text, providers, { external, media })
59 ? await crosspost.publishThread(thread, providers, content)
60 : await crosspost.publish(thread[0] || text, providers, content)
5161 // Surface a partial-failure (some targets failed) as a 207-style payload
5262 // while still returning 200 so the client can report per-provider state.
5363 const allFailed = data.results.length > 0 && data.results.every(result => !result.ok)
app/Services/Social/BlueskyService.tsmodified+24-0
Changes to app/Services/Social/BlueskyService.ts
@@ -187,9 +187,33 @@ export class BlueskyService {
187187 const publishInput = { text: post.body } as Parameters<BlueskyDriver['publish']>[1] & {
188188 external?: { uri: string, title: string, description?: string }
189189 reply?: { root: { uri: string, cid: string }, parent: { uri: string, cid: string } }
190 media?: Array<{ bytes?: Uint8Array, mimeType?: string, altText?: string }>
190191 }
191192 if (content?.external) publishInput.external = content.external
192193 if (content?.reply) publishInput.reply = content.reply
194 if (content?.media?.length) {
195 // The driver embeds raw bytes; fetch URL-only media server-side.
196 const media: Array<{ bytes: Uint8Array, mimeType?: string, altText?: string }> = []
197 for (const item of content.media.slice(0, 4)) {
198 if (item.bytes?.length) {
199 media.push({ bytes: item.bytes, mimeType: item.mimeType, altText: item.altText })
200 }
201 else if (item.url) {
202 try {
203 const response = await fetch(item.url)
204 if (!response.ok) continue
205 const bytes = new Uint8Array(await response.arrayBuffer())
206 media.push({
207 bytes,
208 mimeType: item.mimeType || response.headers.get('content-type') || 'image/jpeg',
209 altText: item.altText,
210 })
211 }
212 catch {}
213 }
214 }
215 if (media.length) publishInput.media = media
216 }
193217
194218 await database.insertInto('post_targets').values({
195219 uuid: targetUuid,
app/Support/Social/types.tsmodified+7-1
Changes to app/Support/Social/types.ts
@@ -57,8 +57,14 @@ export interface PublishContent {
5757 title: string
5858 description?: string
5959 }
60 /**
61 * Attached media. Instagram needs a public `url`; Bluesky uploads
62 * `bytes` (or fetches `url`) as a blob.
63 */
6064 media?: Array<{
61 url: string
65 url?: string
66 bytes?: Uint8Array
67 mimeType?: string
6268 altText?: string
6369 }>
6470 /** Thread chaining refs — only honored by providers that support replies. */
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 /** Attached image file — uploaded to providers that accept bytes (Bluesky). */
28 imageFile?: File
2729 /** Multi-segment thread; reply-chained on providers that support it. */
2830 thread?: string[]
2931}
@@ -68,6 +70,8 @@ export async function publishCrosspost(input: CrosspostInput): Promise<{ postId:
6870 body.set('image_url', input.image.url)
6971 if (input.image.altText) body.set('image_alt', input.image.altText)
7072 }
73 if (input.imageFile)
74 body.set('image', input.imageFile, input.imageFile.name)
7175
7276 const response = await fetch('/api/postline/publish', { method: 'POST', body })
7377 const payload = await response.json()
resources/views/index.stxmodified+47-2
Changes to resources/views/index.stx
@@ -59,8 +59,21 @@
5959 </div>
6060
6161 <footer class="flex gap-3 items-center justify-between p-3 border-t border-zinc-200">
62 <span class="text-xs text-zinc-500" data-bluesky-count>0 / 300 characters</span>
62 <div class="flex gap-2 items-center min-w-0">
63 <span class="text-xs text-zinc-500 whitespace-nowrap" data-bluesky-count>0 / 300 characters</span>
64 <span class="hidden gap-1.5 items-center px-2.5 max-w-48 min-h-7 font-bold text-xs text-zinc-600 bg-zinc-50 border border-zinc-200 rounded-lg" data-attach-chip>
65 <span class="shrink-0 h-3.5 w-3.5 i-hugeicons-image-01" aria-hidden="true"></span>
66 <span class="truncate" data-attach-name></span>
67 <button class="inline-flex items-center justify-center shrink-0 h-4 w-4 text-zinc-500 hover:text-zinc-800 transition-colors" type="button" aria-label="Remove attached image" data-attach-clear>
68 <span class="h-3 w-3 i-hugeicons-cancel-01" aria-hidden="true"></span>
69 </button>
70 </span>
71 </div>
6372 <div class="flex flex-wrap gap-2 items-center justify-end">
73 <input class="hidden" type="file" accept="image/png,image/jpeg,image/webp,image/gif" aria-label="Attach image" data-attach-input>
74 <button class="inline-flex items-center justify-center h-9 w-9 text-neutral-950 bg-white hover:bg-zinc-50 border border-zinc-200 rounded-xl shadow-sm transition-colors" type="button" aria-label="Attach image" title="Attach image" data-attach-button>
75 <span class="h-4 w-4 i-hugeicons-image-01" aria-hidden="true"></span>
76 </button>
6477 <input class="hidden px-2 h-9 text-neutral-950 text-xs bg-white border border-zinc-200 outline-none rounded-xl focus:border-blue-500 focus:ring-2 focus:ring-blue-100" type="datetime-local" aria-label="Schedule time" data-compose-schedule>
6578 <button class="inline-flex items-center justify-center h-9 w-9 text-neutral-950 bg-white hover:bg-zinc-50 border border-zinc-200 rounded-xl shadow-sm transition-colors" type="button" aria-label="Schedule post" title="Schedule post" data-schedule-toggle>
6679 <span class="h-4 w-4 i-hugeicons-calendar-03" aria-hidden="true"></span>
@@ -101,6 +114,19 @@
101114 const composeImage = root.querySelector('[data-compose-image]')
102115 const scheduleToggle = root.querySelector('[data-schedule-toggle]')
103116 const scheduleInput = root.querySelector('[data-compose-schedule]')
117 const attachButton = root.querySelector('[data-attach-button]')
118 const attachInput = root.querySelector('[data-attach-input]')
119 const attachChip = root.querySelector('[data-attach-chip]')
120 const attachName = root.querySelector('[data-attach-name]')
121 const attachClear = root.querySelector('[data-attach-clear]')
122
123 const attachedFile = () => attachInput?.files?.[0] || undefined
124
125 const clearAttachment = () => {
126 if (attachInput) attachInput.value = ''
127 attachChip?.classList.add('hidden')
128 attachChip?.classList.remove('inline-flex')
129 }
104130 const postsRoot = root.querySelector('[data-composer-posts]')
105131 const addPostButton = root.querySelector('[data-add-post]')
106132 const previewButton = root.querySelector('[data-preview-post]')
@@ -568,7 +594,7 @@
568594 : undefined
569595 const imageUrl = composeImage?.value.trim()
570596 const image = imageUrl ? { url: imageUrl } : undefined
571 return await social.publishCrosspost({ text, providers: targetProviders, external, image })
597 return await social.publishCrosspost({ text, providers: targetProviders, external, image, imageFile: attachedFile() })
572598 }
573599
574600 const publish = async () => {
@@ -635,6 +661,7 @@
635661 providers: targetProviders,
636662 external,
637663 image: imageUrl ? { url: imageUrl } : undefined,
664 imageFile: attachedFile(),
638665 }))
639666 }
640667 else {
@@ -656,6 +683,7 @@
656683
657684 updateCount()
658685 saveDraft()
686 clearAttachment()
659687 window.postlineToast?.dismiss?.('publish')
660688 if (failures.length && successCount === 0) {
661689 setStatus(`Publish failed — ${failures.join('; ')}`, 'error', { title: 'Publish failed' })
@@ -723,6 +751,23 @@
723751 updatePublishLabel()
724752 })
725753 scheduleInput?.addEventListener('input', updatePublishLabel)
754 attachButton?.addEventListener('click', () => attachInput?.click())
755 attachInput?.addEventListener('change', () => {
756 const file = attachedFile()
757 if (!file) {
758 clearAttachment()
759 return
760 }
761 if (file.size > 1_000_000) {
762 clearAttachment()
763 setStatus('Images must be 1MB or smaller for Bluesky.', 'error', { title: 'Attachment' })
764 return
765 }
766 if (attachName) attachName.textContent = file.name
767 attachChip?.classList.remove('hidden')
768 attachChip?.classList.add('inline-flex')
769 })
770 attachClear?.addEventListener('click', clearAttachment)
726771 addPostButton?.addEventListener('click', addPost)
727772 previewButton?.addEventListener('click', () => {
728773 const text = activeTextarea()?.value.trim()
storage/framework/core/socials/src/drivers/bluesky.tsmodified+136-0
Changes to storage/framework/core/socials/src/drivers/bluesky.ts
@@ -40,6 +40,66 @@ interface CreateRecordResponse {
4040 cid?: string
4141}
4242
43interface FacetCandidate {
44 byteStart: number
45 byteEnd: number
46 type: 'link' | 'tag' | 'mention'
47 value: string
48}
49
50const utf8 = new TextEncoder()
51
52function byteLength(value: string): number {
53 return utf8.encode(value).length
54}
55
56/**
57 * Find link/hashtag/mention spans in post text with UTF-8 byte offsets
58 * (ATProto facet ranges are byte-indexed, not character-indexed).
59 * Mentions still need their DID resolved before they become facets.
60 */
61export function detectFacetCandidates(text: string): FacetCandidate[] {
62 const candidates: FacetCandidate[] = []
63
64 const linkPattern = /https?:\/\/[^\s<>"']+/g
65 for (const match of text.matchAll(linkPattern)) {
66 const value = match[0].replace(/[),.;!?]+$/, '')
67 candidates.push({
68 byteStart: byteLength(text.slice(0, match.index)),
69 byteEnd: byteLength(text.slice(0, match.index)) + byteLength(value),
70 type: 'link',
71 value,
72 })
73 }
74
75 const insideLink = (start: number, end: number): boolean =>
76 candidates.some(candidate => candidate.type === 'link' && start < candidate.byteEnd && end > candidate.byteStart)
77
78 const tagPattern = /(^|\s)(#[A-Za-z0-9_]+)/g
79 for (const match of text.matchAll(tagPattern)) {
80 const tag = match[2]
81 if (/^#\d+$/.test(tag)) continue
82 const charStart = (match.index ?? 0) + match[1].length
83 const byteStart = byteLength(text.slice(0, charStart))
84 const byteEnd = byteStart + byteLength(tag)
85 if (insideLink(byteStart, byteEnd)) continue
86 candidates.push({ byteStart, byteEnd, type: 'tag', value: tag.slice(1) })
87 }
88
89 // Handles are domain-shaped (user.bsky.social), so require a dot.
90 const mentionPattern = /(^|\s)(@[a-z0-9][a-z0-9.-]*\.[a-z]{2,})/gi
91 for (const match of text.matchAll(mentionPattern)) {
92 const handle = match[2]
93 const charStart = (match.index ?? 0) + match[1].length
94 const byteStart = byteLength(text.slice(0, charStart))
95 const byteEnd = byteStart + byteLength(handle)
96 if (insideLink(byteStart, byteEnd)) continue
97 candidates.push({ byteStart, byteEnd, type: 'mention', value: handle.slice(1).replace(/\.+$/, '') })
98 }
99
100 return candidates.sort((a, b) => a.byteStart - b.byteStart)
101}
102
43103export class BlueskyPublishingDriver implements SocialPublishingDriver {
44104 readonly provider: 'bluesky' = 'bluesky'
45105 characterLimit = 300
@@ -111,6 +171,10 @@ export class BlueskyPublishingDriver implements SocialPublishingDriver {
111171
112172 if (post.langs?.length) record.langs = post.langs
113173 if (post.reply) record.reply = post.reply
174
175 const facets = post.facets ?? await this.buildFacets(post.text)
176 if (facets.length) record.facets = facets
177
114178 if (post.external) {
115179 record.embed = {
116180 $type: 'app.bsky.embed.external',
@@ -122,6 +186,18 @@ export class BlueskyPublishingDriver implements SocialPublishingDriver {
122186 }
123187 }
124188
189 // Bluesky records hold a single embed; attached images win over a link
190 // card (the link is still clickable via its facet).
191 const imageMedia = (post.media || []).filter(item => item.bytes?.length).slice(0, 4)
192 if (imageMedia.length) {
193 const images = []
194 for (const item of imageMedia) {
195 const blob = await this.uploadBlob(identity, item.bytes!, item.mimeType || 'image/jpeg')
196 images.push({ image: blob, alt: item.altText || '' })
197 }
198 record.embed = { $type: 'app.bsky.embed.images', images }
199 }
200
125201 const payload = await this.post<CreateRecordResponse>(
126202 '/xrpc/com.atproto.repo.createRecord',
127203 {
@@ -205,6 +281,66 @@ export class BlueskyPublishingDriver implements SocialPublishingDriver {
205281 })
206282 }
207283
284 /**
285 * Resolve link/tag/mention candidates into ATProto facets. Mentions that
286 * fail handle→DID resolution are dropped rather than failing the post.
287 */
288 protected async buildFacets(text: string): Promise<Array<Record<string, unknown>>> {
289 const facets: Array<Record<string, unknown>> = []
290 for (const candidate of detectFacetCandidates(text)) {
291 let feature: Record<string, unknown> | null = null
292 if (candidate.type === 'link')
293 feature = { $type: 'app.bsky.richtext.facet#link', uri: candidate.value }
294 else if (candidate.type === 'tag')
295 feature = { $type: 'app.bsky.richtext.facet#tag', tag: candidate.value }
296 else if (candidate.type === 'mention') {
297 const did = await this.resolveHandle(candidate.value)
298 if (did) feature = { $type: 'app.bsky.richtext.facet#mention', did }
299 }
300 if (feature) {
301 facets.push({
302 index: { byteStart: candidate.byteStart, byteEnd: candidate.byteEnd },
303 features: [feature],
304 })
305 }
306 }
307 return facets
308 }
309
310 /** Handle → DID, or null when the handle doesn't resolve. */
311 protected async resolveHandle(handle: string): Promise<string | null> {
312 try {
313 const url = new URL(`${this.service}/xrpc/com.atproto.identity.resolveHandle`)
314 url.searchParams.set('handle', handle)
315 const payload = await this.request<{ did?: string }>(url, {})
316 return payload.did || null
317 }
318 catch {
319 return null
320 }
321 }
322
323 /** Upload raw image bytes and return the blob ref for embeds. */
324 async uploadBlob(identity: SocialIdentityCredentials, bytes: Uint8Array, mimeType: string): Promise<unknown> {
325 if (!identity.accessToken) throw new Error('Bluesky access token is missing for this identity.')
326 if (bytes.length > 1_000_000)
327 throw new Error('Bluesky images must be 1MB or smaller.')
328
329 const payload = await this.request<{ blob: unknown }>(
330 new URL(`${this.service}/xrpc/com.atproto.repo.uploadBlob`),
331 {
332 method: 'POST',
333 headers: {
334 'content-type': mimeType,
335 'authorization': `Bearer ${identity.accessToken}`,
336 },
337 body: bytes,
338 },
339 )
340
341 return payload.blob
342 }
343
208344 protected async post<T>(path: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> {
209345 return await this.request<T>(new URL(`${this.service}${path}`), {
210346 method: 'POST',
storage/framework/core/socials/src/types.tsmodified+10-3
Changes to storage/framework/core/socials/src/types.ts
@@ -112,13 +112,20 @@ export interface PublishPostInput {
112112 parent: { uri: string, cid: string }
113113 }
114114 /**
115 * Attached media. Required by media-only providers such as Instagram, and
116 * ignored by text-first providers (Bluesky, LinkedIn) for now.
115 * Attached media. Instagram requires a public `url`; Bluesky uploads
116 * `bytes` (or fetches `url`) as a blob and embeds the images.
117117 */
118118 media?: Array<{
119 url: string
119 url?: string
120 bytes?: Uint8Array
121 mimeType?: string
120122 altText?: string
121123 }>
124 /**
125 * Precomputed rich-text facets (ATProto shape). When omitted, drivers
126 * that support facets detect links/mentions/hashtags automatically.
127 */
128 facets?: unknown[]
122129}
123130
124131export interface PublishedPost {
tests/unit/social-publish.test.tsmodified+104-1
Changes to tests/unit/social-publish.test.ts
@@ -1,5 +1,5 @@
11import { afterEach, describe, expect, test } from 'bun:test'
2import { BlueskyPublishingDriver } from '../../storage/framework/core/socials/src/drivers/bluesky'
2import { BlueskyPublishingDriver, detectFacetCandidates } from '../../storage/framework/core/socials/src/drivers/bluesky'
33
44const realFetch = globalThis.fetch
55
@@ -15,6 +15,22 @@ interface CapturedRequest {
1515function mockBlueskyApi(captured: CapturedRequest[]): void {
1616 globalThis.fetch = (async (input: any, init?: any) => {
1717 const url = String(input)
18
19 if (url.includes('resolveHandle')) {
20 captured.push({ url, body: undefined })
21 const handle = new URL(url).searchParams.get('handle')
22 if (handle === 'ghost.example.com')
23 return new Response(JSON.stringify({ error: 'not found' }), { status: 400 })
24 return new Response(JSON.stringify({ did: `did:plc:${handle}` }), { status: 200 })
25 }
26
27 if (url.includes('uploadBlob')) {
28 captured.push({ url, body: { mimeType: init?.headers?.['content-type'], size: init?.body?.length } })
29 return new Response(JSON.stringify({
30 blob: { $type: 'blob', ref: { $link: `blob${captured.length}` }, mimeType: init?.headers?.['content-type'], size: init?.body?.length },
31 }), { status: 200, headers: { 'content-type': 'application/json' } })
32 }
33
1834 const body = init?.body ? JSON.parse(String(init.body)) : undefined
1935 captured.push({ url, body })
2036 return new Response(JSON.stringify({
@@ -24,6 +40,10 @@ function mockBlueskyApi(captured: CapturedRequest[]): void {
2440 }) as typeof fetch
2541}
2642
43function lastRecord(captured: CapturedRequest[]): any {
44 return captured.find(request => request.url.includes('createRecord'))?.body?.record
45}
46
2747const identity = {
2848 handle: 'tester.bsky.social',
2949 did: 'did:plc:test',
@@ -94,4 +114,87 @@ describe('BlueskyPublishingDriver.publish', () => {
94114 await expect(driver.publish({ handle: 'tester.bsky.social' }, { text: 'hi' })).rejects.toThrow(/access token/)
95115 expect(captured).toHaveLength(0)
96116 })
117
118 test('auto-builds link, tag, and mention facets with resolved DIDs', async () => {
119 const captured: CapturedRequest[] = []
120 mockBlueskyApi(captured)
121
122 const driver = new BlueskyPublishingDriver()
123 await driver.publish(identity, { text: 'Read https://example.com/x. Thanks @alice.bsky.social #stacks' })
124
125 const record = lastRecord(captured)
126 expect(record.facets).toHaveLength(3)
127 const features = record.facets.map((facet: any) => facet.features[0])
128 expect(features[0]).toEqual({ $type: 'app.bsky.richtext.facet#link', uri: 'https://example.com/x' })
129 expect(features[1]).toEqual({ $type: 'app.bsky.richtext.facet#mention', did: 'did:plc:alice.bsky.social' })
130 expect(features[2]).toEqual({ $type: 'app.bsky.richtext.facet#tag', tag: 'stacks' })
131 })
132
133 test('drops mention facets whose handle does not resolve', async () => {
134 const captured: CapturedRequest[] = []
135 mockBlueskyApi(captured)
136
137 const driver = new BlueskyPublishingDriver()
138 await driver.publish(identity, { text: 'cc @ghost.example.com' })
139
140 expect(lastRecord(captured).facets).toBeUndefined()
141 })
142
143 test('uploads image bytes and embeds them, overriding the link card', async () => {
144 const captured: CapturedRequest[] = []
145 mockBlueskyApi(captured)
146
147 const driver = new BlueskyPublishingDriver()
148 await driver.publish(identity, {
149 text: 'photo time',
150 external: { uri: 'https://example.com', title: 'Example' },
151 media: [{ bytes: new Uint8Array([1, 2, 3]), mimeType: 'image/png', altText: 'a chart' }],
152 })
153
154 const upload = captured.find(request => request.url.includes('uploadBlob'))
155 expect(upload?.body.mimeType).toBe('image/png')
156 const record = lastRecord(captured)
157 expect(record.embed.$type).toBe('app.bsky.embed.images')
158 expect(record.embed.images[0].alt).toBe('a chart')
159 expect(record.embed.images[0].image.ref.$link).toBeDefined()
160 })
161
162 test('uploadBlob rejects images over 1MB', async () => {
163 const captured: CapturedRequest[] = []
164 mockBlueskyApi(captured)
165
166 const driver = new BlueskyPublishingDriver()
167 await expect(driver.uploadBlob(identity, new Uint8Array(1_000_001), 'image/png')).rejects.toThrow(/1MB/)
168 expect(captured).toHaveLength(0)
169 })
170})
171
172describe('detectFacetCandidates', () => {
173 test('computes UTF-8 byte offsets, not character offsets', () => {
174 const text = '🦋 fly to https://bsky.app now'
175 const [link] = detectFacetCandidates(text)
176 // '🦋 fly to ' is 4 (emoji) + 8 = 12 bytes
177 expect(link.byteStart).toBe(12)
178 expect(link.byteEnd).toBe(12 + 'https://bsky.app'.length)
179 expect(link.value).toBe('https://bsky.app')
180 })
181
182 test('trims trailing punctuation from links', () => {
183 const [link] = detectFacetCandidates('see https://example.com/docs.')
184 expect(link.value).toBe('https://example.com/docs')
185 })
186
187 test('skips numeric-only hashtags and tags inside links', () => {
188 const candidates = detectFacetCandidates('#2024 https://example.com/#anchor #real')
189 expect(candidates.map(candidate => `${candidate.type}:${candidate.value}`)).toEqual([
190 'link:https://example.com/#anchor',
191 'tag:real',
192 ])
193 })
194
195 test('mentions require a domain-shaped handle', () => {
196 const candidates = detectFacetCandidates('hey @nodot and @real.handle.social')
197 expect(candidates).toHaveLength(1)
198 expect(candidates[0]).toMatchObject({ type: 'mention', value: 'real.handle.social' })
199 })
97200})