Skip to content

Give each channel its own version of the post text - #9

Open
pallaoro wants to merge 2 commits into
multi-image-publishingfrom
per-channel-content
Open

Give each channel its own version of the post text#9
pallaoro wants to merge 2 commits into
multi-image-publishingfrom
per-channel-content

Conversation

@pallaoro

@pallaoro pallaoro commented Sep 3, 2026

Copy link
Copy Markdown
Member

Stacked on #8 — base it on multi-image-publishing so the diff shows only this work. Retarget to main once #8 lands.

The problem

One body of text sent verbatim to every platform is the compromise a multi-channel scheduler exists to avoid. The composer made it worse: the character counter took Math.min across every selected channel, so adding X to a LinkedIn post silently capped that LinkedIn post at 280 characters. There was no way to say "short hook on X, long version on LinkedIn" short of writing two posts.

The shape

post_channels gains a content column. NULL means "inherit the shared draft", so a post nobody customised still stores exactly one body of text and existing posts behave identically. A blank override normalises to NULL rather than "" — emptying the box means "back to the shared draft", never "publish nothing".

publishToChannel now takes its text from one helper, channelContent(), instead of reading posts.content directly, so publishing, previews and the API can't disagree about which text wins.

Two consequences worth naming:

  • The shared draft is only measured against the channels still inheriting it. Give X its own version and LinkedIn stops being capped at 280.
  • Customising every channel and clearing the shared draft is a real post, so publishPost's precondition moves from "posts.content is non-empty" to "some channel has text to send". A channel left with no text fails on its own and the others still go out.

The editor and the preview share one tab strip on purpose — two strips would let you type X's version while looking at LinkedIn's preview. PreviewChannelTabs is renamed ChannelTabs to match what it now does, and grows an "All channels" tab plus a dot marking channels that carry their own version.

What this deliberately does not do

Text length stays unenforced server-side. bluesky.ts:169 already states the house rule: the platform's own rejection is the source of truth, don't pre-guess limits. X counts weighted characters and Bluesky counts graphemes, so a JS .length check would reject valid posts and pass invalid ones. The per-platform counters remain composer guidance, as before. (Media limits stay enforced — an image count has no counting ambiguity.)

Verification

Ran against a local D1 and the running app, not just typechecked:

X customised, LinkedIn inherits each publishes its own text
Request with no channel_content unchanged from before
Blank override stored as NULL
All channels customised, no shared draft publishes; both legs attempted
Genuinely empty post still 400
Save then reopen overrides rehydrate; counter reads 3000, not 280

Also confirmed in the browser: the counter flips 257/280 → 257/3000 the moment X gets its own version, the inherited state renders read-only, and the preview follows the tab.

Notes for review

  • Local dev needs one manual ALTER. Production is covered — the deploy runs applySchemaWithReconcile, which adds the column additively. But CREATE TABLE IF NOT EXISTS is a no-op against an existing local D1, so anyone with a pre-existing .wrangler state needs ALTER TABLE post_channels ADD COLUMN content TEXT; once. This is the template model's known local-dev drift, not new here.
  • Pre-existing, untouched: PUT /api/posts/:id deletes and reinserts post_channels whenever channel_ids is present, which resets each channel's delivery state (status/ref/url/error/attempts). Editing a published post therefore loses its delivery history. Out of scope for this PR; worth its own.
  • First commit is an unrelated 4-line unblock: pnpm-workspace.yaml carried pnpm 11's unanswered allowBuilds stub, so pnpm build failed on a fresh clone before reaching vite.

pnpm 11 writes this key as a stub ("set this to true or false") on first
install and then refuses to install until someone decides, so `pnpm build`
failed on a fresh clone before reaching vite. esbuild and workerd need their
install scripts to produce a usable binary; sharp is vite's optional image
dependency.

The equivalent block in package.json ("pnpm": { "onlyBuiltDependencies" })
is dead — pnpm 11 reads this file instead.
One body of text sent verbatim to every platform is the compromise a
multi-channel scheduler exists to avoid, and the composer made it worse: the
character counter took the minimum limit across every selected channel, so
adding X to a LinkedIn post silently capped that LinkedIn post at 280
characters. There was no way to say "short hook on X, the long version on
LinkedIn" short of writing two posts.

post_channels gains a `content` column. NULL means "inherit the shared draft"
— so a post nobody customised stores exactly one body of text, and existing
posts keep behaving identically. A blank override normalises to NULL rather
than being stored as "", so emptying the box means "go back to the shared
draft", never "publish nothing".

publishToChannel now receives the resolved text from one helper,
channelContent(), rather than posts.content directly, so publishing, the
previews and the API can't disagree about which text wins.

Two consequences worth naming:

- The shared draft is now only measured against the channels still inheriting
  it. Give X its own version and LinkedIn stops being capped at 280.
- Customising every channel and clearing the shared draft is a real post, not
  an empty one, so publishPost's precondition moves from "posts.content is
  non-empty" to "some channel has text to send". A channel that ends up with
  no text at all fails on its own and the others still go out.

The editor and preview share one tab strip on purpose: two strips would let
you type X's version while looking at LinkedIn's preview. PreviewChannelTabs
is renamed ChannelTabs to match what it now does, and grows an "All channels"
tab plus a dot marking the channels that carry their own version.

Text length stays unenforced server-side, deliberately: bluesky.ts already
states the house rule that the platform's own rejection is the source of
truth. X counts weighted characters and Bluesky counts graphemes, so a
JS .length check would reject valid posts and pass invalid ones. The
per-platform counters remain composer guidance, as they were.

Verified end to end against a local D1 and the running app: overrides publish
to the right channel, a legacy request with no channel_content is unchanged,
blank normalises to NULL, an all-customised post with no shared draft
publishes, a genuinely empty one still 400s, and the save/reopen round-trip
restores the overrides.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant