Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

Unobtrusive updates #72

Merged
merged 10 commits into from

5 participants

@nb
Owner
nb commented

This is a followup to #5 and #6, where @borkweb and @zbtirrell shared a lot of ideas and patches.

Currently when a normal user has a liveblog open and there's a new entry, they see a nag with the number of new entries:

Screen Shot 2012-12-20 at 14 05 18

Having to click for each update while following a fast-paced event, or when the user left the liveblog open on a secondary display can quickly get annoying.

The original reason the nag was implemented were two:

  • If there are a bunch of new entries, the user may get lost and don't know which entries are new and which old.
  • Automatically adding new entries messes up with the scroll.

We need to solve this in an unobtrusive way – no action from the user should be required if they haven't scrolled, if they have – going back to the new entries should be easy.

P.S. This is a second attempt, after #62, which was mysteriously merged.

@nb
Owner
nb commented

For completeness, here's my idea from #6:

  • If a user has scrolled to the top we automatically snow the new entries.

  • If a use has scrolled down and a new entry comes in, we show them a non-obtrusive fixed nag that there are new entries with a link to go back to top and view them. Before they click we don't move their scroll at all. The fixed nag could be a semi-transparent thing on the side. The nag shouldn't go away when they scroll.

  • When they click the nag, we scroll them to top and show all the new entries. All the new entries are highlighted. The background fade out time is proportional to the number of entries, so that if there are lots of new entries the highlight doesn't disappear too quickly.

  • When they manually scroll back to the top, we reveal any waiting entries, as if they clicked on the fixed nag.

I like this approach, because the user doesn't need to do anything and we just do what is best for them in any moment:

  • Don't need to communicate what each state means, because the user doesn't need to change them manually.
  • If they're on top and are following the new entries, we just give them more when they come in. *If they have scrolled, we don't mess with their reading older entries, but keep reminding them that there are new entries and whenever they're ready they can go and read them.
@nb
Owner
nb commented

@keoshi, what do you think of this user behavior?

Also, currently the fixed nag looks like this, it may use some visual love :) (feel free to commit to this branch)

Fixed Nag Screenshot

@borkweb

I really like the unobtrusive approach, so +1 for that.

Having a fixed nag seems like a good plan, though, in the example screenshot the nag appears outside of the content area. I would have concerns about how that'd work out when people have their browsers sized only to the width of the content area? (I typically do that while watching liveblogs as I have a few sites open at the same time)

@zbtirrell

Something riding along the top might work with more themes. A possible reference example, scroll down on this page and see what they do with the questions:

http://yourather.com

@nb
Owner
nb commented

@borkweb, @zbtirrell, the initial idea was that the fixed nag was semi-transparent, so we could put it anywhere we wanted, but hovering on top makes lots of sense to me. We could use that space for all error messages and notifications.

@keoshi
Owner

Love that idea, I believe the steps you described work perfectly.

I also feel like the fixed nag should be theme-agnostic as much as possible, so its styling/position could be hard to nail perfectly. As it stands it could potentially end up on top of the content as @borkweb mentioned. Hovering on top as suggested by @zbtirrell makes a lot of sense, has some punch and it's the most usable solution.

Also, have you thought about offering the user the choice to auto-update?

@nb
Owner
nb commented

@keoshi, If you're on top of the page, it's auto-updating:

If a user has scrolled to the top we automatically snow the new entries.

@keoshi
Owner

Sorry, didn't finish my thought there. I meant offering a checkbox so the user can choose between auto-update and nag; something really simple like: □ Auto-update

@nb
Owner
nb commented

We wanted to avoid extra settings, controls and most importantly, wanted to avoid communicating with the user what does auto-update mean exactly.

@keoshi
Owner

Makes sense and it was just a random thought anyway; I love the thought process behind what you initially proposed.

@nb nb was assigned
nb added some commits
@nb nb Switch to adding new entries unobtrusively
See #62 for context.

Here we:
* Add new entries automatically if we're on top
* Show a fixed nag with the # of waiting entries,
otherwise.
* Start including some Backbone magic to untangle the
callback mess. See #70.
* Remove old nag code.

We still aren't:
* Handling updates and deletes separately
* Having the best fixed nag behaviour
fdef301
@nb nb Introduce Entry::type
It can be one of new, update, delete.

This way we won't need to distinguish the 3 by
heuristics like whether there is any content, or
whether the node already exists.
cf48ccd
@nb nb Take advantage of the entry type in the JS side 58c0d64
@nb nb Don't queue update/delete entries
Updating and deleting entries is rare enough, so that
we can screw the user's scroll and not queue those events.
44e218c
@nb nb Use only CSS selector when defining view elements
We don't need to supply a jQuery object.
5e31f6c
@nb nb Move fixed nag to the top
This way it will be as theme agnostic as possible.

If there is an admin bar, we push it right below
the admin bar.

The behaviour didn't change – clicking it will
flush the queue and take us the the top of the page.
14de072
@nb nb Vertically align the updates number 402bf43
@nb nb Get translated nag text from the server side fb7c69e
@nb nb Give the large number some space 21c5db6
@nb
Owner
nb commented

Here are updated looks:

Fixed nag on top

@nb
Owner
nb commented

Opinions?

@keoshi
Owner

Looks good!

@keoshi
Owner

Not sure if the number needs to be that big when compared to the rest, though.

@nb
Owner
nb commented

@keoshi, my reasoning was that a big changing number would be a good visual indicator that something is happening. If people get the visual clue, they won't need to read the text.

@mjangda
Owner

I'm assuming that clicking on the notification takes you to the newest entry?

@borkweb

That looks nice and clean, @nb!

@nb nb Apply modifying entries to the queue
Imagine the following scenario:
 * An editor posts an entry
 * The user gets a nag with 1 new entry
 * The editor deletes the just-added entry
 * The entry is deleted, but since the queue isn't
touched, the user still sees the same message.

In this commit we apply all new modifying (update/delete)
entries to the queue. If we get a delete entry for an
entry in the queue, we remove it from the queue. If we
get an update entry, we just update the html of the
entry in the queue. Both of these trigger a re-render
of the fixed nag and it's always correct.
11646fe
@nb
Owner
nb commented

@mjangda clicking on the fixed nag reveals all waiting entries, scrolls to the top entry, and hides the nag.

@nb nb merged commit 0595560 into from
@nb
Owner
nb commented

