From 542c3d4bb1f5cf4c4436a797a75963a8a721e173 Mon Sep 17 00:00:00 2001 From: tellyworth Date: Thu, 22 Oct 2020 04:05:05 +0000 Subject: [PATCH] Community Events: Display dates and times in the user's time zone. Fixes #51130 Merges [49145], [49146], [49147], [49152], and [49201] to the 5.5 branch. Props sippis, hlashbrooke, audrasjb, Rarst, iandunn Built from https://develop.svn.wordpress.org/branches/5.5@49275 git-svn-id: http://core.svn.wordpress.org/branches/5.5@49037 1a063a9b-81f0-0310-95a4-ce76da25c4cd --- .../includes/class-wp-community-events.php | 85 +++++--- wp-admin/includes/dashboard.php | 6 +- wp-admin/js/dashboard.js | 200 ++++++++++++++++++ wp-admin/js/dashboard.min.js | 2 +- wp-includes/script-loader.php | 10 +- wp-includes/version.php | 2 +- 6 files changed, 263 insertions(+), 42 deletions(-) diff --git a/wp-admin/includes/class-wp-community-events.php b/wp-admin/includes/class-wp-community-events.php index 4ab7149893..3f09e4f25d 100644 --- a/wp-admin/includes/class-wp-community-events.php +++ b/wp-admin/includes/class-wp-community-events.php @@ -77,6 +77,8 @@ class WP_Community_Events { * mitigates possible privacy concerns. * * @since 4.8.0 + * @since 5.5.2 Response no longer contains formatted date field. They're added + * in `wp.communityEvents.populateDynamicEventFields()` now. * * @param string $location_search Optional. City name to help determine the location. * e.g., "Seattle". Default empty string. @@ -158,10 +160,13 @@ class WP_Community_Events { $response_body['location']['description'] = $this->user_location['description']; } + /* + * Store the raw response, because events will expire before the cache does. + * The response will need to be processed every page load. + */ $this->cache_events( $response_body, $expiration ); - $response_body = $this->trim_events( $response_body ); - $response_body = $this->format_event_data_time( $response_body ); + $response_body['events'] = $this->trim_events( $response_body['events'] ); return $response_body; } @@ -340,15 +345,20 @@ class WP_Community_Events { * Gets cached events. * * @since 4.8.0 + * @since 5.5.2 Response no longer contains formatted date field. They're added + * in `wp.communityEvents.populateDynamicEventFields()` now. * * @return array|false An array containing `location` and `events` items * on success, false on failure. */ public function get_cached_events() { $cached_response = get_site_transient( $this->get_events_transient_key( $this->user_location ) ); - $cached_response = $this->trim_events( $cached_response ); - return $this->format_event_data_time( $cached_response ); + if ( isset( $cached_response['events'] ) ) { + $cached_response['events'] = $this->trim_events( $cached_response['events'] ); + } + + return $cached_response; } /** @@ -360,11 +370,18 @@ class WP_Community_Events { * of the user who triggered the cache refresh, rather than their own. * * @since 4.8.0 + * @deprecated 5.6.0 No longer used in core. * * @param array $response_body The response which contains the events. * @return array The response with dates and times formatted. */ protected function format_event_data_time( $response_body ) { + _deprecated_function( + __METHOD__, + '5.5.2', + 'This is no longer used by core, and only kept for backward compatibility.' + ); + if ( isset( $response_body['events'] ) ) { foreach ( $response_body['events'] as $key => $event ) { $timestamp = strtotime( $event['date'] ); @@ -435,44 +452,44 @@ class WP_Community_Events { * * @since 4.8.0 * @since 4.9.7 Stick a WordCamp to the final list. + * @since 5.5.2 Accepts and returns only the events, rather than an entire HTTP response. * - * @param array $response_body The response body which contains the events. + * @param array $events The events that will be prepared. * @return array The response body with events trimmed. */ - protected function trim_events( $response_body ) { - if ( isset( $response_body['events'] ) ) { - $wordcamps = array(); - $today = current_time( 'Y-m-d' ); + protected function trim_events( array $events ) { + $future_events = array(); - foreach ( $response_body['events'] as $key => $event ) { - /* - * Skip WordCamps, because they might be multi-day events. - * Save a copy so they can be pinned later. - */ - if ( 'wordcamp' === $event['type'] ) { - $wordcamps[] = $event; - continue; - } + foreach ( $events as $event ) { + /* + * The API's `date` and `end_date` fields are in the _event's_ local timezone, but UTC is needed so + * it can be converted to the _user's_ local time. + */ + $end_time = (int) $event['end_unix_timestamp']; - // We don't get accurate time with timezone from API, so we only take the date part (Y-m-d). - $event_date = substr( $event['date'], 0, 10 ); - - if ( $today > $event_date ) { - unset( $response_body['events'][ $key ] ); - } - } - - $response_body['events'] = array_slice( $response_body['events'], 0, 3 ); - $trimmed_event_types = wp_list_pluck( $response_body['events'], 'type' ); - - // Make sure the soonest upcoming WordCamp is pinned in the list. - if ( ! in_array( 'wordcamp', $trimmed_event_types, true ) && $wordcamps ) { - array_pop( $response_body['events'] ); - array_push( $response_body['events'], $wordcamps[0] ); + if ( time() < $end_time ) { + array_push( $future_events, $event ); } } - return $response_body; + $future_wordcamps = array_filter( + $future_events, + function( $wordcamp ) { + return 'wordcamp' === $wordcamp['type']; + } + ); + + $future_wordcamps = array_values( $future_wordcamps ); // Remove gaps in indices. + $trimmed_events = array_slice( $future_events, 0, 3 ); + $trimmed_event_types = wp_list_pluck( $trimmed_events, 'type' ); + + // Make sure the soonest upcoming WordCamp is pinned in the list. + if ( $future_wordcamps && ! in_array( 'wordcamp', $trimmed_event_types, true ) ) { + array_pop( $trimmed_events ); + array_push( $trimmed_events, $future_wordcamps[0] ); + } + + return $trimmed_events; } /** diff --git a/wp-admin/includes/dashboard.php b/wp-admin/includes/dashboard.php index 6b49163092..f758c2c662 100644 --- a/wp-admin/includes/dashboard.php +++ b/wp-admin/includes/dashboard.php @@ -1379,9 +1379,11 @@ function wp_print_community_events_templates() {
- {{ event.formatted_date }} + {{ event.user_formatted_date }} <# if ( 'meetup' === event.type ) { #> - {{ event.formatted_time }} + + {{ event.user_formatted_time }} {{ event.timeZoneAbbreviation }} + <# } #>
diff --git a/wp-admin/js/dashboard.js b/wp-admin/js/dashboard.js index 581746bb45..1609d27c3d 100644 --- a/wp-admin/js/dashboard.js +++ b/wp-admin/js/dashboard.js @@ -266,6 +266,11 @@ jQuery( function( $ ) { 'use strict'; var communityEventsData = window.communityEventsData || {}, + dateI18n = wp.date.dateI18n, + format = wp.date.format, + sprintf = wp.i18n.sprintf, + __ = wp.i18n.__, + _x = wp.i18n._x, app; /** @@ -441,6 +446,7 @@ jQuery( function( $ ) { .fail( function() { app.renderEventsTemplate({ 'location' : false, + 'events' : [], 'error' : true }, initiatedBy ); }); @@ -465,6 +471,11 @@ jQuery( function( $ ) { $locationMessage = $( '#community-events-location-message' ), $results = $( '.community-events-results' ); + templateParams.events = app.populateDynamicEventFields( + templateParams.events, + communityEventsData.time_format + ); + /* * Hide all toggleable elements by default, to keep the logic simple. * Otherwise, each block below would have to turn hide everything that @@ -576,6 +587,195 @@ jQuery( function( $ ) { } else { app.toggleLocationForm( 'show' ); } + }, + + /** + * Populate event fields that have to be calculated on the fly. + * + * These can't be stored in the database, because they're dependent on + * the user's current time zone, locale, etc. + * + * @since 5.5.2 + * + * @param {Array} rawEvents The events that should have dynamic fields added to them. + * @param {string} timeFormat A time format acceptable by `wp.date.dateI18n()`. + * + * @returns {Array} + */ + populateDynamicEventFields: function( rawEvents, timeFormat ) { + // Clone the parameter to avoid mutating it, so that this can remain a pure function. + var populatedEvents = JSON.parse( JSON.stringify( rawEvents ) ); + + $.each( populatedEvents, function( index, event ) { + var timeZone = app.getTimeZone( event.start_unix_timestamp * 1000 ); + + event.user_formatted_date = app.getFormattedDate( + event.start_unix_timestamp * 1000, + event.end_unix_timestamp * 1000, + timeZone + ); + + event.user_formatted_time = dateI18n( + timeFormat, + event.start_unix_timestamp * 1000, + timeZone + ); + + event.timeZoneAbbreviation = app.getTimeZoneAbbreviation( event.start_unix_timestamp * 1000 ); + } ); + + return populatedEvents; + }, + + /** + * Returns the user's local/browser time zone, in a form suitable for `wp.date.i18n()`. + * + * @since 5.5.2 + * + * @param startTimestamp + * + * @returns {string|number} + */ + getTimeZone: function( startTimestamp ) { + /* + * Prefer a name like `Europe/Helsinki`, since that automatically tracks daylight savings. This + * doesn't need to take `startTimestamp` into account for that reason. + */ + var timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + /* + * Fall back to an offset for IE11, which declares the property but doesn't assign a value. + */ + if ( 'undefined' === typeof timeZone ) { + /* + * It's important to use the _event_ time, not the _current_ + * time, so that daylight savings time is accounted for. + */ + timeZone = app.getFlippedTimeZoneOffset( startTimestamp ); + } + + return timeZone; + }, + + /** + * Get intuitive time zone offset. + * + * `Data.prototype.getTimezoneOffset()` returns a positive value for time zones + * that are _behind_ UTC, and a _negative_ value for ones that are ahead. + * + * See https://stackoverflow.com/questions/21102435/why-does-javascript-date-gettimezoneoffset-consider-0500-as-a-positive-off. + * + * @since 5.5.2 + * + * @param {number} startTimestamp + * + * @returns {number} + */ + getFlippedTimeZoneOffset: function( startTimestamp ) { + return new Date( startTimestamp ).getTimezoneOffset() * -1; + }, + + /** + * Get a short time zone name, like `PST`. + * + * @since 5.5.2 + * + * @param {number} startTimestamp + * + * @returns {string} + */ + getTimeZoneAbbreviation: function( startTimestamp ) { + var timeZoneAbbreviation, + eventDateTime = new Date( startTimestamp ); + + /* + * Leaving the `locales` argument undefined is important, so that the browser + * displays the abbreviation that's most appropriate for the current locale. For + * some that will be `UTC{+|-}{n}`, and for others it will be a code like `PST`. + * + * This doesn't need to take `startTimestamp` into account, because a name like + * `America/Chicago` automatically tracks daylight savings. + */ + var shortTimeStringParts = eventDateTime.toLocaleTimeString( undefined, { timeZoneName : 'short' } ).split( ' ' ); + + if ( 3 === shortTimeStringParts.length ) { + timeZoneAbbreviation = shortTimeStringParts[2]; + } + + if ( 'undefined' === typeof timeZoneAbbreviation ) { + /* + * It's important to use the _event_ time, not the _current_ + * time, so that daylight savings time is accounted for. + */ + var timeZoneOffset = app.getFlippedTimeZoneOffset( startTimestamp ), + sign = -1 === Math.sign( timeZoneOffset ) ? '' : '+'; + + // translators: Used as part of a string like `GMT+5` in the Events Widget. + timeZoneAbbreviation = _x( 'GMT', 'Events widget offset prefix' ) + sign + ( timeZoneOffset / 60 ); + } + + return timeZoneAbbreviation; + }, + + /** + * Format a start/end date in the user's local time zone and locale. + * + * @since 5.5.2 + * + * @param {int} startDate The Unix timestamp in milliseconds when the the event starts. + * @param {int} endDate The Unix timestamp in milliseconds when the the event ends. + * @param {string} timeZone A time zone string or offset which is parsable by `wp.date.i18n()`. + * + * @returns {string} + */ + getFormattedDate: function( startDate, endDate, timeZone ) { + var formattedDate; + + /* + * The `date_format` option is not used because it's important + * in this context to keep the day of the week in the displayed date, + * so that users can tell at a glance if the event is on a day they + * are available, without having to open the link. + * + * The case of crossing a year boundary is intentionally not handled. + * It's so rare in practice that it's not worth the complexity + * tradeoff. The _ending_ year should be passed to + * `multiple_month_event`, though, just in case. + */ + /* translators: Date format for upcoming events on the dashboard. Include the day of the week. See https://www.php.net/manual/datetime.format.php */ + var singleDayEvent = __( 'l, M j, Y' ), + /* translators: Date string for upcoming events. 1: Month, 2: Starting day, 3: Ending day, 4: Year. */ + multipleDayEvent = __( '%1$s %2$dā€“%3$d, %4$d' ), + /* translators: Date string for upcoming events. 1: Starting month, 2: Starting day, 3: Ending month, 4: Ending day, 5: Ending year. */ + multipleMonthEvent = __( '%1$s %2$d ā€“ %3$s %4$d, %5$d' ); + + // Detect single-day events. + if ( ! endDate || format( 'Y-m-d', startDate ) === format( 'Y-m-d', endDate ) ) { + formattedDate = dateI18n( singleDayEvent, startDate, timeZone ); + + // Multiple day events. + } else if ( format( 'Y-m', startDate ) === format( 'Y-m', endDate ) ) { + formattedDate = sprintf( + multipleDayEvent, + dateI18n( _x( 'F', 'upcoming events month format' ), startDate, timeZone ), + dateI18n( _x( 'j', 'upcoming events day format' ), startDate, timeZone ), + dateI18n( _x( 'j', 'upcoming events day format' ), endDate, timeZone ), + dateI18n( _x( 'Y', 'upcoming events year format' ), endDate, timeZone ) + ); + + // Multi-day events that cross a month boundary. + } else { + formattedDate = sprintf( + multipleMonthEvent, + dateI18n( _x( 'F', 'upcoming events month format' ), startDate, timeZone ), + dateI18n( _x( 'j', 'upcoming events day format' ), startDate, timeZone ), + dateI18n( _x( 'F', 'upcoming events month format' ), endDate, timeZone ), + dateI18n( _x( 'j', 'upcoming events day format' ), endDate, timeZone ), + dateI18n( _x( 'Y', 'upcoming events year format' ), endDate, timeZone ) + ); + } + + return formattedDate; } }; diff --git a/wp-admin/js/dashboard.min.js b/wp-admin/js/dashboard.min.js index c5cd843934..a51b6218a5 100644 --- a/wp-admin/js/dashboard.min.js +++ b/wp-admin/js/dashboard.min.js @@ -1,2 +1,2 @@ /*! This file is auto-generated */ -window.wp=window.wp||{},jQuery(document).ready(function(c){var t,n=c("#welcome-panel"),e=c("#wp_welcome_panel-hide");t=function(e){c.post(ajaxurl,{action:"update-welcome-panel",visible:e,welcomepanelnonce:c("#welcomepanelnonce").val()})},n.hasClass("hidden")&&e.prop("checked")&&n.removeClass("hidden"),c(".welcome-panel-close, .welcome-panel-dismiss a",n).click(function(e){e.preventDefault(),n.addClass("hidden"),t(0),c("#wp_welcome_panel-hide").prop("checked",!1)}),e.click(function(){n.toggleClass("hidden",!this.checked),t(this.checked?1:0)}),window.ajaxWidgets=["dashboard_primary"],window.ajaxPopulateWidgets=function(e){function t(e,t){var n,o=c("#"+t+" div.inside:visible").find(".widget-loading");o.length&&(n=o.parent(),setTimeout(function(){n.load(ajaxurl+"?action=dashboard-widgets&widget="+t+"&pagenow="+pagenow,"",function(){n.hide().slideDown("normal",function(){c(this).css("display","")})})},500*e))}e?(e=e.toString(),-1!==c.inArray(e,ajaxWidgets)&&t(0,e)):c.each(ajaxWidgets,t)},ajaxPopulateWidgets(),postboxes.add_postbox_toggles(pagenow,{pbshow:ajaxPopulateWidgets}),window.quickPressLoad=function(){var t,e=c("#quickpost-action");c('#quick-press .submit input[type="submit"], #quick-press .submit input[type="reset"]').prop("disabled",!1),t=c("#quick-press").submit(function(e){e.preventDefault(),c("#dashboard_quick_press #publishing-action .spinner").show(),c('#quick-press .submit input[type="submit"], #quick-press .submit input[type="reset"]').prop("disabled",!0),c.post(t.attr("action"),t.serializeArray(),function(e){c("#dashboard_quick_press .inside").html(e),c("#quick-press").removeClass("initial-form"),quickPressLoad(),function(){var e=c(".drafts ul li").first();e.css("background","#fffbe5"),setTimeout(function(){e.css("background","none")},1e3)}(),c("#title").focus()})}),c("#publish").click(function(){e.val("post-quickpress-publish")}),c("#quick-press").on("click focusin",function(){wpActiveEditor="content"}),function(){if(document.documentMode&&document.documentMode<9)return;c("body").append('');var o=c(".quick-draft-textarea-clone"),i=c("#content"),a=i.height(),s=c(window).height()-100;o.css({"font-family":i.css("font-family"),"font-size":i.css("font-size"),"line-height":i.css("line-height"),"padding-bottom":i.css("paddingBottom"),"padding-left":i.css("paddingLeft"),"padding-right":i.css("paddingRight"),"padding-top":i.css("paddingTop"),"white-space":"pre-wrap","word-wrap":"break-word",display:"none"}),i.on("focus input propertychange",function(){var e=c(this),t=e.val()+" ",n=o.css("width",e.css("width")).text(t).outerHeight()+2;i.css("overflow-y","auto"),n===a||s<=n&&s<=a||(a=s