Skip to content

Commit ce02546

Browse files
authored
Surface quoted posts when importing Bluesky replies as comments (#157)
1 parent a1d8001 commit ce02546

3 files changed

Lines changed: 525 additions & 4 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: patch
2+
Type: fixed
3+
4+
Bluesky replies that quote another post now keep a link to the quoted post when imported as comments, instead of dropping it.

includes/class-reaction-sync.php

Lines changed: 138 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -566,10 +566,6 @@ private static function process_reply( array $notification ): int|false {
566566

567567
$text = $record['text'] ?? '';
568568

569-
if ( '' === $text ) {
570-
return false;
571-
}
572-
573569
/*
574570
* Bluesky stores `text` as the display string — long URLs are
575571
* truncated to e.g. `bsky.app/profile/jere...` and the real
@@ -584,6 +580,28 @@ private static function process_reply( array $notification ): int|false {
584580
$facets = $record['facets'] ?? array();
585581
$text = Facet::apply( $text, \is_array( $facets ) ? $facets : array() );
586582

583+
/*
584+
* A reply that quotes another post carries the quoted post's
585+
* AT-URI in `embed` (app.bsky.embed.record), not in `text`.
586+
* Surface it as a linked blockquote so the quote isn't dropped on
587+
* import; append after the reply text, mirroring how Bluesky
588+
* renders the quote card below the reply.
589+
*/
590+
$quote = self::build_quote_html( $record );
591+
592+
if ( '' !== $quote ) {
593+
$text = '' === $text ? $quote : $text . "\n\n" . $quote;
594+
}
595+
596+
/*
597+
* Drop replies that carry neither text nor a resolvable quote —
598+
* the gate is here, after both are resolved, so a quote-only
599+
* reply (empty `text`, populated `embed`) still imports.
600+
*/
601+
if ( '' === $text ) {
602+
return false;
603+
}
604+
587605
/**
588606
* Filters whether a reply should be synced as a WordPress comment.
589607
*
@@ -915,6 +933,122 @@ private static function build_bsky_web_url( string $at_uri, string $handle ): st
915933
);
916934
}
917935

936+
/**
937+
* Build a blockquote linking to a reply's quoted post, if any.
938+
*
939+
* Bluesky quote-posts attach the quoted record to the reply's `embed`
940+
* (`app.bsky.embed.record`, or `recordWithMedia` when the quote also
941+
* carries media), pointing at the quoted post's AT-URI — the reply's
942+
* `text` says nothing about it. Without this, the quote is dropped on
943+
* import.
944+
*
945+
* Returns an HTML fragment safe to pass through the caller's
946+
* `wp_kses_post()` gate, or '' when there's no quoted `app.bsky.feed.post`
947+
* to link to. The quoted post's own text is intentionally not resolved:
948+
* it usually lives in another actor's repo, which would require a
949+
* separate network round-trip per quote — a link to the quote is enough
950+
* to stop the drop.
951+
*
952+
* @param array $record The reply record (notification record or own value).
953+
* @return string Blockquote HTML, or '' when there's no linkable quote.
954+
*/
955+
private static function build_quote_html( array $record ): string {
956+
$embed = $record['embed'] ?? null;
957+
958+
if ( ! \is_array( $embed ) ) {
959+
return '';
960+
}
961+
962+
$uri = self::quoted_post_uri( $embed );
963+
$url = '' === $uri ? '' : self::quoted_post_web_url( $uri );
964+
965+
if ( '' === $url ) {
966+
return '';
967+
}
968+
969+
$href = \esc_url( $url );
970+
971+
if ( '' === $href ) {
972+
return '';
973+
}
974+
975+
return '<blockquote class="atmosphere-bsky-quote"><p><a href="' . $href . '">'
976+
. \esc_html__( 'Quoted post on Bluesky', 'atmosphere' )
977+
. '</a></p></blockquote>';
978+
}
979+
980+
/**
981+
* Read the quoted post's AT-URI out of a reply's `embed`.
982+
*
983+
* Handles `app.bsky.embed.record` (URI at `record.uri`) and
984+
* `app.bsky.embed.recordWithMedia` (one level deeper at
985+
* `record.record.uri`), along with their hydrated `#view` forms, which
986+
* share the same paths. Every level is `is_array()`-guarded because the
987+
* embed is untrusted PDS JSON; an unrecognised or malformed shape
988+
* returns '' rather than fataling the cron sync.
989+
*
990+
* @param array $embed The record's `embed` value.
991+
* @return string The quoted post's AT-URI, or '' when absent/malformed.
992+
*/
993+
private static function quoted_post_uri( array $embed ): string {
994+
$type = $embed['$type'] ?? '';
995+
996+
if ( ! \is_string( $type ) || ! \str_starts_with( $type, 'app.bsky.embed.record' ) ) {
997+
return '';
998+
}
999+
1000+
$record = $embed['record'] ?? null;
1001+
1002+
if ( ! \is_array( $record ) ) {
1003+
return '';
1004+
}
1005+
1006+
// recordWithMedia nests the quoted record one level deeper.
1007+
if ( ! isset( $record['uri'] ) && \is_array( $record['record'] ?? null ) ) {
1008+
$record = $record['record'];
1009+
}
1010+
1011+
$uri = $record['uri'] ?? '';
1012+
1013+
return \is_string( $uri ) ? $uri : '';
1014+
}
1015+
1016+
/**
1017+
* Convert a quoted post's AT-URI to its appview web URL.
1018+
*
1019+
* Builds the DID form (`profile/<did>/post/<rkey>`), which resolves
1020+
* without a handle lookup. The host defaults to `bsky.app` and is
1021+
* filterable via `atmosphere_appview_host`. Only `app.bsky.feed.post`
1022+
* records have an appview post page, so any other collection — or a
1023+
* malformed URI — returns ''.
1024+
*
1025+
* @param string $at_uri Quoted post AT-URI.
1026+
* @return string The appview URL, or '' when not a linkable post.
1027+
*/
1028+
private static function quoted_post_web_url( string $at_uri ): string {
1029+
$parts = parse_at_uri( $at_uri );
1030+
1031+
if ( false === $parts ) {
1032+
return '';
1033+
}
1034+
1035+
if ( 'app.bsky.feed.post' !== $parts['collection'] || '' === $parts['did'] || '' === $parts['rkey'] ) {
1036+
return '';
1037+
}
1038+
1039+
// The DID is passed raw, matching the other profile/<did>/post/<rkey>
1040+
// builders (Atmosphere::* and Facet); its colons are valid path chars
1041+
// the appview expects unencoded. Handles, by contrast, are rawurlencode()d.
1042+
return appview_url(
1043+
'profile/' . $parts['did'] . '/post/' . $parts['rkey'],
1044+
array(
1045+
'type' => 'post',
1046+
'did' => $parts['did'],
1047+
'rkey' => $parts['rkey'],
1048+
)
1049+
);
1050+
}
1051+
9181052
/**
9191053
* Find a WordPress post by its Bluesky AT-URI.
9201054
*

0 commit comments

Comments
 (0)