From 682b66b56066ccad8837f9a7bf350003d3db754b Mon Sep 17 00:00:00 2001 From: Andrew Ozz Date: Sun, 3 Feb 2013 07:03:27 +0000 Subject: [PATCH] Heartbeat API: throttle down when the window looses focus or when the user is inactive, always send 'screen_id', change the interval settings to 'fast' (5sec), 'standard' (15sec) and 'slow' (60sec), the interval can be changed from PHP, see #23216 git-svn-id: https://develop.svn.wordpress.org/trunk@23382 602fd350-edb4-49c9-b593-d223f7449a82 --- wp-admin/includes/ajax-actions.php | 23 +- wp-includes/js/heartbeat.js | 350 +++++++++++++++++++++++------ 2 files changed, 296 insertions(+), 77 deletions(-) diff --git a/wp-admin/includes/ajax-actions.php b/wp-admin/includes/ajax-actions.php index 7704ba91f3..cd4ba083e5 100644 --- a/wp-admin/includes/ajax-actions.php +++ b/wp-admin/includes/ajax-actions.php @@ -2074,10 +2074,13 @@ function wp_ajax_send_link_to_editor() { function wp_ajax_heartbeat() { check_ajax_referer( 'heartbeat-nonce', '_nonce' ); - $response = array( 'pagenow' => '' ); + $response = array(); - if ( ! empty($_POST['pagenow']) ) - $response['pagenow'] = sanitize_key($_POST['pagenow']); + // screenid is the same as $current_screen->id and the JS global 'pagenow' + if ( ! empty($_POST['screenid']) ) + $screen_id = sanitize_key($_POST['screenid']); + else + $screen_id = 'site'; if ( ! empty($_POST['data']) ) { $data = (array) $_POST['data']; @@ -2087,16 +2090,20 @@ function wp_ajax_heartbeat() { // todo: separate filters: 'heartbeat_[action]' so we call different callbacks only when there is data for them, // or all callbacks listen to one filter and run when there is something for them in $data? - $response = apply_filters( 'heartbeat_received', $response, $data ); + $response = apply_filters( 'heartbeat_received', $response, $data, $screen_id ); } - $response = apply_filters( 'heartbeat_send', $response ); + $response = apply_filters( 'heartbeat_send', $response, $screen_id ); // Allow the transport to be replaced with long-polling easily - do_action( 'heartbeat_tick', $response ); + do_action( 'heartbeat_tick', $response, $screen_id ); - // always send the current time acording to the server - $response['time'] = time(); + // send the current time acording to the server + $response['servertime'] = time(); + + // Change the interval, format: array( speed, ticks ) + if ( isset($response['heartbeat_interval']) ) + $response['heartbeat_interval'] = (array) $response['heartbeat_interval']; wp_send_json($response); } diff --git a/wp-includes/js/heartbeat.js b/wp-includes/js/heartbeat.js index c31107f9cc..f177dad902 100644 --- a/wp-includes/js/heartbeat.js +++ b/wp-includes/js/heartbeat.js @@ -9,14 +9,22 @@ window.wp = window.wp || {}; var Heartbeat = function() { var self = this, running, - timeout, + beat, nonce, - screen = typeof pagenow != 'undefined' ? pagenow : '', + screenid = typeof pagenow != 'undefined' ? pagenow : '', settings, tick = 0, queue = {}, interval, - lastconnect = 0; + lastconnect = 0, + connecting, + countdown, + tempInterval, + hasFocus = true, + isUserActive, + userActiveEvents, + winBlurTimeout, + frameBlurTimeout = -1; this.url = typeof ajaxurl != 'undefined' ? ajaxurl : 'wp-admin/admin-ajax.php'; this.autostart = true; @@ -29,15 +37,22 @@ window.wp = window.wp || {}; nonce = settings.nonce || ''; delete settings.nonce; - interval = settings.interval || 15000; // default interval + interval = settings.interval || 15; // default interval delete settings.interval; - - // todo: needed? - // 'pagenow' can be added from settings if not already defined - screen = screen || settings.pagenow; - delete settings.pagenow; + // The interval can be from 5 to 60 sec. + if ( interval < 5 ) + interval = 5; + else if ( interval > 60 ) + interval = 60; - // Add public vars + interval = interval * 1000; + + // todo: needed? + // 'screenid' can be added from settings on the front-end where the JS global 'pagenow' is not set + screenid = screenid || settings.screenid || 'site'; + delete settings.screenid; + + // Add or overwrite public vars $.extend( this, settings ); } @@ -48,14 +63,15 @@ window.wp = window.wp || {}; return (new Date()).getTime(); } - // Set error state and fire an event if it persists for over 3 min + // Set error state and fire an event if errors persist for over 2 min when the window has focus + // or 6 min when the window is in the background function errorstate() { var since; if ( lastconnect ) { - since = time() - lastconnect; + since = time() - lastconnect, duration = hasFocus ? 120000 : 360000; - if ( since > 180000 ) { + if ( since > duration ) { self.connectionLost = true; $(document).trigger( 'heartbeat-connection-lost', parseInt(since / 1000) ); } else if ( self.connectionLost ) { @@ -70,88 +86,171 @@ window.wp = window.wp || {}; tick = time(); data.data = $.extend( {}, queue ); - queue = {}; data.interval = interval / 1000; data._nonce = nonce; data.action = 'heartbeat'; - data.pagenow = screen; + data.screenid = screenid; + data.has_focus = hasFocus; + + connecting = true; + self.xhr = $.post( self.url, data, 'json' ) + .done( function( data, textStatus, jqXHR ) { + var interval; + + // Clear the data queue + queue = {}; - self.xhr = $.post( self.url, data, function(r){ - lastconnect = time(); // Clear error state + lastconnect = time(); if ( self.connectionLost ) errorstate(); - - self.tick(r); - }, 'json' ).always( function(){ + + // Change the interval from PHP + interval = data.heartbeat_interval; + delete data.heartbeat_interval; + + self.tick( data, textStatus, jqXHR ); + + // do this last, can trigger the next XHR + if ( interval ) + self.interval.apply( self, data.heartbeat_interval ); + }).always( function(){ + connecting = false; next(); - }).fail( function(r){ + }).fail( function( jqXHR, textStatus, error ){ errorstate(); - self.error(r); + self.error( jqXHR, textStatus, error ); }); }; function next() { - var delta = time() - tick; + var delta = time() - tick, t = interval; if ( !running ) return; - if ( delta < interval ) { - timeout = window.setTimeout( + if ( !hasFocus ) { + t = 120000; // 2 min + } else if ( countdown && tempInterval ) { + t = tempInterval; + countdown--; + } + + window.clearTimeout(beat); + + if ( delta < t ) { + beat = window.setTimeout( function(){ if ( running ) connect(); }, - interval - delta + t - delta ); } else { - window.clearTimeout(timeout); // this has already expired? connect(); } - }; + } - this.interval = function(seconds) { - if ( seconds ) { - // Limit - if ( 5 > seconds || seconds > 60 ) - return false; + function blurred() { + window.clearTimeout(winBlurTimeout); + window.clearTimeout(frameBlurTimeout); + winBlurTimeout = frameBlurTimeout = 0; - interval = seconds * 1000; - } else if ( seconds === 0 ) { - // Allow long polling to be turned on - interval = 0; + hasFocus = false; + + // temp debug + if ( self.debug ) + console.log('### blurred(), slow down...') + } + + function focused() { + window.clearTimeout(winBlurTimeout); + window.clearTimeout(frameBlurTimeout); + winBlurTimeout = frameBlurTimeout = 0; + + isUserActive = time(); + + if ( hasFocus ) + return; + + hasFocus = true; + window.clearTimeout(beat); + + if ( !connecting ) + next(); + + // temp debug + if ( self.debug ) + console.log('### focused(), speed up... ') + } + + function setFrameEvents() { + $('iframe').each( function(i, frame){ + if ( $.data(frame, 'wp-heartbeat-focus') ) + return; + + $.data(frame, 'wp-heartbeat-focus', 1); + + $(frame.contentWindow).on('focus.wp-heartbeat-focus', function(e){ + focused(); + }).on('blur.wp-heartbeat-focus', function(e){ + setFrameEvents(); + frameBlurTimeout = window.setTimeout( function(){ blurred(); }, 500 ); + }); + }); + } + + $(window).on('blur.wp-heartbeat-focus', function(e){ + setFrameEvents(); + winBlurTimeout = window.setTimeout( function(){ blurred(); }, 500 ); + }).on('focus.wp-heartbeat-focus', function(){ + $('iframe').each( function(i, frame){ + $.removeData(frame, 'wp-heartbeat-focus'); + $(frame.contentWindow).off('.wp-heartbeat-focus'); + }); + + focused(); + }); + + function userIsActive() { + userActiveEvents = false; + $(document).off('.wp-heartbeat-active'); + $('iframe').each( function(i, frame){ + $(frame.contentWindow).off('.wp-heartbeat-active'); + }); + + focused(); + + // temp debug + if ( self.debug ) + console.log( 'userIsActive()' ); + } + + // Set 'hasFocus = true' if user is active and the window is in the background. + // Set 'hasFocus = false' if the user has been inactive (no mouse or keyboard activity) for 5 min. even when the window has focus. + function checkUserActive() { + var lastActive = isUserActive ? time() - isUserActive : 0; + + // temp debug + if ( self.debug ) + console.log( 'checkUserActive(), lastActive = %s seconds ago', parseInt(lastActive / 1000) || 'null' ); + + // Throttle down when no mouse or keyboard activity for 5 min + if ( lastActive > 300000 && hasFocus ) + blurred(); + + if ( !userActiveEvents ) { + $(document).on('mouseover.wp-heartbeat-active keyup.wp-heartbeat-active', function(){ userIsActive(); }); + $('iframe').each( function(i, frame){ + $(frame.contentWindow).on('mouseover.wp-heartbeat-active keyup.wp-heartbeat-active', function(){ userIsActive(); }); + }); + userActiveEvents = true; } - return interval / 1000; - }; - - this.start = function() { - // start only once - if ( running ) - return false; - - running = true; - connect(); - - return true; - }; - - this.stop = function() { - if ( !running ) - return false; - - if ( self.xhr ) - self.xhr.abort(); - - running = false; - return true; } - this.send = function(action, data) { - if ( action ) - queue[action] = data; - } + // Check for user activity every 30 seconds. + window.setInterval( function(){ checkUserActive(); }, 30000 ); if ( this.autostart ) { $(document).ready( function(){ @@ -161,15 +260,128 @@ window.wp = window.wp || {}; next(); }); } - + + this.winHasFocus = function() { + return hasFocus; + } + + /** + * Get/Set the interval + * + * When setting the interval to 'fast', the number of ticks is specified wiht the second argument, default 30. + * If the window doesn't have focus, the interval is overridden to 2 min. In this case setting the 'ticks' + * will start counting after the window gets focus. + * + * @param string speed Interval speed: 'fast' (5sec), 'standard' (15sec) default, 'slow' (60sec) + * @param int ticks Number of ticks for the changed interval, optional when setting 'standard' or 'slow' + * @return int Current interval in seconds + */ + this.interval = function(speed, ticks) { + var reset, seconds; + + if ( speed ) { + switch ( speed ) { + case 'fast': + seconds = 5; + countdown = parseInt(ticks) || 30; + break; + case 'slow': + seconds = 60; + countdown = parseInt(ticks) || 0; + break; + case 'long-polling': + // Allow long polling, (experimental) + interval = 0; + return 0; + break; + default: + seconds = 15; + countdown = 0; + } + + // Reset when the new interval value is lower than the current one + reset = seconds * 1000 < interval; + + if ( countdown ) { + tempInterval = seconds * 1000; + } else { + interval = seconds * 1000; + tempInterval = 0; + } + + if ( reset ) + next(); + } + + if ( !hasFocus ) + return 120; + + return tempInterval ? tempInterval / 1000 : interval / 1000; + }; + + // Start. Has no effect if heartbeat is already running + this.start = function() { + if ( running ) + return false; + + running = true; + connect(); + return true; + }; + + // Stop. If a XHR is in progress, abort it + this.stop = function() { + if ( self.xhr && self.xhr.readyState != 4 ) + self.xhr.abort(); + + running = false; + return true; + } + + /** + * Send data with the next XHR + * + * As the data is sent later, this function doesn't return the XHR response. + * To see the response, use the custom jQuery event 'heartbeat-tick' on the document, example: + * $(document).on('heartbeat-tick.myname', function(data, textStatus, jqXHR) { + * // code + * }); + * If the same 'handle' is used more than once, the data is overwritten when the third argument is 'true'. + * Use wp.heartbeat.isQueued('handle') to see if any data is already queued for that handle. + * + * $param string handle Unique handle for the data. The handle is used in PHP to receive the data + * $param mixed data The data to be sent + * $param bool overwrite Whether to overwrite existing data in the queue + * $return bool Whether the data was queued or not + */ + this.send = function(handle, data, overwrite) { + if ( handle ) { + if ( queue.hasOwnProperty(handle) && !overwrite ) + return false; + + queue[handle] = data; + return true; + } + return false; + } + + /** + * Check if data with a particular handle is queued + * + * $param string handle The handle for the data + * $return mixed The data queued with that handle or null + */ + this.isQueued = function(handle) { + return queue[handle]; + } } $.extend( Heartbeat.prototype, { - tick: function(r) { - $(document).trigger( 'heartbeat-tick', r ); + tick: function(data, textStatus, jqXHR) { + $(document).trigger( 'heartbeat-tick', [data, textStatus, jqXHR] ); }, - error: function(r) { - $(document).trigger( 'heartbeat-error', r ); + error: function(jqXHR, textStatus, error) { + $(document).trigger( 'heartbeat-error', [jqXHR, textStatus, error] ); } });