| @@ -88,19 +88,31 @@ | ||
| 88 | 88 | */ |
| 89 | 89 | public const THANKYOU_PROMPT_NOTICE_ID = 'srfm-thankyou-prompt'; |
| 90 | 90 | |
| 91 | 91 | /** |
| 92 | - * Where the dialog's Contact Support button goes. | |
| 92 | + * Where the Contact Support button writes to. | |
| 93 | 93 | * |
| 94 | - * A form rather than an inbox: it collects the licence and site details support | |
| 95 | - * would otherwise have to ask for, and the diagnostics are already on the | |
| 96 | - * clipboard by the time someone gets here. | |
| 94 | + * An inbox rather than a form, restoring the 2.12.6 behaviour. A mailto: opens | |
| 95 | + * the composer the person already has open with the subject and the whole | |
| 96 | + * report in the body, so reporting a fault is one click and a send. The | |
| 97 | + * troubleshooting form could carry neither, which is why 2.12.7 had to gate the | |
| 98 | + * button behind copying the diagnostics by hand first. | |
| 97 | 99 | * |
| 98 | - * @since 2.12.7 | |
| 100 | + * @since 2.12.8 | |
| 99 | 101 | */ |
| 100 | - private const SUPPORT_CONTACT_URL = 'https://sureforms.com/form/troubleshooting-form/'; | |
| 102 | + private const SUPPORT_EMAIL = '[email protected]'; | |
| 101 | 103 | |
| 102 | 104 | /** |
| 105 | + * Longest Contact Support mailto: URL we hand to a mail client. | |
| 106 | + * | |
| 107 | + * Below the roughly 2000-character limit the strictest common clients and | |
| 108 | + * browsers apply to a link, with room to spare. | |
| 109 | + * | |
| 110 | + * @since 2.12.8 | |
| 111 | + */ | |
| 112 | + private const SUPPORT_MAILTO_MAX_LENGTH = 1800; | |
| 113 | + | |
| 114 | + /** | |
| 103 | 115 | * Dashboard widget entries data. |
| 104 | 116 | * |
| 105 | 117 | * @var array |
| 106 | 118 | * @since 1.9.1 |
| @@ -177,9 +189,11 @@ | ||
| 177 | 189 | add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); |
| 178 | 190 | add_action( 'admin_menu', [ $this, 'settings_page' ] ); |
| 179 | 191 | add_action( 'admin_menu', [ $this, 'add_learn_page' ] ); |
| 180 | 192 | add_action( 'admin_menu', [ $this, 'add_new_form' ] ); |
| 181 | - add_action( 'admin_menu', [ $this, 'add_suremail_page' ] ); | |
| 193 | + if ( ! Helper::hide_promotions() ) { | |
| 194 | + add_action( 'admin_menu', [ $this, 'add_suremail_page' ] ); | |
| 195 | + } | |
| 182 | 196 | if ( ! Helper::has_pro() ) { |
| 183 | 197 | add_action( 'admin_menu', [ $this, 'add_quiz_page' ] ); |
| 184 | 198 | add_action( 'admin_menu', [ $this, 'add_survey_reports_page' ] ); |
| 185 | 199 | add_action( 'admin_menu', [ $this, 'add_partial_entries_page' ] ); |
| @@ -232,9 +246,8 @@ | ||
| 232 | 246 | add_action( 'wp_ajax_should_show_pointer', [ $this, 'pointer_should_show' ] ); |
| 233 | 247 | add_action( 'wp_ajax_sureforms_dismiss_pointer', [ $this, 'pointer_dismissed' ] ); |
| 234 | 248 | add_action( 'wp_ajax_sureforms_accept_cta', [ $this, 'pointer_accepted_cta' ] ); |
| 235 | 249 | add_action( 'wp_ajax_srfm_notice_response', [ $this, 'handle_notice_response' ] ); |
| 236 | - add_action( 'wp_ajax_srfm_action_item_details', [ $this, 'handle_action_item_details' ] ); | |
| 237 | 250 | add_action( 'wp_ajax_srfm_dismiss_action_item', [ $this, 'handle_dismiss_action_item' ] ); |
| 238 | 251 | add_action( 'admin_post_srfm_dismiss_action_item_link', [ $this, 'handle_dismiss_action_item_link' ] ); |
| 239 | 252 | add_action( 'wp_ajax_srfm_ai_widget_usage', [ $this, 'track_ai_widget_usage' ] ); |
| 240 | 253 | add_action( 'load-post.php', [ $this, 'maybe_track_edit_form_button_click' ] ); |
| @@ -627,9 +640,9 @@ | ||
| 627 | 640 | * @since 2.12.4 |
| 628 | 641 | * @return void |
| 629 | 642 | */ |
| 630 | 643 | public function register_form_setup_widget() { |
| 631 | - if ( ! Helper::current_user_can() ) { | |
| 644 | + if ( ! Helper::current_user_can() || Helper::hide_promotions() ) { | |
| 632 | 645 | return; |
| 633 | 646 | } |
| 634 | 647 | |
| 635 | 648 | if ( null === self::get_form_setup_card() ) { |
| @@ -734,9 +747,9 @@ | ||
| 734 | 747 | * @since 2.12.4 |
| 735 | 748 | * @return void |
| 736 | 749 | */ |
| 737 | 750 | public function enqueue_form_setup_widget_assets( $hook_suffix ) { |
| 738 | - if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) { | |
| 751 | + if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() || Helper::hide_promotions() ) { | |
| 739 | 752 | return; |
| 740 | 753 | } |
| 741 | 754 | |
| 742 | 755 | $card = self::get_form_setup_card(); |
| @@ -1295,12 +1308,9 @@ | ||
| 1295 | 1308 | public function add_quiz_page() { |
| 1296 | 1309 | add_submenu_page( |
| 1297 | 1310 | 'sureforms_menu', |
| 1298 | 1311 | __( 'Quiz Entries', 'sureforms' ), |
| 1299 | - __( 'Quizzes', 'sureforms' ) . | |
| 1300 | - ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' . | |
| 1301 | - esc_html__( 'New', 'sureforms' ) . | |
| 1302 | - '</span>', | |
| 1312 | + __( 'Quizzes', 'sureforms' ), | |
| 1303 | 1313 | self::$sureforms_page_default_capability, |
| 1304 | 1314 | 'sureforms_quiz_entries', |
| 1305 | 1315 | [ $this, 'render_quiz_empty_state' ], |
| 1306 | 1316 | 5 |
| @@ -1328,12 +1338,9 @@ | ||
| 1328 | 1338 | public function add_survey_reports_page() { |
| 1329 | 1339 | add_submenu_page( |
| 1330 | 1340 | 'sureforms_menu', |
| 1331 | 1341 | __( 'Survey Reports', 'sureforms' ), |
| 1332 | - __( 'Survey Reports', 'sureforms' ) . | |
| 1333 | - ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' . | |
| 1334 | - esc_html__( 'New', 'sureforms' ) . | |
| 1335 | - '</span>', | |
| 1342 | + __( 'Survey Reports', 'sureforms' ), | |
| 1336 | 1343 | self::$sureforms_page_default_capability, |
| 1337 | 1344 | 'sureforms_survey_reports', |
| 1338 | 1345 | [ $this, 'render_survey_empty_state' ], |
| 1339 | 1346 | 6 |
| @@ -1361,12 +1368,9 @@ | ||
| 1361 | 1368 | public function add_partial_entries_page() { |
| 1362 | 1369 | add_submenu_page( |
| 1363 | 1370 | 'sureforms_menu', |
| 1364 | 1371 | __( 'Partial Entries', 'sureforms' ), |
| 1365 | - __( 'Partial Entries', 'sureforms' ) . | |
| 1366 | - ' <span style="color:#4ADE80;font-size:9px;font-weight:600;">' . | |
| 1367 | - esc_html__( 'New', 'sureforms' ) . | |
| 1368 | - '</span>', | |
| 1372 | + __( 'Partial Entries', 'sureforms' ), | |
| 1369 | 1373 | self::$sureforms_page_default_capability, |
| 1370 | 1374 | 'sureforms_partial_entries', |
| 1371 | 1375 | [ $this, 'render_partial_entries_empty_state' ], |
| 1372 | 1376 | 7 |
| @@ -1814,20 +1818,14 @@ | ||
| 1814 | 1818 | 'sureforms_pricing_page' => Helper::get_sureforms_website_url( 'pricing' ), |
| 1815 | 1819 | 'field_spacing_vars' => Helper::get_css_vars(), |
| 1816 | 1820 | 'is_ver_lower_than_6_7' => version_compare( $wp_version, '6.6.2', '<=' ), |
| 1817 | 1821 | 'integrations' => Helper::sureforms_get_integration(), |
| 1818 | - 'rotating_plugin_banner' => Helper::get_rotating_plugin_banner(), | |
| 1822 | + 'hide_promotions' => Helper::hide_promotions(), | |
| 1823 | + // Null makes the dashboard's ExtendTab render nothing. | |
| 1824 | + 'rotating_plugin_banner' => Helper::hide_promotions() ? null : Helper::get_rotating_plugin_banner(), | |
| 1819 | 1825 | 'ajax_url' => admin_url( 'admin-ajax.php' ), |
| 1820 | 1826 | 'client_logs_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_client_logs' ) : '', |
| 1821 | 1827 | 'action_items' => $this->get_action_items(), |
| 1822 | - 'details_dialog' => $this->get_details_dialog_labels(), | |
| 1823 | - // Where Contact Support goes when the details fetch fails and there is | |
| 1824 | - // no category-tagged URL to use. Untagged, because at that point we do | |
| 1825 | - // not know which check sent them -- but still a way out: these notices | |
| 1826 | - // are not dismissible and Contact Support is the only action that | |
| 1827 | - // retires them. | |
| 1828 | - 'support_url' => $this->get_support_contact_url( '' ), | |
| 1829 | - 'action_item_details_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_action_item_details' ) : '', | |
| 1830 | 1828 | 'notice_response_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_notice_response' ) : '', |
| 1831 | 1829 | 'dismiss_action_item_nonce' => Helper::current_user_can() ? wp_create_nonce( 'srfm_dismiss_action_item' ) : '', |
| 1832 | 1830 | 'sf_plugin_manager_nonce' => wp_create_nonce( 'sf_plugin_manager_nonce' ), |
| 1833 | 1831 | 'plugin_installer_nonce' => wp_create_nonce( 'updates' ), |
| @@ -1838,8 +1836,12 @@ | ||
| 1838 | 1836 | 'plugin_installed_text' => __( 'Installed', 'sureforms' ), |
| 1839 | 1837 | 'privacy_policy_url' => Helper::get_sureforms_website_url( 'privacy-policy/' ), |
| 1840 | 1838 | 'is_rtl' => $is_rtl, |
| 1841 | 1839 | 'onboarding_completed' => method_exists( $onboarding_instance, 'get_onboarding_status' ) ? $onboarding_instance->get_onboarding_status() : false, |
| 1840 | + // Read by the onboarding cache-conflict step: the name decides whether the | |
| 1841 | + // step renders, the URL is where "View full guide" points. | |
| 1842 | + 'caching_plugin' => Helper::get_active_caching_plugin(), | |
| 1843 | + 'caching_plugin_doc_url' => Helper::get_caching_plugin_doc_url( 'onboarding' ), | |
| 1842 | 1844 | 'migration_banner_dismissed' => method_exists( $onboarding_instance, 'is_migration_banner_dismissed' ) ? $onboarding_instance->is_migration_banner_dismissed() : false, |
| 1843 | 1845 | 'migration_settings_url' => admin_url( 'admin.php?page=sureforms_form_settings&tab=migration-settings' ), |
| 1844 | 1846 | 'onboarding_redirect' => isset( $_GET['srfm-activation-redirect'] ), // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce is not required for the activation redirection. |
| 1845 | 1847 | 'pointer_nonce' => wp_create_nonce( 'sureforms_pointer_action' ), |
| @@ -2673,10 +2675,10 @@ | ||
| 2673 | 2675 | if ( ! Helper::current_user_can() ) { |
| 2674 | 2676 | return; |
| 2675 | 2677 | } |
| 2676 | 2678 | |
| 2677 | - // Allow the notice to be disabled. | |
| 2678 | - if ( ! apply_filters( 'srfm_show_rating_notice', true ) ) { | |
| 2679 | + // Allow the notice to be disabled; never shown while promotions are hidden. | |
| 2680 | + if ( Helper::hide_promotions() || ! apply_filters( 'srfm_show_rating_notice', true ) ) { | |
| 2679 | 2681 | return; |
| 2680 | 2682 | } |
| 2681 | 2683 | |
| 2682 | 2684 | $notice_id = 'srfm-plugin-review-notice'; |
| @@ -2817,94 +2819,25 @@ | ||
| 2817 | 2819 | wp_localize_script( |
| 2818 | 2820 | 'srfm-notice-response', |
| 2819 | 2821 | 'srfmNoticeResponse', |
| 2820 | 2822 | [ |
| 2821 | - 'ajaxurl' => admin_url( 'admin-ajax.php' ), | |
| 2822 | - 'nonce' => wp_create_nonce( 'srfm_notice_response' ), | |
| 2823 | - // The diagnostics are fetched when the dialog opens rather than | |
| 2824 | - // shipped with every page, so the dialog needs its own nonce. | |
| 2825 | - 'detailsNonce' => wp_create_nonce( 'srfm_action_item_details' ), | |
| 2823 | + 'ajaxurl' => admin_url( 'admin-ajax.php' ), | |
| 2824 | + 'nonce' => wp_create_nonce( 'srfm_notice_response' ), | |
| 2826 | 2825 | // Carousel chrome. Built in the browser rather than printed here so |
| 2827 | 2826 | // that with JavaScript off every notice simply stays visible, which |
| 2828 | 2827 | // is the behaviour this replaced -- controls that cannot work must |
| 2829 | 2828 | // not be what hides a warning. |
| 2830 | - 'carousel' => [ | |
| 2829 | + 'carousel' => [ | |
| 2831 | 2830 | 'previous' => __( 'Previous notice', 'sureforms' ), |
| 2832 | 2831 | 'next' => __( 'Next notice', 'sureforms' ), |
| 2833 | 2832 | /* translators: 1: current position, 2: total notices. */ |
| 2834 | 2833 | 'counter' => __( '%1$d of %2$d', 'sureforms' ), |
| 2835 | 2834 | ], |
| 2836 | - // Details modal chrome, translated here so the script carries no | |
| 2837 | - // user-facing English of its own. | |
| 2838 | - 'details' => $this->get_details_dialog_labels(), | |
| 2839 | - // Where Contact Support goes when the fetch fails and there is no | |
| 2840 | - // category-tagged URL to use. Untagged, because at that point we do | |
| 2841 | - // not know which check sent them -- but still a way out: these | |
| 2842 | - // notices are not dismissible and Contact Support is the only action | |
| 2843 | - // that retires them. | |
| 2844 | - 'supportUrl' => $this->get_support_contact_url( '' ), | |
| 2845 | 2835 | ] |
| 2846 | 2836 | ); |
| 2847 | 2837 | } |
| 2848 | 2838 | |
| 2849 | 2839 | /** |
| 2850 | - * Serve one failure category's diagnostics, on demand. | |
| 2851 | - * | |
| 2852 | - * Hooked - wp_ajax_srfm_action_item_details. | |
| 2853 | - * | |
| 2854 | - * The report is built here rather than shipped with the page. Its content | |
| 2855 | - * comes from the client error log, and that log is filled through a public | |
| 2856 | - * REST route gated on a submit token any visitor can obtain from a form page | |
| 2857 | - * rather than on a capability -- so the text is attacker-authored, and putting | |
| 2858 | - * it in the localisation JSON and a hidden div on every admin screen exposed | |
| 2859 | - * it far beyond the one admin who opens the dialog. | |
| 2860 | - * | |
| 2861 | - * Capability first, then nonce, then the category, matching the ordering of | |
| 2862 | - * the sibling handlers in this class. | |
| 2863 | - * | |
| 2864 | - * @since 2.12.7 | |
| 2865 | - * @return void | |
| 2866 | - */ | |
| 2867 | - public function handle_action_item_details() { | |
| 2868 | - if ( ! Helper::current_user_can() ) { | |
| 2869 | - wp_send_json_error( [ 'message' => __( 'Unauthorized user.', 'sureforms' ) ], 403 ); | |
| 2870 | - return; | |
| 2871 | - } | |
| 2872 | - | |
| 2873 | - if ( ! check_ajax_referer( 'srfm_action_item_details', 'nonce', false ) ) { | |
| 2874 | - wp_send_json_error( [ 'message' => __( 'Invalid nonce.', 'sureforms' ) ], 403 ); | |
| 2875 | - return; | |
| 2876 | - } | |
| 2877 | - | |
| 2878 | - // sanitize_key() returns '' for anything non-scalar (formatting.php:2194), so | |
| 2879 | - // a category[]= in the body arrives here as the empty string and falls into | |
| 2880 | - // the refusal below rather than needing a type branch of its own. | |
| 2881 | - $category = isset( $_POST['category'] ) ? sanitize_key( wp_unslash( $_POST['category'] ) ) : ''; | |
| 2882 | - | |
| 2883 | - // The only check the category needs, and the reason there is no separate | |
| 2884 | - // allowlist above it: get_open_failures() returns nothing but keys in | |
| 2885 | - // Client_Logger::CATEGORIES, so an absent category, an unrecognised one and | |
| 2886 | - // a recognised one with nothing wrong all land here. Asking for a category | |
| 2887 | - // with no fault must not mint a report describing one. | |
| 2888 | - $open = Client_Logger::get_open_failures(); | |
| 2889 | - | |
| 2890 | - if ( ! isset( $open[ $category ] ) ) { | |
| 2891 | - wp_send_json_error( [ 'message' => __( 'Nothing to report.', 'sureforms' ) ], 404 ); | |
| 2892 | - return; | |
| 2893 | - } | |
| 2894 | - | |
| 2895 | - $form_title = Helper::get_string_value( $open[ $category ]['form_title'] ?? '' ); | |
| 2896 | - | |
| 2897 | - wp_send_json_success( | |
| 2898 | - [ | |
| 2899 | - 'details' => $this->get_support_message( $category, $form_title ) | |
| 2900 | - . "\n\n" . $this->get_support_log_block( 8000 ), | |
| 2901 | - 'support_url' => $this->get_support_contact_url( $category ), | |
| 2902 | - ] | |
| 2903 | - ); | |
| 2904 | - } | |
| 2905 | - | |
| 2906 | - /** | |
| 2907 | 2840 | * Handle the notice response AJAX request. |
| 2908 | 2841 | * |
| 2909 | 2842 | * Validates the request and records the analytics event |
| 2910 | 2843 | * for the notice button that was clicked. |
| @@ -2946,23 +2879,17 @@ | ||
| 2946 | 2879 | 'dismissed' => 'database_error_notice_dismiss', |
| 2947 | 2880 | ], |
| 2948 | 2881 | // The "Finish setting up" prompt (#3030): three CTAs, plus the ✕. |
| 2949 | 2882 | 'form_submission_error' => [ |
| 2950 | - 'view_details' => 'submission_failure_notice_view', | |
| 2951 | - 'copy_details' => 'submission_failure_notice_copy', | |
| 2952 | 2883 | 'contact_support' => 'submission_failure_notice_cta', |
| 2953 | 2884 | 'dismissed' => 'submission_failure_notice_dismiss', |
| 2954 | 2885 | ], |
| 2955 | 2886 | 'notification_error' => [ |
| 2956 | - 'view_details' => 'notification_failure_notice_view', | |
| 2957 | - 'copy_details' => 'notification_failure_notice_copy', | |
| 2958 | 2887 | 'contact_support' => 'notification_failure_notice_cta', |
| 2959 | 2888 | 'help_me_fix' => 'notification_failure_notice_guide', |
| 2960 | 2889 | 'dismissed' => 'notification_failure_notice_dismiss', |
| 2961 | 2890 | ], |
| 2962 | 2891 | 'integration_error' => [ |
| 2963 | - 'view_details' => 'integration_failure_notice_view', | |
| 2964 | - 'copy_details' => 'integration_failure_notice_copy', | |
| 2965 | 2892 | 'contact_support' => 'integration_failure_notice_cta', |
| 2966 | 2893 | 'dismissed' => 'integration_failure_notice_dismiss', |
| 2967 | 2894 | ], |
| 2968 | 2895 | 'caching_plugin' => [ |
| @@ -2987,10 +2914,12 @@ | ||
| 2987 | 2914 | |
| 2988 | 2915 | $this->track_notice_event( $valid[ $notice_id ][ $button ] ); |
| 2989 | 2916 | |
| 2990 | 2917 | // Reporting the failures retires the notice until something new fails. |
| 2991 | - // Handled here rather than in the browser so it holds for the classic | |
| 2992 | - // wp-admin notice too, which is a plain link with no JavaScript. | |
| 2918 | + // Handled here rather than in the browser so both surfaces share it. The | |
| 2919 | + // click still reaches here only through JavaScript -- notice-response.js | |
| 2920 | + // on the classic notice, ActionItems.js on the dashboard -- so with | |
| 2921 | + // JavaScript off the link opens the email but the notice stays. | |
| 2993 | 2922 | $categories = [ |
| 2994 | 2923 | 'form_submission_error' => 'submission', |
| 2995 | 2924 | 'notification_error' => 'notification', |
| 2996 | 2925 | 'integration_error' => 'integration', |
| @@ -3147,10 +3076,11 @@ | ||
| 3147 | 3076 | * @since 1.9.1 |
| 3148 | 3077 | */ |
| 3149 | 3078 | public function maybe_register_dashboard_widget() { |
| 3150 | 3079 | |
| 3151 | - // Only for users with manage_options capability. | |
| 3152 | - if ( ! Helper::current_user_can() ) { | |
| 3080 | + // Only for users with manage_options capability, and never while | |
| 3081 | + // promotions are hidden: no SureForms widget on the WordPress dashboard. | |
| 3082 | + if ( ! Helper::current_user_can() || Helper::hide_promotions() ) { | |
| 3153 | 3083 | return; |
| 3154 | 3084 | } |
| 3155 | 3085 | |
| 3156 | 3086 | // Register the AI quick draft widget for capable users (the capability gate above applies); unlike the recent-entries widget below, it is not conditional on having entries. |
| @@ -3253,9 +3183,9 @@ | ||
| 3253 | 3183 | * @since 2.12.1 |
| 3254 | 3184 | */ |
| 3255 | 3185 | public function enqueue_ai_dashboard_widget_assets( $hook_suffix ) { |
| 3256 | 3186 | // Only on the main dashboard, and only for capable users (matches the widget gate). |
| 3257 | - if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() ) { | |
| 3187 | + if ( 'index.php' !== $hook_suffix || ! Helper::current_user_can() || Helper::hide_promotions() ) { | |
| 3258 | 3188 | return; |
| 3259 | 3189 | } |
| 3260 | 3190 | |
| 3261 | 3191 | // Register an inline-only handle (empty src) — the WordPress-core pattern for attaching |
| @@ -3626,19 +3556,12 @@ | ||
| 3626 | 3556 | class="<?php echo $has_guide ? 'button' : 'button button-primary'; ?>" |
| 3627 | 3557 | data-srfm-notice-id="<?php echo esc_attr( Helper::get_string_value( $item['id'] ) ); ?>" |
| 3628 | 3558 | data-srfm-button="<?php echo esc_attr( Helper::get_string_value( $item['cta_action'] ?? '' ) ); ?>" |
| 3629 | 3559 | <?php |
| 3630 | - // With details to fetch, the click opens them here instead | |
| 3631 | - // of following the href. The href stays as the no-JS | |
| 3632 | - // path: it goes to the dashboard, where the same details | |
| 3633 | - // are readable. | |
| 3634 | - if ( ! empty( $item['has_details'] ) ) { | |
| 3635 | - printf( | |
| 3636 | - 'data-srfm-details-for="%1$s" data-srfm-category="%2$s"', | |
| 3637 | - esc_attr( Helper::get_string_value( $item['id'] ) ), | |
| 3638 | - esc_attr( Helper::get_string_value( $item['category'] ?? '' ) ) | |
| 3639 | - ); | |
| 3640 | - } elseif ( 0 !== strpos( Helper::get_string_value( $item['cta_url'] ), 'mailto:' ) ) { | |
| 3560 | + // A mailto: must reach the mail client, not a new tab -- | |
| 3561 | + // there is no document to open, so _blank leaves a blank | |
| 3562 | + // one behind. | |
| 3563 | + if ( 0 !== strpos( Helper::get_string_value( $item['cta_url'] ), 'mailto:' ) ) { | |
| 3641 | 3564 | echo 'target="_blank" rel="noopener noreferrer"'; |
| 3642 | 3565 | } |
| 3643 | 3566 | ?> |
| 3644 | 3567 | > |
| @@ -3785,12 +3708,11 @@ | ||
| 3785 | 3708 | * cta_label, cta_url and dismissible. Only ids in |
| 3786 | 3709 | * handle_dismiss_action_item()'s allowlist can actually be dismissed, so |
| 3787 | 3710 | * adding a dismissible item here also needs a line there. |
| 3788 | 3711 | * |
| 3789 | - * The details dialog is not available here: it is served by | |
| 3790 | - * handle_action_item_details(), which reads SureForms' own client error log | |
| 3791 | - * and knows nothing about a third-party item. Such an item's cta_url is | |
| 3792 | - * followed as a link, which is what it does with JavaScript off anyway. | |
| 3712 | + * A third-party item's cta_url is followed as a plain link. The prefilled | |
| 3713 | + * support email is built only for SureForms' own failure items, from its own | |
| 3714 | + * client error log. | |
| 3793 | 3715 | * |
| 3794 | 3716 | * @since 2.12.6 |
| 3795 | 3717 | * |
| 3796 | 3718 | * @param array<int,array<string,mixed>> $items Action items. |
| @@ -3829,13 +3751,14 @@ | ||
| 3829 | 3751 | [ 'http', 'https', 'mailto' ] |
| 3830 | 3752 | ); |
| 3831 | 3753 | } |
| 3832 | 3754 | |
| 3833 | - // The id ends up in a data attribute the dialog matches on with an | |
| 3834 | - // attribute selector, and in the dismiss allowlist. sanitize_key() is | |
| 3835 | - // what both dismiss paths already apply, so applying it once here means | |
| 3836 | - // the value that renders is the value they compare against -- and a | |
| 3837 | - // filter-contributed id carrying a quote cannot break the selector. | |
| 3755 | + // The id ends up in the notice's data-srfm-notice-id attribute, which | |
| 3756 | + // notice-response.js matches on, and in the dismiss allowlist. | |
| 3757 | + // sanitize_key() is what both dismiss paths already apply, so applying | |
| 3758 | + // it once here means the value that renders is the value they compare | |
| 3759 | + // against -- and a filter-contributed id carrying a quote cannot break | |
| 3760 | + // the selector. | |
| 3838 | 3761 | if ( isset( $item['id'] ) ) { |
| 3839 | 3762 | $items[ $index ]['id'] = sanitize_key( Helper::get_string_value( $item['id'] ) ); |
| 3840 | 3763 | } |
| 3841 | 3764 | } |
| @@ -3877,17 +3800,16 @@ | ||
| 3877 | 3800 | wp_send_json_success(); |
| 3878 | 3801 | } |
| 3879 | 3802 | |
| 3880 | 3803 | /** |
| 3881 | - * The stylesheet for the notice carousel and the details dialog. | |
| 3804 | + * The stylesheet for the notice carousel. | |
| 3882 | 3805 | * |
| 3883 | 3806 | * In a stylesheet rather than inline style assignments in |
| 3884 | - * notice-response.js, so the rules use logical properties, an RTL sheet can | |
| 3885 | - * override them, and a site can restyle the dialog without patching a script. | |
| 3807 | + * notice-response.js, so the rules use logical properties and an RTL sheet can | |
| 3808 | + * override them. | |
| 3886 | 3809 | * |
| 3887 | - * Only the classic wp-admin surface needs these. The SureForms dashboard's | |
| 3888 | - * dialog is force-ui's, styled by the Tailwind build, so nothing here reaches | |
| 3889 | - * it -- the two surfaces share their strings, not their markup. | |
| 3810 | + * Only the classic wp-admin surface needs these. The SureForms dashboard is | |
| 3811 | + * styled by the Tailwind build, so nothing here reaches it. | |
| 3890 | 3812 | * |
| 3891 | 3813 | * Attached to a registered handle with no file of its own, which is the WP way |
| 3892 | 3814 | * to ship CSS tied to one script. |
| 3893 | 3815 | * |
| @@ -3898,15 +3820,8 @@ | ||
| 3898 | 3820 | * rendered expanded before collapsing to one, the carousel controls overlapped |
| 3899 | 3821 | * the notice text, and the defensive `display: none` on the hidden payload was |
| 3900 | 3822 | * inert, which is the exact window that rule exists for. |
| 3901 | 3823 | * |
| 3902 | - * The buttons are painted explicitly. They carry core's `button` classes for | |
| 3903 | - * their shape and focus behaviour, and core paints those with | |
| 3904 | - * `var(--wp-admin-theme-color)` -- so without this the dialog renders in | |
| 3905 | - * whichever admin colour scheme the user picked, which on a default install is | |
| 3906 | - * blue, on a SureForms panel that is otherwise entirely brand orange. Same | |
| 3907 | - * approach and same values as print_srfm_notice_styles(). | |
| 3908 | - * | |
| 3909 | 3824 | * @since 2.12.7 |
| 3910 | 3825 | * @return void |
| 3911 | 3826 | */ |
| 3912 | 3827 | public function enqueue_action_item_styles() { |
| @@ -3955,106 +3870,8 @@ | ||
| 3955 | 3870 | display: flex; |
| 3956 | 3871 | align-items: center; |
| 3957 | 3872 | gap: 8px; |
| 3958 | 3873 | } |
| 3959 | -.srfm-details-overlay { | |
| 3960 | - position: fixed; | |
| 3961 | - inset: 0; | |
| 3962 | - z-index: 999999; | |
| 3963 | - display: flex; | |
| 3964 | - align-items: center; | |
| 3965 | - justify-content: center; | |
| 3966 | - background: rgba(0, 0, 0, .5); | |
| 3967 | - padding: 16px; | |
| 3968 | -} | |
| 3969 | -.srfm-details-panel { | |
| 3970 | - background: #fff; | |
| 3971 | - border-radius: 8px; | |
| 3972 | - padding: 16px; | |
| 3973 | - width: 100%; | |
| 3974 | - max-width: 800px; | |
| 3975 | - box-shadow: 0 10px 30px rgba(0, 0, 0, .2); | |
| 3976 | -} | |
| 3977 | -.srfm-details-panel h2 { margin: 0 0 4px; font-size: 14px; } | |
| 3978 | -.srfm-details-panel .srfm-details-description { margin: 0 0 12px; color: #50575e; } | |
| 3979 | -.srfm-details-panel pre { | |
| 3980 | - margin: 0; | |
| 3981 | - max-height: 320px; | |
| 3982 | - overflow: auto; | |
| 3983 | - white-space: pre-wrap; | |
| 3984 | - word-break: break-word; | |
| 3985 | - background: #f6f7f7; | |
| 3986 | - padding: 12px; | |
| 3987 | - border-radius: 6px; | |
| 3988 | - font-size: 12px; | |
| 3989 | -} | |
| 3990 | -.srfm-details-actions { | |
| 3991 | - display: flex; | |
| 3992 | - gap: 8px; | |
| 3993 | - align-items: center; | |
| 3994 | - flex-wrap: wrap; | |
| 3995 | - justify-content: flex-end; | |
| 3996 | - margin: 12px 0 0; | |
| 3997 | -} | |
| 3998 | -.srfm-details-hint { | |
| 3999 | - margin-inline-end: auto; | |
| 4000 | - font-size: 12px; | |
| 4001 | - color: #4b5563; | |
| 4002 | -} | |
| 4003 | -/* Core paints .button with the admin colour scheme, so these say what they are | |
| 4004 | - rather than inheriting whichever scheme the user picked. */ | |
| 4005 | -.srfm-details-panel .srfm-details-close.button-link { | |
| 4006 | - color: #50575e; | |
| 4007 | - text-decoration: none; | |
| 4008 | -} | |
| 4009 | -.srfm-details-panel .srfm-details-close.button-link:hover, | |
| 4010 | -.srfm-details-panel .srfm-details-close.button-link:focus { | |
| 4011 | - color: #1e1e1e; | |
| 4012 | -} | |
| 4013 | -.srfm-details-panel .srfm-details-copy.button { | |
| 4014 | - background: #fff; | |
| 4015 | - border-color: #c3c4c7; | |
| 4016 | - color: #1e1e1e; | |
| 4017 | -} | |
| 4018 | -.srfm-details-panel .srfm-details-copy.button:hover, | |
| 4019 | -.srfm-details-panel .srfm-details-copy.button:focus { | |
| 4020 | - background: #f6f7f7; | |
| 4021 | - border-color: #8c8f94; | |
| 4022 | - color: #1e1e1e; | |
| 4023 | -} | |
| 4024 | -.srfm-details-panel .srfm-details-contact.button-primary, | |
| 4025 | -.srfm-details-panel .srfm-details-contact.button-primary:hover, | |
| 4026 | -.srfm-details-panel .srfm-details-contact.button-primary:focus { | |
| 4027 | - background: #D54407; | |
| 4028 | - border-color: #D54407; | |
| 4029 | - color: #fff; | |
| 4030 | - box-shadow: none; | |
| 4031 | - text-shadow: none; | |
| 4032 | - text-decoration: none; | |
| 4033 | -} | |
| 4034 | -.srfm-details-panel .srfm-details-contact.button-primary:hover, | |
| 4035 | -.srfm-details-panel .srfm-details-contact.button-primary:focus { | |
| 4036 | - background: #C83B00; | |
| 4037 | - border-color: #C83B00; | |
| 4038 | -} | |
| 4039 | -/* Grey rather than a dimmed orange fill. Core sets the disabled text colour with | |
| 4040 | - !important, so an orange background here leaves grey on orange at 1.31:1 -- | |
| 4041 | - and a control that cannot be used should not wear the primary colour anyway. | |
| 4042 | - This is what core gives every other disabled button, and what force-ui renders | |
| 4043 | - for the same state on the dashboard, so the two surfaces agree. */ | |
| 4044 | -.srfm-details-panel .srfm-details-contact.button-primary[aria-disabled="true"], | |
| 4045 | -.srfm-details-panel .srfm-details-contact.button-primary[aria-disabled="true"]:hover, | |
| 4046 | -.srfm-details-panel .srfm-details-contact.button-primary[aria-disabled="true"]:focus { | |
| 4047 | - background: #f6f7f7; | |
| 4048 | - border-color: #dcdcde; | |
| 4049 | - pointer-events: none; | |
| 4050 | - box-shadow: none; | |
| 4051 | -} | |
| 4052 | -.srfm-details-panel .button:focus { | |
| 4053 | - outline: 2px solid #D54407; | |
| 4054 | - outline-offset: 1px; | |
| 4055 | - box-shadow: none; | |
| 4056 | -} | |
| 4057 | 3874 | CSS; |
| 4058 | 3875 | |
| 4059 | 3876 | wp_add_inline_style( 'srfm-action-items', $css ); |
| 4060 | 3877 | } |
| @@ -4059,50 +3876,8 @@ | ||
| 4059 | 3876 | wp_add_inline_style( 'srfm-action-items', $css ); |
| 4060 | 3877 | } |
| 4061 | 3878 | |
| 4062 | 3879 | /** |
| 4063 | - * The details dialog's strings. | |
| 4064 | - * | |
| 4065 | - * One array, two consumers: the classic wp-admin dialog in | |
| 4066 | - * notice-response.js, and the dashboard's force-ui one. Declared here rather | |
| 4067 | - * than inline in each, because the same sentence written as `__()` in PHP and | |
| 4068 | - * again in JSX looks identical to translators until the first edit to either, | |
| 4069 | - * after which one surface silently reverts to English. | |
| 4070 | - * | |
| 4071 | - * @since 2.12.7 | |
| 4072 | - * @return array<string,string> | |
| 4073 | - */ | |
| 4074 | - private function get_details_dialog_labels() { | |
| 4075 | - return [ | |
| 4076 | - 'title' => __( 'Details', 'sureforms' ), | |
| 4077 | - 'description' => __( 'What we recorded about this problem. Copy it into your support request so we can start from the cause rather than a description of it.', 'sureforms' ), | |
| 4078 | - 'copy' => __( 'Copy details', 'sureforms' ), | |
| 4079 | - 'copied' => __( 'Copied', 'sureforms' ), | |
| 4080 | - 'contact' => __( 'Contact Support', 'sureforms' ), | |
| 4081 | - 'close' => __( 'Close', 'sureforms' ), | |
| 4082 | - // Shown beside the buttons rather than as a title attribute: | |
| 4083 | - // pointer-events:none suppresses the native tooltip, a title | |
| 4084 | - // never fires on keyboard focus, and screen readers commonly | |
| 4085 | - // drop it on an unavailable control -- so the sentence saying | |
| 4086 | - // why the button is inert could not be read by anyone. | |
| 4087 | - 'copyFirst' => __( 'Copy the details first, so you have them to paste.', 'sureforms' ), | |
| 4088 | - // The unlock changes the label, the icon and whether Contact | |
| 4089 | - // Support works, none of which was announced. This goes in a | |
| 4090 | - // role="status" node so it is. | |
| 4091 | - 'unlocked' => __( 'Copied. Contact Support is now available.', 'sureforms' ), | |
| 4092 | - 'copyFailed' => __( 'Your browser would not let us copy. Select the text above and copy it by hand.', 'sureforms' ), | |
| 4093 | - // The scrollable diagnostics block is focusable, so it needs a name of | |
| 4094 | - // its own. | |
| 4095 | - 'logRegion' => __( 'Recorded diagnostics', 'sureforms' ), | |
| 4096 | - // The dialog opens before its payload arrives -- see | |
| 4097 | - // handle_action_item_details() for why the report is not shipped with | |
| 4098 | - // the page. | |
| 4099 | - 'loading' => __( 'Collecting the details…', 'sureforms' ), | |
| 4100 | - 'unavailable' => __( 'We could not collect the details. Contact Support and describe what happened, and we will take it from there.', 'sureforms' ), | |
| 4101 | - ]; | |
| 4102 | - } | |
| 4103 | - | |
| 4104 | - /** | |
| 4105 | 3880 | * SureForms' own action items, before the filter. |
| 4106 | 3881 | * |
| 4107 | 3882 | * Split out so the Enable Logs gate in get_action_items() can sit above this |
| 4108 | 3883 | * rather than above `srfm_action_items`. An item contributed through that |
| @@ -4169,32 +3944,26 @@ | ||
| 4169 | 3944 | 'title' => '' !== $form_title |
| 4170 | 3945 | ? sprintf( $copy['title'], $form_title ) |
| 4171 | 3946 | : $copy['generic'], |
| 4172 | 3947 | 'message' => $copy['message'], |
| 4173 | - // Shows what would be sent before anything is sent. Someone reporting | |
| 4174 | - // a fault on their own site is entitled to read the diagnostics and | |
| 4175 | - // the log first, and a support agent gets a cleaner paste than a | |
| 4176 | - // screenshot of a notice. | |
| 4177 | - 'cta_label' => __( 'View details', 'sureforms' ), | |
| 4178 | - // Where the classic wp-admin notice sends people, since it cannot open | |
| 4179 | - // the panel's dialog. The dashboard is where the details are readable. | |
| 4180 | - 'cta_url' => admin_url( 'admin.php?page=sureforms_menu' ), | |
| 4181 | - 'cta_action' => 'view_details', | |
| 4182 | - // Not the payload itself, only that one exists. The diagnostics are | |
| 4183 | - // fetched when the dialog opens -- see handle_action_item_details(). | |
| 3948 | + // Straight to a composed email, as 2.12.6 did. The subject, the | |
| 3949 | + // diagnostics and the log tail are already in it, so reporting a | |
| 3950 | + // fault is one click and a send. | |
| 4184 | 3951 | // |
| 4185 | - // They used to ride along in the localisation JSON and in a hidden | |
| 4186 | - // div on every admin page. The content is authored by whoever | |
| 4187 | - // triggered the failure, and the client-error-log route is a public | |
| 4188 | - // endpoint gated on a submit token rather than a capability, so an | |
| 4189 | - // anonymous visitor can fill that excerpt. Broadcasting it to every | |
| 4190 | - // admin screen -- read or not -- put attacker-authored text in page | |
| 4191 | - // source site-wide and made any future escaping slip a | |
| 4192 | - // manage_options-context problem. On demand, it reaches only the | |
| 4193 | - // admin who asked for it. | |
| 4194 | - 'has_details' => true, | |
| 4195 | - // Which record to fetch. Not the payload, just the key. | |
| 4196 | - 'category' => $category, | |
| 3952 | + // Built when the page renders, so the report ships in the href of the | |
| 3953 | + // classic notice on every admin screen and in srfm_admin.action_items | |
| 3954 | + // on the dashboard, both for capable users only. Its log comes from | |
| 3955 | + // the client error log, which any visitor with a form's submit token | |
| 3956 | + // can write to, so treat it as untrusted text. It is inert here: | |
| 3957 | + // http_build_query() percent-encodes all of it, so it cannot break | |
| 3958 | + // out of the attribute or add &cc= / &bcc= to the mailto:, and the | |
| 3959 | + // URL is length-capped. Building it on click instead would bring back | |
| 3960 | + // an AJAX round trip and a nonce to open an email -- the 2.12.7 | |
| 3961 | + // dialog's machinery -- for text the person reads in the composer | |
| 3962 | + // before anything is sent. | |
| 3963 | + 'cta_label' => __( 'Contact Support', 'sureforms' ), | |
| 3964 | + 'cta_url' => $this->get_support_contact_url( $category, $form_title ), | |
| 3965 | + 'cta_action' => 'contact_support', | |
| 4197 | 3966 | 'dismissible' => false, |
| 4198 | 3967 | ]; |
| 4199 | 3968 | |
| 4200 | 3969 | // A second, optional action. Absent keys render nothing, so a category |
| @@ -4769,11 +4538,13 @@ | ||
| 4769 | 4538 | private function is_admin_pointer_visible() { |
| 4770 | 4539 | global $pagenow; |
| 4771 | 4540 | $allowed_pages = [ 'index.php', 'options-general.php' ]; |
| 4772 | 4541 | |
| 4773 | - // Do not show if pointer dismissed, accepted, or more than 1 form exists. | |
| 4542 | + // Do not show if promotions are hidden, the pointer was dismissed or | |
| 4543 | + // accepted, or more than 1 form exists. | |
| 4774 | 4544 | if ( |
| 4775 | - ! empty( Helper::get_srfm_option( 'pointer_popup_dismissed' ) ) | |
| 4545 | + Helper::hide_promotions() | |
| 4546 | + || ! empty( Helper::get_srfm_option( 'pointer_popup_dismissed' ) ) | |
| 4776 | 4547 | || ! empty( Helper::get_srfm_option( 'pointer_popup_accepted' ) ) |
| 4777 | 4548 | || (int) ( wp_count_posts( SRFM_FORMS_POST_TYPE )->publish ?? 0 ) > 1 |
| 4778 | 4549 | ) { |
| 4779 | 4550 | return false; |
| @@ -4898,106 +4669,147 @@ | ||
| 4898 | 4669 | Analytics::events()->track( $event_name, (string) $counts[ $event_name ], [], true ); |
| 4899 | 4670 | } |
| 4900 | 4671 | |
| 4901 | 4672 | /** |
| 4902 | - * The contact form's address, tagged with where the click came from. | |
| 4673 | + * A pre-addressed support email for the failure being reported. | |
| 4903 | 4674 | * |
| 4904 | - * One campaign, tagged per failure, so the report answers which check actually | |
| 4905 | - * sends people to support rather than only how many arrive. A submission | |
| 4906 | - * failure and a caching advisory are different problems and it is worth knowing | |
| 4907 | - * which one drives the tickets. | |
| 4675 | + * Restores the 2.12.6 behaviour: the button opens the composer the person | |
| 4676 | + * already uses, with the subject and the whole report written for them. What | |
| 4677 | + * 2.12.7 replaced it with -- a web form -- could carry neither the diagnostics | |
| 4678 | + * nor the log, so the button had to be gated behind copying them by hand and | |
| 4679 | + * pasting them into a field on the far side. That is three deliberate steps to | |
| 4680 | + * report a fault the plugin had already written up. | |
| 4908 | 4681 | * |
| 4909 | - * Prefilled with what SureForms already knows -- the admin's address, which | |
| 4910 | - * failure it is, and the site host -- so the person reporting a fault does not | |
| 4911 | - * retype it. Worth knowing that the address travels in the query string, so it | |
| 4912 | - * reaches browser history and any referrer along the way; it is the site | |
| 4913 | - * owner's own address going to SureForms' own form, which is the flow this | |
| 4914 | - * button exists for. | |
| 4682 | + * The log is pasted into the body rather than attached because mailto has no | |
| 4683 | + * attachment parameter -- browsers drop any attempt to add one -- and it is a | |
| 4684 | + * tail rather than the whole file because a megabyte of JSON would exceed the | |
| 4685 | + * URL length every mail client enforces. The finished URL is capped at | |
| 4686 | + * SUPPORT_MAILTO_MAX_LENGTH, and the log is what gives way to meet it. | |
| 4915 | 4687 | * |
| 4916 | - * Built with add_query_arg rather than string concatenation, so it stays | |
| 4917 | - * correct if the constant ever gains a query string of its own. | |
| 4688 | + * The subject and body are English on every site, deliberately untranslated: | |
| 4689 | + * they are written for SureForms support, and plain literals cannot be | |
| 4690 | + * rewritten by a locale, a translation plugin or a gettext filter. | |
| 4918 | 4691 | * |
| 4919 | - * @param string $category One of Client_Logger::CATEGORIES, naming the failure | |
| 4920 | - * the visitor is reporting. | |
| 4921 | - * @since 2.12.7 | |
| 4692 | + * @param string $category One of Client_Logger::CATEGORIES, naming the failure | |
| 4693 | + * being reported. An unknown or absent one gets | |
| 4694 | + * deliberately neutral wording via get_support_copy(). | |
| 4695 | + * @param string $form_title Form the failure was recorded against, when known. | |
| 4696 | + * @since 2.12.8 | |
| 4922 | 4697 | * @return string |
| 4923 | 4698 | */ |
| 4924 | - private function get_support_contact_url( $category ) { | |
| 4925 | - // Deliberately not translated. These are matched against the options on the | |
| 4926 | - // troubleshooting form, so they are machine values, not copy -- a German | |
| 4927 | - // site sending "E-Mail-Benachrichtigungsfehler" would arrive as an | |
| 4928 | - // unrecognised subject and land in the wrong queue. | |
| 4929 | - $subjects = [ | |
| 4930 | - 'submission' => 'Form submission failure', | |
| 4931 | - 'notification' => 'Email notification failure', | |
| 4932 | - 'integration' => 'Integration failure', | |
| 4933 | - ]; | |
| 4699 | + private function get_support_contact_url( $category, $form_title = '' ) { | |
| 4700 | + $copy = $this->get_support_copy( $category ); | |
| 4701 | + $host = Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) ); | |
| 4934 | 4702 | |
| 4935 | - $user = wp_get_current_user(); | |
| 4703 | + $subject = sprintf( $copy['subject'], $host ); | |
| 4936 | 4704 | |
| 4937 | - $url = add_query_arg( | |
| 4938 | - [ | |
| 4939 | - // Prefills the form, so the person reporting a fault does not retype | |
| 4940 | - // what SureForms already knows. Empty rather than absent when the | |
| 4941 | - // address is unusable, so the form still opens. | |
| 4942 | - 'mail' => is_email( $user->user_email ) ? $user->user_email : '', | |
| 4943 | - // Falls back to "Other" for a category SureForms does not define -- | |
| 4944 | - // srfm_action_items is public, so an item can carry any category or | |
| 4945 | - // none. | |
| 4946 | - 'subject' => $subjects[ $category ] ?? 'Other', | |
| 4947 | - 'site_url' => Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) ), | |
| 4948 | - 'utm_source' => 'sureforms', | |
| 4949 | - 'utm_medium' => 'form_checks', | |
| 4950 | - 'utm_campaign' => 'contact_support', | |
| 4951 | - // Which check sent them. The one part that differs per button, and | |
| 4952 | - // the reason for tagging at all. | |
| 4953 | - 'utm_content' => $category, | |
| 4954 | - ], | |
| 4955 | - self::SUPPORT_CONTACT_URL | |
| 4956 | - ); | |
| 4705 | + $url = $this->build_support_mailto_within_limit( $category, $form_title, $subject ); | |
| 4957 | 4706 | |
| 4707 | + // The last resort: the subject alone still names the problem and the site. | |
| 4708 | + if ( '' === $url ) { | |
| 4709 | + $url = $this->build_support_mailto( $subject ); | |
| 4710 | + } | |
| 4711 | + | |
| 4958 | 4712 | /** |
| 4959 | 4713 | * Filter where the Contact Support action sends people. |
| 4960 | 4714 | * |
| 4961 | - * Replaces the `srfm_support_email_address` filter, which pointed at an | |
| 4962 | - * inbox and has no destination left to change now that the action opens a | |
| 4963 | - * form. A white-label install wants to point this at its own support page. | |
| 4715 | + * A white-label install wants its own inbox or its own support page, so both | |
| 4716 | + * are accepted. Returning an http(s) URL is supported but drops the body -- | |
| 4717 | + * a web form cannot carry it -- so the person arrives without the site | |
| 4718 | + * details or the debug log. They can still download the log from SureForms → | |
| 4719 | + * Settings → General, but nothing prompts them to, so a filter returning a | |
| 4720 | + * page should ask for it there. | |
| 4964 | 4721 | * |
| 4965 | 4722 | * @since 2.12.7 |
| 4966 | 4723 | * |
| 4967 | - * @param string $url Contact form URL, already UTM-tagged. | |
| 4968 | - * @param string $category The failure being reported. | |
| 4724 | + * @param string $url The pre-addressed mailto: URL. | |
| 4725 | + * @param string $category The failure being reported. | |
| 4726 | + * @param string $form_title Form the failure was recorded against, or ''. | |
| 4969 | 4727 | */ |
| 4970 | - $filtered = Helper::get_string_value( apply_filters( 'srfm_support_contact_url', $url, $category ) ); | |
| 4728 | + $filtered = Helper::get_string_value( apply_filters( 'srfm_support_contact_url', $url, $category, $form_title ) ); | |
| 4971 | 4729 | |
| 4972 | 4730 | // Escaped after the filter, not before: the point of escaping here is that |
| 4973 | - // neither renderer has to trust what comes back. mailto: is allowed because | |
| 4974 | - // an inbox is a legitimate destination for a white-label support contact, | |
| 4975 | - // and get_action_items() already allows it on the sibling item URLs. | |
| 4731 | + // neither renderer has to trust what comes back. | |
| 4976 | 4732 | $safe = esc_url_raw( $filtered, [ 'http', 'https', 'mailto' ] ); |
| 4977 | 4733 | |
| 4978 | 4734 | // Never empty. Contact Support is the only action that retires these |
| 4979 | 4735 | // notices and they are dismissible => false, so returning '' for a filter |
| 4980 | 4736 | // value that cannot survive escaping leaves an undismissable notice with |
| 4981 | - // nothing on it that works. Falling back to SureForms' own form is worse | |
| 4982 | - // for a white-label than their own URL and better than a dead end, and the | |
| 4983 | - // unfiltered URL is built here rather than supplied, so it always escapes. | |
| 4984 | - return '' !== $safe ? $safe : esc_url_raw( $url, [ 'http', 'https' ] ); | |
| 4737 | + // nothing on it that works. The unfiltered URL is built here rather than | |
| 4738 | + // supplied, so it always escapes. | |
| 4739 | + return '' !== $safe ? $safe : esc_url_raw( $url, [ 'mailto' ] ); | |
| 4985 | 4740 | } |
| 4986 | 4741 | |
| 4987 | 4742 | /** |
| 4743 | + * The fullest support mailto: that fits SUPPORT_MAILTO_MAX_LENGTH. | |
| 4744 | + * | |
| 4745 | + * A mailto: is a URL and every client enforces a length limit on it. | |
| 4746 | + * Overrunning it does not truncate politely -- it drops the body, or the | |
| 4747 | + * whole link -- while the click still retires the notice. So the cap is on | |
| 4748 | + * the encoded URL, not the raw log: JSON-escaped non-ASCII text grows about | |
| 4749 | + * eight times once percent-encoded. The log gives way first, because the | |
| 4750 | + * site details are the part support cannot do without. | |
| 4751 | + * | |
| 4752 | + * @param string $category The failure being reported. | |
| 4753 | + * @param string $form_title Form the failure was recorded against, or ''. | |
| 4754 | + * @param string $subject Subject line. | |
| 4755 | + * @since 2.12.8 | |
| 4756 | + * @return string The URL, or '' when even the body without a log is too long. | |
| 4757 | + */ | |
| 4758 | + private function build_support_mailto_within_limit( $category, $form_title, $subject ) { | |
| 4759 | + // CRLF, not "\n". RFC 6068 leaves the line ending to the client and the | |
| 4760 | + // major composers normalise either, but Outlook renders a bare LF body as a | |
| 4761 | + // single run-on line -- which is exactly the report a support agent has to | |
| 4762 | + // read. | |
| 4763 | + $message = str_replace( "\n", "\r\n", $this->get_support_message( $category, $form_title ) ); | |
| 4764 | + | |
| 4765 | + foreach ( [ 1200, 800, 400, 0 ] as $budget ) { | |
| 4766 | + $log = 0 < $budget | |
| 4767 | + ? $this->get_support_log_block( $budget ) | |
| 4768 | + : '---' . "\n" . 'Debug log left out to keep this email short enough to send. The full log can be downloaded from SureForms → Settings → General.'; | |
| 4769 | + | |
| 4770 | + $url = $this->build_support_mailto( $subject, $message . "\r\n\r\n" . str_replace( "\n", "\r\n", $log ) ); | |
| 4771 | + | |
| 4772 | + if ( strlen( $url ) <= self::SUPPORT_MAILTO_MAX_LENGTH ) { | |
| 4773 | + return $url; | |
| 4774 | + } | |
| 4775 | + } | |
| 4776 | + | |
| 4777 | + return ''; | |
| 4778 | + } | |
| 4779 | + | |
| 4780 | + /** | |
| 4781 | + * A mailto: to the support inbox. | |
| 4782 | + * | |
| 4783 | + * @param string $subject Subject line. | |
| 4784 | + * @param string $body Body, with CRLF line endings. Omitted when empty. | |
| 4785 | + * @since 2.12.8 | |
| 4786 | + * @return string | |
| 4787 | + */ | |
| 4788 | + private function build_support_mailto( $subject, $body = '' ) { | |
| 4789 | + $query = [ 'subject' => $subject ]; | |
| 4790 | + | |
| 4791 | + if ( '' !== $body ) { | |
| 4792 | + $query['body'] = $body; | |
| 4793 | + } | |
| 4794 | + | |
| 4795 | + return 'mailto:' . self::SUPPORT_EMAIL . '?' . http_build_query( | |
| 4796 | + $query, | |
| 4797 | + '', | |
| 4798 | + '&', | |
| 4799 | + // RFC 3986, so a space is %20 rather than +. A mail client reading a | |
| 4800 | + // mailto: body decodes it as a URI, not as form data, so + arrives as a | |
| 4801 | + // literal plus in every word gap. | |
| 4802 | + PHP_QUERY_RFC3986 | |
| 4803 | + ); | |
| 4804 | + } | |
| 4805 | + | |
| 4806 | + /** | |
| 4988 | 4807 | * The log tail, formatted for pasting. |
| 4989 | 4808 | * |
| 4990 | - * One builder, so the text someone reads before sending is the text that gets | |
| 4991 | - * sent. They used to be built separately, which is how a "details" view drifts | |
| 4992 | - * from what it claims to show. | |
| 4809 | + * The budget is a parameter because it goes into a mailto: URL, and | |
| 4810 | + * get_support_contact_url() lowers it until the encoded URL fits. | |
| 4993 | 4811 | * |
| 4994 | - * The budget is a parameter because nothing here is going into a URL any more. | |
| 4995 | - * Client_Logger::get_tail()'s 1200-character default existed to fit a compose | |
| 4996 | - * URL; a clipboard and a <pre> have no such limit, so the dialog asks for more | |
| 4997 | - * and the note below describes the real constraint rather than a mail client | |
| 4998 | - * that is not in this flow. | |
| 4999 | - * | |
| 5000 | 4812 | * @param int $max_chars Characters of log to include. |
| 5001 | 4813 | * @since 2.12.7 |
| 5002 | 4814 | * @return string |
| 5003 | 4815 | */ |
| @@ -5005,14 +4817,13 @@ | ||
| 5005 | 4817 | $log = Client_Logger::get_tail( $max_chars ); |
| 5006 | 4818 | $block = '---' . "\n"; |
| 5007 | 4819 | |
| 5008 | 4820 | if ( '' === $log['text'] ) { |
| 5009 | - return $block . __( 'Debug log: no entries recorded.', 'sureforms' ); | |
| 4821 | + return $block . 'Debug log: no entries recorded.'; | |
| 5010 | 4822 | } |
| 5011 | 4823 | |
| 5012 | 4824 | $block .= sprintf( |
| 5013 | - /* translators: 1: entries shown, 2: entries recorded. */ | |
| 5014 | - __( 'Debug log (most recent %1$d of %2$d entries)', 'sureforms' ), | |
| 4825 | + 'Debug log (most recent %1$d of %2$d entries)', | |
| 5015 | 4826 | $log['shown'], |
| 5016 | 4827 | $log['total'] |
| 5017 | 4828 | ) . "\n"; |
| 5018 | 4829 | |
| @@ -5020,9 +4831,9 @@ | ||
| 5020 | 4831 | // Markdown is rendered. |
| 5021 | 4832 | $block .= '```' . "\n" . $log['text'] . "\n" . '```'; |
| 5022 | 4833 | |
| 5023 | 4834 | if ( $log['shown'] < $log['total'] ) { |
| 5024 | - $block .= "\n\n" . __( 'Older entries were left out to keep this excerpt readable. The full log can be downloaded from SureForms → Settings → General.', 'sureforms' ); | |
| 4835 | + $block .= "\n\n" . 'Older entries were left out to keep this excerpt readable. The full log can be downloaded from SureForms → Settings → General.'; | |
| 5025 | 4836 | } |
| 5026 | 4837 | |
| 5027 | 4838 | return $block; |
| 5028 | 4839 | } |
| @@ -5031,10 +4842,9 @@ | ||
| 5031 | 4842 | * Subject and countless opening line for one kind of failure. |
| 5032 | 4843 | * |
| 5033 | 4844 | * Both come from here so they cannot drift apart: a subject naming one problem |
| 5034 | 4845 | * over a body describing another is worse than either alone. The counted form |
| 5035 | - * of the opening line lives in get_support_count_sentence(), which needs | |
| 5036 | - * `_n()`'s literals and so cannot be an array lookup. | |
| 4846 | + * of the opening line lives in get_support_count_sentence(). | |
| 5037 | 4847 | * |
| 5038 | 4848 | * An unknown or absent category gets deliberately neutral wording. The |
| 5039 | 4849 | * alternative -- defaulting to the submission copy -- states something specific |
| 5040 | 4850 | * that may not be true, and an item contributed through srfm_action_items has no |
| @@ -5046,24 +4856,18 @@ | ||
| 5046 | 4856 | */ |
| 5047 | 4857 | private function get_support_copy( $category ) { |
| 5048 | 4858 | $copy = [ |
| 5049 | 4859 | 'submission' => [ |
| 5050 | - /* translators: %s: site host. */ | |
| 5051 | - 'subject' => __( 'SureForms: form submissions are failing on %s', 'sureforms' ), | |
| 5052 | - /* translators: %s: site host. */ | |
| 5053 | - 'anon' => __( 'SureForms has recorded form submissions on %s that could not be completed.', 'sureforms' ), | |
| 4860 | + 'subject' => 'SureForms: form submissions are failing on %s', | |
| 4861 | + 'anon' => 'SureForms has recorded form submissions on %s that could not be completed.', | |
| 5054 | 4862 | ], |
| 5055 | 4863 | 'notification' => [ |
| 5056 | - /* translators: %s: site host. */ | |
| 5057 | - 'subject' => __( 'SureForms: notification emails are not being sent on %s', 'sureforms' ), | |
| 5058 | - /* translators: %s: site host. */ | |
| 5059 | - 'anon' => __( 'SureForms saved entries on %s but could not send the notification emails for them.', 'sureforms' ), | |
| 4864 | + 'subject' => 'SureForms: notification emails are not being sent on %s', | |
| 4865 | + 'anon' => 'SureForms saved entries on %s but could not send the notification emails for them.', | |
| 5060 | 4866 | ], |
| 5061 | 4867 | 'integration' => [ |
| 5062 | - /* translators: %s: site host. */ | |
| 5063 | - 'subject' => __( 'SureForms: an integration is not receiving entries on %s', 'sureforms' ), | |
| 5064 | - /* translators: %s: site host. */ | |
| 5065 | - 'anon' => __( 'SureForms saved entries on %s but could not pass them to a connected service.', 'sureforms' ), | |
| 4868 | + 'subject' => 'SureForms: an integration is not receiving entries on %s', | |
| 4869 | + 'anon' => 'SureForms saved entries on %s but could not pass them to a connected service.', | |
| 5066 | 4870 | ], |
| 5067 | 4871 | ]; |
| 5068 | 4872 | |
| 5069 | 4873 | if ( isset( $copy[ $category ] ) ) { |
| @@ -5070,12 +4874,10 @@ | ||
| 5070 | 4874 | return $copy[ $category ]; |
| 5071 | 4875 | } |
| 5072 | 4876 | |
| 5073 | 4877 | return [ |
| 5074 | - /* translators: %s: site host. */ | |
| 5075 | - 'subject' => __( 'SureForms: a problem with the forms on %s', 'sureforms' ), | |
| 5076 | - /* translators: %s: site host. */ | |
| 5077 | - 'anon' => __( 'SureForms has recorded a problem with the forms on %s.', 'sureforms' ), | |
| 4878 | + 'subject' => 'SureForms: a problem with the forms on %s', | |
| 4879 | + 'anon' => 'SureForms has recorded a problem with the forms on %s.', | |
| 5078 | 4880 | ]; |
| 5079 | 4881 | } |
| 5080 | 4882 | |
| 5081 | 4883 | /** |
| @@ -5080,14 +4882,10 @@ | ||
| 5080 | 4882 | |
| 5081 | 4883 | /** |
| 5082 | 4884 | * The sentence that opens the support email, with the failure count in it. |
| 5083 | 4885 | * |
| 5084 | - * A switch with literal `_n()` calls rather than a singular/plural pair looked | |
| 5085 | - * up from an array. `_n()` has to see its two literals at extraction time to | |
| 5086 | - * emit an `msgid_plural`, and only that lets a locale supply the number of | |
| 5087 | - * forms it actually uses -- Polish and Russian need three, Arabic six, | |
| 5088 | - * Japanese one. Choosing on `1 === $count` in PHP is correct for English and | |
| 5089 | - * wrong everywhere with a different plural rule. | |
| 4886 | + * English only, like the rest of the support email, so `1 === $count` is the | |
| 4887 | + * whole plural rule. | |
| 5090 | 4888 | * |
| 5091 | 4889 | * @param string $category One of Client_Logger::CATEGORIES. Unknown or absent |
| 5092 | 4890 | * gets neutral wording rather than a specific claim. |
| 5093 | 4891 | * @param int $count Failures recorded for that category. |
| @@ -5097,51 +4895,35 @@ | ||
| 5097 | 4895 | private function get_support_count_sentence( $category, $count ) { |
| 5098 | 4896 | switch ( $category ) { |
| 5099 | 4897 | case 'submission': |
| 5100 | 4898 | return sprintf( |
| 5101 | - /* translators: %d: number of failed submissions. */ | |
| 5102 | - _n( | |
| 5103 | - 'SureForms has recorded %d form submission that could not be completed.', | |
| 5104 | - 'SureForms has recorded %d form submissions that could not be completed.', | |
| 5105 | - $count, | |
| 5106 | - 'sureforms' | |
| 5107 | - ), | |
| 4899 | + ( 1 === $count | |
| 4900 | + ? 'SureForms has recorded %d form submission that could not be completed.' | |
| 4901 | + : 'SureForms has recorded %d form submissions that could not be completed.' ), | |
| 5108 | 4902 | $count |
| 5109 | 4903 | ); |
| 5110 | 4904 | |
| 5111 | 4905 | case 'notification': |
| 5112 | 4906 | return sprintf( |
| 5113 | - /* translators: %d: number of failed notifications. */ | |
| 5114 | - _n( | |
| 5115 | - 'SureForms saved %d entry but could not send the notification email for it.', | |
| 5116 | - 'SureForms saved %d entries but could not send the notification emails for them.', | |
| 5117 | - $count, | |
| 5118 | - 'sureforms' | |
| 5119 | - ), | |
| 4907 | + ( 1 === $count | |
| 4908 | + ? 'SureForms saved %d entry but could not send the notification email for it.' | |
| 4909 | + : 'SureForms saved %d entries but could not send the notification emails for them.' ), | |
| 5120 | 4910 | $count |
| 5121 | 4911 | ); |
| 5122 | 4912 | |
| 5123 | 4913 | case 'integration': |
| 5124 | 4914 | return sprintf( |
| 5125 | - /* translators: %d: number of failed integration hand-offs. */ | |
| 5126 | - _n( | |
| 5127 | - 'SureForms saved %d entry but could not pass it to a connected service.', | |
| 5128 | - 'SureForms saved %d entries but could not pass them to a connected service.', | |
| 5129 | - $count, | |
| 5130 | - 'sureforms' | |
| 5131 | - ), | |
| 4915 | + ( 1 === $count | |
| 4916 | + ? 'SureForms saved %d entry but could not pass it to a connected service.' | |
| 4917 | + : 'SureForms saved %d entries but could not pass them to a connected service.' ), | |
| 5132 | 4918 | $count |
| 5133 | 4919 | ); |
| 5134 | 4920 | |
| 5135 | 4921 | default: |
| 5136 | 4922 | return sprintf( |
| 5137 | - /* translators: %d: number of recorded problems. */ | |
| 5138 | - _n( | |
| 5139 | - 'SureForms has recorded %d problem with the forms on this site.', | |
| 5140 | - 'SureForms has recorded %d problems with the forms on this site.', | |
| 5141 | - $count, | |
| 5142 | - 'sureforms' | |
| 5143 | - ), | |
| 4923 | + ( 1 === $count | |
| 4924 | + ? 'SureForms has recorded %d problem with the forms on this site.' | |
| 4925 | + : 'SureForms has recorded %d problems with the forms on this site.' ), | |
| 5144 | 4926 | $count |
| 5145 | 4927 | ); |
| 5146 | 4928 | } |
| 5147 | 4929 | } |
| @@ -5173,9 +4955,9 @@ | ||
| 5173 | 4955 | // for a count nobody recorded -- a number support would then chase. |
| 5174 | 4956 | $host = Helper::get_string_value( wp_parse_url( home_url(), PHP_URL_HOST ) ); |
| 5175 | 4957 | |
| 5176 | 4958 | $lines = [ |
| 5177 | - __( 'Hello SureForms support,', 'sureforms' ), | |
| 4959 | + 'Hello SureForms support,', | |
| 5178 | 4960 | '', |
| 5179 | 4961 | $count > 0 |
| 5180 | 4962 | ? $this->get_support_count_sentence( $category, $count ) |
| 5181 | 4963 | : sprintf( $copy['anon'], $host ), |
| @@ -5183,10 +4965,9 @@ | ||
| 5183 | 4965 | |
| 5184 | 4966 | if ( '' !== $form_title ) { |
| 5185 | 4967 | $lines[] = ''; |
| 5186 | 4968 | $lines[] = sprintf( |
| 5187 | - /* translators: %s: form title. */ | |
| 5188 | - __( 'Form: %s', 'sureforms' ), | |
| 4969 | + 'Form: %s', | |
| 5189 | 4970 | $form_title |
| 5190 | 4971 | ); |
| 5191 | 4972 | } |
| 5192 | 4973 | |
| @@ -5197,35 +4978,24 @@ | ||
| 5197 | 4978 | $lines, |
| 5198 | 4979 | [ |
| 5199 | 4980 | '', |
| 5200 | 4981 | '---', |
| 5201 | - __( 'Site details', 'sureforms' ), | |
| 5202 | - // Labels translated, values not. The site owner reads this on screen | |
| 5203 | - // before sending it, so the labels are copy; the values are machine | |
| 5204 | - // data -- a version, a URL, a plugin name -- and stay verbatim. The | |
| 5205 | - // debug log below is left alone entirely for the same reason. | |
| 5206 | - /* translators: %s: site address. */ | |
| 5207 | - sprintf( __( 'Site: %s', 'sureforms' ), home_url() ), | |
| 5208 | - /* translators: %s: SureForms version. */ | |
| 5209 | - sprintf( __( 'SureForms: %s', 'sureforms' ), SRFM_VER ), | |
| 4982 | + 'Site details', | |
| 4983 | + sprintf( 'Site: %s', home_url() ), | |
| 4984 | + sprintf( 'SureForms: %s', SRFM_VER ), | |
| 5210 | 4985 | sprintf( |
| 5211 | - /* translators: %s: SureForms Pro version, or a note that it is not active. */ | |
| 5212 | - __( 'SureForms Pro: %s', 'sureforms' ), | |
| 5213 | - Helper::has_pro() && defined( 'SRFM_PRO_VER' ) ? SRFM_PRO_VER : __( 'not active', 'sureforms' ) | |
| 4986 | + 'SureForms Pro: %s', | |
| 4987 | + Helper::has_pro() && defined( 'SRFM_PRO_VER' ) ? SRFM_PRO_VER : 'not active' | |
| 5214 | 4988 | ), |
| 5215 | - /* translators: %s: WordPress version. */ | |
| 5216 | - sprintf( __( 'WordPress: %s', 'sureforms' ), Helper::get_string_value( $wp_version ) ), | |
| 5217 | - /* translators: %s: PHP version. */ | |
| 5218 | - sprintf( __( 'PHP: %s', 'sureforms' ), PHP_VERSION ), | |
| 4989 | + sprintf( 'WordPress: %s', Helper::get_string_value( $wp_version ) ), | |
| 4990 | + sprintf( 'PHP: %s', PHP_VERSION ), | |
| 5219 | 4991 | sprintf( |
| 5220 | - /* translators: %s: caching plugin name, or a note that none was detected. */ | |
| 5221 | - __( 'Caching: %s', 'sureforms' ), | |
| 5222 | - '' !== $caching ? $caching : __( 'none detected', 'sureforms' ) | |
| 4992 | + 'Caching: %s', | |
| 4993 | + '' !== $caching ? $caching : 'none detected' | |
| 5223 | 4994 | ), |
| 5224 | 4995 | sprintf( |
| 5225 | - /* translators: %s: number of recorded failures, or a note that none were. */ | |
| 5226 | - __( 'Recorded failures: %s', 'sureforms' ), | |
| 5227 | - $count > 0 ? Helper::get_string_value( $count ) : __( 'none recorded', 'sureforms' ) | |
| 4996 | + 'Recorded failures: %s', | |
| 4997 | + $count > 0 ? Helper::get_string_value( $count ) : 'none recorded' | |
| 5228 | 4998 | ), |
| 5229 | 4999 | ] |
| 5230 | 5000 | ); |
| 5231 | 5001 | |
| @@ -5246,10 +5016,9 @@ | ||
| 5246 | 5016 | $acked_at = Helper::get_integer_value( $failures[ $category ]['acked_at'] ?? 0 ); |
| 5247 | 5017 | |
| 5248 | 5018 | if ( $acked_at > 0 ) { |
| 5249 | 5019 | $lines[] = sprintf( |
| 5250 | - /* translators: %s: date and time of the previous report, in the site's timezone. */ | |
| 5251 | - __( 'Previously reported: %s', 'sureforms' ), | |
| 5020 | + 'Previously reported: %s', | |
| 5252 | 5021 | Helper::get_string_value( wp_date( 'Y-m-d H:i T', $acked_at ) ) |
| 5253 | 5022 | ); |
| 5254 | 5023 | } |
| 5255 | 5024 | |