| 1 |
<?php |
| 2 |
/** |
| 3 |
* PHP render for the Donor Comments block. |
| 4 |
* |
| 5 |
* @package SureDonation |
| 6 |
* @since 1.6.0 |
| 7 |
*/ |
| 8 |
|
| 9 |
namespace SureDonation\Inc\Blocks\Campaign_Donor_Comments; |
| 10 |
|
| 11 |
use SureDonation\Inc\Blocks\Base; |
| 12 |
use SureDonation\Inc\Campaigns\Campaign_Page; |
| 13 |
use SureDonation\Inc\Campaigns\Campaign_Stats; |
| 14 |
use SureDonation\Inc\Helper; |
| 15 |
use SureDonation\Inc\Payments\Payment_Helper; |
| 16 |
|
| 17 |
if ( ! defined( 'ABSPATH' ) ) { |
| 18 |
exit; // Exit if accessed directly. |
| 19 |
} |
| 20 |
|
| 21 |
/** |
| 22 |
* Donor Comments block. |
| 23 |
* |
| 24 |
* @since 1.6.0 |
| 25 |
*/ |
| 26 |
class Block extends Base { |
| 27 |
/** |
| 28 |
* Render the block. |
| 29 |
* |
| 30 |
* @param array<string, mixed> $attributes Block attributes. |
| 31 |
* @param string $content Block content. |
| 32 |
* @return string |
| 33 |
* @since 1.6.0 |
| 34 |
*/ |
| 35 |
public function render( $attributes, $content = '' ) { |
| 36 |
unset( $content ); |
| 37 |
|
| 38 |
$campaign_id = Campaign_Page::resolve_campaign_id( $attributes ); |
| 39 |
if ( ! $campaign_id ) { |
| 40 |
return ''; |
| 41 |
} |
| 42 |
|
| 43 |
wp_enqueue_style( 'suredonation-campaign-blocks' ); |
| 44 |
|
| 45 |
$limit = isset( $attributes['commentsToShow'] ) ? absint( Helper::get_string_value( $attributes['commentsToShow'] ) ) : 5; |
| 46 |
$limit = max( 1, $limit ); |
| 47 |
$show_anon = ! isset( $attributes['showAnonymous'] ) || $attributes['showAnonymous']; |
| 48 |
|
| 49 |
// Anonymity filtering is done by the cached reader, across the whole |
| 50 |
// cached window, before it slices to $limit — see the note there. Doing |
| 51 |
// it here instead meant a run of anonymous comments at the top could |
| 52 |
// swallow the slice and render "No comments yet." while non-anonymous |
| 53 |
// approved comments sat just past the cut. |
| 54 |
$comments = Campaign_Stats::get_cached_donor_comments( $campaign_id, $limit, 300, ! $show_anon ); |
| 55 |
|
| 56 |
if ( ! is_array( $comments ) ) { |
| 57 |
$comments = []; |
| 58 |
} |
| 59 |
|
| 60 |
$show_avatar = ! isset( $attributes['showAvatar'] ) || $attributes['showAvatar']; |
| 61 |
$show_amount = ! isset( $attributes['showAmount'] ) || $attributes['showAmount']; |
| 62 |
$show_date = ! isset( $attributes['showDate'] ) || $attributes['showDate']; |
| 63 |
|
| 64 |
// 0 disables truncation entirely — the whole comment always renders. |
| 65 |
$max_chars = isset( $attributes['commentLength'] ) ? absint( Helper::get_string_value( $attributes['commentLength'] ) ) : 150; |
| 66 |
$read_more_text = ! empty( $attributes['readMoreText'] ) ? Helper::get_string_value( $attributes['readMoreText'] ) : __( 'Read more', 'suredonation' ); |
| 67 |
|
| 68 |
$wrapper = get_block_wrapper_attributes( [ 'class' => 'suredonation-campaign-donor-comments' ] ); |
| 69 |
|
| 70 |
ob_start(); |
| 71 |
?> |
| 72 |
<div <?php echo $wrapper; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes() returns escaped markup. ?>> |
| 73 |
<div class="suredonation-campaign-donor-comments__header"> |
| 74 |
<h2 class="suredonation-campaign-donor-comments__title"><?php esc_html_e( 'Donor Comments', 'suredonation' ); ?></h2> |
| 75 |
</div> |
| 76 |
<?php if ( empty( $comments ) ) : ?> |
| 77 |
<p class="suredonation-campaign-donor-comments__empty"><?php esc_html_e( 'No comments yet.', 'suredonation' ); ?></p> |
| 78 |
<?php else : ?> |
| 79 |
<ul class="suredonation-campaign-donor-comments__list"> |
| 80 |
<?php foreach ( $comments as $comment ) : ?> |
| 81 |
<?php |
| 82 |
$is_anon = ! empty( $comment['is_anonymous'] ); |
| 83 |
// An anonymous donation masks the donor's name but keeps |
| 84 |
// the comment — it is the donor's own message, and the |
| 85 |
// showAnonymous toggle above is how an author drops them |
| 86 |
// entirely. Matches Charitable; GiveWP hides both. |
| 87 |
$name = $is_anon ? __( 'Anonymous', 'suredonation' ) : Helper::get_string_value( $comment['donor_name'] ?? __( 'Anonymous', 'suredonation' ) ); |
| 88 |
$time = ! empty( $comment['created_at'] ) ? strtotime( Helper::get_string_value( $comment['created_at'] ) ) : false; |
| 89 |
$avatar_url = $show_avatar ? Campaign_Page::donor_avatar_url( Helper::get_string_value( $comment['donor_email'] ?? '' ), $is_anon ) : ''; |
| 90 |
// Decoded, then trimmed, then measured and cut — in that order, |
| 91 |
// and all before the escaping at each sink below. |
| 92 |
// |
| 93 |
// Decode first: sanitize_textarea_field() runs |
| 94 |
// wp_pre_kses_less_than at write time, so a bare '<' is stored |
| 95 |
// as '<'. That matters for the CUT, not for the rendering — |
| 96 |
// esc_html() does not double-encode, so an undecoded entity |
| 97 |
// would still display correctly. What breaks undecoded is |
| 98 |
// truncate(): the stored form is longer than the visible one, |
| 99 |
// so the limit is measured against the wrong length and the |
| 100 |
// cut can land inside '<', rendering as a literal 'a <'. |
| 101 |
// |
| 102 |
// Then trim: decoding can expose leading whitespace that is not |
| 103 |
// content, and the moderator edit path does not trim the way |
| 104 |
// the donor-facing capture path does. |
| 105 |
$text = trim( wp_specialchars_decode( Helper::get_string_value( $comment['donor_comment'] ?? '' ) ) ); |
| 106 |
// Whether to truncate is decided here, not inferred from the |
| 107 |
// excerpt being empty — see the note on truncate(). |
| 108 |
$needs_excerpt = $max_chars > 0 && mb_strlen( $text ) > $max_chars; |
| 109 |
$excerpt = $needs_excerpt ? self::truncate( $text, $max_chars ) : ''; |
| 110 |
// Unique per render, not just per donation: the same campaign |
| 111 |
// can be rendered twice on one page (the block plus the |
| 112 |
// Elementor widget or Bricks element), and a duplicated id |
| 113 |
// makes both cards' labels drive the first card's checkbox. |
| 114 |
$toggle_id = wp_unique_id( 'sd-donor-comment-more-' ); |
| 115 |
?> |
| 116 |
<li class="suredonation-campaign-donor-comments__item"> |
| 117 |
<?php if ( $avatar_url ) : ?> |
| 118 |
<div class="suredonation-campaign-donor-comments__avatar"> |
| 119 |
<img src="<?php echo esc_url( $avatar_url ); ?>" alt="" loading="lazy" /> |
| 120 |
</div> |
| 121 |
<?php endif; ?> |
| 122 |
<div class="suredonation-campaign-donor-comments__info"> |
| 123 |
<div class="suredonation-campaign-donor-comments__meta"> |
| 124 |
<span class="suredonation-campaign-donor-comments__name"><?php echo esc_html( $name ); ?></span> |
| 125 |
<?php if ( $show_amount ) : ?> |
| 126 |
<span class="suredonation-campaign-donor-comments__amount"><?php echo esc_html( Payment_Helper::format_amount( Helper::get_float_value( $comment['amount'] ?? 0 ) ) ); ?></span> |
| 127 |
<?php endif; ?> |
| 128 |
</div> |
| 129 |
<?php if ( $needs_excerpt ) : ?> |
| 130 |
<?php |
| 131 |
// CSS-only "read more": the hidden checkbox swaps the |
| 132 |
// excerpt for the full text, so the block needs no |
| 133 |
// script of its own. Escaped first, then nl2br so the |
| 134 |
// donor's own line breaks survive. |
| 135 |
?> |
| 136 |
<div class="suredonation-campaign-donor-comments__body"> |
| 137 |
<?php |
| 138 |
// aria-label outranks the two <label for> elements |
| 139 |
// that drive this checkbox; without it a screen |
| 140 |
// reader concatenates both into the accessible |
| 141 |
// name ("Read more Show less, checkbox"), because |
| 142 |
// the display:none one still folds in. The visible |
| 143 |
// labels stay as click targets. |
| 144 |
?> |
| 145 |
<input type="checkbox" id="<?php echo esc_attr( $toggle_id ); ?>" class="suredonation-campaign-donor-comments__toggle" aria-label="<?php esc_attr_e( 'Show the full comment', 'suredonation' ); ?>" /> |
| 146 |
<p class="suredonation-campaign-donor-comments__excerpt"> |
| 147 |
<?php echo nl2br( esc_html( $excerpt ) ); ?>… |
| 148 |
</p> |
| 149 |
<p class="suredonation-campaign-donor-comments__text"> |
| 150 |
<?php echo nl2br( esc_html( $text ) ); ?> |
| 151 |
</p> |
| 152 |
<label class="suredonation-campaign-donor-comments__read-more" for="<?php echo esc_attr( $toggle_id ); ?>"><?php echo esc_html( $read_more_text ); ?></label> |
| 153 |
<?php |
| 154 |
// The expanded state needs its own control: hiding the |
| 155 |
// read-more label on :checked would otherwise destroy the |
| 156 |
// element the keyboard user just activated, dropping focus |
| 157 |
// to the top of the document. |
| 158 |
?> |
| 159 |
<label class="suredonation-campaign-donor-comments__read-less" for="<?php echo esc_attr( $toggle_id ); ?>"><?php esc_html_e( 'Show less', 'suredonation' ); ?></label> |
| 160 |
</div> |
| 161 |
<?php else : ?> |
| 162 |
<p class="suredonation-campaign-donor-comments__text"> |
| 163 |
<?php echo nl2br( esc_html( $text ) ); ?> |
| 164 |
</p> |
| 165 |
<?php endif; ?> |
| 166 |
<?php if ( $show_date && $time ) : ?> |
| 167 |
<span class="suredonation-campaign-donor-comments__date"> |
| 168 |
<?php |
| 169 |
/* translators: %s: human-readable time difference, e.g. "3 weeks". */ |
| 170 |
printf( esc_html__( '%s ago', 'suredonation' ), esc_html( human_time_diff( $time ) ) ); |
| 171 |
?> |
| 172 |
</span> |
| 173 |
<?php endif; ?> |
| 174 |
</div> |
| 175 |
</li> |
| 176 |
<?php endforeach; ?> |
| 177 |
</ul> |
| 178 |
<?php endif; ?> |
| 179 |
</div> |
| 180 |
<?php |
| 181 |
$output = ob_get_clean(); |
| 182 |
|
| 183 |
return false !== $output ? $output : ''; |
| 184 |
} |
| 185 |
|
| 186 |
/** |
| 187 |
* Build a truncated excerpt of a comment. |
| 188 |
* |
| 189 |
* Cuts at the last space before the limit so the excerpt never ends |
| 190 |
* mid-word, falling back to the hard character cut when there is no space in |
| 191 |
* range (e.g. CJK, or one long token). |
| 192 |
* |
| 193 |
* The caller decides *whether* to truncate; this only builds the excerpt, and |
| 194 |
* never returns '' for a comment that needs one. An earlier version signalled |
| 195 |
* "already fits" by returning '', which the trailing rtrim() could also |
| 196 |
* produce for a genuinely over-long comment — a run of punctuation ("-----") |
| 197 |
* or leading whitespace then rendered in full with no read-more control, |
| 198 |
* silently defeating the configured limit. |
| 199 |
* |
| 200 |
* @param string $text The full comment, already known to exceed $max_chars. |
| 201 |
* @param int $max_chars Maximum characters. |
| 202 |
* @return string The excerpt. Never '' unless $text itself is ''. |
| 203 |
* @since 1.6.0 |
| 204 |
*/ |
| 205 |
private static function truncate( $text, $max_chars ) { |
| 206 |
// Character-accurate cut for the limit itself. |
| 207 |
$hard = mb_substr( $text, 0, absint( $max_chars ) ); |
| 208 |
|
| 209 |
// strrpos/substr (byte functions) rather than the mb_* pair: the needle is |
| 210 |
// an ASCII space, which can never occur inside a UTF-8 multibyte sequence, |
| 211 |
// so cutting at that byte offset always yields valid UTF-8. WordPress core |
| 212 |
// polyfills mb_substr() and mb_strlen() but NOT mb_strrpos(), so the mb_* |
| 213 |
// form would fatal on a host without the mbstring extension. |
| 214 |
$last_space = strrpos( $hard, ' ' ); |
| 215 |
|
| 216 |
if ( false !== $last_space && $last_space > 0 ) { |
| 217 |
$word_cut = rtrim( substr( $hard, 0, $last_space ), " \t\n\r\0\x0B.,;:!?-" ); |
| 218 |
|
| 219 |
if ( '' !== $word_cut ) { |
| 220 |
return $word_cut; |
| 221 |
} |
| 222 |
} |
| 223 |
|
| 224 |
// The word-boundary cut left nothing usable, so keep the hard cut: the |
| 225 |
// limit still has to be enforced. |
| 226 |
return rtrim( $hard, " \t\n\r\0\x0B" ); |
| 227 |
} |
| 228 |
} |
| 229 |
|