Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
878acaa
WIP: long-form teaser-thread strategy
kraftbj Apr 24, 2026
fa9408d
Add atmosphere_long_form_composition filter and long-form record comp…
kraftbj Apr 24, 2026
5c9d20a
Tests: cover build_long_form_records strategy branches
kraftbj Apr 24, 2026
6581a0f
Publisher: thread-aware publish/update/delete with partial-meta write…
kraftbj Apr 24, 2026
dd2cc53
Tests: Publisher thread writes, rollback, legacy fallback
kraftbj Apr 24, 2026
821f38f
add: changelog for atmosphere_long_form_composition + thread meta
kraftbj Apr 24, 2026
72ac474
Address initial review: createdAt, force-delete, rollback orphans, in…
kraftbj Apr 24, 2026
75d7d28
Tests: assert hook ends at complete word, not just whitespace
kraftbj Apr 24, 2026
62bdee8
Pass 1 review fixes: reply createdAt, filter guards, URI index, tests
kraftbj Apr 24, 2026
2e2925e
Roll back thread publish when the root CID is missing from the PDS re…
kraftbj Apr 24, 2026
5b53289
Merge branch 'trunk' into add/long-form-teaser-thread
pfefferle Apr 24, 2026
4601714
Codex adversarial review: harden rewrite_thread and partial-state rec…
kraftbj Apr 24, 2026
68534a6
Merge branch 'add/long-form-teaser-thread' of github.com:Automattic/w…
kraftbj Apr 24, 2026
fbe2ea0
Legacy-fallback: treat a bare TID without a URI as "nothing published"
kraftbj Apr 24, 2026
0e1e9cc
Harden long-form publish review fixes
kraftbj Apr 28, 2026
317ee8a
Merge branch 'trunk' into add/long-form-teaser-thread
kraftbj Apr 28, 2026
b92fd1c
Address review: doc-ref half-publish + downgrade hook + docblock drift
kraftbj Apr 28, 2026
c464fda
Address review: atmosphere_teaser_thread_posts minimum-entry guard
kraftbj Apr 28, 2026
c6a86e8
Address review: stop leaking pre_http_request filter from publisher t…
kraftbj Apr 28, 2026
14f21d2
Address review: cosmetic cleanup
kraftbj Apr 28, 2026
f441dee
Test: assert downgrade action fires on long-permalink fallback
kraftbj Apr 28, 2026
8374793
Codex review: downgrade teaser-thread when full CTA exceeds 300 chars
kraftbj Apr 28, 2026
694f3c5
Codex review: persist deferred doc-ref failure to META_DOC_REF_PENDING
kraftbj Apr 28, 2026
880af50
Codex review: include ambiguous reply rkey in thread rollback
kraftbj Apr 28, 2026
6bd6a32
Settings UI for the long-form composition strategy (#42)
pfefferle Apr 29, 2026
d63bba5
Merge remote-tracking branch 'origin/trunk' into add/long-form-teaser…
pfefferle Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/add-long-form-composition-setting
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Choose how long-form posts publish to Bluesky from the ATmosphere settings page — link card (default), a single post combining body text with the permalink, or a two-post teaser thread.
4 changes: 4 additions & 0 deletions .github/changelog/long-form-teaser-thread
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Long-form posts can now be published to Bluesky as a short thread that points readers back to the full article. Sites can keep the existing single-post behavior, publish a shortened text version with a link, or use a two-post teaser thread. When a threaded post is edited, ATmosphere updates the existing Bluesky posts when possible so links and replies stay connected. If the publishing format changes, ATmosphere replaces the old Bluesky posts with new ones.
89 changes: 89 additions & 0 deletions includes/class-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,45 @@ public static function upload_blob( string $file_path, string $mime_type ): arra
* @return array|\WP_Error
*/
public static function apply_writes( array $writes ): array|\WP_Error {
/**
* Short-circuits the applyWrites call before it reaches the PDS.
*
* Return a non-null array (success shape: `[ 'results' => [...] ]`,
* with one array result per write) or a `WP_Error` to bypass the
* real HTTP round-trip. Used by
* the PHPUnit suite, the FOSSE end-to-end harness, and anything
* else that needs to observe or mock a write batch without
* actually hitting the PDS.
*
* A common use is `pre_http_request`, but that filter fires
* inside `wp_remote_request`, which is only reached after the
* DPoP proof has been built — so in test environments without
* a real DPoP JWK, the call errors out first. This filter runs
* before any of that.
*
* @param null|array|\WP_Error $short_circuit Short-circuit value. Return null to skip.
* @param array $writes The write batch about to be sent.
*/
$short_circuit = \apply_filters( 'atmosphere_pre_apply_writes', null, $writes );

if ( \is_wp_error( $short_circuit ) ) {
return $short_circuit;
}
Comment thread
pfefferle marked this conversation as resolved.

if ( \is_array( $short_circuit ) ) {
return self::validate_apply_writes_response( $short_circuit, $writes );
}

if ( null !== $short_circuit ) {
// Malformed filter return (scalar / object / etc). Surface as a
// WP_Error instead of letting PHP fatal on the `array|\WP_Error`
// return type.
return new \WP_Error(
'atmosphere_invalid_pre_apply_writes_return',
\__( 'atmosphere_pre_apply_writes must return null, an array, or a WP_Error.', 'atmosphere' )
);
}

return self::post(
'/xrpc/com.atproto.repo.applyWrites',
array(
Expand All @@ -176,6 +215,56 @@ public static function apply_writes( array $writes ): array|\WP_Error {
);
}

/**
* Validate a short-circuited applyWrites success response.
*
* @param array $response Short-circuited applyWrites response.
* @param array $writes Write batch the response represents.
* @return array|\WP_Error
*/
private static function validate_apply_writes_response( array $response, array $writes ): array|\WP_Error {
if ( ! isset( $response['results'] )
|| ! \is_array( $response['results'] )
|| ! \array_is_list( $response['results'] )
|| \count( $response['results'] ) !== \count( $writes )
) {
return self::invalid_apply_writes_response();
}

foreach ( $response['results'] as $i => $result ) {
if ( ! \is_array( $result ) ) {
return self::invalid_apply_writes_response();
}

$type = $writes[ $i ]['$type'] ?? '';
if ( \in_array(
$type,
array(
'com.atproto.repo.applyWrites#create',
'com.atproto.repo.applyWrites#update',
),
true
) && ( empty( $result['uri'] ) || empty( $result['cid'] ) )
) {
return self::invalid_apply_writes_response();
}
}

return $response;
}

/**
* Build a consistent malformed applyWrites response error.
*
* @return \WP_Error
*/
private static function invalid_apply_writes_response(): \WP_Error {
return new \WP_Error(
'atmosphere_invalid_pre_apply_writes_response',
\__( 'atmosphere_pre_apply_writes success responses must include one results array entry for each write.', 'atmosphere' )
);
}

/**
* Get a single record from the PDS.
*
Expand Down
81 changes: 73 additions & 8 deletions includes/class-atmosphere.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
*/
class Atmosphere {

/**
* Allowed values for the long-form composition strategy filter and
* the matching `atmosphere_long_form_composition` option.
*/
public const LONG_FORM_STRATEGIES = array( 'link-card', 'truncate-link', 'teaser-thread' );

/**
* Wire up all hooks.
*/
Expand All @@ -34,6 +40,13 @@ public function init(): void {
\add_action( 'init', array( Admin::class, 'register' ), 5 );
\add_action( 'init', array( Backfill::class, 'register' ), 5 );

/*
* Seed the long-form composition strategy from the user's
* setting. Priority 1 so any downstream filter at the default
* priority can still override it per post.
*/
\add_filter( 'atmosphere_long_form_composition', array( self::class, 'seed_long_form_composition' ), 1 );

// REST route (always active for client-metadata).
\add_action( 'rest_api_init', array( Admin::class, 'register_rest_routes' ) );

Expand Down Expand Up @@ -306,8 +319,11 @@ public function on_status_change( string $new_status, string $old_status, \WP_Po
/**
* Schedule AT Protocol record deletion before a post is permanently deleted.
*
* Captures TIDs from post meta before they're lost, then schedules
* an async delete via cron.
* Captures every Bluesky TID (including thread replies) and the
* document TID from post meta before they're lost, then schedules
* an async delete via cron. Thread-strategy posts: reads
* `Post::META_THREAD_RECORDS` and batches every bsky tid into the
* cron event so a single delete covers root + replies.
*
* @param int $post_id Post ID being deleted.
*/
Expand All @@ -330,11 +346,28 @@ public function on_before_delete( int $post_id ): void {
* Gating this on current support would orphan already-published
* records whenever a site narrows its configuration.
*/
$bsky_tid = \get_post_meta( $post_id, Transformer\Post::META_TID, true );
$doc_tid = \get_post_meta( $post_id, Transformer\Document::META_TID, true );
$bsky_tids = array();

if ( $bsky_tid || $doc_tid ) {
\wp_schedule_single_event( \time(), 'atmosphere_delete_records', array( $bsky_tid, $doc_tid ) );
$thread_records = \get_post_meta( $post_id, Transformer\Post::META_THREAD_RECORDS, true );
if ( \is_array( $thread_records ) && ! empty( $thread_records ) ) {
foreach ( $thread_records as $record ) {
if ( ! empty( $record['tid'] ) ) {
$bsky_tids[] = (string) $record['tid'];
}
}
}

if ( empty( $bsky_tids ) ) {
$legacy_tid = \get_post_meta( $post_id, Transformer\Post::META_TID, true );
if ( $legacy_tid ) {
$bsky_tids[] = (string) $legacy_tid;
}
}

$doc_tid = (string) \get_post_meta( $post_id, Transformer\Document::META_TID, true );

if ( ! empty( $bsky_tids ) || '' !== $doc_tid ) {
\wp_schedule_single_event( \time(), 'atmosphere_delete_records', array( $bsky_tids, $doc_tid ) );
}
}

Expand Down Expand Up @@ -362,6 +395,38 @@ public function cron_refresh_token(): void {
Client::refresh();
}

/**
* Seed the `atmosphere_long_form_composition` filter from the option.
*
* Returns the configured strategy when valid; otherwise returns the
* incoming `$strategy` (so downstream filters and the `link-card`
* default still apply). An invalid stored value is logged at most
* once per hour so operators can spot config drift.
*
* @param string $strategy Strategy passed in by `apply_filters()`.
* @return string
*/
public static function seed_long_form_composition( string $strategy ): string {
$option = (string) \get_option( 'atmosphere_long_form_composition', 'link-card' );

if ( \in_array( $option, self::LONG_FORM_STRATEGIES, true ) ) {
return $option;
}

if ( ! \get_transient( 'atmosphere_invalid_long_form_composition_logged' ) ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
\error_log(
\sprintf(
'[atmosphere] invalid `atmosphere_long_form_composition` option value %s; falling through to default',
\wp_json_encode( $option )
)
);
\set_transient( 'atmosphere_invalid_long_form_composition_logged', 1, \HOUR_IN_SECONDS );
}

return $strategy;
}

/**
* Register async action hooks (called by WP-Cron).
*/
Expand Down Expand Up @@ -413,8 +478,8 @@ static function (): void {

\add_action(
'atmosphere_delete_records',
static function ( string $bsky_tid, string $doc_tid ): void {
Publisher::delete_by_tids( $bsky_tid, $doc_tid );
static function ( $bsky_tids, string $doc_tid ): void {
Publisher::delete_by_tids( $bsky_tids, $doc_tid );
},
10,
2
Expand Down
Loading