Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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.
45 changes: 45 additions & 0 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 @@ -354,6 +367,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
100 changes: 100 additions & 0 deletions includes/wp-admin/class-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

\defined( 'ABSPATH' ) || exit;

use Atmosphere\Atmosphere;
use Atmosphere\Backfill;
use Atmosphere\OAuth\Client;
use Atmosphere\Publisher;
Expand Down Expand Up @@ -68,6 +69,22 @@ public static function register_settings(): void {
)
);

\register_setting(
'atmosphere',
'atmosphere_long_form_composition',
array(
'type' => 'string',
'description' => 'Composition strategy for long-form Bluesky posts.',
'default' => 'link-card',
'sanitize_callback' => array( self::class, 'sanitize_long_form_composition' ),
'show_in_rest' => array(
'schema' => array(
'enum' => Atmosphere::LONG_FORM_STRATEGIES,
),
),
)
);

\register_setting(
'atmosphere',
'atmosphere_handle',
Expand Down Expand Up @@ -116,6 +133,14 @@ public static function register_settings(): void {
'atmosphere_publishing'
);

\add_settings_field(
'atmosphere_long_form_composition',
\__( 'Long-form posts', 'atmosphere' ),
array( self::class, 'render_long_form_composition_field' ),
'atmosphere',
'atmosphere_publishing'
);

\add_settings_field(
'atmosphere_backfill',
\__( 'Backfill', 'atmosphere' ),
Expand Down Expand Up @@ -247,6 +272,81 @@ public static function render_auto_publish_field(): void {
<?php
}

/**
* Render the long-form composition radio group.
*/
public static function render_long_form_composition_field(): void {
$current = \get_option( 'atmosphere_long_form_composition', 'link-card' );

?>
<fieldset>
<legend class="screen-reader-text">
<?php \esc_html_e( 'Long-form composition', 'atmosphere' ); ?>
</legend>
<?php
foreach ( Atmosphere::LONG_FORM_STRATEGIES as $strategy ) :
$choice = self::long_form_composition_choice( $strategy );
?>
<p>
<label>
<input
type="radio"
name="atmosphere_long_form_composition"
value="<?php echo \esc_attr( $strategy ); ?>"
<?php \checked( $current, $strategy ); ?>
>
<strong><?php echo \esc_html( $choice['label'] ); ?></strong>
</label>
<br>
<span class="description"><?php echo \esc_html( $choice['help'] ); ?></span>
</p>
<?php endforeach; ?>
</fieldset>
<p class="description">
<?php \esc_html_e( 'How posts longer than the Bluesky 300-character limit are published. Plugins can override this per post via the atmosphere_long_form_composition filter.', 'atmosphere' ); ?>
</p>
<?php
}

/**
* Return the translatable label/help for a long-form strategy.
*
* @param string $strategy Strategy slug from `Atmosphere::LONG_FORM_STRATEGIES`.
* @return array{label: string, help: string}
*/
private static function long_form_composition_choice( string $strategy ): array {
switch ( $strategy ) {
case 'truncate-link':
return array(
'label' => \__( 'Truncated post with link', 'atmosphere' ),
'help' => \__( 'A single Bluesky post containing the body text followed by an inline permalink. No card.', 'atmosphere' ),
);
case 'teaser-thread':
return array(
'label' => \__( 'Teaser thread', 'atmosphere' ),
'help' => \__( 'A two-post Bluesky thread: a hook followed by a "continue reading" reply with the permalink.', 'atmosphere' ),
);
case 'link-card':
default:
return array(
'label' => \__( 'Link card', 'atmosphere' ),
'help' => \__( 'A single Bluesky post with the title, an excerpt, and a permalink card. (Default — unchanged behavior.)', 'atmosphere' ),
);
}
}

/**
* Sanitize the long-form composition setting.
*
* @param mixed $value Submitted value.
* @return string
*/
public static function sanitize_long_form_composition( $value ): string {
$value = \is_string( $value ) ? \sanitize_text_field( $value ) : '';

return \in_array( $value, Atmosphere::LONG_FORM_STRATEGIES, true ) ? $value : 'link-card';
}

/**
* Render the Backfill field.
*/
Expand Down
108 changes: 108 additions & 0 deletions tests/phpunit/tests/class-test-long-form-composition-setting.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php
/**
* Tests for the long-form composition setting.
*
* Covers the sanitize callback and the option-driven seed filter.
*
* @package Atmosphere
* @group atmosphere
* @group settings
*/

namespace Atmosphere\Tests;

use WP_UnitTestCase;
use Atmosphere\Atmosphere;
use Atmosphere\WP_Admin\Admin;

/**
* Long-form composition setting tests.
*/
class Test_Long_Form_Composition_Setting extends WP_UnitTestCase {

/**
* Re-register the seed filter and clear option/transient state.
*
* Other test classes call `remove_all_filters( 'atmosphere_long_form_composition' )`
* in their `tear_down`, which would strip the production seed registered once on
* `plugins_loaded`. Re-add it here so each test in this class starts with the
* expected filter wiring regardless of execution order.
*/
public function set_up(): void {
parent::set_up();

\remove_all_filters( 'atmosphere_long_form_composition' );
\add_filter( 'atmosphere_long_form_composition', array( Atmosphere::class, 'seed_long_form_composition' ), 1 );
\delete_transient( 'atmosphere_invalid_long_form_composition_logged' );
}

/**
* Reset state between tests.
*/
public function tear_down(): void {
Comment thread
kraftbj marked this conversation as resolved.
\delete_option( 'atmosphere_long_form_composition' );
\delete_transient( 'atmosphere_invalid_long_form_composition_logged' );
\remove_all_filters( 'atmosphere_long_form_composition' );

parent::tear_down();
}

/**
* Sanitize callback accepts each known strategy.
*/
public function test_sanitize_accepts_known_values() {
$this->assertSame( 'link-card', Admin::sanitize_long_form_composition( 'link-card' ) );
$this->assertSame( 'truncate-link', Admin::sanitize_long_form_composition( 'truncate-link' ) );
$this->assertSame( 'teaser-thread', Admin::sanitize_long_form_composition( 'teaser-thread' ) );
}

/**
* Sanitize callback falls back to the default for unknown / non-string input.
*/
public function test_sanitize_rejects_unknown_values() {
$this->assertSame( 'link-card', Admin::sanitize_long_form_composition( 'something-else' ) );
$this->assertSame( 'link-card', Admin::sanitize_long_form_composition( '' ) );
$this->assertSame( 'link-card', Admin::sanitize_long_form_composition( null ) );
$this->assertSame( 'link-card', Admin::sanitize_long_form_composition( array( 'teaser-thread' ) ) );
}

/**
* The option seeds the `atmosphere_long_form_composition` filter.
*/
public function test_option_seeds_filter() {
\update_option( 'atmosphere_long_form_composition', 'teaser-thread' );

$result = \apply_filters( 'atmosphere_long_form_composition', 'link-card', null );

$this->assertSame( 'teaser-thread', $result );
}

/**
* Downstream filters at the default priority override the option.
*/
public function test_downstream_filter_overrides_option() {
\update_option( 'atmosphere_long_form_composition', 'teaser-thread' );

\add_filter(
'atmosphere_long_form_composition',
static function (): string {
return 'truncate-link';
}
);

$result = \apply_filters( 'atmosphere_long_form_composition', 'link-card', null );

$this->assertSame( 'truncate-link', $result );
}

/**
* Unknown stored values are ignored; the default flows through.
*/
public function test_corrupt_option_falls_through_to_default() {
\update_option( 'atmosphere_long_form_composition', 'bogus-strategy' );

$result = \apply_filters( 'atmosphere_long_form_composition', 'link-card', null );

$this->assertSame( 'link-card', $result );
}
}