|
1 /** |
|
2 * Heartbeat API |
|
3 * |
|
4 * Note: this API is "experimental" meaning it will likely change a lot |
|
5 * in the next few releases based on feedback from 3.6.0. If you intend |
|
6 * to use it, please follow the development closely. |
|
7 * |
|
8 * Heartbeat is a simple server polling API that sends XHR requests to |
|
9 * the server every 15 seconds and triggers events (or callbacks) upon |
|
10 * receiving data. Currently these 'ticks' handle transports for post locking, |
|
11 * login-expiration warnings, and related tasks while a user is logged in. |
|
12 * |
|
13 * Available filters in ajax-actions.php: |
|
14 * - heartbeat_received |
|
15 * - heartbeat_send |
|
16 * - heartbeat_tick |
|
17 * - heartbeat_nopriv_received |
|
18 * - heartbeat_nopriv_send |
|
19 * - heartbeat_nopriv_tick |
|
20 * @see wp_ajax_nopriv_heartbeat(), wp_ajax_heartbeat() |
|
21 * |
|
22 * @since 3.6.0 |
|
23 */ |
|
24 |
|
25 // Ensure the global `wp` object exists. |
|
26 window.wp = window.wp || {}; |
|
27 |
|
28 (function($){ |
|
29 var Heartbeat = function() { |
|
30 var self = this, |
|
31 running, |
|
32 beat, |
|
33 screenId = typeof pagenow != 'undefined' ? pagenow : '', |
|
34 url = typeof ajaxurl != 'undefined' ? ajaxurl : '', |
|
35 settings, |
|
36 tick = 0, |
|
37 queue = {}, |
|
38 interval, |
|
39 connecting, |
|
40 countdown = 0, |
|
41 errorcount = 0, |
|
42 tempInterval, |
|
43 hasFocus = true, |
|
44 isUserActive, |
|
45 userActiveEvents, |
|
46 winBlurTimeout, |
|
47 frameBlurTimeout = -1, |
|
48 hasConnectionError = null; |
|
49 |
|
50 /** |
|
51 * Returns a boolean that's indicative of whether or not there is a connection error |
|
52 * |
|
53 * @returns boolean |
|
54 */ |
|
55 this.hasConnectionError = function() { |
|
56 return !! hasConnectionError; |
|
57 }; |
|
58 |
|
59 if ( typeof( window.heartbeatSettings ) == 'object' ) { |
|
60 settings = $.extend( {}, window.heartbeatSettings ); |
|
61 |
|
62 // Add private vars |
|
63 url = settings.ajaxurl || url; |
|
64 delete settings.ajaxurl; |
|
65 delete settings.nonce; |
|
66 |
|
67 interval = settings.interval || 15; // default interval |
|
68 delete settings.interval; |
|
69 // The interval can be from 15 to 60 sec. and can be set temporarily to 5 sec. |
|
70 if ( interval < 15 ) |
|
71 interval = 15; |
|
72 else if ( interval > 60 ) |
|
73 interval = 60; |
|
74 |
|
75 interval = interval * 1000; |
|
76 |
|
77 // 'screenId' can be added from settings on the front-end where the JS global 'pagenow' is not set |
|
78 screenId = screenId || settings.screenId || 'front'; |
|
79 delete settings.screenId; |
|
80 |
|
81 // Add or overwrite public vars |
|
82 $.extend( this, settings ); |
|
83 } |
|
84 |
|
85 function time(s) { |
|
86 if ( s ) |
|
87 return parseInt( (new Date()).getTime() / 1000 ); |
|
88 |
|
89 return (new Date()).getTime(); |
|
90 } |
|
91 |
|
92 function isLocalFrame( frame ) { |
|
93 var origin, src = frame.src; |
|
94 |
|
95 if ( src && /^https?:\/\//.test( src ) ) { |
|
96 origin = window.location.origin ? window.location.origin : window.location.protocol + '//' + window.location.host; |
|
97 |
|
98 if ( src.indexOf( origin ) !== 0 ) |
|
99 return false; |
|
100 } |
|
101 |
|
102 try { |
|
103 if ( frame.contentWindow.document ) |
|
104 return true; |
|
105 } catch(e) {} |
|
106 |
|
107 return false; |
|
108 } |
|
109 |
|
110 // Set error state and fire an event on XHR errors or timeout |
|
111 function errorstate( error, status ) { |
|
112 var trigger; |
|
113 |
|
114 if ( error ) { |
|
115 switch ( error ) { |
|
116 case 'abort': |
|
117 // do nothing |
|
118 break; |
|
119 case 'timeout': |
|
120 // no response for 30 sec. |
|
121 trigger = true; |
|
122 break; |
|
123 case 'parsererror': |
|
124 case 'error': |
|
125 case 'empty': |
|
126 case 'unknown': |
|
127 errorcount++; |
|
128 |
|
129 if ( errorcount > 2 ) |
|
130 trigger = true; |
|
131 |
|
132 break; |
|
133 } |
|
134 |
|
135 if ( 503 == status && false === hasConnectionError ) { |
|
136 trigger = true; |
|
137 } |
|
138 |
|
139 if ( trigger && ! self.hasConnectionError() ) { |
|
140 hasConnectionError = true; |
|
141 $(document).trigger( 'heartbeat-connection-lost', [error, status] ); |
|
142 } |
|
143 } else if ( self.hasConnectionError() ) { |
|
144 errorcount = 0; |
|
145 hasConnectionError = false; |
|
146 $(document).trigger( 'heartbeat-connection-restored' ); |
|
147 } else if ( null === hasConnectionError ) { |
|
148 hasConnectionError = false; |
|
149 } |
|
150 } |
|
151 |
|
152 function connect() { |
|
153 var send = {}, data, i, empty = true, |
|
154 nonce = typeof window.heartbeatSettings == 'object' ? window.heartbeatSettings.nonce : ''; |
|
155 tick = time(); |
|
156 |
|
157 data = $.extend( {}, queue ); |
|
158 // Clear the data queue, anything added after this point will be send on the next tick |
|
159 queue = {}; |
|
160 |
|
161 $(document).trigger( 'heartbeat-send', [data] ); |
|
162 |
|
163 for ( i in data ) { |
|
164 if ( data.hasOwnProperty( i ) ) { |
|
165 empty = false; |
|
166 break; |
|
167 } |
|
168 } |
|
169 |
|
170 // If nothing to send (nothing is expecting a response), |
|
171 // schedule the next tick and bail |
|
172 if ( empty && ! self.hasConnectionError() ) { |
|
173 connecting = false; |
|
174 next(); |
|
175 return; |
|
176 } |
|
177 |
|
178 send.data = data; |
|
179 send.interval = interval / 1000; |
|
180 send._nonce = nonce; |
|
181 send.action = 'heartbeat'; |
|
182 send.screen_id = screenId; |
|
183 send.has_focus = hasFocus; |
|
184 |
|
185 connecting = true; |
|
186 self.xhr = $.ajax({ |
|
187 url: url, |
|
188 type: 'post', |
|
189 timeout: 30000, // throw an error if not completed after 30 sec. |
|
190 data: send, |
|
191 dataType: 'json' |
|
192 }).done( function( response, textStatus, jqXHR ) { |
|
193 var new_interval; |
|
194 |
|
195 if ( ! response ) |
|
196 return errorstate( 'empty' ); |
|
197 |
|
198 // Clear error state |
|
199 if ( self.hasConnectionError() ) |
|
200 errorstate(); |
|
201 |
|
202 if ( response.nonces_expired ) { |
|
203 $(document).trigger( 'heartbeat-nonces-expired' ); |
|
204 return; |
|
205 } |
|
206 |
|
207 // Change the interval from PHP |
|
208 if ( response.heartbeat_interval ) { |
|
209 new_interval = response.heartbeat_interval; |
|
210 delete response.heartbeat_interval; |
|
211 } |
|
212 |
|
213 self.tick( response, textStatus, jqXHR ); |
|
214 |
|
215 // do this last, can trigger the next XHR if connection time > 5 sec. and new_interval == 'fast' |
|
216 if ( new_interval ) |
|
217 self.interval.call( self, new_interval ); |
|
218 }).always( function() { |
|
219 connecting = false; |
|
220 next(); |
|
221 }).fail( function( jqXHR, textStatus, error ) { |
|
222 errorstate( textStatus || 'unknown', jqXHR.status ); |
|
223 self.error( jqXHR, textStatus, error ); |
|
224 }); |
|
225 } |
|
226 |
|
227 function next() { |
|
228 var delta = time() - tick, t = interval; |
|
229 |
|
230 if ( ! running ) |
|
231 return; |
|
232 |
|
233 if ( ! hasFocus ) { |
|
234 t = 100000; // 100 sec. Post locks expire after 120 sec. |
|
235 } else if ( countdown > 0 && tempInterval ) { |
|
236 t = tempInterval; |
|
237 countdown--; |
|
238 } |
|
239 |
|
240 window.clearTimeout(beat); |
|
241 |
|
242 if ( delta < t ) { |
|
243 beat = window.setTimeout( |
|
244 function(){ |
|
245 if ( running ) |
|
246 connect(); |
|
247 }, |
|
248 t - delta |
|
249 ); |
|
250 } else { |
|
251 connect(); |
|
252 } |
|
253 } |
|
254 |
|
255 function blurred() { |
|
256 window.clearTimeout(winBlurTimeout); |
|
257 window.clearTimeout(frameBlurTimeout); |
|
258 winBlurTimeout = frameBlurTimeout = 0; |
|
259 |
|
260 hasFocus = false; |
|
261 } |
|
262 |
|
263 function focused() { |
|
264 window.clearTimeout(winBlurTimeout); |
|
265 window.clearTimeout(frameBlurTimeout); |
|
266 winBlurTimeout = frameBlurTimeout = 0; |
|
267 |
|
268 isUserActive = time(); |
|
269 |
|
270 if ( hasFocus ) |
|
271 return; |
|
272 |
|
273 hasFocus = true; |
|
274 window.clearTimeout(beat); |
|
275 |
|
276 if ( ! connecting ) |
|
277 next(); |
|
278 } |
|
279 |
|
280 function setFrameEvents() { |
|
281 $('iframe').each( function( i, frame ){ |
|
282 if ( ! isLocalFrame( frame ) ) |
|
283 return; |
|
284 |
|
285 if ( $.data( frame, 'wp-heartbeat-focus' ) ) |
|
286 return; |
|
287 |
|
288 $.data( frame, 'wp-heartbeat-focus', 1 ); |
|
289 |
|
290 $( frame.contentWindow ).on( 'focus.wp-heartbeat-focus', function(e) { |
|
291 focused(); |
|
292 }).on('blur.wp-heartbeat-focus', function(e) { |
|
293 setFrameEvents(); |
|
294 frameBlurTimeout = window.setTimeout( function(){ blurred(); }, 500 ); |
|
295 }); |
|
296 }); |
|
297 } |
|
298 |
|
299 $(window).on( 'blur.wp-heartbeat-focus', function(e) { |
|
300 setFrameEvents(); |
|
301 winBlurTimeout = window.setTimeout( function(){ blurred(); }, 500 ); |
|
302 }).on( 'focus.wp-heartbeat-focus', function() { |
|
303 $('iframe').each( function( i, frame ) { |
|
304 if ( !isLocalFrame( frame ) ) |
|
305 return; |
|
306 |
|
307 $.removeData( frame, 'wp-heartbeat-focus' ); |
|
308 $( frame.contentWindow ).off( '.wp-heartbeat-focus' ); |
|
309 }); |
|
310 |
|
311 focused(); |
|
312 }); |
|
313 |
|
314 function userIsActive() { |
|
315 userActiveEvents = false; |
|
316 $(document).off( '.wp-heartbeat-active' ); |
|
317 $('iframe').each( function( i, frame ) { |
|
318 if ( ! isLocalFrame( frame ) ) |
|
319 return; |
|
320 |
|
321 $( frame.contentWindow ).off( '.wp-heartbeat-active' ); |
|
322 }); |
|
323 |
|
324 focused(); |
|
325 } |
|
326 |
|
327 // Set 'hasFocus = true' if user is active and the window is in the background. |
|
328 // Set 'hasFocus = false' if the user has been inactive (no mouse or keyboard activity) for 5 min. even when the window has focus. |
|
329 function checkUserActive() { |
|
330 var lastActive = isUserActive ? time() - isUserActive : 0; |
|
331 |
|
332 // Throttle down when no mouse or keyboard activity for 5 min |
|
333 if ( lastActive > 300000 && hasFocus ) |
|
334 blurred(); |
|
335 |
|
336 if ( ! userActiveEvents ) { |
|
337 $(document).on( 'mouseover.wp-heartbeat-active keyup.wp-heartbeat-active', function(){ userIsActive(); } ); |
|
338 |
|
339 $('iframe').each( function( i, frame ) { |
|
340 if ( ! isLocalFrame( frame ) ) |
|
341 return; |
|
342 |
|
343 $( frame.contentWindow ).on( 'mouseover.wp-heartbeat-active keyup.wp-heartbeat-active', function(){ userIsActive(); } ); |
|
344 }); |
|
345 |
|
346 userActiveEvents = true; |
|
347 } |
|
348 } |
|
349 |
|
350 // Check for user activity every 30 seconds. |
|
351 window.setInterval( function(){ checkUserActive(); }, 30000 ); |
|
352 $(document).ready( function() { |
|
353 // Start one tick (15 sec) after DOM ready |
|
354 running = true; |
|
355 tick = time(); |
|
356 next(); |
|
357 }); |
|
358 |
|
359 this.hasFocus = function() { |
|
360 return hasFocus; |
|
361 }; |
|
362 |
|
363 /** |
|
364 * Get/Set the interval |
|
365 * |
|
366 * When setting to 'fast', the interval is 5 sec. for the next 30 ticks (for 2 min and 30 sec). |
|
367 * If the window doesn't have focus, the interval slows down to 2 min. |
|
368 * |
|
369 * @param string speed Interval speed: 'fast' (5sec), 'standard' (15sec) default, 'slow' (60sec) |
|
370 * @param string ticks Used with speed = 'fast', how many ticks before the speed reverts back |
|
371 * @return int Current interval in seconds |
|
372 */ |
|
373 this.interval = function( speed, ticks ) { |
|
374 var reset, seconds; |
|
375 ticks = parseInt( ticks, 10 ) || 30; |
|
376 ticks = ticks < 1 || ticks > 30 ? 30 : ticks; |
|
377 |
|
378 if ( speed ) { |
|
379 switch ( speed ) { |
|
380 case 'fast': |
|
381 seconds = 5; |
|
382 countdown = ticks; |
|
383 break; |
|
384 case 'slow': |
|
385 seconds = 60; |
|
386 countdown = 0; |
|
387 break; |
|
388 case 'long-polling': |
|
389 // Allow long polling, (experimental) |
|
390 interval = 0; |
|
391 return 0; |
|
392 break; |
|
393 default: |
|
394 seconds = 15; |
|
395 countdown = 0; |
|
396 } |
|
397 |
|
398 // Reset when the new interval value is lower than the current one |
|
399 reset = seconds * 1000 < interval; |
|
400 |
|
401 if ( countdown > 0 ) { |
|
402 tempInterval = seconds * 1000; |
|
403 } else { |
|
404 interval = seconds * 1000; |
|
405 tempInterval = 0; |
|
406 } |
|
407 |
|
408 if ( reset ) |
|
409 next(); |
|
410 } |
|
411 |
|
412 if ( ! hasFocus ) |
|
413 return 120; |
|
414 |
|
415 return tempInterval ? tempInterval / 1000 : interval / 1000; |
|
416 }; |
|
417 |
|
418 /** |
|
419 * Enqueue data to send with the next XHR |
|
420 * |
|
421 * As the data is sent later, this function doesn't return the XHR response. |
|
422 * To see the response, use the custom jQuery event 'heartbeat-tick' on the document, example: |
|
423 * $(document).on( 'heartbeat-tick.myname', function( event, data, textStatus, jqXHR ) { |
|
424 * // code |
|
425 * }); |
|
426 * If the same 'handle' is used more than once, the data is not overwritten when the third argument is 'true'. |
|
427 * Use wp.heartbeat.isQueued('handle') to see if any data is already queued for that handle. |
|
428 * |
|
429 * $param string handle Unique handle for the data. The handle is used in PHP to receive the data. |
|
430 * $param mixed data The data to send. |
|
431 * $param bool dont_overwrite Whether to overwrite existing data in the queue. |
|
432 * $return bool Whether the data was queued or not. |
|
433 */ |
|
434 this.enqueue = function( handle, data, dont_overwrite ) { |
|
435 if ( handle ) { |
|
436 if ( dont_overwrite && this.isQueued( handle ) ) |
|
437 return false; |
|
438 |
|
439 queue[handle] = data; |
|
440 return true; |
|
441 } |
|
442 return false; |
|
443 }; |
|
444 |
|
445 /** |
|
446 * Check if data with a particular handle is queued |
|
447 * |
|
448 * $param string handle The handle for the data |
|
449 * $return bool Whether some data is queued with this handle |
|
450 */ |
|
451 this.isQueued = function( handle ) { |
|
452 if ( handle ) |
|
453 return queue.hasOwnProperty( handle ); |
|
454 }; |
|
455 |
|
456 /** |
|
457 * Remove data with a particular handle from the queue |
|
458 * |
|
459 * $param string handle The handle for the data |
|
460 * $return void |
|
461 */ |
|
462 this.dequeue = function( handle ) { |
|
463 if ( handle ) |
|
464 delete queue[handle]; |
|
465 }; |
|
466 |
|
467 /** |
|
468 * Get data that was enqueued with a particular handle |
|
469 * |
|
470 * $param string handle The handle for the data |
|
471 * $return mixed The data or undefined |
|
472 */ |
|
473 this.getQueuedItem = function( handle ) { |
|
474 if ( handle ) |
|
475 return this.isQueued( handle ) ? queue[handle] : undefined; |
|
476 }; |
|
477 }; |
|
478 |
|
479 $.extend( Heartbeat.prototype, { |
|
480 tick: function( data, textStatus, jqXHR ) { |
|
481 $(document).trigger( 'heartbeat-tick', [data, textStatus, jqXHR] ); |
|
482 }, |
|
483 error: function( jqXHR, textStatus, error ) { |
|
484 $(document).trigger( 'heartbeat-error', [jqXHR, textStatus, error] ); |
|
485 } |
|
486 }); |
|
487 |
|
488 wp.heartbeat = new Heartbeat(); |
|
489 |
|
490 }(jQuery)); |