Make WordPress Core

Ticket #40702: 40702.2.diff

File 40702.2.diff, 54.9 KB (added by iandunn, 7 years ago)

Add method to anonymize user IP

  • src/wp-admin/css/dashboard.css

    diff --git src/wp-admin/css/dashboard.css src/wp-admin/css/dashboard.css
    index 98fb99378d..2b80450a4a 100644
     
    301301        content: "\f153";
    302302}
    303303
     304/* Dashboard WordPress events */
     305
     306.community-events-errors {
     307        margin: 0;
     308}
     309
     310.community-events-loading {
     311        padding: 10px 12px 8px;
     312}
     313
     314.community-events {
     315        margin-bottom: 6px;
     316        padding: 0 12px;
     317}
     318
     319.community-events .spinner {
     320        float: none;
     321        margin: 0;
     322        padding-bottom: 3px;
     323}
     324
     325.community-events-errors[aria-hidden="true"],
     326.community-events-errors *[aria-hidden="true"],
     327.community-events-loading[aria-hidden="true"],
     328.community-events[aria-hidden="true"],
     329.community-events *[aria-hidden="true"] {
     330        display: none;
     331}
     332
     333.community-events .activity-block:first-child,
     334.community-events h2 {
     335        padding-top: 12px;
     336        padding-bottom: 10px;
     337}
     338
     339.community-events-form {
     340        margin: 5px 0;
     341}
     342
     343.community-events-form .regular-text {
     344        margin-top: 2px;
     345        width: 40%;
     346}
     347
     348.community-events-form label {
     349        display: inline-block;
     350        padding-bottom: 3px;
     351}
     352
     353.community-events .activity-block > p {
     354        margin-bottom: 0;
     355        display: inline;
     356}
     357
     358#community-events-submit {
     359        margin-left: 2px;
     360}
     361
     362.community-events .button-link:hover,
     363.community-events .button-link:active {
     364        color: #00a0d2;
     365}
     366
     367.community-events-cancel.button.button-link {
     368        color: #0073aa;
     369        text-decoration: underline;
     370        margin-left: 2px;
     371}
     372
     373.community-events ul {
     374        background-color: #fafafa;
     375        padding-left: 0;
     376        padding-right: 0;
     377        padding-bottom: 0;
     378}
     379
     380.community-events li {
     381        margin: 0;
     382        padding: 8px 12px;
     383        color: #72777c;
     384}
     385.community-events li:first-child {
     386        border-top: 1px solid #eee;
     387}
     388
     389.community-events li ~ li {
     390        border-top: 1px solid #eee;
     391}
     392
     393.community-events .activity-block {
     394        border-bottom: 0;
     395}
     396.community-events .activity-block.last {
     397        border-bottom: 1px solid #eee;
     398        padding-top: 0;
     399}
     400
     401.community-events .event-info {
     402        display: block;
     403}
     404
     405.event-icon {
     406        height: 18px;
     407        padding-right: 10px;
     408        width: 18px;
     409        display: none; /* Hide on smaller screens */
     410}
     411.rtl .event-icon {
     412        padding-right: 0;
     413        padding-left: 10px;
     414}
     415
     416.event-icon:before {
     417        color: #82878C;
     418        font-size: 18px;
     419}
     420.event-meetup .event-icon:before {
     421        content: "\f484";
     422}
     423.event-wordcamp .event-icon:before {
     424        content: "\f486";
     425}
     426
     427.community-events .event-title {
     428        font-weight: 600;
     429        display: block;
     430}
     431
     432.community-events .event-date,
     433.community-events .event-time {
     434        display: block;
     435}
     436
     437.community-events-footer {
     438        margin-top: 0;
     439        margin-bottom: 0;
     440        padding: 12px;
     441        border-top: 1px solid #eee;
     442        color: #ddd;
     443}
     444
    304445/* Dashboard WordPress news */
    305446
    306447#dashboard_primary .inside {
    body #dashboard-widgets .postbox form .submit { 
    333474}
    334475
    335476#dashboard_primary .rss-widget {
    336         border-bottom: 1px solid #eee;
    337477        font-size: 13px;
    338         padding: 8px 12px 10px;
     478        padding: 0 12px 0;
    339479}
    340480
    341481#dashboard_primary .rss-widget:last-child {
    body #dashboard-widgets .postbox form .submit { 
    357497}
    358498
    359499#dashboard_primary .rss-widget ul li {
    360         margin-bottom: 8px;
     500        padding: 4px 0;
     501        margin: 0;
    361502}
    362503
    363504/* Dashboard right now */
    form.initial-form.quickpress-open input#title { 
    8741015}
    8751016
    8761017a.rsswidget {
    877         font-size: 14px;
     1018        font-size: 13px;
    8781019        font-weight: 600;
    879         line-height: 1.7em;
     1020        line-height: 1.4em;
    8801021}
    8811022
    8821023.rss-widget ul li {
    a.rsswidget { 
    10871228                width: 30px;
    10881229                margin: 4px 10px 5px 0;
    10891230        }
     1231
     1232        .community-events-toggle-location {
     1233                height: 38px;
     1234        }
    10901235}
    10911236
    10921237/* Smartphone */
    a.rsswidget { 
    11101255                left: -35px;
    11111256        }
    11121257}
     1258
     1259@media screen and (min-width: 355px) {
     1260        .community-events .event-info {
     1261                display: table-row;
     1262                float: left;
     1263                max-width: 59%;
     1264        }
     1265        .rtl .community-events .event-info {
     1266                float: right;
     1267        }
     1268
     1269        .event-icon,
     1270        .event-icon[aria-hidden="true"] {
     1271                display: table-cell;
     1272        }
     1273
     1274        .event-info-inner {
     1275                display: table-cell;
     1276        }
     1277
     1278        .community-events .event-date-time {
     1279                float: right;
     1280                max-width: 39%;
     1281        }
     1282        .rtl .community-events .event-date-time {
     1283                float: left;
     1284        }
     1285
     1286        .community-events .event-date,
     1287        .community-events .event-time {
     1288                text-align: right;
     1289        }
     1290        .rtl .community-events .event-date,
     1291        .rtl .community-events .event-time {
     1292                text-align: left;
     1293        }
     1294}
  • new file src/wp-admin/includes/class-wp-community-events.php

    diff --git src/wp-admin/includes/class-wp-community-events.php src/wp-admin/includes/class-wp-community-events.php
    new file mode 100644
    index 0000000000..a87c2f6bc9
    - +  
     1<?php
     2/**
     3 * Administration: Community Events class
     4 *
     5 * @package WordPress
     6 * @subpackage Administration
     7 * @since 4.8.0
     8 */
     9
     10/**
     11 * Class WP_Community_Events
     12 *
     13 * A client for api.wordpress.org/events.
     14 *
     15 * @since 4.8.0
     16 */
     17class WP_Community_Events {
     18        /**
     19         * WP user ID.
     20         *
     21         * @access protected
     22         * @since 4.8.0
     23         *
     24         * @var int
     25         */
     26        protected $user_id = 0;
     27
     28        /**
     29         * Stored location data for the user.
     30         *
     31         * @access protected
     32         * @since 4.8.0
     33         *
     34         * @var bool|array
     35         */
     36        protected $user_location = false;
     37
     38        /**
     39         * WP_Community_Events constructor.
     40         *
     41         * @access public
     42         * @since 4.8.0
     43         *
     44         * @param int        $user_id       WP user ID.
     45         * @param bool|array $user_location Stored location data for the user.
     46         *                                  `false` to pass no location;
     47         *                                  `array` to pass a location {
     48         *     @type string $description The name of the location
     49         *     @type string $latitude    The latitude in decimal degrees notation, without the degree
     50         *                               symbol. e.g., `47.615200`.
     51         *     @type string $longitude   The longitude in decimal degrees notation, without the degree
     52         *                               symbol. e.g., `-122.341100`.
     53         *     @type string $country     The ISO 3166-1 alpha-2 country code. e.g., `BR`
     54         * }
     55         */
     56        public function __construct( $user_id, $user_location = false ) {
     57                $this->user_id       = absint( $user_id );
     58                $this->user_location = $user_location;
     59        }
     60
     61        /**
     62         * Get data about events near a particular location.
     63         *
     64         * If the `user_location` property is set and there are cached events for this
     65         * location, these will be immediately returned.
     66         *
     67         * If not, this method will send a request to the Events API with location data.
     68         * The API will send back a recognized location based on the data, along with
     69         * nearby events.
     70         *
     71         * @access public
     72         * @since 4.8.0
     73         *
     74         * @param string $location_search Optional city name to help determine the location.
     75         *                                e.g., "Seattle". Default empty string.
     76         * @param string $timezone        Optional timezone to help determine the location.
     77         *                                Default empty string.
     78         * @return array|WP_Error         A WP_Error on failure; an array with location and events on
     79         *                                success.
     80         */
     81        public function get_events( $location_search = '', $timezone = '' ) {
     82                $cached_events = $this->get_cached_events();
     83
     84                if ( ! $location_search && $cached_events ) {
     85                        return $cached_events;
     86                }
     87
     88                $request_url    = $this->get_request_url( $location_search, $timezone );
     89                $response       = wp_remote_get( $request_url );
     90                $response_code  = wp_remote_retrieve_response_code( $response );
     91                $response_body  = json_decode( wp_remote_retrieve_body( $response ), true );
     92                $response_error = null;
     93                $debugging_info = compact( 'request_url', 'response_code', 'response_body' );
     94
     95                if ( is_wp_error( $response ) ) {
     96                        $response_error = $response;
     97                } elseif ( 200 !== $response_code ) {
     98                        $response_error = new WP_Error(
     99                                'api-error',
     100                                /* translators: %s is a numeric HTTP status code; e.g., 400, 403, 500, 504, etc. */
     101                                esc_html( sprintf( __( 'Invalid API response code (%d)' ), $response_code ) )
     102                        );
     103                } elseif ( ! isset( $response_body['location'], $response_body['events'] ) ) {
     104                        $response_error = new WP_Error(
     105                                'api-invalid-response',
     106                                isset( $response_body['error'] ) ? $response_body['error'] : __( 'Unknown API error.' )
     107                        );
     108                }
     109
     110                if ( is_wp_error( $response_error ) ) {
     111                        $this->maybe_log_events_response( $response_error->get_error_message(), $debugging_info );
     112
     113                        return $response_error;
     114                } else {
     115                        $expiration = false;
     116
     117                        if ( isset( $response_body['ttl'] ) ) {
     118                                $expiration = $response_body['ttl'];
     119                                unset( $response_body['ttl'] );
     120                        }
     121
     122                        $this->cache_events( $response_body, $expiration );
     123
     124                        $response_body = $this->trim_events( $response_body );
     125                        $response_body = $this->format_event_data_time( $response_body );
     126
     127                        // Avoid bloating the log with all the event data, but keep the count.
     128                        $debugging_info['response_body']['events'] = count( $debugging_info['response_body']['events'] ) . ' events trimmed.';
     129
     130                        $this->maybe_log_events_response( 'Valid response received', $debugging_info );
     131
     132                        return $response_body;
     133                }
     134        }
     135
     136        /**
     137         * Build a URL for requests to the Events API
     138         *
     139         * @access protected
     140         * @since 4.8.0
     141         *
     142         * @param  string $search   City search string. Default empty string.
     143         * @param  string $timezone Timezone string. Default empty string.
     144         * @return string           The request URL.
     145         */
     146        protected function get_request_url( $search = '', $timezone = '' ) {
     147                $api_url = 'https://api.wordpress.org/events/1.0/';
     148                $args    = array( 'number' => 5 ); // Get more than three in case some get trimmed out.
     149
     150                /*
     151                 * Send the minimal set of necessary arguments in order to increase the
     152                 * chances of a cache-hit on the API side.
     153                 */
     154                if ( empty( $search ) && isset( $this->user_location['latitude'], $this->user_location['longitude'] ) ) {
     155                        $args['latitude']  = $this->user_location['latitude'];
     156                        $args['longitude'] = $this->user_location['longitude'];
     157                } else {
     158                        $args['ip']     = $this->maybe_anonymize_ip_address( $this->get_unsafe_client_ip() );
     159                        $args['locale'] = get_user_locale( $this->user_id );
     160
     161                        if ( $timezone ) {
     162                                $args['timezone'] = $timezone;
     163                        }
     164
     165                        if ( $search ) {
     166                                $args['location'] = $search;
     167                        }
     168                }
     169
     170                return add_query_arg( $args, $api_url );
     171        }
     172
     173        /**
     174         * Determine the user's actual IP if possible
     175         *
     176         * If the user is making their request through a proxy, or if the web server
     177         * is behind a proxy, then $_SERVER['REMOTE_ADDR'] will be the proxy address
     178         * rather than the user's actual address.
     179         *
     180         * Modified from http://stackoverflow.com/a/2031935/450127, MIT license.
     181         *
     182         * SECURITY WARNING: This function is _NOT_ intended to be used in
     183         * circumstances where the authenticity of the IP address matters. This does
     184         * _NOT_ guarantee that the returned address is valid or accurate, and it can
     185         * be easily spoofed.
     186         *
     187         * @access protected
     188         * @since 4.8.0
     189         *
     190         * @return false|string `false` on failure, the `string` address on success
     191         */
     192        protected function get_unsafe_client_ip() {
     193                $client_ip = false;
     194
     195                // In order of preference, with the best ones for this purpose first.
     196                $address_headers = array(
     197                        'HTTP_CLIENT_IP',
     198                        'HTTP_X_FORWARDED_FOR',
     199                        'HTTP_X_FORWARDED',
     200                        'HTTP_X_CLUSTER_CLIENT_IP',
     201                        'HTTP_FORWARDED_FOR',
     202                        'HTTP_FORWARDED',
     203                        'REMOTE_ADDR',
     204                );
     205
     206                foreach ( $address_headers as $header ) {
     207                        if ( array_key_exists( $header, $_SERVER ) ) {
     208                                /*
     209                                 * HTTP_X_FORWARDED_FOR can contain a chain of comma-separated
     210                                 * addresses. The first one is the original client. It can't be
     211                                 * trusted for authenticity, but we don't need to for this purpose.
     212                                 */
     213                                $address_chain = explode( ',', $_SERVER[ $header ] );
     214                                $client_ip     = trim( $address_chain[0] );
     215
     216                                break;
     217                        }
     218                }
     219
     220                return $client_ip;
     221        }
     222
     223        /**
     224         * Attempt to partially anonymize an IP address by converting it a network ID
     225         *
     226         * Geolocating the network ID will usually return a similar location as the
     227         * actual IP, but provides some privacy for the user.
     228         *
     229         * Modified from https://github.com/geertw/php-ip-anonymizer, MIT license.
     230         *
     231         * @access protected
     232         * @since 4.8.0
     233         *
     234         * @param  string      $address The IP address that should be anonymized.
     235         * @return bool|string          The anonymized address on success;
     236         *                              the given address or false on failure.
     237         */
     238        protected function maybe_anonymize_ip_address( $address ) {
     239                // These functions are not available on Windows until PHP 5.3
     240                if ( ! function_exists( 'inet_pton' ) || ! function_exists( 'inet_ntop' ) ) {
     241                        return $address;
     242                }
     243
     244                $netmask = 4 === strlen( inet_pton( $address ) ) ? '255.255.255.0' : 'ffff:ffff:ffff:ffff:0000:0000:0000:0000';
     245
     246                return inet_ntop( inet_pton( $address ) & inet_pton( $netmask ) );
     247        }
     248
     249        /**
     250         * Generate a transient key based on user location
     251         *
     252         * This could be reduced to a one-liner in the calling functions, but it's
     253         * intentionally a separate function because it's called from multiple
     254         * functions, and having it abstracted keeps the logic consistent and DRY,
     255         * which is less prone to errors.
     256         *
     257         * @access protected
     258         * @since 4.8.0
     259         *
     260         * @param  array       $location Should contain 'latitude' and 'longitude' indexes.
     261         * @return bool|string           `false` on failure, or a string on success
     262         */
     263        protected function get_events_transient_key( $location ) {
     264                $key = false;
     265
     266                if ( isset( $location['latitude'], $location['longitude'] ) ) {
     267                        $key = 'community-events-' . md5( $location['latitude'] . $location['longitude'] );
     268                }
     269
     270                return $key;
     271        }
     272
     273        /**
     274         * Cache an array of events data from the Events API.
     275         *
     276         * @access protected
     277         * @since 4.8.0
     278         *
     279         * @param array    $events               Response body from the API request.
     280         * @param int|bool $expiration Optional. Amount of time to cache the events. Defaults to false.
     281         * @return bool `true` if events were cached; `false` if not.
     282         */
     283        protected function cache_events( $events, $expiration = false ) {
     284                $set              = false;
     285                $transient_key    = $this->get_events_transient_key( $events['location'] );
     286                $cache_expiration = $expiration ? absint( $expiration ) : HOUR_IN_SECONDS * 12;
     287
     288                if ( $transient_key ) {
     289                        $set = set_site_transient( $transient_key, $events, $cache_expiration );
     290                }
     291
     292                return $set;
     293        }
     294
     295        /**
     296         * Get cached events
     297         *
     298         * @access public
     299         * @since 4.8.0
     300         *
     301         * @return false|array `false` on failure; an array containing `location`
     302         *                     and `events` items on success.
     303         */
     304        public function get_cached_events() {
     305                $cached_response = get_site_transient( $this->get_events_transient_key( $this->user_location ) );
     306                $cached_response = $this->trim_events( $cached_response );
     307
     308                return $this->format_event_data_time( $cached_response );
     309        }
     310
     311        /**
     312         * Add formatted date and time items for each event in an API response
     313         *
     314         * This has to be called after the data is pulled from the cache, because
     315         * the cached events are shared by all users. If it was called before storing
     316         * the cache, then all users would see the events in the localized data/time
     317         * of the user who triggered the cache refresh, rather than their own.
     318         *
     319         * @access protected
     320         * @since 4.8.0
     321         *
     322         * @param  array $response_body The response which contains the events.
     323         * @return array                The response with dates and times formatted
     324         */
     325        protected function format_event_data_time( $response_body ) {
     326                if ( isset( $response_body['events'] ) ) {
     327                        foreach ( $response_body['events'] as $key => $event ) {
     328                                $timestamp = strtotime( $event['date'] );
     329
     330                                /*
     331                                 * The `date_format` option is not used because it's important
     332                                 * in this context to keep the day of the week in the formatted date,
     333                                 * so that users can tell at a glance if the event is on a day they
     334                                 * are available, without having to open the link.
     335                                 */
     336                                /* translators: Date format for upcoming events on the dashboard. Include the day of the week. See https://secure.php.net/date. */
     337                                $response_body['events'][ $key ]['formatted_date'] = date_i18n( __( 'l, M j, Y' ), $timestamp );
     338                                $response_body['events'][ $key ]['formatted_time'] = date_i18n( get_option( 'time_format' ), $timestamp );
     339                        }
     340                }
     341
     342                return $response_body;
     343        }
     344
     345        /**
     346         * Discard events that occurred more than 24 hours ago, then reduce the remaining list down to three items.
     347         *
     348         * @access protected
     349         * @since 4.8.0
     350         *
     351         * @param  array $response_body The response body which contains the events.
     352         * @return array                The response body with events trimmed.
     353         */
     354        protected function trim_events( $response_body ) {
     355                if ( isset( $response_body['events'] ) ) {
     356                        $current_timestamp = current_time('timestamp' );
     357
     358                        foreach ( $response_body['events'] as $key => $event ) {
     359                                // Skip WordCamps, because they might be multi-day events.
     360                                if ( 'meetup' !== $event['type'] ) {
     361                                        continue;
     362                                }
     363
     364                                $event_timestamp = strtotime( $event['date'] );
     365
     366                                if ( $current_timestamp > $event_timestamp && ( $current_timestamp - $event_timestamp ) > DAY_IN_SECONDS ) {
     367                                        unset( $response_body['events'][ $key ] );
     368                                }
     369                        }
     370
     371                        $response_body['events'] = array_slice( $response_body['events'], 0, 3 );
     372                }
     373
     374                return $response_body;
     375        }
     376
     377        /**
     378         * Log responses to Events API requests
     379         *
     380         * All responses are logged when debugging, even if they're not WP_Errors.
     381         * Debugging info is still needed for "successful" responses, because
     382         * the API might have returned a different location than the one the user
     383         * intended to receive. In those cases, knowing the exact `request_url` is
     384         * critical.
     385         *
     386         * Errors are logged instead of being triggered, to avoid breaking the JSON
     387         * response when called from AJAX handlers and `display_errors` is enabled.
     388         *
     389         * @access protected
     390         * @since 4.8.0
     391         *
     392         * @param string $message        A description of what occurred
     393         * @param array  $debugging_info Details that provide more context for the
     394         *                               log entry
     395         */
     396        protected function maybe_log_events_response( $message, $details ) {
     397                if ( ! WP_DEBUG_LOG ) {
     398                        return;
     399                }
     400
     401                error_log( sprintf(
     402                        '%s: %s. Details: %s',
     403                        __METHOD__,
     404                        trim( $message, '.' ),
     405                        wp_json_encode( $details )
     406                ) );
     407        }
     408}
  • src/wp-admin/includes/dashboard.php

    diff --git src/wp-admin/includes/dashboard.php src/wp-admin/includes/dashboard.php
    index be0b201c9f..4e2b7245f3 100644
    function wp_dashboard_setup() { 
    5252                wp_add_dashboard_widget( 'dashboard_quick_press', $quick_draft_title, 'wp_dashboard_quick_press' );
    5353        }
    5454
    55         // WordPress News
    56         wp_add_dashboard_widget( 'dashboard_primary', __( 'WordPress News' ), 'wp_dashboard_primary' );
     55        // WordPress Events and News
     56        wp_add_dashboard_widget( 'dashboard_primary', __( 'WordPress Events and News' ), 'wp_dashboard_events_news' );
    5757
    5858        if ( is_network_admin() ) {
    5959
    function wp_dashboard_setup() { 
    130130}
    131131
    132132/**
     133 * Get the community events data that needs to be passed to dashboard.js
     134 *
     135 * @since 4.8.0
     136 *
     137 * @return array The script data.
     138 */
     139function get_community_events_script_data() {
     140        require_once( ABSPATH . 'wp-admin/includes/class-wp-community-events.php' );
     141
     142        $user_id       = get_current_user_id();
     143        $user_location = get_user_option( 'community-events-location', $user_id );
     144        $events_client = new WP_Community_Events( $user_id, $user_location );
     145
     146        $script_data = array(
     147                'cache' => $events_client->get_cached_events(),
     148
     149                'l10n' => array(
     150                        'enter_closest_city' => __( 'Enter your closest city name to find nearby events' ),
     151                        'error_occurred_please_try_again' => __( 'An error occured. Please try again.' ),
     152
     153                        /*
     154                         * These specific examples were chosen to highlight the fact that a
     155                         * state is not needed, even for cities whose name is not unique.
     156                         * It would be too cumbersome to include that in the instructions
     157                         * to the user, so it's left as an implication.
     158                         */
     159                        /* translators: %s is the name of the city we couldn't locate. Replace the examples with cities in your locale, but test that they match the expected location before including them. Use endonyms (native locale names) whenever possible. */
     160                        'could_not_locate_city' => __( "We couldn't locate <em>%s</em>. Please try another nearby city. For example: Kansas City; Springfield; Portland." ),
     161
     162                        // This one is only used with wp.a11y.speak(), so it can/should be more brief.
     163                        /* translators: %s is the name of a city. */
     164                        'city_updated' => __( 'City updated. Listing events near %s.' ),
     165                )
     166        );
     167
     168        return $script_data;
     169}
     170
     171/**
    133172 * Adds a new dashboard widget.
    134173 *
    135174 * @since 2.7.0
    function wp_dashboard_rss_control( $widget_id, $form_inputs = array() ) { 
    10691108        wp_widget_rss_form( $widget_options[$widget_id], $form_inputs );
    10701109}
    10711110
     1111
     1112/**
     1113 * Callback function to render the Events and News dashboard widget
     1114 *
     1115 * @since 4.8.0
     1116 */
     1117function wp_dashboard_events_news() {
     1118        wp_print_community_events_markup();
     1119
     1120        ?>
     1121
     1122        <div class="wordpress-news hide-if-no-js">
     1123                <?php wp_dashboard_primary(); ?>
     1124        </div>
     1125
     1126        <p class="community-events-footer">
     1127                <a href="https://make.wordpress.org/community/meetups-landing-page" target="_blank">
     1128                        <?php esc_html_e( 'Meetups' ); ?> <span class="dashicons dashicons-external"></span>
     1129                </a>
     1130
     1131                |
     1132
     1133                <a href="https://central.wordcamp.org/schedule/" target="_blank">
     1134                        <?php esc_html_e( 'WordCamps' ); ?> <span class="dashicons dashicons-external"></span>
     1135                </a>
     1136
     1137                |
     1138
     1139                <?php // translators: If a Rosetta site exists (e.g. https://es.wordpress.org/news/), then use that. Otherwise, leave untranslated. ?>
     1140                <a href="<?php esc_html_e( 'https://wordpress.org/news/' ); ?>" target="_blank">
     1141                        <?php esc_html_e( 'News' ); ?> <span class="dashicons dashicons-external"></span>
     1142                </a>
     1143        </p>
     1144
     1145        <?php
     1146}
     1147
     1148/**
     1149 * Print the markup for the Community Events section of the Events and News Dashboard widget
     1150 *
     1151 * @since 4.8.0
     1152 */
     1153function wp_print_community_events_markup() {
     1154        $script_data = get_community_events_script_data();
     1155
     1156        ?>
     1157
     1158        <div class="community-events-errors notice notice-error inline hide-if-js">
     1159                <p class="hide-if-js">
     1160                        <?php esc_html_e( 'This widget requires JavaScript.'); ?>
     1161                </p>
     1162
     1163                <p class="community-events-error-occurred" aria-hidden="true">
     1164                        <?php echo esc_html( $script_data['l10n']['error_occurred_please_try_again'] ); ?>
     1165                </p>
     1166
     1167                <p class="community-events-could-not-locate" aria-hidden="true"></p>
     1168        </div>
     1169
     1170        <div class="community-events-loading hide-if-no-js">
     1171                <?php esc_html_e( 'Loading&hellip;'); ?>
     1172        </div>
     1173
     1174        <?php
     1175        /*
     1176         * Hide the main element when the page first loads, because the content
     1177         * won't be ready until wp.communityEvents.renderEventsTemplate() has run.
     1178         */
     1179        ?>
     1180        <div id="community-events" class="community-events" aria-hidden="true">
     1181                <div class="activity-block">
     1182                        <p>
     1183                                <span id="community-events-location-message"></span>
     1184
     1185                                <button class="button-link community-events-toggle-location" aria-label="<?php esc_attr_e( 'Edit city'); ?>" aria-expanded="false">
     1186                                        <span class="dashicons dashicons-edit"></span>
     1187                                </button>
     1188                        </p>
     1189
     1190                        <form class="community-events-form" aria-hidden="true" action="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>" method="post">
     1191                                <label for="community-events-location">
     1192                                        <?php esc_html_e( 'City name:' ); ?>
     1193                                </label>
     1194                                <?php /* translators: Replace with the name of a city in your locale that shows events. Use only the city name itself, without any region or country. Use the endonym instead of the English name. */ ?>
     1195                                <input id="community-events-location" class="regular-text" type="text" name="community-events-location" placeholder="<?php esc_attr_e( 'Cincinnati' ); ?>" />
     1196
     1197                                <?php submit_button( esc_html__( 'Submit' ), 'secondary', 'community-events-submit', false ); ?>
     1198
     1199                                <button class="community-events-cancel button button-link" type="button" aria-expanded="false">
     1200                                        <?php esc_html_e( 'Cancel' ); ?>
     1201                                </button>
     1202
     1203                                <span class="spinner"></span>
     1204                        </form>
     1205                </div>
     1206
     1207                <ul class="community-events-results activity-block last"></ul>
     1208        </div>
     1209
     1210        <?php
     1211}
     1212
     1213/**
     1214 * Render the events templates for the Event and News widget
     1215 *
     1216 * @since 4.8.0
     1217 */
     1218function wp_print_community_events_templates() {
     1219        $script_data = get_community_events_script_data();
     1220
     1221        ?>
     1222
     1223        <script id="tmpl-community-events-attend-event-near" type="text/template">
     1224                <?php printf(
     1225                        /* translators: %s is a placeholder for the name of a city. */
     1226                        __( 'Attend an upcoming event near <strong>%s</strong>' ),
     1227                        '{{ data.location.description }}'
     1228                ); ?>
     1229        </script>
     1230
     1231        <script id="tmpl-community-events-could-not-locate" type="text/template">
     1232                <?php printf(
     1233                        $script_data['l10n']['could_not_locate_city'],
     1234                        '{{data.unknownCity}}'
     1235                ); ?>
     1236        </script>
     1237
     1238        <script id="tmpl-community-events-event-list" type="text/template">
     1239                <# _.each( data.events, function( event ) { #>
     1240                        <li class="event event-{{ event.type }} wp-clearfix">
     1241                                <div class="event-info">
     1242                                        <div class="dashicons event-icon" aria-hidden="true"></div>
     1243                                        <div class="event-info-inner">
     1244                                                <a class="event-title" href="{{ event.url }}">{{ event.title }}</a>
     1245                                                <span class="event-city">{{ event.location.location }}</span>
     1246                                        </div>
     1247                                </div>
     1248
     1249                                <div class="event-date-time">
     1250                                        <span class="event-date">{{ event.formatted_date }}</span>
     1251                                        <# if ( 'meetup' === event.type ) { #>
     1252                                                <span class="event-time">{{ event.formatted_time }}</span>
     1253                                        <# } #>
     1254                                </div>
     1255                        </li>
     1256                <# } ) #>
     1257        </script>
     1258
     1259        <script id="tmpl-community-events-no-upcoming-events" type="text/template">
     1260                <li class="event-none">
     1261                        <?php printf(
     1262                                /* translators: Replace the URL if a locale-specific one exists */
     1263                                __( 'There aren\'t any events scheduled near %s at the moment. Would you like to <a href="https://make.wordpress.org/community/handbook/meetup-organizer/welcome/">organize one</a>?' ),
     1264                                '{{data.location.description}}'
     1265                        ); ?>
     1266                </li>
     1267        </script>
     1268
     1269        <?php
     1270}
     1271
    10721272/**
    10731273 * WordPress News dashboard widget.
    10741274 *
    function wp_dashboard_primary() { 
    11051305                         */
    11061306                        'title'        => apply_filters( 'dashboard_primary_title', __( 'WordPress Blog' ) ),
    11071307                        'items'        => 1,
    1108                         'show_summary' => 1,
     1308                        'show_summary' => 0,
    11091309                        'show_author'  => 0,
    1110                         'show_date'    => 1,
     1310                        'show_date'    => 0,
    11111311                ),
    11121312                'planet' => array(
    11131313
    function wp_dashboard_primary() { 
    11521352                )
    11531353        );
    11541354
    1155         if ( ( ! wp_disallow_file_mods( 'dashboard_widget' ) ) && ( ! is_multisite() && is_blog_admin() && current_user_can( 'install_plugins' ) ) || ( is_network_admin() && current_user_can( 'manage_network_plugins' ) && current_user_can( 'install_plugins' ) ) ) {
    1156                 $feeds['plugins'] = array(
    1157                         'link'         => '',
    1158                         'url'          => array(
    1159                                 'popular' => 'http://wordpress.org/plugins/rss/browse/popular/',
    1160                         ),
    1161                         'title'        => '',
    1162                         'items'        => 1,
    1163                         'show_summary' => 0,
    1164                         'show_author'  => 0,
    1165                         'show_date'    => 0,
    1166                 );
    1167         }
    1168 
    11691355        wp_dashboard_cached_rss_widget( 'dashboard_primary', 'wp_dashboard_primary_output', $feeds );
    11701356}
    11711357
    function wp_dashboard_primary_output( $widget_id, $feeds ) { 
    11811367        foreach ( $feeds as $type => $args ) {
    11821368                $args['type'] = $type;
    11831369                echo '<div class="rss-widget">';
    1184                 if ( $type === 'plugins' ) {
    1185                         wp_dashboard_plugins_output( $args['url'], $args );
    1186                 } else {
    11871370                        wp_widget_rss_output( $args['url'], $args );
    1188                 }
    11891371                echo "</div>";
    11901372        }
    11911373}
    11921374
    11931375/**
    1194  * Display plugins text for the WordPress news widget.
    1195  *
    1196  * @since 2.5.0
    1197  *
    1198  * @param string $rss  The RSS feed URL.
    1199  * @param array  $args Array of arguments for this RSS feed.
    1200  */
    1201 function wp_dashboard_plugins_output( $rss, $args = array() ) {
    1202         // Plugin feeds plus link to install them
    1203         $popular = fetch_feed( $args['url']['popular'] );
    1204 
    1205         if ( false === $plugin_slugs = get_transient( 'plugin_slugs' ) ) {
    1206                 $plugin_slugs = array_keys( get_plugins() );
    1207                 set_transient( 'plugin_slugs', $plugin_slugs, DAY_IN_SECONDS );
    1208         }
    1209 
    1210         echo '<ul>';
    1211 
    1212         foreach ( array( $popular ) as $feed ) {
    1213                 if ( is_wp_error( $feed ) || ! $feed->get_item_quantity() )
    1214                         continue;
    1215 
    1216                 $items = $feed->get_items(0, 5);
    1217 
    1218                 // Pick a random, non-installed plugin
    1219                 while ( true ) {
    1220                         // Abort this foreach loop iteration if there's no plugins left of this type
    1221                         if ( 0 == count($items) )
    1222                                 continue 2;
    1223 
    1224                         $item_key = array_rand($items);
    1225                         $item = $items[$item_key];
    1226 
    1227                         list($link, $frag) = explode( '#', $item->get_link() );
    1228 
    1229                         $link = esc_url($link);
    1230                         if ( preg_match( '|/([^/]+?)/?$|', $link, $matches ) )
    1231                                 $slug = $matches[1];
    1232                         else {
    1233                                 unset( $items[$item_key] );
    1234                                 continue;
    1235                         }
    1236 
    1237                         // Is this random plugin's slug already installed? If so, try again.
    1238                         reset( $plugin_slugs );
    1239                         foreach ( $plugin_slugs as $plugin_slug ) {
    1240                                 if ( $slug == substr( $plugin_slug, 0, strlen( $slug ) ) ) {
    1241                                         unset( $items[$item_key] );
    1242                                         continue 2;
    1243                                 }
    1244                         }
    1245 
    1246                         // If we get to this point, then the random plugin isn't installed and we can stop the while().
    1247                         break;
    1248                 }
    1249 
    1250                 // Eliminate some common badly formed plugin descriptions
    1251                 while ( ( null !== $item_key = array_rand($items) ) && false !== strpos( $items[$item_key]->get_description(), 'Plugin Name:' ) )
    1252                         unset($items[$item_key]);
    1253 
    1254                 if ( !isset($items[$item_key]) )
    1255                         continue;
    1256 
    1257                 $raw_title = $item->get_title();
    1258 
    1259                 $ilink = wp_nonce_url('plugin-install.php?tab=plugin-information&plugin=' . $slug, 'install-plugin_' . $slug) . '&amp;TB_iframe=true&amp;width=600&amp;height=800';
    1260                 echo '<li class="dashboard-news-plugin"><span>' . __( 'Popular Plugin' ) . ':</span> ' . esc_html( $raw_title ) .
    1261                         '&nbsp;<a href="' . $ilink . '" class="thickbox open-plugin-details-modal" aria-label="' .
    1262                         /* translators: %s: plugin name */
    1263                         esc_attr( sprintf( __( 'Install %s' ), $raw_title ) ) . '">(' . __( 'Install' ) . ')</a></li>';
    1264 
    1265                 $feed->__destruct();
    1266                 unset( $feed );
    1267         }
    1268 
    1269         echo '</ul>';
    1270 }
    1271 
    1272 /**
    12731376 * Display file upload quota on dashboard.
    12741377 *
    12751378 * Runs on the {@see 'activity_box_end'} hook in wp_dashboard_right_now().
  • src/wp-admin/includes/deprecated.php

    diff --git src/wp-admin/includes/deprecated.php src/wp-admin/includes/deprecated.php
    index 2bf25d3336..76f7709ff2 100644
    function wp_dashboard_secondary() {} 
    12951295function wp_dashboard_secondary_control() {}
    12961296
    12971297/**
     1298 * Display plugins text for the WordPress news widget.
     1299 *
     1300 * @since 2.5.0
     1301 * @deprecated 4.8.0
     1302 *
     1303 * @param string $rss  The RSS feed URL.
     1304 * @param array  $args Array of arguments for this RSS feed.
     1305 */
     1306function wp_dashboard_plugins_output( $rss, $args = array() ) {
     1307        _deprecated_function( __FUNCTION__, '4.8.0' );
     1308
     1309        // Plugin feeds plus link to install them
     1310        $popular = fetch_feed( $args['url']['popular'] );
     1311
     1312        if ( false === $plugin_slugs = get_transient( 'plugin_slugs' ) ) {
     1313                $plugin_slugs = array_keys( get_plugins() );
     1314                set_transient( 'plugin_slugs', $plugin_slugs, DAY_IN_SECONDS );
     1315        }
     1316
     1317        echo '<ul>';
     1318
     1319        foreach ( array( $popular ) as $feed ) {
     1320                if ( is_wp_error( $feed ) || ! $feed->get_item_quantity() )
     1321                        continue;
     1322
     1323                $items = $feed->get_items(0, 5);
     1324
     1325                // Pick a random, non-installed plugin
     1326                while ( true ) {
     1327                        // Abort this foreach loop iteration if there's no plugins left of this type
     1328                        if ( 0 == count($items) )
     1329                                continue 2;
     1330
     1331                        $item_key = array_rand($items);
     1332                        $item = $items[$item_key];
     1333
     1334                        list($link, $frag) = explode( '#', $item->get_link() );
     1335
     1336                        $link = esc_url($link);
     1337                        if ( preg_match( '|/([^/]+?)/?$|', $link, $matches ) )
     1338                                $slug = $matches[1];
     1339                        else {
     1340                                unset( $items[$item_key] );
     1341                                continue;
     1342                        }
     1343
     1344                        // Is this random plugin's slug already installed? If so, try again.
     1345                        reset( $plugin_slugs );
     1346                        foreach ( $plugin_slugs as $plugin_slug ) {
     1347                                if ( $slug == substr( $plugin_slug, 0, strlen( $slug ) ) ) {
     1348                                        unset( $items[$item_key] );
     1349                                        continue 2;
     1350                                }
     1351                        }
     1352
     1353                        // If we get to this point, then the random plugin isn't installed and we can stop the while().
     1354                        break;
     1355                }
     1356
     1357                // Eliminate some common badly formed plugin descriptions
     1358                while ( ( null !== $item_key = array_rand($items) ) && false !== strpos( $items[$item_key]->get_description(), 'Plugin Name:' ) )
     1359                        unset($items[$item_key]);
     1360
     1361                if ( !isset($items[$item_key]) )
     1362                        continue;
     1363
     1364                $raw_title = $item->get_title();
     1365
     1366                $ilink = wp_nonce_url('plugin-install.php?tab=plugin-information&plugin=' . $slug, 'install-plugin_' . $slug) . '&amp;TB_iframe=true&amp;width=600&amp;height=800';
     1367                echo '<li class="dashboard-news-plugin"><span>' . __( 'Popular Plugin' ) . ':</span> ' . esc_html( $raw_title ) .
     1368                        '&nbsp;<a href="' . $ilink . '" class="thickbox open-plugin-details-modal" aria-label="' .
     1369                        /* translators: %s: plugin name */
     1370                        esc_attr( sprintf( __( 'Install %s' ), $raw_title ) ) . '">(' . __( 'Install' ) . ')</a></li>';
     1371
     1372                $feed->__destruct();
     1373                unset( $feed );
     1374        }
     1375
     1376        echo '</ul>';
     1377}
     1378
     1379/**
     1380 * noop the Nearby WordPress Events feature plugin bootstrap process
     1381 *
     1382 * This function was never used in Core, but it's necessary to prevent the
     1383 * feature plugin from bootstrapping now that its functionality has been merged
     1384 * into Core.
     1385 *
     1386 * See https://plugins.trac.wordpress.org/browser/nearby-wp-events/tags/0.7/nearby-wordpress-events.php?marks=58-87#L55
     1387 *
     1388 * @deprecated 4.8.0
     1389 */
     1390function wp_get_nearby_events() {}
     1391
     1392/**
    12981393 * This was once used to move child posts to a new parent.
    12991394 *
    13001395 * @since 2.3.0
  • src/wp-admin/index.php

    diff --git src/wp-admin/index.php src/wp-admin/index.php
    index d2d7ec889d..e2dde48aaa 100644
    require_once(ABSPATH . 'wp-admin/includes/dashboard.php'); 
    1515wp_dashboard_setup();
    1616
    1717wp_enqueue_script( 'dashboard' );
     18wp_localize_script( 'dashboard', 'communityEventsData', get_community_events_script_data() );
     19
    1820if ( current_user_can( 'edit_theme_options' ) )
    1921        wp_enqueue_script( 'customize-loader' );
    2022if ( current_user_can( 'install_plugins' ) ) {
    include( ABSPATH . 'wp-admin/admin-header.php' ); 
    138140</div><!-- wrap -->
    139141
    140142<?php
     143wp_print_community_events_templates();
     144
    141145require( ABSPATH . 'wp-admin/admin-footer.php' );
  • src/wp-admin/js/dashboard.js

    diff --git src/wp-admin/js/dashboard.js src/wp-admin/js/dashboard.js
    index fa100dd16c..a6a5662871 100644
    jQuery(document).ready( function($) { 
    187187        }
    188188
    189189} );
     190
     191
     192wp.communityEvents = wp.communityEvents || {};
     193
     194jQuery( function( $ ) {
     195        'use strict';
     196
     197        var app = wp.communityEvents = {
     198                initialized: false,
     199                model: null,
     200
     201                /**
     202                 * Main entry point
     203                 *
     204                 * @since 4.8.0
     205                 */
     206                init: function() {
     207                        if ( app.initialized ) {
     208                                return;
     209                        }
     210
     211                        var $container = $( '#community-events' );
     212
     213                        /*
     214                         * When JavaScript is disabled, the errors container is shown, so
     215                         * that "This widget requires Javascript" message can be seen.
     216                         *
     217                         * When JS is enabled, the container is hidden at first, and then
     218                         * revealed during the template rendering, if there actually are
     219                         * errors to show.
     220                         *
     221                         * The display indicator switches from `hide-if-js` to `aria-hidden`
     222                         * here in order to maintain consistency with all the other fields
     223                         * that key off of `aria-hidden` to determine their visibility.
     224                         * `aria-hidden` can't be used initially, because there would be no
     225                         * way to set it to false when JavaScript is disabled, which would
     226                         * prevent people from seeing the "This widget requires JavaScript"
     227                         * message.
     228                         */
     229                        $( '.community-events-errors' )
     230                                .attr( 'aria-hidden', true )
     231                                .removeClass( 'hide-if-js' );
     232
     233                        $container.on( 'click', '.community-events-toggle-location', app.toggleLocationForm );
     234                        $container.on( 'click', '.community-events-cancel', app.toggleLocationForm );
     235
     236                        $container.on( 'submit', '.community-events-form', function( event ) {
     237                                event.preventDefault();
     238
     239                                app.getEvents( {
     240                                        location: $( '#community-events-location' ).val()
     241                                });
     242                        });
     243
     244                        if ( communityEventsData && communityEventsData.cache && communityEventsData.cache.location && communityEventsData.cache.events ) {
     245                                app.renderEventsTemplate( communityEventsData.cache, 'app' );
     246                        } else {
     247                                app.getEvents();
     248                        }
     249
     250                        app.initialized = true;
     251                },
     252
     253                /**
     254                 * Toggle the visibility of the Edit Location form
     255                 *
     256                 * @since 4.8.0
     257                 *
     258                 * @param {event|string} action 'show' or 'hide' to specify a state;
     259                 *                              Or an event object to flip between states
     260                 */
     261                toggleLocationForm: function( action ) {
     262                        var $toggleButton = $( '.community-events-toggle-location' ),
     263                            $cancelButton = $( '.community-events-cancel' ),
     264                            $form         = $( '.community-events-form' );
     265
     266                        if ( 'object' === typeof action ) {
     267                                // Strict comparison doesn't work in this case.
     268                                action = 'true' == $toggleButton.attr( 'aria-expanded' ) ? 'hide' : 'show';
     269                        }
     270
     271                        if ( 'hide' === action ) {
     272                                $toggleButton.attr( 'aria-expanded', false );
     273                                $cancelButton.attr( 'aria-expanded', false );
     274                                $form.attr( 'aria-hidden', true );
     275                        } else {
     276                                $toggleButton.attr( 'aria-expanded', true );
     277                                $cancelButton.attr( 'aria-expanded', true );
     278                                $form.attr( 'aria-hidden', false );
     279                        }
     280                },
     281
     282                /**
     283                 * Send Ajax request to fetch events for the widget
     284                 *
     285                 * @since 4.8.0
     286                 *
     287                 * @param {object} requestParams
     288                 */
     289                getEvents: function( requestParams ) {
     290                        var initiatedBy,
     291                            app = this,
     292                            $spinner = $( '.community-events-form' ).children( '.spinner' ),
     293                            dashboardLoadPromise = wp.api.init( { 'versionString': 'wp/dashboard/v1/' } );
     294
     295                        requestParams          = requestParams || {};
     296                        requestParams.timezone = window.Intl ? window.Intl.DateTimeFormat().resolvedOptions().timeZone : '';
     297
     298                        initiatedBy = requestParams.location ? 'user' : 'app';
     299
     300                        $spinner.addClass( 'is-active' );
     301
     302                        dashboardLoadPromise.done( function() {
     303                                if ( ! app.model ) {
     304                                        app.model = new wp.api.models.CommunityEventsMe();
     305                                }
     306
     307                                $.when( app.model.fetch( { data: requestParams } ) )
     308                                        .always( function() {
     309                                                $spinner.removeClass( 'is-active' );
     310                                        })
     311
     312                                        .done( function( response ) {
     313                                                if ( 'no_location_available' === response.error ) {
     314                                                        if ( requestParams.location ) {
     315                                                                response.unknownCity = requestParams.location;
     316                                                        } else {
     317                                                                /*
     318                                                                 * No location was passed, which means that this was an automatic query
     319                                                                 * based on IP, locale, and timezone. Since the user didn't initiate it,
     320                                                                 * it should fail silently. Otherwise, the error could confuse and/or
     321                                                                 * annoy them.
     322                                                                 */
     323                                                                delete response.error;
     324                                                        }
     325                                                }
     326                                                app.renderEventsTemplate( response, initiatedBy );
     327                                        })
     328
     329                                        .fail( function() {
     330                                                app.renderEventsTemplate( {
     331                                                        'location' : false,
     332                                                        'error'    : true
     333                                                }, initiatedBy );
     334                                        });
     335                        });
     336                },
     337
     338                /**
     339                 * Render the template for the Events section of the Events & News widget
     340                 *
     341                 * @since 4.8.0
     342                 *
     343                 * @param {Object} templateParams The various parameters that will get passed to wp.template
     344                 * @param {string} initiatedBy    'user' to indicate that this was triggered manually by the user;
     345                 *                                'app' to indicate it was triggered automatically by the app itself.
     346                 */
     347                renderEventsTemplate: function( templateParams, initiatedBy ) {
     348                        var template,
     349                            elementVisibility,
     350                            l10nPlaceholder  = /%(?:\d\$)?s/g, // Match `%s`, `%1$s`, `%2$s`, etc.
     351                            $locationMessage = $( '#community-events-location-message' ),
     352                            $results         = $( '.community-events-results' );
     353
     354                        /*
     355                         * Hide all toggleable elements by default, to keep the logic simple.
     356                         * Otherwise, each block below would have to turn hide everything that
     357                         * could have been shown at an earlier point.
     358                         *
     359                         * The exception to that is that the .community-events container. It's hidden
     360                         * when the page is first loaded, because the content isn't ready yet,
     361                         * but once we've reached this point, it should always be shown.
     362                         */
     363                        elementVisibility = {
     364                                '.community-events'                  : true,
     365                                '.community-events-loading'          : false,
     366                                '.community-events-errors'           : false,
     367                                '.community-events-error-occurred'   : false,
     368                                '.community-events-could-not-locate' : false,
     369                                '#community-events-location-message' : false,
     370                                '.community-events-toggle-location'  : false,
     371                                '.community-events-results'          : false
     372                        };
     373
     374                        /*
     375                         * Determine which templates should be rendered and which elements
     376                         * should be displayed
     377                         */
     378                        if ( templateParams.location && templateParams.location.description ) {
     379                                template = wp.template( 'community-events-attend-event-near' );
     380                                $locationMessage.html( template( templateParams ) );
     381
     382                                if ( templateParams.events.length ) {
     383                                        template = wp.template( 'community-events-event-list' );
     384                                        $results.html( template( templateParams ) );
     385                                } else {
     386                                        template = wp.template( 'community-events-no-upcoming-events' );
     387                                        $results.html( template( templateParams ) );
     388                                }
     389                                wp.a11y.speak( communityEventsData.l10n.city_updated.replace( l10nPlaceholder, templateParams.location.description ) );
     390
     391                                elementVisibility['#community-events-location-message'] = true;
     392                                elementVisibility['.community-events-toggle-location']  = true;
     393                                elementVisibility['.community-events-results']          = true;
     394
     395                        } else if ( templateParams.unknownCity ) {
     396                                template = wp.template( 'community-events-could-not-locate' );
     397                                $( '.community-events-could-not-locate' ).html( template( templateParams ) );
     398                                wp.a11y.speak( communityEventsData.l10n.could_not_locate_city.replace( l10nPlaceholder, templateParams.unknownCity ) );
     399
     400                                elementVisibility['.community-events-errors']           = true;
     401                                elementVisibility['.community-events-could-not-locate'] = true;
     402
     403                        } else if ( templateParams.error && 'user' === initiatedBy ) {
     404                                /*
     405                                 * Errors messages are only shown for requests that were initiated
     406                                 * by the user, not for ones that were initiated by the app itself.
     407                                 * Showing error messages for an event that user isn't aware of
     408                                 * could be confusing or unnecessarily distracting.
     409                                 */
     410                                wp.a11y.speak( communityEventsData.l10n.error_occurred_please_try_again );
     411
     412                                elementVisibility['.community-events-errors']         = true;
     413                                elementVisibility['.community-events-error-occurred'] = true;
     414
     415                        } else {
     416                                $locationMessage.text( communityEventsData.l10n.enter_closest_city );
     417
     418                                elementVisibility['#community-events-location-message'] = true;
     419                                elementVisibility['.community-events-toggle-location']  = true;
     420                        }
     421
     422                        // Set the visibility of toggleable elements.
     423                        _.each( elementVisibility, function( isVisible, element ) {
     424                                $( element ).attr( 'aria-hidden', ! isVisible );
     425                        });
     426
     427                        $( '.community-events-toggle-location' ).attr( 'aria-expanded', elementVisibility['.community-events-toggle-location'] );
     428
     429                        /*
     430                         * During the initial page load, the location form should be hidden
     431                         * by default if the user has saved a valid location during a previous
     432                         * session. It's safe to assume that they want to continue using that
     433                         * location, and displaying the form would unnecessarily clutter the
     434                         * widget.
     435                         */
     436                        if ( 'app' === initiatedBy && templateParams.location.description ) {
     437                                app.toggleLocationForm( 'hide' );
     438                        } else {
     439                                app.toggleLocationForm( 'show' );
     440                        }
     441                }
     442        };
     443
     444        if ( $( '#dashboard_primary' ).is( ':visible' ) ) {
     445                app.init();
     446        } else {
     447                $( document ).on( 'postbox-toggled', function( event, postbox ) {
     448                        var $postbox = $( postbox );
     449
     450                        if ( 'dashboard_primary' === $postbox.attr( 'id' ) && $postbox.is( ':visible' ) ) {
     451                                app.init();
     452                        }
     453                });
     454        }
     455});
  • src/wp-admin/network/index.php

    diff --git src/wp-admin/network/index.php src/wp-admin/network/index.php
    index 81ededbe37..e3a2c03ad7 100644
    get_current_screen()->set_help_sidebar( 
    5454wp_dashboard_setup();
    5555
    5656wp_enqueue_script( 'dashboard' );
     57wp_localize_script( 'dashboard', 'communityEventsData', get_community_events_script_data() );
    5758wp_enqueue_script( 'plugin-install' );
    5859add_thickbox();
    5960
    require_once( ABSPATH . 'wp-admin/admin-header.php' ); 
    7374
    7475</div><!-- wrap -->
    7576
    76 <?php include( ABSPATH . 'wp-admin/admin-footer.php' ); ?>
     77<?php
     78wp_print_community_events_templates();
     79include( ABSPATH . 'wp-admin/admin-footer.php' );
  • src/wp-includes/rest-api.php

    diff --git src/wp-includes/rest-api.php src/wp-includes/rest-api.php
    index e6cdc3a959..8da64fdc5f 100644
    function create_initial_rest_routes() { 
    237237        // Settings.
    238238        $controller = new WP_REST_Settings_Controller;
    239239        $controller->register_routes();
     240
     241        // Dashboard.
     242        register_rest_route(
     243                'wp/dashboard/v1',
     244                '/community-events/me',
     245                array(
     246                        'methods'             => WP_REST_Server::READABLE,
     247                        'callback'            => 'rest_get_community_events',
     248                        'permission_callback' => 'rest_get_community_events_permissions_check',
     249
     250                        'args' => array(
     251                                'location' => array( 'validate_callback' => 'sanitize_text_field' ),
     252                                'timezone' => array( 'validate_callback' => 'sanitize_text_field' ),
     253                        ),
     254                )
     255        );
     256}
     257
     258/**
     259 * Checks if a given request has access to get community events.
     260 *
     261 * @return bool
     262 */
     263function rest_get_community_events_permissions_check() {
     264        return current_user_can( 'read' );
     265}
     266
     267/**
     268 * Retrieves nearby events.
     269 *
     270 * @since 4.8.0
     271 *
     272 * @param  WP_REST_Request $request Full details about the request.
     273 * @return array|WP_Error           WP_REST_Response on success, or WP_Error object on failure.
     274 */
     275function rest_get_community_events( $request ) {
     276        $user_id = get_current_user_id();
     277
     278        if ( empty( $user_id ) ) {
     279                return new WP_Error( 'rest_not_logged_in', __( 'You are not currently logged in.' ), array( 'status' => 401 ) );
     280        }
     281
     282        require_once( ABSPATH . 'wp-admin/includes/class-wp-community-events.php' );
     283
     284        $saved_location = get_user_option( 'community-events-location', $user_id );
     285        $events_client  = new WP_Community_Events( $user_id, $saved_location );
     286        $events         = $events_client->get_events( $request->get_param('location'), $request->get_param('timezone') );
     287
     288        // Store the location network-wide, so the user doesn't have to set it on each site.
     289        if ( ! is_wp_error( $events ) && isset( $events['location'] ) ) {
     290                update_user_option( $user_id, 'community-events-location', $events['location'], true );
     291        }
     292
     293        return $events;
    240294}
    241295
    242296/**
  • src/wp-includes/script-loader.php

    diff --git src/wp-includes/script-loader.php src/wp-includes/script-loader.php
    index def438c260..8bcfa03d93 100644
    function wp_default_scripts( &$scripts ) { 
    724724                        'current' => __( 'Current Color' ),
    725725                ) );
    726726
    727                 $scripts->add( 'dashboard', "/wp-admin/js/dashboard$suffix.js", array( 'jquery', 'admin-comments', 'postbox' ), false, 1 );
     727                $scripts->add( 'dashboard', "/wp-admin/js/dashboard$suffix.js", array( 'jquery', 'admin-comments', 'postbox', 'wp-api', 'wp-util', 'wp-a11y' ), false, 1 );
    728728
    729729                $scripts->add( 'list-revisions', "/wp-includes/js/wp-list-revisions$suffix.js" );
    730730
  • new file tests/phpunit/tests/admin/includesCommunityEvents.php

    diff --git tests/phpunit/tests/admin/includesCommunityEvents.php tests/phpunit/tests/admin/includesCommunityEvents.php
    new file mode 100644
    index 0000000000..f0a0a7eafe
    - +  
     1<?php
     2/**
     3 * Unit tests for methods in WP_Community_Events.
     4 *
     5 * @package WordPress
     6 * @subpackage UnitTests
     7 * @since 4.8.0
     8 */
     9
     10/**
     11 * Class Test_WP_Community_Events.
     12 *
     13 * @group admin
     14 * @group community-events
     15 *
     16 * @since 4.8.0
     17 */
     18class Test_WP_Community_Events extends WP_UnitTestCase {
     19        /**
     20         * An instance of the class to test.
     21         *
     22         * @access private
     23         * @since 4.8.0
     24         *
     25         * @var WP_Community_Events
     26         */
     27        private $instance;
     28
     29        /**
     30         * Perform for every test.
     31         *
     32         * @access public
     33         * @since 4.8.0
     34         */
     35        public function setUp() {
     36                parent::setUp();
     37
     38                require_once( ABSPATH . 'wp-admin/includes/class-wp-community-events.php' );
     39
     40                $this->instance = new WP_Community_Events( 1, $this->get_user_location() );
     41        }
     42
     43        /**
     44         * Simulate a stored user location.
     45         *
     46         * @access private
     47         * @since 4.8.0
     48         *
     49         * @return array The mock location.
     50         */
     51        private function get_user_location() {
     52                return array(
     53                        'description' => 'San Francisco',
     54                        'latitude'    => '37.7749300',
     55                        'longitude'   => '-122.4194200',
     56                        'country'     => 'US',
     57                );
     58        }
     59
     60        /**
     61         * Test: `get_events()` should return an instance of WP_Error if the response code is not 200.
     62         *
     63         * @access public
     64         * @since 4.8.0
     65         */
     66        public function test_get_events_bad_response_code() {
     67                add_filter( 'pre_http_request', array( $this, '_http_request_bad_response_code' ) );
     68
     69                $this->assertWPError( $this->instance->get_events() );
     70
     71                remove_filter( 'pre_http_request', array( $this, '_http_request_bad_response_code' ) );
     72        }
     73
     74        /**
     75         * Test: The response body should not be cached if the response code is not 200.
     76         *
     77         * @access public
     78         * @since 4.8.0
     79         */
     80        public function test_get_cached_events_bad_response_code() {
     81                add_filter( 'pre_http_request', array( $this, '_http_request_bad_response_code' ) );
     82
     83                $this->instance->get_events();
     84
     85                $this->assertFalse( $this->instance->get_cached_events() );
     86
     87                remove_filter( 'pre_http_request', array( $this, '_http_request_bad_response_code' ) );
     88        }
     89
     90        /**
     91         * Simulate an HTTP response with a non-200 response code.
     92         *
     93         * @access public
     94         * @since 4.8.0
     95         *
     96         * @return array A mock response with a 404 HTTP status code
     97         */
     98        public function _http_request_bad_response_code() {
     99                return array(
     100                        'headers'  => '',
     101                        'body'     => '',
     102                        'response' => array(
     103                                'code' => 404,
     104                        ),
     105                        'cookies'  => '',
     106                        'filename' => '',
     107                );
     108        }
     109
     110        /**
     111         * Test: `get_events()` should return an instance of WP_Error if the response body does not have
     112         * the required properties.
     113         *
     114         * @access public
     115         * @since 4.8.0
     116         */
     117        public function test_get_events_invalid_response() {
     118                add_filter( 'pre_http_request', array( $this, '_http_request_invalid_response' ) );
     119
     120                $this->assertWPError( $this->instance->get_events() );
     121
     122                remove_filter( 'pre_http_request', array( $this, '_http_request_invalid_response' ) );
     123        }
     124
     125        /**
     126         * Test: The response body should not be cached if it does not have the required properties.
     127         *
     128         * @access public
     129         * @since 4.8.0
     130         */
     131        public function test_get_cached_events_invalid_response() {
     132                add_filter( 'pre_http_request', array( $this, '_http_request_invalid_response' ) );
     133
     134                $this->instance->get_events();
     135
     136                $this->assertFalse( $this->instance->get_cached_events() );
     137
     138                remove_filter( 'pre_http_request', array( $this, '_http_request_invalid_response' ) );
     139        }
     140
     141        /**
     142         * Simulate an HTTP response with a body that does not have the required properties.
     143         *
     144         * @access public
     145         * @since 4.8.0
     146         *
     147         * @return array A mock response that's missing required properties.
     148         */
     149        public function _http_request_invalid_response() {
     150                return array(
     151                        'headers'  => '',
     152                        'body'     => wp_json_encode( array() ),
     153                        'response' => array(
     154                                'code' => 200,
     155                        ),
     156                        'cookies'  => '',
     157                        'filename' => '',
     158                );
     159        }
     160
     161        /**
     162         * Test: With a valid response, `get_events()` should return an associated array containing a location array and
     163         * an events array with individual events that have formatted time and date.
     164         *
     165         * @access public
     166         * @since 4.8.0
     167         */
     168        public function test_get_events_valid_response() {
     169                add_filter( 'pre_http_request', array( $this, '_http_request_valid_response' ) );
     170
     171                $response = $this->instance->get_events();
     172
     173                $this->assertNotWPError( $response );
     174                $this->assertEqualSetsWithIndex( $this->get_user_location(), $response['location'] );
     175                $this->assertEquals( date( 'l, M j, Y', strtotime( 'next Sunday 1pm' ) ), $response['events'][0]['formatted_date'] );
     176                $this->assertEquals( '1:00 pm', $response['events'][0]['formatted_time'] );
     177
     178                remove_filter( 'pre_http_request', array( $this, '_http_request_valid_response' ) );
     179        }
     180
     181        /**
     182         * Test: `get_cached_events()` should return the same data as `get_events()`, including formatted time
     183         * and date values for each event.
     184         *
     185         * @access public
     186         * @since 4.8.0
     187         */
     188        public function test_get_cached_events_valid_response() {
     189                add_filter( 'pre_http_request', array( $this, '_http_request_valid_response' ) );
     190
     191                $this->instance->get_events();
     192
     193                $cached_events = $this->instance->get_cached_events();
     194
     195                $this->assertNotWPError( $cached_events );
     196                $this->assertEqualSetsWithIndex( $this->get_user_location(), $cached_events['location'] );
     197                $this->assertEquals( date( 'l, M j, Y', strtotime( 'next Sunday 1pm' ) ), $cached_events['events'][0]['formatted_date'] );
     198                $this->assertEquals( '1:00 pm', $cached_events['events'][0]['formatted_time'] );
     199
     200                remove_filter( 'pre_http_request', array( $this, '_http_request_valid_response' ) );
     201        }
     202
     203        /**
     204         * Simulate an HTTP response with valid location and event data.
     205         *
     206         * @access public
     207         * @since 4.8.0
     208         *
     209         * @return array A mock HTTP response with valid data.
     210         */
     211        public function _http_request_valid_response() {
     212                return array(
     213                        'headers'  => '',
     214                        'body'     => wp_json_encode( array(
     215                                'location' => $this->get_user_location(),
     216                                'events'   => array(
     217                                        array(
     218                                                'type'           => 'meetup',
     219                                                'title'          => 'Flexbox + CSS Grid: Magic for Responsive Layouts',
     220                                                'url'            => 'https://www.meetup.com/Eastbay-WordPress-Meetup/events/236031233/',
     221                                                'meetup'         => 'The East Bay WordPress Meetup Group',
     222                                                'meetup_url'     => 'https://www.meetup.com/Eastbay-WordPress-Meetup/',
     223                                                'date'           => date( 'Y-m-d H:i:s', strtotime( 'next Sunday 1pm' ) ),
     224                                                'location'       => array(
     225                                                        'location'  => 'Oakland, CA, USA',
     226                                                        'country'   => 'us',
     227                                                        'latitude'  => 37.808453,
     228                                                        'longitude' => -122.26593,
     229                                                ),
     230                                        ),
     231                                        array(
     232                                                'type'           => 'meetup',
     233                                                'title'          => 'Part 3- Site Maintenance - Tools to Make It Easy',
     234                                                'url'            => 'https://www.meetup.com/Wordpress-Bay-Area-CA-Foothills/events/237706839/',
     235                                                'meetup'         => 'WordPress Bay Area Foothills Group',
     236                                                'meetup_url'     => 'https://www.meetup.com/Wordpress-Bay-Area-CA-Foothills/',
     237                                                'date'           => date( 'Y-m-d H:i:s', strtotime( 'next Wednesday 1:30pm' ) ),
     238                                                'location'       => array(
     239                                                        'location'  => 'Milpitas, CA, USA',
     240                                                        'country'   => 'us',
     241                                                        'latitude'  => 37.432813,
     242                                                        'longitude' => -121.907095,
     243                                                ),
     244                                        ),
     245                                        array(
     246                                                'type'           => 'wordcamp',
     247                                                'title'          => 'WordCamp Kansas City',
     248                                                'url'            => 'https://2017.kansascity.wordcamp.org',
     249                                                'meetup'         => null,
     250                                                'meetup_url'     => null,
     251                                                'date'           => date( 'Y-m-d H:i:s', strtotime( 'next Saturday' ) ),
     252                                                'location'       => array(
     253                                                        'location'  => 'Kansas City, MO',
     254                                                        'country'   => 'US',
     255                                                        'latitude'  => 39.0392325,
     256                                                        'longitude' => -94.577076,
     257                                                ),
     258                                        ),
     259                                ),
     260                        ) ),
     261                        'response' => array(
     262                                'code' => 200,
     263                        ),
     264                        'cookies'  => '',
     265                        'filename' => '',
     266                );
     267        }
     268}