I think this is good to go.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Jan 4, 2013
  1. @nb

    Switch to adding new entries unobtrusively

    nb authored
    See #62 for context.
    
    Here we:
    * Add new entries automatically if we're on top
    * Show a fixed nag with the # of waiting entries,
    otherwise.
    * Start including some Backbone magic to untangle the
    callback mess. See #70.
    * Remove old nag code.
    
    We still aren't:
    * Handling updates and deletes separately
    * Having the best fixed nag behaviour
  2. @nb

    Introduce Entry::type

    nb authored
    It can be one of new, update, delete.
    
    This way we won't need to distinguish the 3 by
    heuristics like whether there is any content, or
    whether the node already exists.
  3. @nb
  4. @nb

    Don't queue update/delete entries

    nb authored
    Updating and deleting entries is rare enough, so that
    we can screw the user's scroll and not queue those events.
  5. @nb

    Use only CSS selector when defining view elements

    nb authored
    We don't need to supply a jQuery object.
  6. @nb

    Move fixed nag to the top

    nb authored
    This way it will be as theme agnostic as possible.
    
    If there is an admin bar, we push it right below
    the admin bar.
    
    The behaviour didn't change – clicking it will
    flush the queue and take us the the top of the page.
  7. @nb
  8. @nb
  9. @nb

    Give the large number some space

    nb authored
  10. @nb

    Apply modifying entries to the queue

    nb authored
    Imagine the following scenario:
     * An editor posts an entry
     * The user gets a nag with 1 new entry
     * The editor deletes the just-added entry
     * The entry is deleted, but since the queue isn't
    touched, the user still sees the same message.
    
    In this commit we apply all new modifying (update/delete)
    entries to the queue. If we get a delete entry for an
    entry in the queue, we remove it from the queue. If we
    get an update entry, we just update the html of the
    entry in the queue. Both of these trigger a re-render
    of the fixed nag and it's always correct.
