@@ -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