PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / apps / comments / parts / app.php

app.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.10, at apps/comments/parts/app.php

489 lines 15.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Comments app — the model: what the rail and the conversation read,
4 * and what each dispatched action does to the state.
5 *
6 * Reads go through the in-process REST proxy against `wp/v2/comments`
7 * — the same collection, the same `openstation_*` fields and the same
8 * filterable `_fields` projection a browser would fetch — so a row
9 * here is byte-identical to a row there. Writes go through the
10 * operations in `rest.php`, shared with the public routes.
11 *
12 * @package OpenStation
13 */
14
15 namespace OpenStation\Apps\Comments;
16
17 use OpenStation\App\Os;
18 use OpenStation\App\State;
19
20 defined( 'ABSPATH' ) || exit;
21
22 /**
23 * Fields the conversation pane renders for a thread message —
24 * narrower than the rail's projection: a thread message needs no
25 * post title or link (the head carries them for the root) beyond the
26 * root's own row, and no per-row moderation flag (the viewer's cap is
27 * a config fact).
28 */
29 const THREAD_FIELDS = 'id,post,parent,author,author_name,author_avatar_urls,date_gmt,content,status,'
30 . 'openstation_post_title,openstation_post_link,openstation_can_edit';
31
32 /**
33 * The most thread pages fetched for one conversation (100 rows each).
34 * A post with more replies than that is a forum, not a comment thread;
35 * the pane says "showing the first N" rather than paging forever.
36 */
37 const THREAD_MAX_PAGES = 10;
38
39 /**
40 * `tab` → `wp/v2/comments` `status`. Single values only: the collection
41 * declares `status` as a `sanitize_key` string, which silently STRIPS
42 * commas, so a comma list reaches `WP_Comment_Query` as one nonsense
43 * status and returns an empty list with a 200. `all` is approved AND
44 * pending (not spam/trash); `any` is every status.
45 *
46 * @param string $tab Tab value.
47 * @return string
48 */
49 function status_for_tab( $tab ) {
50 switch ( (string) $tab ) {
51 case 'all':
52 return 'all';
53 case 'spam':
54 return 'spam';
55 case 'trash':
56 return 'trash';
57 case 'mine':
58 // Every status the viewer authored on; the author filter
59 // is applied by the caller.
60 return 'any';
61 default:
62 return 'hold';
63 }
64 }
65
66 /**
67 * The rail's query: the filtered default args (`per_page` included —
68 * `openstation_comments_window_query_args` is where the page size is
69 * set), then the tab, page, search, viewer (Mine), post scope, and
70 * `parent=0` — the rail lists conversations, so it asks the server for
71 * roots rather than client-filtering a mixed page (a page of nothing
72 * but replies used to render an empty rail while the badge still
73 * counted them).
74 *
75 * @param State $state State.
76 * @return array<string,mixed>
77 */
78 function rail_query( State $state ) {
79 $query = \openstation_comments_window_default_query_args();
80 unset( $query['status'] );
81 $tab = (string) $state->get( 'tab' );
82 $query['status'] = status_for_tab( $tab );
83 $query['page'] = max( 1, (int) $state->get( 'page' ) );
84 $search = trim( (string) $state->get( 'search' ) );
85 if ( '' !== $search ) {
86 $query['search'] = $search;
87 }
88 // `author`, `post` and `parent` are array params on the collection
89 // (`author__in`, `post__in`, `parent__in`); `parent = [0]` is a
90 // non-empty array, so the `comment_parent IN (0)` clause applies.
91 if ( 'mine' === $tab && get_current_user_id() > 0 ) {
92 $query['author'] = array( get_current_user_id() );
93 }
94 $post = (int) $state->get( 'post' );
95 if ( $post > 0 ) {
96 $query['post'] = array( $post );
97 }
98 $query['parent'] = array( 0 );
99 return $query;
100 }
101
102 /**
103 * The identity of the rail's result set — what the client's page
104 * accumulation is keyed on. `gen` is bumped by every mutation that
105 * moves rows between views, so a moderation or a reply starts the
106 * accumulation clean from page 1.
107 *
108 * @param State $state State.
109 * @return string
110 */
111 function rail_key( State $state ) {
112 return implode(
113 '|',
114 array(
115 (string) $state->get( 'tab' ),
116 trim( (string) $state->get( 'search' ) ),
117 (string) (int) $state->get( 'post' ),
118 (string) (int) $state->get( 'gen' ),
119 )
120 );
121 }
122
123 /**
124 * Direct-reply counts for a set of comment ids in ONE grouped query —
125 * the `openstation_replies_count` field costs a COUNT per row, which
126 * on a 20-row page is twenty queries for one number each. Counts the
127 * approved and pending replies, what `get_comments( status => 'all' )`
128 * counts.
129 *
130 * @param int[] $ids Parent comment ids.
131 * @return array<int,int> `id => count`, every id present.
132 */
133 function reply_counts( array $ids ) {
134 global $wpdb;
135 $ids = array_values( array_unique( array_filter( array_map( 'intval', $ids ) ) ) );
136 $out = array_fill_keys( $ids, 0 );
137 if ( array() === $ids ) {
138 return $out;
139 }
140 $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
141 $rows = $wpdb->get_results(
142 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- `$placeholders` is a list of `%d`; the values are bound below.
143 $wpdb->prepare(
144 "SELECT comment_parent, COUNT(*) AS n FROM {$wpdb->comments} WHERE comment_parent IN ( $placeholders ) AND comment_approved IN ( '1', '0' ) GROUP BY comment_parent",
145 $ids
146 ),
147 ARRAY_A
148 );
149 foreach ( (array) $rows as $row ) {
150 $out[ (int) $row['comment_parent'] ] = (int) $row['n'];
151 }
152 return $out;
153 }
154
155 /**
156 * The rail page for a state, memoised per dispatch so an action that
157 * needs the rows (to auto-select) and `data()` share one query (the
158 * memo is dropped when `data()` has read it — a later dispatch in the
159 * same process, as in a test, starts clean). The reply counts ride
160 * each row as `openstation_replies_count`, from one grouped query
161 * rather than the per-row REST field.
162 *
163 * @param State|null $state State; null forgets the memo.
164 * @return array{items:array<int,mixed>,total:int,pages:int,page:int,perPage:int,error:string,code:string}
165 */
166 function rail( ?State $state ) {
167 static $memo = array();
168 if ( null === $state ) {
169 $memo = array();
170 return array();
171 }
172 $query = rail_query( $state );
173 $key = (string) wp_json_encode( $query );
174 if ( ! isset( $memo[ $key ] ) ) {
175 $page = \openstation_app_rest_page( 'wp/v2/comments', $query );
176 $counts = reply_counts( array_map( 'intval', wp_list_pluck( $page['items'], 'id' ) ) );
177 foreach ( $page['items'] as $i => $row ) {
178 $page['items'][ $i ]['openstation_replies_count'] = $counts[ (int) ( $row['id'] ?? 0 ) ] ?? 0;
179 }
180 $memo[ $key ] = $page;
181 }
182 return $memo[ $key ];
183 }
184
185 /**
186 * Every comment on the selected conversation's post (all depths, all
187 * statuses), paged in 100s — the conversation pane builds the nested
188 * tree from it. `status=any` is the vocabulary for "no status clause
189 * at all", so a spam or trashed reply still renders in context; it is
190 * a protected collection param requiring `edit_posts`, the cap the app
191 * is gated on. Null when nothing is selected or the read failed (the
192 * client then paints the root alone).
193 *
194 * @param int $selected Selected root comment id.
195 * @return array{rows:array<int,mixed>,truncated:bool}|null
196 */
197 function thread( $selected ) {
198 $selected = (int) $selected;
199 if ( $selected <= 0 ) {
200 return null;
201 }
202 $root = get_comment( $selected );
203 if ( ! $root instanceof \WP_Comment ) {
204 return null;
205 }
206 $query = \openstation_comments_window_default_query_args();
207 unset( $query['status'], $query['parent'], $query['_fields'] );
208 $query['post'] = array( (int) $root->comment_post_ID );
209 $query['per_page'] = 100;
210 $query['orderby'] = 'date';
211 $query['order'] = 'asc';
212 $query['status'] = 'any';
213 $query['_fields'] = THREAD_FIELDS;
214
215 $rows = array();
216 $pages = 1;
217 for ( $page = 1; $page <= $pages && $page <= THREAD_MAX_PAGES; $page++ ) {
218 $query['page'] = $page;
219 $result = \openstation_app_rest( 'GET', 'wp/v2/comments', $query );
220 if ( ! $result['ok'] || ! is_array( $result['data'] ) ) {
221 return 1 === $page
222 ? null
223 : array(
224 'rows' => $rows,
225 'truncated' => true,
226 );
227 }
228 $rows = array_merge( $rows, array_values( $result['data'] ) );
229 $pages = max( 1, (int) $result['pages'] );
230 }
231 // The tree build relies on sibling order being chronological —
232 // make that a property of this function rather than of the query.
233 usort(
234 $rows,
235 static function ( $a, $b ) {
236 return strcmp( (string) ( $a['date_gmt'] ?? '' ), (string) ( $b['date_gmt'] ?? '' ) );
237 }
238 );
239 return array(
240 'rows' => $rows,
241 'truncated' => $pages > THREAD_MAX_PAGES,
242 );
243 }
244
245 /**
246 * The halves of `data()` an action knows it left untouched — `select`
247 * changes the thread but not the rail, Load more the rail but not the
248 * thread. A skipped half is omitted from the response and the client
249 * keeps what it has. Scoped to one dispatch: `data()`, which runs at
250 * the end of every dispatch, reads the marks and clears them.
251 *
252 * @param string|null $half `rail` | `thread` to mark, `'take'` to read and clear.
253 * @return array<string,bool>
254 */
255 function skipped( $half = null ) {
256 static $skip = array();
257 if ( 'take' === $half ) {
258 $taken = $skip;
259 $skip = array();
260 return $taken;
261 }
262 if ( null !== $half ) {
263 $skip[ $half ] = true;
264 }
265 return $skip;
266 }
267
268 /**
269 * Everything the client view paints from.
270 *
271 * @param State $state State.
272 * @return array<string,mixed>
273 */
274 function data( State $state ) {
275 $skip = skipped( 'take' );
276 $out = array( 'counts' => \openstation_comments_window_counts() );
277 if ( empty( $skip['rail'] ) ) {
278 $out['rail'] = rail( $state );
279 $out['railKey'] = rail_key( $state );
280 }
281 if ( empty( $skip['thread'] ) ) {
282 $out['thread'] = thread( (int) $state->get( 'selected' ) );
283 }
284 rail( null );
285 return $out;
286 }
287
288 // ---------------------------------------------------------------- actions
289
290 /**
291 * Start a fresh result set: page 1, a new accumulation key.
292 *
293 * @param State $state State.
294 */
295 function restart( State $state ) {
296 $state->set( 'page', 1 );
297 $state->set( 'gen', (int) $state->get( 'gen' ) + 1 );
298 }
299
300 /**
301 * Keep the selection honest against page 1: a conversation that left
302 * the view gives way to the first one still in it, and an empty view
303 * clears it.
304 *
305 * @param State $state State.
306 */
307 function auto_select( State $state ) {
308 if ( 1 !== (int) $state->get( 'page' ) ) {
309 return;
310 }
311 $items = rail( $state )['items'];
312 $selected = (int) $state->get( 'selected' );
313 foreach ( $items as $row ) {
314 if ( isset( $row['id'] ) && (int) $row['id'] === $selected ) {
315 return;
316 }
317 }
318 $first = isset( $items[0]['id'] ) ? (int) $items[0]['id'] : 0;
319 $state->set( 'selected', $first );
320 }
321
322 /**
323 * Scope the rail to the `post` open-time param — the
324 * `edit-comments.php?p=<id>` deep link. A scoped open starts on "All"
325 * so the post's whole thread is visible, not just its pending
326 * comments; `0` (a plain open) clears the scope.
327 *
328 * @param State $state State.
329 * @param Os $os Host handle.
330 */
331 function scope_from_params( State $state, Os $os ) {
332 $post = max( 0, (int) $os->param( 'post', 0 ) );
333 $state->set( 'post', $post );
334 if ( $post > 0 ) {
335 $state->set( 'tab', 'all' );
336 }
337 restart( $state );
338 auto_select( $state );
339 }
340
341 /**
342 * `mount` — the first render.
343 *
344 * @param State $state State.
345 * @param Os $os Host handle.
346 */
347 function mount( State $state, Os $os ) {
348 scope_from_params( $state, $os );
349 }
350
351 /**
352 * `reopen` — a live window asked to open again. Only a CHANGED scope
353 * re-scopes: a dock click, which reopens with the scope the window
354 * already has, keeps its pages and its selection.
355 *
356 * @param State $state State.
357 * @param Os $os Host handle.
358 */
359 function reopen_action( State $state, Os $os ) {
360 if ( max( 0, (int) $os->param( 'post', 0 ) ) === (int) $state->get( 'post' ) ) {
361 return;
362 }
363 scope_from_params( $state, $os );
364 }
365
366 /**
367 * `filter` — the tab, the search or the post scope changed (the bound
368 * value already rode up with the state; `post` may also arrive as an
369 * argument, from the scope banner's Show all).
370 *
371 * @param State $state State.
372 * @param Os $os Host handle.
373 * @param array<string,mixed> $args Args.
374 */
375 function filter_action( State $state, Os $os, array $args ) {
376 if ( array_key_exists( 'post', $args ) ) {
377 $state->set( 'post', max( 0, (int) $args['post'] ) );
378 }
379 restart( $state );
380 auto_select( $state );
381 }
382
383 /**
384 * `page` — Load more: the next rail page joins the accumulation. The
385 * thread on screen did not change; it is left out of the response.
386 *
387 * @param State $state State.
388 * @param Os $os Host handle.
389 * @param array<string,mixed> $args `page`.
390 */
391 function page_action( State $state, Os $os, array $args ) {
392 $state->set( 'page', max( 1, (int) ( $args['page'] ?? 1 ) ) );
393 skipped( 'thread' );
394 }
395
396 /**
397 * `select` — read this conversation. The rail did not change; it is
398 * left out of the response.
399 *
400 * @param State $state State.
401 * @param Os $os Host handle.
402 * @param array<string,mixed> $args `id`.
403 */
404 function select_action( State $state, Os $os, array $args ) {
405 $state->set( 'selected', max( 0, (int) ( $args['id'] ?? 0 ) ) );
406 skipped( 'rail' );
407 }
408
409 /**
410 * `moderate` — approve / unapprove / spam / unspam / trash / untrash.
411 *
412 * @param State $state State.
413 * @param Os $os Host handle.
414 * @param array<string,mixed> $args `ids`, `action`.
415 * @throws \RuntimeException When refused or nothing could be processed.
416 */
417 function moderate_action( State $state, Os $os, array $args ) {
418 if ( ! $os->can( 'moderate_comments' ) ) {
419 throw new \RuntimeException( esc_html__( 'You are not allowed to moderate comments.', 'desktop-mode' ) );
420 }
421 $verb = (string) ( $args['action'] ?? '' );
422 $result = \openstation_comments_window_moderate( (array) ( $args['ids'] ?? array() ), $verb );
423 if ( is_wp_error( $result ) ) {
424 throw new \RuntimeException( esc_html( $result->get_error_message() ) );
425 }
426 if ( array() === $result['processed'] ) {
427 throw new \RuntimeException( esc_html__( 'Action failed.', 'desktop-mode' ) );
428 }
429 $changes = array(
430 'trash' => 'trashed',
431 'spam' => 'trashed',
432 'untrash' => 'untrashed',
433 'unspam' => 'untrashed',
434 );
435 $os->announce( 'comment', $changes[ $verb ] ?? 'updated', $result['processed'] );
436 restart( $state );
437 auto_select( $state );
438 }
439
440 /**
441 * `reply` — post a reply under a comment.
442 *
443 * @param State $state State.
444 * @param Os $os Host handle.
445 * @param array<string,mixed> $args `parent`, `content`.
446 * @throws \RuntimeException When refused.
447 */
448 function reply_action( State $state, Os $os, array $args ) {
449 if ( ! $os->can( 'edit_posts' ) ) {
450 throw new \RuntimeException( esc_html__( 'You are not allowed to reply.', 'desktop-mode' ) );
451 }
452 $result = \openstation_comments_window_create_reply( (int) ( $args['parent'] ?? 0 ), (string) ( $args['content'] ?? '' ) );
453 if ( is_wp_error( $result ) ) {
454 throw new \RuntimeException( esc_html( $result->get_error_message() ) );
455 }
456 $os->announce( 'comment', 'created', array( (int) $result['id'] ) );
457 restart( $state );
458 auto_select( $state );
459 }
460
461 /**
462 * `edit` — rewrite a comment's body, through the core controller so
463 * its sanitisation and its per-target permission stay the truth. The
464 * rows keep their places (a rewrite moves nothing between views), so
465 * the accumulation is left alone: the thread and the current rail
466 * page come back fresh, the pages before it keep their text until the
467 * next reload.
468 *
469 * @param State $state State.
470 * @param Os $os Host handle.
471 * @param array<string,mixed> $args `id`, `content`.
472 * @throws \RuntimeException When refused or the write failed.
473 */
474 function edit_action( State $state, Os $os, array $args ) {
475 $id = (int) ( $args['id'] ?? 0 );
476 $content = (string) ( $args['content'] ?? '' );
477 if ( $id <= 0 || ! $os->can( 'edit_comment', $id ) ) {
478 throw new \RuntimeException( esc_html__( 'You are not allowed to edit this comment.', 'desktop-mode' ) );
479 }
480 if ( \openstation_comments_window_is_blank( $content ) ) {
481 throw new \RuntimeException( esc_html__( 'A comment cannot be empty.', 'desktop-mode' ) );
482 }
483 $result = \openstation_app_rest( 'POST', 'wp/v2/comments/' . $id, array(), array( 'content' => $content ) );
484 if ( ! $result['ok'] ) {
485 throw new \RuntimeException( esc_html( '' !== $result['error'] ? $result['error'] : __( 'Edit failed.', 'desktop-mode' ) ) );
486 }
487 $os->announce( 'comment', 'updated', array( $id ) );
488 }
489