Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,43 @@

defined( 'WPINC' ) || die();

/**
* The post meta key holding the moderator's message to the author, which is
* included in the "pattern unlisted" email.
*/
const UNLISTED_DETAIL_META = '_wporg_unlist_reason_detail';

/**
* Actions and filters.
*/
add_action( 'wp_after_insert_post', __NAMESPACE__ . '\trigger_notifications', 20, 4 );
add_action( 'init', __NAMESPACE__ . '\register_unlisted_meta' );

/**
* Register the post meta used to store the moderator's message to the author.
*
* Only moderators can write it, from the Unlist modal in the block editor. It is
* read in the edit context only, so the public API never exposes it, and it is
* deleted as soon as the pattern is unlisted.
*
* @return void
*/
function register_unlisted_meta() {
register_post_meta(
PATTERN,
UNLISTED_DETAIL_META,
array(
'type' => 'string',
'description' => 'A message from the moderator, included in the email sent to the author when a pattern is unlisted.',
'single' => true,
'show_in_rest' => array( 'schema' => array( 'context' => array( 'edit' ) ) ),
'sanitize_callback' => 'sanitize_textarea_field',
'auth_callback' => function () {
return current_user_can( get_post_type_object( PATTERN )->cap->edit_others_posts );
},
)
);
}