This page is out of date. Refresh to see the latest.
View
1  HACKING.md
@@ -7,6 +7,7 @@ In this file you'll find technical overview of how the liveblog works.
* Liveblog post – a WordPress post, which has the liveblog checbox checked, shows the liveblog entries in real time, and offers authorized users to insert new entries.
* Refresh interval – how often the client side checks for entries' updates.
* Nag – when there's a new update, we show the nag to the users, instead of loading the new entries directly. The nag contains a link to load the new entries.
+* Modifying Entry – an entry, which is not an actual entry, but updates or deletes (replaces) an existing entry.
# Major Design Decisions
* **Each entry is a comment** – adding a lot of posts quickly leads to too much cache invalidations. Comments don't have cache entry per comment, so it's much easier to create a scalable liveblog.
View
12 classes/class-wpcom-liveblog-entry.php
@@ -14,10 +14,17 @@ class WPCOM_Liveblog_Entry {
const replaces_meta_key = 'liveblog_replaces';
private $comment;
+ private $type = 'new';
public function __construct( $comment ) {
$this->comment = $comment;
$this->replaces = get_comment_meta( $comment->comment_ID, self::replaces_meta_key, true );
+ if ( $this->replaces && $this->get_content() ) {
+ $this->type = 'update';
+ }
+ if ( $this->replaces && !$this->get_content() ) {
+ $this->type = 'delete';
+ }
}
public static function from_comment( $comment ) {
@@ -37,6 +44,10 @@ public function get_content() {
return $this->comment->comment_content;
}
+ public function get_type() {
+ return $this->type;
+ }
+
/**
* Get the GMT timestamp for the comment
*
@@ -49,6 +60,7 @@ public function get_timestamp() {
public function for_json() {
return (object) array(
'id' => $this->replaces ? $this->replaces : $this->get_id(),
+ 'type' => $this->get_type(),
'html' => $this->render(),
);
}
View
34 css/liveblog.css
@@ -160,12 +160,7 @@
border-radius: 3px;
text-shadow: 1px 1px 0 #0074a2;
}
-.liveblog-nag {
- display: block;
- color: white;
- background: #2ea2cc;
- cursor: pointer;
-}
+
.liveblog-hidden {
display: none;
}
@@ -227,4 +222,29 @@
.liveblog-entry .liveblog-entry-actions,
.liveblog-entry .liveblog-entry-actions li {
list-style: none;
-}
+#liveblog-fixed-nag {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ color: white;
+ text-align: center;
+ -webkit-box-shadow: 0px 0px 8px 0px #000000;
+ -moz-box-shadow: 0px 0px 8px 0px #000000;
+ box-shadow: 0px 0px 8px 0px #000000;
+ background-color: rgba(1, 1, 1, 0.6);
+}
+#liveblog-fixed-nag a {
+ text-decoration: none;
+ display: block;
+ color: white;
+}
+#liveblog-fixed-nag a .num {
+ font-size: 30px;
+ margin: 0 3px;
+ font-weight: bold;
+ display: inline-block;
+ vertical-align: middle;
+}
View
252 js/jquery.ba-throttle-debounce.js
@@ -0,0 +1,252 @@
+/*!
+ * jQuery throttle / debounce - v1.1 - 3/7/2010
+ * http://benalman.com/projects/jquery-throttle-debounce-plugin/
+ *
+ * Copyright (c) 2010 "Cowboy" Ben Alman
+ * Dual licensed under the MIT and GPL licenses.
+ * http://benalman.com/about/license/
+ */
+
+// Script: jQuery throttle / debounce: Sometimes, less is more!
+//
+// *Version: 1.1, Last updated: 3/7/2010*
+//
+// Project Home - http://benalman.com/projects/jquery-throttle-debounce-plugin/
+// GitHub - http://github.com/cowboy/jquery-throttle-debounce/
+// Source - http://github.com/cowboy/jquery-throttle-debounce/raw/master/jquery.ba-throttle-debounce.js
+// (Minified) - http://github.com/cowboy/jquery-throttle-debounce/raw/master/jquery.ba-throttle-debounce.min.js (0.7kb)
+//
+// About: License
+//
+// Copyright (c) 2010 "Cowboy" Ben Alman,
+// Dual licensed under the MIT and GPL licenses.
+// http://benalman.com/about/license/
+//
+// About: Examples
+//
+// These working examples, complete with fully commented code, illustrate a few
+// ways in which this plugin can be used.
+//
+// Throttle - http://benalman.com/code/projects/jquery-throttle-debounce/examples/throttle/
+// Debounce - http://benalman.com/code/projects/jquery-throttle-debounce/examples/debounce/
+//
+// About: Support and Testing
+//
+// Information about what version or versions of jQuery this plugin has been
+// tested with, what browsers it has been tested in, and where the unit tests
+// reside (so you can test it yourself).
+//
+// jQuery Versions - none, 1.3.2, 1.4.2
+// Browsers Tested - Internet Explorer 6-8, Firefox 2-3.6, Safari 3-4, Chrome 4-5, Opera 9.6-10.1.
+// Unit Tests - http://benalman.com/code/projects/jquery-throttle-debounce/unit/
+//
+// About: Release History
+//
+// 1.1 - (3/7/2010) Fixed a bug in <jQuery.throttle> where trailing callbacks
+// executed later than they should. Reworked a fair amount of internal
+// logic as well.
+// 1.0 - (3/6/2010) Initial release as a stand-alone project. Migrated over
+// from jquery-misc repo v0.4 to jquery-throttle repo v1.0, added the
+// no_trailing throttle parameter and debounce functionality.
+//
+// Topic: Note for non-jQuery users
+//
+// jQuery isn't actually required for this plugin, because nothing internal
+// uses any jQuery methods or properties. jQuery is just used as a namespace
+// under which these methods can exist.
+//
+// Since jQuery isn't actually required for this plugin, if jQuery doesn't exist
+// when this plugin is loaded, the method described below will be created in
+// the `Cowboy` namespace. Usage will be exactly the same, but instead of
+// $.method() or jQuery.method(), you'll need to use Cowboy.method().
+
+(function(window,undefined){
+ '$:nomunge'; // Used by YUI compressor.
+
+ // Since jQuery really isn't required for this plugin, use `jQuery` as the
+ // namespace only if it already exists, otherwise use the `Cowboy` namespace,
+ // creating it if necessary.
+ var $ = window.jQuery || window.Cowboy || ( window.Cowboy = {} ),
+
+ // Internal method reference.
+ jq_throttle;
+
+ // Method: jQuery.throttle
+ //
+ // Throttle execution of a function. Especially useful for rate limiting
+ // execution of handlers on events like resize and scroll. If you want to
+ // rate-limit execution of a function to a single time, see the
+ // <jQuery.debounce> method.
+ //
+ // In this visualization, | is a throttled-function call and X is the actual
+ // callback execution:
+ //
+ // > Throttled with `no_trailing` specified as false or unspecified:
+ // > ||||||||||||||||||||||||| (pause) |||||||||||||||||||||||||
+ // > X X X X X X X X X X X X
+ // >
+ // > Throttled with `no_trailing` specified as true:
+ // > ||||||||||||||||||||||||| (pause) |||||||||||||||||||||||||
+ // > X X X X X X X X X X
+ //
+ // Usage:
+ //
+ // > var throttled = jQuery.throttle( delay, [ no_trailing, ] callback );
+ // >
+ // > jQuery('selector').bind( 'someevent', throttled );
+ // > jQuery('selector').unbind( 'someevent', throttled );
+ //
+ // This also works in jQuery 1.4+:
+ //
+ // > jQuery('selector').bind( 'someevent', jQuery.throttle( delay, [ no_trailing, ] callback ) );
+ // > jQuery('selector').unbind( 'someevent', callback );
+ //
+ // Arguments:
+ //
+ // delay - (Number) A zero-or-greater delay in milliseconds. For event
+ // callbacks, values around 100 or 250 (or even higher) are most useful.
+ // no_trailing - (Boolean) Optional, defaults to false. If no_trailing is
+ // true, callback will only execute every `delay` milliseconds while the
+ // throttled-function is being called. If no_trailing is false or
+ // unspecified, callback will be executed one final time after the last
+ // throttled-function call. (After the throttled-function has not been
+ // called for `delay` milliseconds, the internal counter is reset)
+ // callback - (Function) A function to be executed after delay milliseconds.
+ // The `this` context and all arguments are passed through, as-is, to
+ // `callback` when the throttled-function is executed.
+ //
+ // Returns:
+ //
+ // (Function) A new, throttled, function.
+
+ $.throttle = jq_throttle = function( delay, no_trailing, callback, debounce_mode ) {
+ // After wrapper has stopped being called, this timeout ensures that
+ // `callback` is executed at the proper times in `throttle` and `end`
+ // debounce modes.
+ var timeout_id,
+
+ // Keep track of the last time `callback` was executed.
+ last_exec = 0;
+
+ // `no_trailing` defaults to falsy.
+ if ( typeof no_trailing !== 'boolean' ) {
+ debounce_mode = callback;
+ callback = no_trailing;
+ no_trailing = undefined;
+ }
+
+ // The `wrapper` function encapsulates all of the throttling / debouncing
+ // functionality and when executed will limit the rate at which `callback`
+ // is executed.
+ function wrapper() {
+ var that = this,
+ elapsed = +new Date() - last_exec,
+ args = arguments;
+
+ // Execute `callback` and update the `last_exec` timestamp.
+ function exec() {
+ last_exec = +new Date();
+ callback.apply( that, args );
+ };
+
+ // If `debounce_mode` is true (at_begin) this is used to clear the flag
+ // to allow future `callback` executions.
+ function clear() {
+ timeout_id = undefined;
+ };
+
+ if ( debounce_mode && !timeout_id ) {
+ // Since `wrapper` is being called for the first time and
+ // `debounce_mode` is true (at_begin), execute `callback`.
+ exec();
+ }
+
+ // Clear any existing timeout.
+ timeout_id && clearTimeout( timeout_id );
+
+ if ( debounce_mode === undefined && elapsed > delay ) {
+ // In throttle mode, if `delay` time has been exceeded, execute
+ // `callback`.
+ exec();
+
+ } else if ( no_trailing !== true ) {
+ // In trailing throttle mode, since `delay` time has not been
+ // exceeded, schedule `callback` to execute `delay` ms after most
+ // recent execution.
+ //
+ // If `debounce_mode` is true (at_begin), schedule `clear` to execute
+ // after `delay` ms.
+ //
+ // If `debounce_mode` is false (at end), schedule `callback` to
+ // execute after `delay` ms.
+ timeout_id = setTimeout( debounce_mode ? clear : exec, debounce_mode === undefined ? delay - elapsed : delay );
+ }
+ };
+
+ // Set the guid of `wrapper` function to the same of original callback, so
+ // it can be removed in jQuery 1.4+ .unbind or .die by using the original
+ // callback as a reference.
+ if ( $.guid ) {
+ wrapper.guid = callback.guid = callback.guid || $.guid++;
+ }
+
+ // Return the wrapper function.
+ return wrapper;
+ };
+
+ // Method: jQuery.debounce
+ //
+ // Debounce execution of a function. Debouncing, unlike throttling,
+ // guarantees that a function is only executed a single time, either at the
+ // very beginning of a series of calls, or at the very end. If you want to
+ // simply rate-limit execution of a function, see the <jQuery.throttle>
+ // method.
+ //
+ // In this visualization, | is a debounced-function call and X is the actual
+ // callback execution:
+ //
+ // > Debounced with `at_begin` specified as false or unspecified:
+ // > ||||||||||||||||||||||||| (pause) |||||||||||||||||||||||||
+ // > X X
+ // >
+ // > Debounced with `at_begin` specified as true:
+ // > ||||||||||||||||||||||||| (pause) |||||||||||||||||||||||||
+ // > X X
+ //
+ // Usage:
+ //
+ // > var debounced = jQuery.debounce( delay, [ at_begin, ] callback );
+ // >
+ // > jQuery('selector').bind( 'someevent', debounced );
+ // > jQuery('selector').unbind( 'someevent', debounced );
+ //
+ // This also works in jQuery 1.4+:
+ //
+ // > jQuery('selector').bind( 'someevent', jQuery.debounce( delay, [ at_begin, ] callback ) );
+ // > jQuery('selector').unbind( 'someevent', callback );
+ //
+ // Arguments:
+ //
+ // delay - (Number) A zero-or-greater delay in milliseconds. For event
+ // callbacks, values around 100 or 250 (or even higher) are most useful.
+ // at_begin - (Boolean) Optional, defaults to false. If at_begin is false or
+ // unspecified, callback will only be executed `delay` milliseconds after
+ // the last debounced-function call. If at_begin is true, callback will be
+ // executed only at the first debounced-function call. (After the
+ // throttled-function has not been called for `delay` milliseconds, the
+ // internal counter is reset)
+ // callback - (Function) A function to be executed after delay milliseconds.
+ // The `this` context and all arguments are passed through, as-is, to
+ // `callback` when the debounced-function is executed.
+ //
+ // Returns:
+ //
+ // (Function) A new, debounced, function.
+
+ $.debounce = function( delay, at_begin, callback ) {
+ return callback === undefined
+ ? jq_throttle( delay, at_begin, false )
+ : jq_throttle( delay, callback, at_begin !== false );
+ };
+
+})(this);
View
9 js/jquery.ba-throttle-debounce.min.js
@@ -0,0 +1,9 @@
+/*
+ * jQuery throttle / debounce - v1.1 - 3/7/2010
+ * http://benalman.com/projects/jquery-throttle-debounce-plugin/
+ *
+ * Copyright (c) 2010 "Cowboy" Ben Alman
+ * Dual licensed under the MIT and GPL licenses.
+ * http://benalman.com/about/license/
+ */
+(function(b,c){var $=b.jQuery||b.Cowboy||(b.Cowboy={}),a;$.throttle=a=function(e,f,j,i){var h,d=0;if(typeof f!=="boolean"){i=j;j=f;f=c}function g(){var o=this,m=+new Date()-d,n=arguments;function l(){d=+new Date();j.apply(o,n)}function k(){h=c}if(i&&!h){l()}h&&clearTimeout(h);if(i===c&&m>e){l()}else{if(f!==true){h=setTimeout(i?k:l,i===c?e-m:e)}}}if($.guid){g.guid=j.guid=j.guid||$.guid++}return g};$.debounce=function(d,e,f){return f===c?a(d,e,false):a(d,f,e!==false)}})(this);
View
218 js/jquery.scrollTo.js
@@ -0,0 +1,218 @@
+/*!
+ * jQuery.ScrollTo
+ * Copyright (c) 2007-2012 Ariel Flesler - aflesler(at)gmail(dot)com | http://flesler.blogspot.com
+ * Dual licensed under MIT and GPL.
+ * Date: 4/09/2012
+ *
+ * @projectDescription Easy element scrolling using jQuery.
+ * http://flesler.blogspot.com/2007/10/jqueryscrollto.html
+ * @author Ariel Flesler
+ * @version 1.4.4
+ *
+ * @id jQuery.scrollTo
+ * @id jQuery.fn.scrollTo
+ * @param {String, Number, DOMElement, jQuery, Object} target Where to scroll the matched elements.
+ * The different options for target are:
+ * - A number position (will be applied to all axes).
+ * - A string position ('44', '100px', '+=90', etc ) will be applied to all axes
+ * - A jQuery/DOM element ( logically, child of the element to scroll )
+ * - A string selector, that will be relative to the element to scroll ( 'li:eq(2)', etc )
+ * - A hash { top:x, left:y }, x and y can be any kind of number/string like above.
+ * - A percentage of the container's dimension/s, for example: 50% to go to the middle.
+ * - The string 'max' for go-to-end.
+ * @param {Number, Function} duration The OVERALL length of the animation, this argument can be the settings object instead.
+ * @param {Object,Function} settings Optional set of settings or the onAfter callback.
+ * @option {String} axis Which axis must be scrolled, use 'x', 'y', 'xy' or 'yx'.
+ * @option {Number, Function} duration The OVERALL length of the animation.
+ * @option {String} easing The easing method for the animation.
+ * @option {Boolean} margin If true, the margin of the target element will be deducted from the final position.
+ * @option {Object, Number} offset Add/deduct from the end position. One number for both axes or { top:x, left:y }.
+ * @option {Object, Number} over Add/deduct the height/width multiplied by 'over', can be { top:x, left:y } when using both axes.
+ * @option {Boolean} queue If true, and both axis are given, the 2nd axis will only be animated after the first one ends.
+ * @option {Function} onAfter Function to be called after the scrolling ends.
+ * @option {Function} onAfterFirst If queuing is activated, this function will be called after the first scrolling ends.
+ * @return {jQuery} Returns the same jQuery object, for chaining.
+ *
+ * @desc Scroll to a fixed position
+ * @example $('div').scrollTo( 340 );
+ *
+ * @desc Scroll relatively to the actual position
+ * @example $('div').scrollTo( '+=340px', { axis:'y' } );
+ *
+ * @desc Scroll using a selector (relative to the scrolled element)
+ * @example $('div').scrollTo( 'p.paragraph:eq(2)', 500, { easing:'swing', queue:true, axis:'xy' } );
+ *
+ * @desc Scroll to a DOM element (same for jQuery object)
+ * @example var second_child = document.getElementById('container').firstChild.nextSibling;
+ * $('#container').scrollTo( second_child, { duration:500, axis:'x', onAfter:function(){
+ * alert('scrolled!!');
+ * }});
+ *
+ * @desc Scroll on both axes, to different values
+ * @example $('div').scrollTo( { top: 300, left:'+=200' }, { axis:'xy', offset:-20 } );
+ */
+
+;(function( $ ){
+
+ var $scrollTo = $.scrollTo = function( target, duration, settings ){
+ $(window).scrollTo( target, duration, settings );
+ };
+
+ $scrollTo.defaults = {
+ axis:'xy',
+ duration: parseFloat($.fn.jquery) >= 1.3 ? 0 : 1,
+ limit:true
+ };
+
+ // Returns the element that needs to be animated to scroll the window.
+ // Kept for backwards compatibility (specially for localScroll & serialScroll)
+ $scrollTo.window = function( scope ){
+ return $(window)._scrollable();
+ };
+
+ // Hack, hack, hack :)
+ // Returns the real elements to scroll (supports window/iframes, documents and regular nodes)
+ $.fn._scrollable = function(){
+ return this.map(function(){
+ var elem = this,
+ isWin = !elem.nodeName || $.inArray( elem.nodeName.toLowerCase(), ['iframe','#document','html','body'] ) != -1;
+
+ if( !isWin )
+ return elem;
+
+ var doc = (elem.contentWindow || elem).document || elem.ownerDocument || elem;
+
+ return /webkit/i.test(navigator.userAgent) || doc.compatMode == 'BackCompat' ?
+ doc.body :
+ doc.documentElement;
+ });
+ };
+
+ $.fn.scrollTo = function( target, duration, settings ){
+ if( typeof duration == 'object' ){
+ settings = duration;
+ duration = 0;
+ }
+ if( typeof settings == 'function' )
+ settings = { onAfter:settings };
+
+ if( target == 'max' )
+ target = 9e9;
+
+ settings = $.extend( {}, $scrollTo.defaults, settings );
+ // Speed is still recognized for backwards compatibility
+ duration = duration || settings.duration;
+ // Make sure the settings are given right
+ settings.queue = settings.queue && settings.axis.length > 1;
+
+ if( settings.queue )
+ // Let's keep the overall duration
+ duration /= 2;
+ settings.offset = both( settings.offset );
+ settings.over = both( settings.over );
+
+ return this._scrollable().each(function(){
+ // Null target yields nothing, just like jQuery does
+ if (target == null) return;
+
+ var elem = this,
+ $elem = $(elem),
+ targ = target, toff, attr = {},
+ win = $elem.is('html,body');
+
+ switch( typeof targ ){
+ // A number will pass the regex
+ case 'number':
+ case 'string':
+ if( /^([+-]=)?\d+(\.\d+)?(px|%)?$/.test(targ) ){
+ targ = both( targ );
+ // We are done
+ break;
+ }
+ // Relative selector, no break!
+ targ = $(targ,this);
+ if (!targ.length) return;
+ case 'object':
+ // DOMElement / jQuery
+ if( targ.is || targ.style )
+ // Get the real position of the target
+ toff = (targ = $(targ)).offset();
+ }
+ $.each( settings.axis.split(''), function( i, axis ){
+ var Pos = axis == 'x' ? 'Left' : 'Top',
+ pos = Pos.toLowerCase(),
+ key = 'scroll' + Pos,
+ old = elem[key],
+ max = $scrollTo.max(elem, axis);
+
+ if( toff ){// jQuery / DOMElement
+ attr[key] = toff[pos] + ( win ? 0 : old - $elem.offset()[pos] );
+
+ // If it's a dom element, reduce the margin
+ if( settings.margin ){
+ attr[key] -= parseInt(targ.css('margin'+Pos)) || 0;
+ attr[key] -= parseInt(targ.css('border'+Pos+'Width')) || 0;
+ }
+
+ attr[key] += settings.offset[pos] || 0;
+
+ if( settings.over[pos] )
+ // Scroll to a fraction of its width/height
+ attr[key] += targ[axis=='x'?'width':'height']() * settings.over[pos];
+ }else{
+ var val = targ[pos];
+ // Handle percentage values
+ attr[key] = val.slice && val.slice(-1) == '%' ?
+ parseFloat(val) / 100 * max
+ : val;
+ }
+
+ // Number or 'number'
+ if( settings.limit && /^\d+$/.test(attr[key]) )
+ // Check the limits
+ attr[key] = attr[key] <= 0 ? 0 : Math.min( attr[key], max );
+
+ // Queueing axes
+ if( !i && settings.queue ){
+ // Don't waste time animating, if there's no need.
+ if( old != attr[key] )
+ // Intermediate animation
+ animate( settings.onAfterFirst );
+ // Don't animate this axis again in the next iteration.
+ delete attr[key];
+ }
+ });
+
+ animate( settings.onAfter );
+
+ function animate( callback ){
+ $elem.animate( attr, duration, settings.easing, callback && function(){
+ callback.call(this, target, settings);
+ });
+ };
+
+ }).end();
+ };
+
+ // Max scrolling position, works on quirks mode
+ // It only fails (not too badly) on IE, quirks mode.
+ $scrollTo.max = function( elem, axis ){
+ var Dim = axis == 'x' ? 'Width' : 'Height',
+ scroll = 'scroll'+Dim;
+
+ if( !$(elem).is('html,body') )
+ return elem[scroll] - $(elem)[Dim.toLowerCase()]();
+
+ var size = 'client' + Dim,
+ html = elem.ownerDocument.documentElement,
+ body = elem.ownerDocument.body;
+
+ return Math.max( html[scroll], body[scroll] )
+ - Math.min( html[size] , body[size] );
+ };
+
+ function both( val ){
+ return typeof val == 'object' ? val : { top:val, left:val };
+ };
+
+})( jQuery );
View
7 js/jquery.scrollTo.min.js
@@ -0,0 +1,7 @@
+/**
+ * Copyright (c) 2007-2012 Ariel Flesler - aflesler(at)gmail(dot)com | http://flesler.blogspot.com
+ * Dual licensed under MIT and GPL.
+ * @author Ariel Flesler
+ * @version 1.4.4
+ */
+;(function($){var h=$.scrollTo=function(a,b,c){$(window).scrollTo(a,b,c)};h.defaults={axis:'xy',duration:parseFloat($.fn.jquery)>=1.3?0:1,limit:true};h.window=function(a){return $(window)._scrollable()};$.fn._scrollable=function(){return this.map(function(){var a=this,isWin=!a.nodeName||$.inArray(a.nodeName.toLowerCase(),['iframe','#document','html','body'])!=-1;if(!isWin)return a;var b=(a.contentWindow||a).document||a.ownerDocument||a;return/webkit/i.test(navigator.userAgent)||b.compatMode=='BackCompat'?b.body:b.documentElement})};$.fn.scrollTo=function(e,f,g){if(typeof f=='object'){g=f;f=0}if(typeof g=='function')g={onAfter:g};if(e=='max')e=9e9;g=$.extend({},h.defaults,g);f=f||g.duration;g.queue=g.queue&&g.axis.length>1;if(g.queue)f/=2;g.offset=both(g.offset);g.over=both(g.over);return this._scrollable().each(function(){if(e==null)return;var d=this,$elem=$(d),targ=e,toff,attr={},win=$elem.is('html,body');switch(typeof targ){case'number':case'string':if(/^([+-]=)?\d+(\.\d+)?(px|%)?$/.test(targ)){targ=both(targ);break}targ=$(targ,this);if(!targ.length)return;case'object':if(targ.is||targ.style)toff=(targ=$(targ)).offset()}$.each(g.axis.split(''),function(i,a){var b=a=='x'?'Left':'Top',pos=b.toLowerCase(),key='scroll'+b,old=d[key],max=h.max(d,a);if(toff){attr[key]=toff[pos]+(win?0:old-$elem.offset()[pos]);if(g.margin){attr[key]-=parseInt(targ.css('margin'+b))||0;attr[key]-=parseInt(targ.css('border'+b+'Width'))||0}attr[key]+=g.offset[pos]||0;if(g.over[pos])attr[key]+=targ[a=='x'?'width':'height']()*g.over[pos]}else{var c=targ[pos];attr[key]=c.slice&&c.slice(-1)=='%'?parseFloat(c)/100*max:c}if(g.limit&&/^\d+$/.test(attr[key]))attr[key]=attr[key]<=0?0:Math.min(attr[key],max);if(!i&&g.queue){if(old!=attr[key])animate(g.onAfterFirst);delete attr[key]}});animate(g.onAfter);function animate(a){$elem.animate(attr,f,g.easing,a&&function(){a.call(this,e,g)})}}).end()};h.max=function(a,b){var c=b=='x'?'Width':'Height',scroll='scroll'+c;if(!$(a).is('html,body'))return a[scroll]-$(a)[c.toLowerCase()]();var d='client'+c,html=a.ownerDocument.documentElement,body=a.ownerDocument.body;return Math.max(html[scroll],body[scroll])-Math.min(html[d],body[d])};function both(a){return typeof a=='object'?a:{top:a,left:a}}})(jQuery);
View
1  js/liveblog-publisher.js
@@ -5,7 +5,6 @@
liveblog.publisher = {};
liveblog.publisher.init = function() {
- liveblog.disable_nag();
liveblog.publisher.$entry_text = $( '#liveblog-form-entry' );
liveblog.publisher.$entry_button = $( '#liveblog-form-entry-submit' );
View
236 js/liveblog.js
@@ -1,6 +1,114 @@
window.liveblog = {};
( function( $ ) {
+ liveblog.EntriesView = Backbone.View.extend({
+ el: '#liveblog-container',
+ initialize: function() {
+ var view = this;
+ liveblog.queue.on('reset', this.scrollToTop, this);
+ $(window).scroll($.throttle(250, this.flushQueueWhenOnTop));
+ },
+ scrollToTop: function() {
+ $(window).scrollTop(this.$el.offset().top);
+ },
+ flushQueueWhenOnTop: function(e) {
+ if (liveblog.is_at_the_top()) {
+ liveblog.queue.flush();
+ }
+ }
+ });
+
+ liveblog.Entry = Backbone.Model.extend({});
+
+ liveblog.EntriesQueue = Backbone.Collection.extend({
+ model: liveblog.Entry,
+ flush: function() {
+ if (this.isEmpty()) {
+ return;
+ }
+ liveblog.display_entries(this.models);
+ this.reset([]);
+ },
+ applyModifyingEntries: function(entries) {
+ console.log(entries);
+ var collection = this;
+ _.each(entries, function(entry) {
+ collection.applyModifyingEntry(entry);
+ });
+ },
+ applyModifyingEntry: function(entry) {
+ var existing = this.get(entry.id);
+ if (!existing) {
+ return;
+ }
+ console.log(entry);
+ if ("delete" == entry.type) {
+ this.remove(existing);
+ }
+ if ("update" == entry.type) {
+ existing.set("html", entry.html);
+ }
+ },
+ });
+
+ liveblog.FixedNagView = Backbone.View.extend({
+ el: '#liveblog-fixed-nag',
+ events: {
+ 'click a': 'flush'
+ },
+ initialize: function() {
+ liveblog.queue.on('all', this.render, this);
+ },
+ render: function() {
+ var entries_in_queue = liveblog.queue.length;
+ if ( entries_in_queue ) {
+ this.show();
+ this.updateNumber(liveblog.queue.length);
+ } else {
+ this.hide();
+ }
+ },
+ show: function() {
+ this.$el.show();
+ this._moveBelowAdminBar();
+ },
+ hide: function() {
+ this.$el.hide();
+ },
+ flush: function(e) {
+ e.preventDefault();
+ liveblog.queue.flush();
+ },
+ updateNumber: function(number) {
+ var template = number==1? liveblog_settings.new_update : liveblog_settings.new_updates,
+ html = template.replace('{number}', '<span class="num">' + number + '</span>');
+ this.$('a').html(html);
+ },
+ _moveBelowAdminBar: function() {
+ var $adminbar = $('#wpadminbar');
+ if ($adminbar.length) {
+ this.$el.css('top', $adminbar.height());
+ }
+ }
+ });
+
+ liveblog.TitleBarCountView = Backbone.View.extend({
+ initialize: function() {
+ liveblog.queue.on('all', this.render, this);
+ this.originalTitle = document.title;
+ },
+ render: function() {
+ var entries_in_queue = liveblog.queue.length,
+ count_string;
+ if ( entries_in_queue ) {
+ count_string = '(' + entries_in_queue + ')';
+ document.title = document.title.replace( /^\(\d+\)\s+/, '' );
+ document.title = count_string + ' ' + document.title;
+ } else {
+ document.title = this.originalTitle;
+ }
+ }
+ });
// A dummy proxy DOM element, which allows us to use arbitrary events
// via the jQuery events system
@@ -9,9 +117,16 @@ window.liveblog = {};
liveblog.init = function() {
liveblog.$entry_container = $( '#liveblog-entries' );
liveblog.$spinner = $( '#liveblog-update-spinner' );
+
+ liveblog.queue = new liveblog.EntriesQueue();
+ liveblog.fixedNag = new liveblog.FixedNagView();
+ liveblog.entriesContainer = new liveblog.EntriesView();
+ liveblog.titleBarCount = new liveblog.TitleBarCountView();
+
liveblog.cast_settings_numbers();
liveblog.reset_timer();
liveblog.set_initial_timestamps();
+
liveblog.$events.trigger( 'after-init' );
};
@@ -31,6 +146,7 @@ window.liveblog = {};
liveblog_settings.delay_threshold = parseInt( liveblog_settings.delay_threshold, 10 );
liveblog_settings.delay_multiplier = parseFloat( liveblog_settings.delay_multiplier, 10 );
liveblog_settings.latest_entry_timestamp = parseInt( liveblog_settings.latest_entry_timestamp, 10 );
+ liveblog_settings.fade_out_duration = parseInt( liveblog_settings.fade_out_duration, 10 );
};
liveblog.kill_timer = function() {
@@ -43,13 +159,15 @@ window.liveblog = {};
};
liveblog.undelay_timer = function() {
- if ( liveblog_settings.original_refresh_interval )
+ if ( liveblog_settings.original_refresh_interval ) {
liveblog_settings.refresh_interval = liveblog_settings.original_refresh_interval;
+ }
};
liveblog.delay_timer = function() {
- if ( ! liveblog_settings.original_refresh_interval )
+ if ( ! liveblog_settings.original_refresh_interval ) {
liveblog_settings.original_refresh_interval = liveblog_settings.refresh_interval;
+ }
liveblog_settings.refresh_interval *= liveblog_settings.delay_multiplier;
@@ -68,18 +186,31 @@ window.liveblog = {};
};
liveblog.get_recent_entries_success = function( response, status, xhr ) {
+ var added, modifying;
liveblog.consecutive_failures_count = 0;
liveblog.hide_spinner();
- if ( response && response.latest_timestamp )
+ if ( response && response.latest_timestamp ) {
liveblog.latest_entry_timestamp = response.latest_timestamp;
+ }
liveblog.latest_response_server_timestamp = liveblog.server_timestamp_from_xhr( xhr );
liveblog.latest_response_local_timestamp = liveblog.current_timestamp();
- liveblog.display_entries( response.entries );
+ if ( response.entries.length ) {
+ if ( liveblog.is_at_the_top() && liveblog.queue.isEmpty() ) {
+ liveblog.display_entries( response.entries );
+ } else {
+ added = _.filter(response.entries, function(entry) { return 'new' == entry.type; } );
+ modifying = _.filter(response.entries, function(entry) { return 'update' == entry.type || 'delete' == entry.type; } );
+ liveblog.queue.add(added);
+ liveblog.queue.applyModifyingEntries(modifying);
+ // updating and deleting entries is rare enough, so that we can screw the user's scroll and not queue those events
+ liveblog.display_entries(modifying);
+ }
+ }
liveblog.reset_timer();
liveblog.undelay_timer();
@@ -90,8 +221,9 @@ window.liveblog = {};
liveblog.hide_spinner();
// Have a max number of checks, which causes the auto-update to shut off or slow down the auto-update
- if ( ! liveblog.consecutive_failures_count )
+ if ( ! liveblog.consecutive_failures_count ) {
liveblog.consecutive_failures_count = 0;
+ }
liveblog.consecutive_failures_count++;
@@ -113,100 +245,46 @@ window.liveblog = {};
return;
}
+ // if we insert a few entries at once we should give the user more time to
+ // seperate new from old ones
+ var duration = entries.length * 1000 * liveblog_settings.fade_out_duration;
+
for ( var i = 0; i < entries.length; i++ ) {
var entry = entries[i];
- liveblog.display_entry( entry );
- }
-
- liveblog.show_nag( entries );
- };
-
- liveblog.show_nag = function( entries ) {
- var hidden_entries = liveblog.get_hidden_entries(),
- hidden_entries_count = hidden_entries.length;
-
- if ( !entries || !entries.length ) {
- return;
- }
-
- if ( ! hidden_entries_count ) {
- return;
- }
-
- if ( liveblog.is_nag_disabled() ) {
- liveblog.unhide_entries();
- return;
- }
-
- // Update count in title
- if ( ! liveblog.original_title )
- liveblog.original_title = document.title;
-
- liveblog.update_count_in_title( hidden_entries_count );
-
- if ( ! liveblog.$update_nag ) {
- liveblog.$update_nag = $( '<div/>' );
- liveblog.$update_nag
- .addClass( 'liveblog-nag liveblog-message' )
- .slideUp();
+ liveblog.display_entry( entry, duration );
}
-
- var nag_text = 1 < hidden_entries_count ? liveblog_settings.update_nag_plural : liveblog_settings.update_nag_singular;
- nag_text = nag_text.replace( '%d', hidden_entries_count );
-
- liveblog.$update_nag
- .html( nag_text )
- .prependTo( liveblog.$entry_container )
- .one( 'click', function() {
- liveblog.unhide_entries();
- $( this ).slideUp();
- document.title = liveblog.original_title;
- } )
- .slideDown();
- };
-
- liveblog.update_count_in_title = function( count ) {
- var count_string = '(' + count + ')';
- document.title = document.title.replace( /^\(\d+\)\s+/, '' );
- document.title = count_string + ' ' + document.title;
- };
-
- liveblog.disable_nag = function() {
- liveblog.nag_disabled = true;
- };
-
- liveblog.is_nag_disabled = function() {
- return liveblog.nag_disabled;
};
liveblog.get_entry_by_id = function( id ) {
return $( '#liveblog-entry-' + id );
};
- liveblog.display_entry = function( new_entry ) {
+ liveblog.display_entry = function( new_entry, duration ) {
+ if ( new_entry instanceof liveblog.Entry ) {
+ new_entry = new_entry.attributes
+ }
+
var $entry = liveblog.get_entry_by_id( new_entry.id );
- if ( $entry.length ) {
+ if ('new' == new_entry.type && !$entry.length)
+ liveblog.add_entry( new_entry, duration );
+ else if ('update' == new_entry.type && $entry.length)
liveblog.update_entry( $entry, new_entry );
- } else {
- liveblog.add_entry( new_entry );
- }
+ else if ('delete' == new_entry.type && $entry.length)
+ liveblog.delete_entry( $entry );
+
$( document.body ).trigger( 'post-load' );
};
- liveblog.add_entry = function( new_entry ) {
+ liveblog.add_entry = function( new_entry, duration ) {
var $new_entry = $( new_entry.html );
- $new_entry.addClass( 'liveblog-hidden' ).prependTo( liveblog.$entry_container );
+ $new_entry.addClass('highlight').prependTo( liveblog.$entry_container ).animate({backgroundColor: 'white'}, {duration: duration});
};
liveblog.update_entry = function( $entry, updated_entry ) {
var $updated_entry = $( updated_entry.html );
var updated_text = $( '.liveblog-entry-text', $updated_entry ).html();
- if ( updated_text ) {
- $( '.liveblog-entry-text', $entry ).html( updated_text );
- } else {
- liveblog.delete_entry( $entry );
- }
+ $( '.liveblog-entry-text', $entry ).html( updated_text );
};
liveblog.delete_entry = function( $entry ) {
@@ -274,7 +352,7 @@ window.liveblog = {};
liveblog.is_at_the_top = function() {
return $(document).scrollTop() < liveblog.$entry_container.offset().top;
- }
+ }
// Initialize everything!
if ( 'archive' != liveblog_settings.state ) {
View
9 liveblog.php
@@ -46,6 +46,7 @@
const max_consecutive_retries = 100; // max number of failed tries before polling is disabled
const delay_threshold = 5; // how many failed tries after which we should increase the refresh interval
const delay_multiplier = 2; // by how much should we inscrease the refresh interval
+ const fade_out_duration = 5; // how much time should take fading out the background of new entries
/** Variables *************************************************************/
@@ -443,7 +444,8 @@ public static function enqueue_scripts() {
return;
wp_enqueue_style( self::key, plugins_url( 'css/liveblog.css', __FILE__ ) );
- wp_enqueue_script( self::key, plugins_url( 'js/liveblog.js', __FILE__ ), array( 'jquery', 'jquery-color' ), self::version, true );
+ wp_register_script( 'jquery-throttle', plugins_url( 'js/jquery.ba-throttle-debounce.min.js', __FILE__ ) );
+ wp_enqueue_script( self::key, plugins_url( 'js/liveblog.js', __FILE__ ), array( 'jquery', 'jquery-color', 'backbone', 'jquery-throttle' ), self::version, true );
if ( self::current_user_can_edit_liveblog() ) {
wp_enqueue_script( 'liveblog-publisher', plugins_url( 'js/liveblog-publisher.js', __FILE__ ), array( self::key, 'jquery-ui-tabs' ), self::version, true );
@@ -472,14 +474,15 @@ public static function enqueue_scripts() {
'max_consecutive_retries'=> self::max_consecutive_retries,
'delay_threshold' => self::delay_threshold,
'delay_multiplier' => self::delay_multiplier,
+ 'fade_out_duration' => self::fade_out_duration,
'endpoint_url' => self::get_entries_endpoint_url(),
// i18n
- 'update_nag_singular' => __( '%d new update', 'liveblog' ),
- 'update_nag_plural' => __( '%d new updates', 'liveblog' ),
'delete_confirmation' => __( 'Do you really want do delete this entry? There is no way back.', 'liveblog' ),
'error_message_template' => __( 'Error {error-code}: {error-message}', 'liveblog' ),
+ 'new_update' => __( 'Liveblog: {number} new update' ),
+ 'new_updates' => __( 'Liveblog: {number} new updates' ),
) )
);
}
View
17 t/test-entry.php
@@ -18,6 +18,11 @@ function test_insert_should_return_entry() {
$this->assertInstanceOf( 'WPCOM_Liveblog_Entry', $entry );
}
+ function test_insert_should_return_entry_with_type_new() {
+ $entry = $this->insert_entry();
+ $this->assertEquals( 'new', $entry->get_type() );
+ }
+
function test_insert_should_fire_liveblog_insert_entry() {
unset( $GLOBALS['liveblog_hook_fired'] );
add_action( 'liveblog_insert_entry', function() { $GLOBALS['liveblog_hook_fired'] = true; } );
@@ -31,6 +36,12 @@ function test_update_should_replace_the_content_in_the_query() {
$this->assertEquals( $entry->get_id(), $update_entry->replaces );
}
+ function test_update_should_return_entry_with_type_update() {
+ $entry = $this->insert_entry();
+ $update_entry = WPCOM_Liveblog_Entry::update( $this->build_entry_args( array( 'entry_id' => $entry->get_id(), 'content' => 'updated' ) ) );
+ $this->assertEquals( 'update', $update_entry->get_type() );
+ }
+
function test_update_should_fire_liveblog_update_entry() {
unset( $GLOBALS['liveblog_hook_fired'] );
add_action( 'liveblog_update_entry', function() { $GLOBALS['liveblog_hook_fired'] = true; } );
@@ -53,6 +64,12 @@ function test_delete_should_replace_the_content_in_the_query() {
$this->assertEquals( '', $update_entry->get_content() );
}
+ function test_delete_should_return_entry_with_type_delete() {
+ $entry = $this->insert_entry();
+ $update_entry = WPCOM_Liveblog_Entry::delete( $this->build_entry_args( array( 'entry_id' => $entry->get_id() ) ) );
+ $this->assertEquals( 'delete', $update_entry->get_type() );
+ }
+
function test_delete_should_delete_original_entry() {
$entry = $this->insert_entry();
$update_entry = WPCOM_Liveblog_Entry::delete( $this->build_entry_args( array( 'entry_id' => $entry->get_id() ) ) );
View
5 templates/liveblog-loop.php
@@ -6,4 +6,9 @@
<?php endforeach; ?>
+ <div id="liveblog-fixed-nag">
+ <a href="#">
+ </a>
+ </div>
+
</div>
Something went wrong with that request. Please try again.