Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 266
0.00% covered (danger)
0.00%
0 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
jetpack_is_psh_active
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
Jetpack_Plugin_Search
0.00% covered (danger)
0.00%
0 / 256
0.00% covered (danger)
0.00%
0 / 20
4830
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 start
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 plugin_details
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 register_endpoints
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
 can_request
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_hint_id
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 dismiss
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 get_dismissed_hints
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 add_to_dismissed_hints
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 should_display_hint
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
42
 load_plugins_search_script
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
2
 get_jetpack_plugin_data
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 get_extra_features
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
2
 inject_jetpack_module_suggestion
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
132
 filter_cards
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 sanitize_search_term
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 by_sorting_option
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_configure_url
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
42
 insert_module_related_links
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
240
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2/**
3 * Plugin Search Hints, aka Feature Suggestions.
4 *
5 * @since 7.1.0
6 *
7 * @package automattic/jetpack
8 */
9
10// phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- TODO: Move classes to appropriately-named class files.
11
12use Automattic\Jetpack\Constants;
13use Automattic\Jetpack\Current_Plan as Jetpack_Plan;
14use Automattic\Jetpack\Redirect;
15use Automattic\Jetpack\Tracking;
16
17// Disable direct access and execution.
18if ( ! defined( 'ABSPATH' ) ) {
19    exit( 0 );
20}
21
22if (
23    is_admin() &&
24    Jetpack::is_connection_ready() &&
25    /** This filter is documented in _inc/lib/admin-pages/class.jetpack-react-page.php */
26    apply_filters( 'jetpack_show_promotions', true ) &&
27    // Disable feature hints when plugins cannot be installed.
28    ! Constants::is_true( 'DISALLOW_FILE_MODS' ) &&
29    jetpack_is_psh_active()
30) {
31    Jetpack_Plugin_Search::init();
32}
33
34// Register endpoints when WP REST API is initialized.
35add_action( 'rest_api_init', array( 'Jetpack_Plugin_Search', 'register_endpoints' ) );
36
37/**
38 * Class that includes cards in the plugin search results when users enter terms that match some Jetpack feature.
39 * Card can be dismissed and includes a title, description, button to enable the feature and a link for more information.
40 *
41 * @since 7.1.0
42 */
43class Jetpack_Plugin_Search {
44
45    /**
46     * PSH slug name.
47     *
48     * @var string
49     */
50    public static $slug = 'jetpack-plugin-search';
51
52    /**
53     * Singleton constructor.
54     *
55     * @return Jetpack_Plugin_Search
56     */
57    public static function init() {
58        static $instance = null;
59
60        if ( ! $instance ) {
61            $instance = new Jetpack_Plugin_Search();
62        }
63
64        return $instance;
65    }
66
67    /**
68     * Jetpack_Plugin_Search constructor.
69     */
70    public function __construct() {
71        add_action( 'current_screen', array( $this, 'start' ) );
72    }
73
74    /**
75     * Add actions and filters only if this is the plugin installation screen and it's the first page.
76     *
77     * @param object $screen WP SCreen object.
78     *
79     * @since 7.1.0
80     */
81    public function start( $screen ) {
82        if ( 'plugin-install' === $screen->base && ( ! isset( $_GET['paged'] ) || 1 === intval( $_GET['paged'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
83            add_action( 'admin_enqueue_scripts', array( $this, 'load_plugins_search_script' ) );
84            add_filter( 'plugins_api_result', array( $this, 'inject_jetpack_module_suggestion' ), 10, 3 );
85            add_filter( 'self_admin_url', array( $this, 'plugin_details' ) );
86            add_filter( 'plugin_install_action_links', array( $this, 'insert_module_related_links' ), 10, 2 );
87        }
88    }
89
90    /**
91     * Modify URL used to fetch to plugin information so it pulls Jetpack plugin page.
92     *
93     * @param string $url URL to load in dialog pulling the plugin page from wporg.
94     *
95     * @since 7.1.0
96     *
97     * @return string The URL with 'jetpack' instead of 'jetpack-plugin-search'.
98     */
99    public function plugin_details( $url ) {
100        return false !== stripos( $url, 'tab=plugin-information&amp;plugin=' . self::$slug )
101            ? 'plugin-install.php?tab=plugin-information&amp;plugin=jetpack&amp;TB_iframe=true&amp;width=600&amp;height=550'
102            : $url;
103    }
104
105    /**
106     * Register REST API endpoints.
107     *
108     * @since 7.1.0
109     */
110    public static function register_endpoints() {
111        register_rest_route(
112            'jetpack/v4',
113            '/hints',
114            array(
115                'methods'             => WP_REST_Server::EDITABLE,
116                'callback'            => __CLASS__ . '::dismiss',
117                'permission_callback' => __CLASS__ . '::can_request',
118                'args'                => array(
119                    'hint' => array(
120                        'default'           => '',
121                        'type'              => 'string',
122                        'required'          => true,
123                        'validate_callback' => __CLASS__ . '::is_hint_id',
124                    ),
125                ),
126            )
127        );
128    }
129
130    /**
131     * A WordPress REST API permission callback method that accepts a request object and
132     * decides if the current user has enough privileges to act.
133     *
134     * @since 7.1.0
135     *
136     * @return bool does a current user have enough privileges.
137     */
138    public static function can_request() {
139        return current_user_can( 'jetpack_admin_page' );
140    }
141
142    /**
143     * Validates that the ID of the hint to dismiss is a string.
144     *
145     * @since 7.1.0
146     *
147     * @param string|bool     $value Value to check.
148     * @param WP_REST_Request $request The request sent to the WP REST API.
149     * @param string          $param Name of the parameter passed to endpoint holding $value.
150     *
151     * @return bool|WP_Error
152     */
153    public static function is_hint_id( $value, $request, $param ) {
154        return in_array( $value, Jetpack::get_available_modules(), true )
155            ? true
156            /* translators: %s is the name of a parameter passed to an endpoint. */
157            : new WP_Error( 'invalid_param', sprintf( esc_html__( '%s must be an alphanumeric string.', 'jetpack' ), $param ) );
158    }
159
160    /**
161     * A WordPress REST API callback method that accepts a request object and decides what to do with it.
162     *
163     * @param WP_REST_Request $request {
164     *     Array of parameters received by request.
165     *
166     *     @type string $hint Slug of card to dismiss.
167     * }
168     *
169     * @since 7.1.0
170     *
171     * @return bool|array|WP_Error a resulting value or object, or an error.
172     */
173    public static function dismiss( WP_REST_Request $request ) {
174        return self::add_to_dismissed_hints( $request['hint'] )
175            ? rest_ensure_response( array( 'code' => 'success' ) )
176            : new WP_Error( 'not_dismissed', esc_html__( 'The card could not be dismissed', 'jetpack' ), array( 'status' => 400 ) );
177    }
178
179    /**
180     * Returns a list of previously dismissed hints.
181     *
182     * @since 7.1.0
183     *
184     * @return array List of dismissed hints.
185     */
186    protected static function get_dismissed_hints() {
187        $dismissed_hints = Jetpack_Options::get_option( 'dismissed_hints' );
188        return isset( $dismissed_hints ) && is_array( $dismissed_hints )
189            ? $dismissed_hints
190            : array();
191    }
192
193    /**
194     * Save the hint in the list of dismissed hints.
195     *
196     * @since 7.1.0
197     *
198     * @param string $hint The hint id, which is a Jetpack module slug.
199     *
200     * @return bool Whether the card was added to the list and hence dismissed.
201     */
202    protected static function add_to_dismissed_hints( $hint ) {
203        return Jetpack_Options::update_option( 'dismissed_hints', array_merge( self::get_dismissed_hints(), array( $hint ) ) );
204    }
205
206    /**
207     * Checks that the module slug passed should be displayed.
208     *
209     * A feature hint will be displayed if it has not been dismissed before or if 2 or fewer other hints have been dismissed.
210     *
211     * @since 7.2.1
212     *
213     * @param string $hint The hint id, which is a Jetpack module slug.
214     *
215     * @return bool True if $hint should be displayed.
216     */
217    protected function should_display_hint( $hint ) {
218        $dismissed_hints = static::get_dismissed_hints();
219        // If more than 2 hints have been dismissed, then show no more.
220        if ( 2 < count( $dismissed_hints ) ) {
221            return false;
222        }
223
224        $plan = Jetpack_Plan::get();
225        if ( isset( $plan['class'] ) && ( 'free' === $plan['class'] || 'personal' === $plan['class'] ) && 'vaultpress' === $hint ) {
226            return false;
227        }
228
229        return ! in_array( $hint, $dismissed_hints, true );
230    }
231
232    /**
233     * Load the search scripts and CSS for PSH.
234     */
235    public function load_plugins_search_script() {
236        wp_enqueue_script( self::$slug, plugins_url( 'modules/plugin-search/plugin-search.js', JETPACK__PLUGIN_FILE ), array( 'jquery' ), JETPACK__VERSION, true );
237        wp_localize_script(
238            self::$slug,
239            'jetpackPluginSearch',
240            array(
241                'nonce'          => wp_create_nonce( 'wp_rest' ),
242                'base_rest_url'  => rest_url( '/jetpack/v4' ),
243                'manageSettings' => esc_html__( 'Configure', 'jetpack' ),
244                'activateModule' => esc_html__( 'Activate Module', 'jetpack' ),
245                'getStarted'     => esc_html__( 'Get started', 'jetpack' ),
246                'activated'      => esc_html__( 'Activated', 'jetpack' ),
247                'activating'     => esc_html__( 'Activating', 'jetpack' ),
248                'logo'           => 'https://ps.w.org/jetpack/assets/icon.svg?rev=1791404',
249                'legend'         => esc_html__(
250                    'This suggestion was made by Jetpack, the security and performance plugin already installed on your site.',
251                    'jetpack'
252                ),
253                'supportText'    => esc_html__(
254                    'Learn more about these suggestions.',
255                    'jetpack'
256                ),
257                'supportLink'    => Redirect::get_url( 'plugin-hint-learn-support' ),
258                'hideText'       => esc_html__( 'Hide this suggestion', 'jetpack' ),
259            )
260        );
261
262        wp_enqueue_style( self::$slug, plugins_url( 'modules/plugin-search/plugin-search.css', JETPACK__PLUGIN_FILE ), array(), JETPACK__VERSION );
263    }
264
265    /**
266     * Get the plugin repo's data for Jetpack to populate the fields with.
267     *
268     * @return array|mixed|object|WP_Error
269     */
270    public static function get_jetpack_plugin_data() {
271        $data = get_transient( 'jetpack_plugin_data' );
272
273        if ( false === $data || is_wp_error( $data ) ) {
274            include_once ABSPATH . 'wp-admin/includes/plugin-install.php';
275            $data = plugins_api(
276                'plugin_information',
277                array(
278                    'slug'   => 'jetpack',
279                    'is_ssl' => is_ssl(),
280                    'fields' => array(
281                        'banners'         => true,
282                        'reviews'         => true,
283                        'active_installs' => true,
284                        'versions'        => false,
285                        'sections'        => false,
286                    ),
287                )
288            );
289            set_transient( 'jetpack_plugin_data', $data, DAY_IN_SECONDS );
290        }
291
292        return $data;
293    }
294
295    /**
296     * Create a list with additional features for those we don't have a module, like Akismet.
297     *
298     * @since 7.1.0
299     *
300     * @return array List of features.
301     */
302    public function get_extra_features() {
303        return array(
304            'akismet'       => array(
305                'name'                => 'Akismet',
306                'search_terms'        => 'akismet, anti-spam, antispam, comments, spam, spam protection, form spam, captcha, no captcha, nocaptcha, recaptcha, phising, google',
307                'short_description'   => esc_html__( 'Keep your visitors and search engines happy by stopping comment and contact form spam with Akismet.', 'jetpack' ),
308                'requires_connection' => true,
309                'module'              => 'akismet',
310                'sort'                => '16',
311                'learn_more_button'   => Redirect::get_url( 'plugin-hint-upgrade-akismet' ),
312                'configure_url'       => admin_url( 'admin.php?page=akismet-key-config' ),
313            ),
314            'sharing-block' => array(
315                'name'                => esc_html__( 'Sharing buttons block', 'jetpack' ),
316                'search_terms'        => 'share, sharing, sharing block, sharing button, social buttons, buttons, share facebook, share twitter, social share, icons, email, facebook, twitter, x, linkedin, pinterest, pocket, social media',
317                'short_description'   => esc_html__( 'Add sharing buttons blocks anywhere on your website to help your visitors share your content.', 'jetpack' ),
318                'requires_connection' => false,
319                'module'              => 'sharing-block',
320                'sort'                => '13',
321                'learn_more_button'   => Redirect::get_url( 'jetpack-support-sharing-block' ),
322                'configure_url'       => admin_url( 'site-editor.php?path=%2Fwp_template' ),
323            ),
324        );
325    }
326
327    /**
328     * Intercept the plugins API response and add in an appropriate card for Jetpack
329     *
330     * @param object $result Plugin search results.
331     * @param string $action unused.
332     * @param object $args Search args.
333     */
334    public function inject_jetpack_module_suggestion( $result, $action, $args ) {
335        /*
336         * Bail if something else hooks into the Plugins' API response
337         * and does not return results.
338         */
339        if ( empty( $result->plugins ) || is_wp_error( $result ) ) {
340            return $result;
341        }
342
343        // Looks like a search query; it's matching time.
344        if ( ! empty( $args->search ) ) {
345            $searchable_modules = array(
346                'contact-form',
347                'monitor',
348                'photon',
349                'photon-cdn',
350                'protect',
351                'publicize',
352                'related-posts',
353                'akismet',
354                'vaultpress',
355                'videopress',
356                'search',
357            );
358
359            /*
360             * Let's handle the Sharing feature differently.
361             * If we're using a block-based theme, we should suggest the sharing block.
362             * If using a classic theme, we should suggest the old sharing module.
363             */
364            if ( wp_is_block_theme() ) {
365                $searchable_modules[] = 'sharing-block';
366            } else {
367                $searchable_modules[] = 'sharedaddy';
368            }
369
370            require_once JETPACK__PLUGIN_DIR . 'class.jetpack-admin.php';
371            $tracking             = new Tracking();
372            $jetpack_modules_list = array_intersect_key(
373                array_merge( $this->get_extra_features(), Jetpack_Admin::init()->get_modules() ),
374                array_flip( $searchable_modules )
375            );
376            uasort( $jetpack_modules_list, array( $this, 'by_sorting_option' ) );
377
378            // Record event when user searches for a term over 3 chars (less than 3 is not very useful).
379            if ( strlen( $args->search ) >= 3 ) {
380                $tracking->record_user_event( 'wpa_plugin_search_term', array( 'search_term' => $args->search ) );
381            }
382
383            // Lowercase, trim, remove punctuation/special chars, decode url, remove 'jetpack'.
384            $normalized_term = $this->sanitize_search_term( $args->search );
385
386            $matching_module = null;
387
388            // Try to match a passed search term with module's search terms.
389            foreach ( $jetpack_modules_list as $module_slug => $module_opts ) {
390                /*
391                * Does the site's current plan support the feature?
392                * We don't use Jetpack_Plan::supports() here because
393                * that check always returns Akismet as supported,
394                * since Akismet has a free version.
395                */
396                $current_plan         = Jetpack_Plan::get();
397                $is_supported_by_plan = in_array( $module_slug, $current_plan['supports'], true );
398
399                if (
400                    false !== stripos( $module_opts['search_terms'] . ', ' . $module_opts['name'], $normalized_term )
401                    && $is_supported_by_plan
402                ) {
403                    $matching_module = $module_slug;
404                    break;
405                }
406            }
407
408            if ( isset( $matching_module ) && $this->should_display_hint( $matching_module ) ) {
409                // Record event when a matching feature is found.
410                $tracking->record_user_event( 'wpa_plugin_search_match_found', array( 'feature' => $matching_module ) );
411
412                $inject    = (array) self::get_jetpack_plugin_data();
413                $image_url = plugins_url( 'modules/plugin-search/psh', JETPACK__PLUGIN_FILE );
414                $overrides = array(
415                    'plugin-search'       => true, // Helps to determine if that an injected card.
416                    'name'                => sprintf(       // Supplement name/description so that they clearly indicate this was added.
417                        /* translators: Jetpack module name */
418                        esc_html_x( 'Jetpack: %s', 'Jetpack: Module Name', 'jetpack' ),
419                        $jetpack_modules_list[ $matching_module ]['name']
420                    ),
421                    'short_description'   => $jetpack_modules_list[ $matching_module ]['short_description'],
422                    'author'              => esc_attr__( 'Jetpack (installed)', 'jetpack' ),
423                    'requires_connection' => (bool) $jetpack_modules_list[ $matching_module ]['requires_connection'],
424                    'slug'                => self::$slug,
425                    'version'             => JETPACK__VERSION,
426                    'icons'               => array(
427                        '1x'  => "$image_url-128.png",
428                        '2x'  => "$image_url-256.png",
429                        'svg' => "$image_url.svg",
430                    ),
431                );
432
433                // Splice in the base module data.
434                $inject = array_merge( $inject, $jetpack_modules_list[ $matching_module ], $overrides );
435
436                // Add it to the top of the list.
437                $result->plugins = array_filter( $result->plugins, array( $this, 'filter_cards' ) );
438                array_unshift( $result->plugins, $inject );
439            }
440        }
441        return $result;
442    }
443
444    /**
445     * Remove cards for Jetpack plugins since we don't want duplicates.
446     *
447     * @since 7.1.0
448     * @since 7.2.0 Only remove Jetpack.
449     * @since 7.4.0 Simplify for WordPress 5.1+.
450     *
451     * @param array|object $plugin WordPress search result card.
452     *
453     * @return bool
454     */
455    public function filter_cards( $plugin ) {
456        /*
457         * $plugin is normally an array.
458         * However, since the response data can be filtered,
459         * we cannot fully trust its format.
460         * Let's handle both arrays and objects, and bail if it's neither.
461         */
462        if ( is_array( $plugin ) && ! empty( $plugin['slug'] ) ) {
463            $slug = $plugin['slug'];
464        } elseif ( is_object( $plugin ) && ! empty( $plugin->slug ) ) {
465            $slug = $plugin->slug;
466        } else {
467            return false;
468        }
469
470        return ! in_array( $slug, array( 'jetpack' ), true );
471    }
472
473    /**
474     * Take a raw search query and return something a bit more standardized and
475     * easy to work with.
476     *
477     * @param  string $term The raw search term.
478     * @return string A simplified/sanitized version.
479     */
480    private function sanitize_search_term( $term ) {
481        $term = strtolower( urldecode( $term ) );
482
483        // remove non-alpha/space chars.
484        $term = preg_replace( '/[^a-z ]/', '', $term );
485
486        // remove strings that don't help matches.
487        $term = trim( str_replace( array( 'jetpack', 'jp', 'free', 'wordpress' ), '', $term ) );
488
489        return $term;
490    }
491
492    /**
493     * Callback function to sort the array of modules by the sort option.
494     *
495     * @param array $m1 Array 1 to sort.
496     * @param array $m2 Array 2 to sort.
497     */
498    private function by_sorting_option( $m1, $m2 ) {
499        return $m1['sort'] <=> $m2['sort'];
500    }
501
502    /**
503     * Modify the URL to the feature settings, for example Publicize.
504     * Sharing is included here because while we still have a page in WP Admin,
505     * we prefer to send users to Calypso.
506     *
507     * @param string $feature Feature.
508     * @param string $configure_url URL to configure feature.
509     *
510     * @return string
511     * @since 7.1.0
512     */
513    private function get_configure_url( $feature, $configure_url ) {
514        switch ( $feature ) {
515            case 'sharing':
516            case 'publicize':
517                $configure_url = Redirect::get_url( 'calypso-marketing-connections' );
518                break;
519            case 'seo-tools':
520                $configure_url = Redirect::get_url(
521                    'calypso-marketing-traffic',
522                    array(
523                        'anchor' => 'seo',
524                    )
525                );
526                break;
527            case 'google-analytics':
528                $configure_url = Redirect::get_url(
529                    'calypso-marketing-traffic',
530                    array(
531                        'anchor' => 'analytics',
532                    )
533                );
534                break;
535            case 'wordads':
536                $configure_url = Redirect::get_url( 'wpcom-ads-settings' );
537                break;
538        }
539        return $configure_url;
540    }
541
542    /**
543     * Put some more appropriate links on our custom result cards.
544     *
545     * @param array $links Related links.
546     * @param array $plugin Plugin result information.
547     */
548    public function insert_module_related_links( $links, $plugin ) {
549        if ( self::$slug !== $plugin['slug'] ) {
550            return $links;
551        }
552
553        // By the time this filter is applied, self_admin_url was already applied and we don't need it anymore.
554        remove_filter( 'self_admin_url', array( $this, 'plugin_details' ) );
555
556        $links = array();
557
558        if ( 'sharing-block' === $plugin['module'] ) {
559            $links['jp_get_started'] = '<a
560                id="plugin-select-settings"
561                class="jetpack-plugin-search__primary jetpack-plugin-search__get-started button"
562                href="' . esc_url( admin_url( 'site-editor.php?path=%2Fwp_template' ) ) . '"
563                data-module="' . esc_attr( $plugin['module'] ) . '"
564                data-track="get_started"
565                >' . esc_html__( 'Add block', 'jetpack' ) . '</a>';
566        } elseif ( 'akismet' === $plugin['module'] || 'vaultpress' === $plugin['module'] ) {
567            $links['jp_get_started'] = '<a
568                id="plugin-select-settings"
569                class="jetpack-plugin-search__primary jetpack-plugin-search__get-started button"
570                href="' . esc_url( Redirect::get_url( 'plugin-hint-learn-' . $plugin['module'] ) ) . '"
571                data-module="' . esc_attr( $plugin['module'] ) . '"
572                data-track="get_started"
573                >' . esc_html__( 'Get started', 'jetpack' ) . '</a>';
574            // Jetpack installed, active, feature not enabled; prompt to enable.
575        } elseif (
576            current_user_can( 'jetpack_activate_modules' ) &&
577            ! Jetpack::is_module_active( $plugin['module'] ) &&
578            Jetpack_Plan::supports( $plugin['module'] )
579        ) {
580            $links[] = '<button
581                    id="plugin-select-activate"
582                    class="jetpack-plugin-search__primary button"
583                    data-module="' . esc_attr( $plugin['module'] ) . '"
584                    data-configure-url="' . esc_url( $this->get_configure_url( $plugin['module'], $plugin['configure_url'] ) ) . '"
585                    > ' . esc_html__( 'Enable', 'jetpack' ) . '</button>';
586
587            // Jetpack installed, active, feature enabled; link to settings.
588        } elseif (
589            ! empty( $plugin['configure_url'] ) &&
590            current_user_can( 'jetpack_configure_modules' ) &&
591            Jetpack::is_module_active( $plugin['module'] ) &&
592            /** This filter is documented in class.jetpack-admin.php */
593            apply_filters( 'jetpack_module_configurable_' . $plugin['module'], false )
594        ) {
595            $links[] = '<a
596                id="plugin-select-settings"
597                class="jetpack-plugin-search__primary button jetpack-plugin-search__configure"
598                href="' . esc_url( $this->get_configure_url( $plugin['module'], $plugin['configure_url'] ) ) . '"
599                data-module="' . esc_attr( $plugin['module'] ) . '"
600                data-track="configure"
601                >' . esc_html__( 'Configure', 'jetpack' ) . '</a>';
602            // Module is active, doesn't have options to configure.
603        } elseif ( Jetpack::is_module_active( $plugin['module'] ) ) {
604            $links['jp_get_started'] = '<a
605                id="plugin-select-settings"
606                class="jetpack-plugin-search__primary jetpack-plugin-search__get-started button"
607                href="' . esc_url( Redirect::get_url( 'plugin-hint-learn-' . $plugin['module'] ) ) . '"
608                data-module="' . esc_attr( $plugin['module'] ) . '"
609                data-track="get_started"
610                >' . esc_html__( 'Get started', 'jetpack' ) . '</a>';
611        }
612
613        // Add link pointing to a relevant doc page in jetpack.com only if the Get started button isn't displayed.
614        if ( ! empty( $plugin['learn_more_button'] ) && ! isset( $links['jp_get_started'] ) ) {
615            $links[] = '<a
616                class="jetpack-plugin-search__learn-more"
617                href="' . esc_url( $plugin['learn_more_button'] ) . '"
618                target="_blank"
619                data-module="' . esc_attr( $plugin['module'] ) . '"
620                data-track="learn_more"
621                >' . esc_html__( 'Learn more', 'jetpack' ) . '</a>';
622        }
623
624        // Dismiss link.
625        $links[] = '<a
626            class="jetpack-plugin-search__dismiss"
627            data-module="' . esc_attr( $plugin['module'] ) . '"
628            >' . esc_html__( 'Hide this suggestion', 'jetpack' ) . '</a>';
629
630        return $links;
631    }
632}
633
634/**
635 * Master control that checks if Plugin search hints is active.
636 *
637 * @since 7.1.1
638 *
639 * @return bool True if PSH is active.
640 */
641function jetpack_is_psh_active() {
642    /**
643     * Disables the Plugin Search Hints feature found when searching the plugins page.
644     *
645     * @since 8.7.0
646     *
647     * @param bool Set false to disable the feature.
648     */
649    return apply_filters( 'jetpack_psh_active', true );
650}