PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.0
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / blocks / campaign-donor-comments / block.php

block.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.6.0, at inc/blocks/campaign-donor-comments/block.php

229 lines 10.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 '&lt;'. 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 '&lt;', rendering as a literal 'a &lt'.
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 ) ); ?>&hellip;
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