/**
* Fire off relevant notification when a post is finished updating.
Expand Down Expand Up @@ -196,6 +229,11 @@ function ( \WP_Term $reason ) {
* @return void
*/
function notify_pattern_unlisted( $post ) {
// The message is single-use: consume it before anything can bail out, so a
// later unlisting doesn't resend it.
$detail = get_post_meta( $post->ID, UNLISTED_DETAIL_META, true );
delete_post_meta( $post->ID, UNLISTED_DETAIL_META );

$author = get_user_by( 'id', $post->post_author );
if ( ! $author ) {
return;
Expand All @@ -221,6 +259,11 @@ function notify_pattern_unlisted( $post ) {
$reason = get_default_reason_description();
}

// Append the moderator's message to the author, if one was provided.
if ( $detail ) {
$reason .= "\n\n" . $detail;
}

$subject = esc_html__( 'Pattern unlisted', 'wporg-patterns' );

$message = sprintf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use function WordPressdotorg\Pattern_Directory\Pattern_Flag_Post_Type\has_reached_flag_threshold;
use const WordPressdotorg\Pattern_Directory\Pattern_Post_Type\{ POST_TYPE, UNLISTED_STATUS, SPAM_STATUS };
use const WordPressdotorg\Pattern_Directory\Pattern_Flag_Post_Type\TAX_TYPE as FLAG_REASON;
use const WordPressdotorg\Pattern_Directory\Notifications\UNLISTED_DETAIL_META;

/**
* The rendered text fields, the ones the directory outputs and so has to check.
Expand Down Expand Up @@ -57,7 +58,7 @@ function reject_control_characters( $prepared_post, $request ) {
return $prepared_post;
}

$values = array( $request['meta'] ?? null );
$values = array( get_rendered_meta( $request ) );
foreach ( RENDERED_FIELDS as $field ) {
$values[] = $prepared_post->$field ?? null;
}
Expand All @@ -73,6 +74,24 @@ function reject_control_characters( $prepared_post, $request ) {
return $prepared_post;
}

/**
* The submitted meta that the directory may render, and so has to check.
*
* The moderator's unlisting message is left out: it is only ever sent to the author as a plain-text
* email, so checking it would just block the unlisting it travels with.
*
* @param \WP_REST_Request $request Request being validated.
* @return mixed The submitted meta, without the unlisting message.
*/
function get_rendered_meta( $request ) {
$meta = $request['meta'] ?? null;
if ( is_array( $meta ) ) {
unset( $meta[ UNLISTED_DETAIL_META ] );
}

return $meta;
}

/**
* Whether a value, or anything nested in one, carries a control character.
*
Expand Down Expand Up @@ -580,8 +599,9 @@ function validate_block_directives( $prepared_post, $request ) {
* Meta never reaches `$prepared_post`, and `render_block_core_footnotes()` emits `footnotes` through
* `wp_kses_post()`, which keeps `data-*`.
*/
if ( ! $has_directive && is_array( $request['meta'] ?? null ) ) {
$has_directive = attribute_has_directive( $request['meta'] );
$meta = get_rendered_meta( $request );
if ( ! $has_directive && is_array( $meta ) ) {
$has_directive = attribute_has_directive( $meta );
}

if ( $has_directive ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { PluginDocumentSettingPanel } from '@wordpress/edit-post';
import { ComboboxControl, FormTokenField, TextControl, TextareaControl } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { store as editorStore } from '@wordpress/editor';
import { PluginDocumentSettingPanel, store as editorStore } from '@wordpress/editor';

const KEYWORD_SLUG = 'wpop_keywords';
const DESCRIPTION_SLUG = 'wpop_description';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { PluginPostStatusInfo } from '@wordpress/edit-post';
import { Button } from '@wordpress/components';
import { store as editorStore } from '@wordpress/editor';
import { PluginPostStatusInfo, store as editorStore } from '@wordpress/editor';
import { useDispatch, useSelect } from '@wordpress/data';
import { useState } from '@wordpress/element';

Expand All @@ -20,15 +19,21 @@ export const UnlistButton = () => {
const _post = select( editorStore ).getCurrentPost();
return _post.status;
} );
const { didPostSaveRequestFail } = useSelect( editorStore );
const { editPost, savePost } = useDispatch( editorStore );
const [ showModal, setShowModal ] = useState( false );

const onSubmit = ( reasonId ) => {
const onSubmit = async ( reasonId, details = '' ) => {
editPost( {
status: UNLISTED_STATUS,
'wporg-pattern-flag-reason': [ reasonId ],
meta: { _wporg_unlist_reason_detail: details },

@gedex gedex Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A message that mentions data-wp- fails the save with "Patterns cannot contain interactivity directives", so the pattern stays published

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving _wporg_unlist_reason_detail out of the two $request['meta'] checks in pattern-validation.php would fix it, since this text only goes into the email.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2933b48: added get_rendered_meta(), which drops _wporg_unlist_reason_detail from $request['meta'] for both the control-character and directive checks. The test unlists with a message mentioning data-wp-interactive.

} );
savePost();
// `savePost` resolves even when the request fails, so check the result.
await savePost();
if ( didPostSaveRequestFail() ) {
throw new Error( __( 'The pattern could not be unlisted. Please try again.', 'wporg-patterns' ) );
}
};

const className = status === UNLISTED_STATUS ? 'wporg-patterns-unlist-notice' : 'wporg-patterns-unlist-button';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,22 @@ const UnlistModal = ( { onClose, onSubmit } ) => {
} );
}, [] );

const submittedText = __(
'The pattern has been unlisted, and your internal note has been saved.',
'wporg-patterns'
);
const submittedText = __( 'The pattern has been unlisted.', 'wporg-patterns' );

const handleError = ( err ) => {
dispatch( {
status: 'ERROR',
message: err.message,
} );

speak(
sprintf(
/* translators: %s: Error message. */
__( 'Error: %s', 'wporg-patterns' ),
err.message
)
);
};

const handleSubmit = ( event ) => {
event.preventDefault();
Expand All @@ -99,28 +111,20 @@ const UnlistModal = ( { onClose, onSubmit } ) => {
sendUnlistedNote( {
url: apiUrl,
note: details ? `UNLISTED: ${ reason.label } — ${ details }` : `UNLISTED: ${ reason.label }`,
onSuccess: () => {
if ( 'function' === typeof onSubmit ) {
onSubmit( selectedOption );
onSuccess: async () => {
try {
if ( 'function' === typeof onSubmit ) {
await onSubmit( selectedOption, details );
}
} catch ( err ) {
handleError( err );
return;
}
dispatch( { status: 'NOTE_RECIEVED' } );
speak( submittedText );
container.current.closest( '[role="dialog"]' ).focus();
},
onFailure: ( err ) => {
dispatch( {
status: 'ERROR',
message: err.message,
} );

speak(
sprintf(
/* translators: %s: Error message. */
__( 'Error: %s', 'wporg-patterns' ),
err.message
)
);
},
onFailure: handleError,
} );
};
const handleClose = () => {
Expand Down Expand Up @@ -155,9 +159,9 @@ const UnlistModal = ( { onClose, onSubmit } ) => {
<Spinner />
) }
<TextareaControl
label={ __( 'Please provide internal details', 'wporg-patterns' ) }
label={ __( 'Message to the pattern author', 'wporg-patterns' ) }
help={ __(
'This note will only be seen by other admins and moderators.',
'This message will be emailed to the pattern author, along with the reason selected above.',
'wporg-patterns'
) }
value={ details }
Expand Down
Loading
Loading