Jetpack Infinite Scroll for Single Posts!

Had a problem come up recently where folks wanted to keep engaging visitors on their website on a single post page — keep loading more posts afterwards when they kept scrolling.

I’ve heard of this before, and even seen some plugins accomplish it — for example, Infinite Post Transporter, by Tom Harriganwp.org / github — the codebase of which looks to be a modified version of Jetpack’s Infinite Scroll from about six years ago — (contemporary link).

So, I was curious to see how far I could go about getting close to what we needed just by playing with Jetpack’s own library, rather than duplicating a bunch of the code in a second plugin.

For anyone that wants to skip to the end and just get something to play with, here’s the gist that I’ve got the code shoved into for now.

First, a couple specifications we’re working with here:

  • I want to make this work on single post pages, specifically of the post post_type.
  • I don’t want to modify Jetpack files, or deregister / replace them with customized versions of the files. Filters / actions only.

So, skimming through the Jetpack Infinite Scroll codebase, there’s a couple conditionals we’re gonna need to short out to get things triggering on single post pages.

The bulk of the per-page code comes from The_Neverending_Home_Page::action_template_redirect() — this is the function that will register/enqueue Infinite Scroll’s scripts and styles, and set up footer actions to populate some javascript globals that specify state. However, for single posts, we’ll need to override two points.

		if ( ! current_theme_supports( 'infinite-scroll' ) || ! self::archive_supports_infinity() )
			return;

By default, single posts aren’t archives, and don’t work. So let’s hotwire it! Digging into The_Neverending_Home_Page::archive_supports_infinity() we can see that the whole response to that call is simply passed through a filter — `infinite_scroll_archive_supported`

		/**
		 * Allow plugins to filter what archives Infinite Scroll supports.
		 *
		 * @module infinite-scroll
		 *
		 * @since 2.0.0
		 *
		 * @param bool $supported Does the Archive page support Infinite Scroll.
		 * @param object self::get_settings() IS settings provided by theme.
		 */
		return (bool) apply_filters( 'infinite_scroll_archive_supported', $supported, self::get_settings() );

So to filter this, we’ll want to make sure we’re using the right conditionals and not filtering too many pages. In this case, is_singular( 'post' ) feels appropriate.

For brevity here, I’ll just be using anonymous functions, but in practice it’s likely far better to name your functions so other code can remove the filters if it becomes necessary.

add_filter( 'infinite_scroll_archive_supported', function ( $supported ) {
	if ( is_singular( 'post' ) ) {
		return true;
	}
	return $supported;
} );

Groovy, now we’ve got the function registering the scripts we care about. But there’s a second conditional later in the ::action_template_redirect() method that we also get snagged on — ::is_last_batch().

		// Make sure there are enough posts for IS
		if ( self::is_last_batch() ) {
			return;
		}

Fortunately, like our last example, The_Neverending_Home_Page::is_last_batch() is also filterable.

		/**
		 * Override whether or not this is the last batch for a request
		 *
		 * @module infinite-scroll
		 *
		 * @since 4.8.0
		 *
		 * @param bool|null null                 Bool if value should be overridden, null to determine from query
		 * @param object    self::wp_query()     WP_Query object for current request
		 * @param object    self::get_settings() Infinite Scroll settings
		 */
		$override = apply_filters( 'infinite_scroll_is_last_batch', null, self::wp_query(), self::get_settings() );
		if ( is_bool( $override ) ) {
			return $override;
		}

So again, we can just override it as we had before, only this case returning false:

add_filter( 'infinite_scroll_is_last_batch', function ( $is_last_batch ) {
	if ( is_singular( 'post' ) ) {
		return false; // Possibly retool later to confirm there are other posts.
	}
	return $is_last_batch;
} );

Great! So now we’ve got the scripts that do the work being output on our single posts page, but we’re not quite there yet! We need to change some of the variables being passed in to Jetpack’s Infinite Scroll. To modify those variables, we have — you guessed it — another filter.

The JS Settings are being output in The_Neverending_Home_Page::action_wp_footer_settings() (rather than being added via wp_localize_script), and here’s the filter we’ll be working off of:

		/**
		 * Filter the Infinite Scroll JS settings outputted in the head.
		 *
		 * @module infinite-scroll
		 *
		 * @since 2.0.0
		 *
		 * @param array $js_settings Infinite Scroll JS settings.
		 */
		$js_settings = apply_filters( 'infinite_scroll_js_settings', $js_settings );

so we’ll start with a generic action like this, and start customizing:

add_filter( 'infinite_scroll_js_settings', function ( $js_settings ) {
	if ( is_singular( 'post' ) ) {
		$js_settings['foo'] = 'bar'; // any we need to make!
	}
	return $js_settings;
} );

We can verify this change is in place by loading up a single post page, and searching for foo — confirming that it’s in the encoded string that’s being output to the page. So now we need to start looking at the javascript that runs and see what changes to inputs we may need to make.

First, we’ll need to make sure that the wrapper container we want to append our new posts to is the same as the ID that we use on archive pages. If it’s not, we just need to override that. In my case, I had to change that to main on the single post pages — which can be done like so:

$js_settings['id'] = 'main';

If your wrapper id is the same as archive pages, this can just be skipped. Here we can also override some other options — for example if we would rather change the verbiage on the load more posts button we could do

$js_settings['text'] = __( 'Read Next' );

or to switch from the button click, to autoloading on scroll we could do

$js_settings['type'] = 'scroll';

Now, if you tried what we have so far, you may notice that instead of getting new posts, you’ll likely see the same post loading over and over forever. That’s because of the parameters getting passed through as query_args — if you pop open your browser window to examine infiniteScroll.settings and look at the query_args param, you’ll likely notice that we’ve gotten the infiniteScroll.settings.query_args.name populated! This is getting passed directly to the ajax query, so when trying to pull up more posts, it’s restricting it to only the already queried post, as that’s from the query WordPress ran to generate the current url’s response.

So let’s just nuke it, and for good measure ensure we don’t inadvertently re-display the post in question.

$js_settings['query_args']['name'] = null;
$js_settings['query_args']['post__not_in'][] = get_the_ID();

Cool cool cool. So at this point, when you run the post, everything /should/ look about right, except for one small thing! You may see the url updating as you scroll to append /page/2/ to the path!

Unfortunately a lot of this functionality is hard-coded and I couldn’t find a good way to override it to update the url to — for example — the url of the post you’ve currently got in view, so I wound up doing the next best thing — nuking the functionality entirely.

	/**
	 * Update address bar to reflect archive page URL for a given page number.
	 * Checks if URL is different to prevent pollution of browser history.
	 */
	Scroller.prototype.updateURL = function ( page ) {
		// IE only supports pushState() in v10 and above, so don't bother if those conditions aren't met.
		if ( ! window.history.pushState ) {
			return;
		}
		var self = this,
			pageSlug = self.origURL;

		if ( -1 !== page ) {
			pageSlug =
				window.location.protocol +
				'//' +
				self.history.host +
				self.history.path.replace( /%d/, page ) +
				self.history.parameters;
		}

		if ( window.location.href != pageSlug ) {
			history.pushState( null, null, pageSlug );
		}
	};

As the js relies on rewriting the url via calling .replace() on a string, if we eat the token it searches for off the end, it’ll just wind up replacing the url with itself, and doesn’t look awkward. And so —

$js_settings['history']['path'] = str_replace( 'page/%d/', '', $js_settings['history']['path'] );

So, functionally this should get you most of the way there. The only other bit that I had added to my implementation was something that could trivially be done anywhere — Custom CSS in the Customizer, or theme files — but I wanted to hide Post Navigation links in my theme. So I just did a fun little trick of enqueueing my one line css tweak (with appropriate conditionals) like so:

add_action( 'template_redirect', function () {
	if ( ! class_exists( 'Jetpack' ) || ! Jetpack::is_module_active( 'infinite-scroll' ) ) {
		return;
	}

	if ( is_singular( 'post' ) ) {
		// jisfsp = Jetpack Infinite Scroll for Single Posts
		wp_register_style( 'jisfsp', null );
		wp_enqueue_style( 'jisfsp' );
		wp_add_inline_style( 'jisfsp', 'nav.post-navigation { display: none; }' );
	}
} );

This way, if someone disables either Jetpack or infinite scroll, the conditional trips and it stops hiding post navigation links.

I hope this has been somewhat useful to someone. It’s probably not at the point where I’d be comfortable packaging it up as a plugin for end-user installation, but if you’ve got a passing understanding of code and how WordPress works, it shouldn’t be difficult to re-implement for a client. If there’s any other tweaks on Infinite Scroll that you wind up finding and would like to suggest, please feel free to leave a comment below, and it’ll hopefully be useful to others. Cheers!

I’m Learning the Core Media Modal.

Disclaimer: This is basically me stream-of-thought’ing things as I’m learning the Core Media Modal’s codebase.  It’s my scratchpad, and I’m merely making it public in the hopes that it may be useful to someone else at some point in the future.  Some things are probably very wrong.  If I catch it, I’ll likely come back and edit it later to be less wrong.  If you see me doing or saying something stupid, please leave a comment, so I can be less stupid.  Thanks!

The media is written in Backbone, using the `wp.template` wrapper around Underscore templates for rendering.  If you want to really dive in depth, but don’t yet have a really solid understanding of Backbone, I’ve had several people recommend Addy Osmani’s book “Developing Backbone.js Applications” to me.  As luck would have it, it’s available for free online.

When exploring the code in WordPress, it looks like it’s best to do the investigating in the develop.svn.wordpress.org repository’s src directory (yes, develop.svn matches to core.trac — basically because legacy reasons and not wanting to change core.trac’s url when they changed core.svn over to be the Grunt’d version), before the build tools such as Grunt have a chance to run Browserify on it #.  If you try to read through the code on the GitHub mirror, you’re gonna have a bad time, as that doesn’t have the `wp-includes/js/media/` directory with the source files in it.

Browserify is a slick little tool in Node that bundles up a bunch of files, and puts them into a single file, so you can `require()` them in JS.  This makes them easier to work with in the source, and quicker to load in a browser.  WordPress has been using it to compile the Javascript for media since 4.2 (#28510), when the great splittening happened.  If this intrigues or confuses you, Scott Taylor has a great write-up on that ticket about the whys, hows, and whatnot.  It originally merged in at [31373] halfway through the 4.2 cycle.

Oh, and all the actual templates that are parsed and rendered by the views are in `wp-includes/media-template.php`

Okay, time to dig in.  (So that I’m not inadvertently writing a book, I’m going to split this into a series — but if you’d like to read them all, I’m dropping them in a tag.  You can find them all here.)

Frameworks: It’s dangerous to go alone.

I get the feeling, quite often, that frameworks get the short end of the stick in the popular mindset.  You’ll often hear things like

  • “Yes, they’re useful for a beginner maybe, but if you’re a professional developer, you shouldn’t need them.”
  • “They’re sluggish.”
  • “They add bulk to a project.”
  • “You clearly haven’t optimized enough.”

Honestly, it’s a choice that needs to be made on a project by project base — both whether to use a framework, and how large of a framework to use.  Regardless of these choices, it’s never a question of whether you’ve optimized your project — it is a question of what you’ve chosen to optimize your project for.

As a case study for this question, let’s look at: Do you want to use jQuery (or some other established project — Prototype, Backbone, Underscore, React, whatever) in your project or not?

Well, if you do use jQuery, it can smooth over a lot of browser inconsistencies (most of which have nothing to do with old IE versions), and give you a more reliable output.  It can keep your code more readable, and more maintainable, as all of the browser fixes are bundled into jQuery itself!  Keep your version of jQuery current, and many future browser inconsistencies or changes in how browsers handle things will be handled for you.

If you want a lighter weight webpage, and you’re trying to optimize for faster performance, you may prefer to use vanilla javascript instead.  A friend of mine remarked on Twitter that he prefers to handle browser inconsistencies himself, because he can get faster performance:

The downside of this is that by optimizing so heavily for performance, it can make it far more difficult to maintain your project down the road.  When another developer picks up your project in a few months or a few years down the road, is the optimized code going to make sense?  Are your ‘code around’-s still going to work, and has someone (you?) been actively maintaining it (and all your other disparate projects) to account for new browser issues that have cropped up since?  If the application is expanded by a new developer, will they have the same level of experience as you, and properly handle cross-browser issues in the new code as well?

So, there’s always tradeoffs.  The final judgement will often depend on the sort of project and the sort of client or company that the project is for.

If you’re launching an Enterprise-level website, a HTML5 Game, or something that will have an active team of developers behind it, you may well find that it’s worth doing something custom for it.

If you’re an agency building client sites that — once launched — may get someone looking at them every few months for further work or maintenance … jQuery probably makes a lot more sense.  It will keep your code shorter and more readable, and if you keep jQuery up to date (which WordPress will do for you if you use its bundled version — and of course, keep WordPress updated) any future browser inconsistencies will be handled as well.

If you’re a freelancer or commercial theme/plugin vendor, using jQuery rather than something custom has always struck me as a common courtesy.  By using an established, documented library, you’re leaving the codebase in an understandable and tidy state for the next developer who has to step in and figure out what’s going on in order to make modifications down the road.

So in the end, the answer is always going to be that it depends.  The trade-offs that one project can make without a second thought may be inconceivable to thrust upon another.

Twitter oEmbed is Leaking Data

oEmbeds are fun.  They make it easy to embed third-party content on your site, like tweets, status updates, videos, images, all sorts of stuff.

Unfortunately, to do this, third-party code gets injected into your page.  Don’t worry, this is by design, but it does mean that you should only oEmbed from reputable sites.  WordPress Core is very picky as to the providers that it chooses to accept as oEmbed sources.

Twitter is one of these oEmbed providers.  Here’s an example of an embedded tweet:

Neat, isn’t it?

Now, hover over my name.

See that little url that shows on the bottom left corner of your browser (probably)?  It probably looks just like http://twitter.com/daljo628!

Now, click it.  Don’t worry, I’ll wait.

Did the page you landed on have a bunch of extra cruft appended to the end of it?

Maybe it looked something like https://twitter.com/daljo628?original_referer=http%3A%2F%2Fstephanis.info%2F2014%2F05%2F22%2Ftwitter-oembed-is-leaking-data%2F&tw_i=469505591946129408&tw_p=tweetembed?

If you right-click and inspect the element, the URL is just what you expected! If you right-click and open in a new tab — same thing! But if you click normally and let it trigger a Javascript event, it modifies the link before your browser actually processes it.

After you’ve clicked on it normally once, you can come back and re-inspect it, to see that the URL on the link has now changed to the one with the referer data on it — they’re rewriting it inline and intentionally delaying it so when you first click, you wouldn’t realize that the data was being appended.

This can be a problem because some sites employ concealers for the referer http header (No, I didn’t misspell referrer) like href.li for example. By embedding this in a get parameter forcibly, it’s leaking data in a way very difficult to block, by taking advantage of the trust offered via accepting Twitter as an oEmbed provider.

Quick and Dirty Parallax

window.onscroll = function() {
// Mobile doesn't normally work well with Parallax, so let's bail out.
if( navigator.userAgent.match(/mobile/i) != null ) return;
els = d.querySelectorAll( '[data-parallax]' );
for( i in els ) {
el = els[i];
if( typeof el.parentNode == 'undefined' ) continue;
multiplier = el.getAttribute( 'data-parallax' );
offset = ( el.parentNode.offsetTop window.pageYOffset ) / multiplier;
xOffset = 0 Math.max( offset, 0 );
if( el.hasAttribute( 'data-parallax-flip-horz' ) )
xOffset = 0 xOffset;
yOffset = Math.min( offset, 0 );
if( el.hasAttribute( 'data-parallax-flip-vert' ) )
yOffset = 0 yOffset;
transform = 'translateY(' + yOffset + 'px) translateX(' + xOffset + 'px)';
el.style.webkitTransform = transform;
el.style.MozTransform = transform;
el.style.msTransform = transform;
el.style.OTransform = transform;
el.style.transform = transform;
}
}
/* Usage:
<div style="padding-top:20em;">
<h1 data-parallax="2">I enter from the left, then go up</h1>
<h2 data-parallax="3" data-parallax-flip-horz="true">I enter from the right, then go up</h2>
<h3 data-parallax="4" data-parallax-flip-vert="true" >I enter from the left, then &darr; I go!</h3>
<img src="http://placekitten.com/300/300/&quot; alt="I enter from the right, then down I go!" data-parallax="2" data-parallax-flip-vert="true" data-parallax-flip-horz="true" />
</div>
/**/

view raw

parallax.js

hosted with ❤ by GitHub

Make your nav stick to the top of the screen when you scroll past it!

A pretty simple JS include to use when you don’t already have jQuery or the like included on a page.  Just adds a data-nav=”fixed” attribute to your body element that you can style off of via body[data-nav=”fixed”] .whatever {} — this also has the benefit of not hardcoding any styles — you’re free to do it all via your CSS files.

Just remember to swap out ‘nav’ for whatever ID or selector you’ve got on your actual nav.

Potential optimizations would be not using document.getElementById over and over, and just leaving the element cached in a global.  I would advise against caching nav’s offsetTop, though — as it’s possible that things may change in the dom, and that could change!

window.onscroll = function() {
    var d = document,
        nav = d.getElementById( 'nav' ),
        att = ( window.pageYOffset > d.getElementById( 'nav' ).offsetTop ) ? 'fixed' : 'normal';
    d.body.setAttribute( 'data-nav', att );
}

Draw Something Cool

So I’ve had a lot of awesome feedback for the “Draw Something Cool” bit that I’ve added to my Contact Form.  It’s in actuality just the Signature add-on for GravityForms!

That being said, here are some of the best images that I’ve had people submit through it thus far:

1211108976534

PVP Redesign has UX Problems

So PVP, a webcomic that I’ve followed since about 2002 (back when it was still physically published by Dork Storm Press) just launched a site redesign.

And it looks hideous.

Not in the way that most people would say hideous, mind you.  But rather from a User Experience (UX) perspective.  The header of the interior pages is 215px tall, and the front page is 415px.  When you add that to the typical browser/OS overhead of 100px or so, that means on the homepage, the content is starting about 550px down the screen.

Worse yet, Scott Kurtz (creator of PVP) has done that highly obnoxious thing (that I seem to recall him venting a couple years ago about Penny-Arcade doing) — moving the single piece of content that 95% of your visitors are coming to your site to view off of your front-page, thereby making them click through to the new page, doubling your page views, and doubling your Ad Impressions. (And doubling the aggravation of everyone who wants to just see today’s comic) (with doublemint gum)

Yes, the new site looks more modern and fresh.  However from the perspective of any visitor, with regard to usability, it has gone way down.

So as I hate criticizing things unless I’m ready to step up to the plate and do something to help, here’s a JS `bookmarklet` that you can drag to your browser bar to see how much nicer pvponline could look without that hideous massive header obscuring the screens of most of his audience.

Because let’s face it, Scott — most of your audience isn’t viewing the website on a 27″ iMac like you.

Without further ado (Yes, I do tend to ramble) here’s your bookmarklet:

PVPretty

Here’s the code that it executes:

s=document.createElement('style');
c=document.createTextNode('#header,#headerSub{background:#000000;}'
+'#header .content,#headerSub .content{height:auto; padding:0;}'
+'#header .content > #adLeaderboard,#headerSub .content > #adLeaderboard,'
+'#header .content > h1,#headerSub .content > h1,'
+'#header .content > #featured{display:none;}'
+'#header .content .nav,#headerSub .content .nav{position:relative; top:0;}');
s.appendChild(c);
document.head.appendChild(s);

And the CSS that it puts in the page.

#header,
#headerSub {background:#000000;}
#header .content,
#headerSub .content {height:auto; padding:0;}
#header .content > #adLeaderboard,
#headerSub .content > #adLeaderboard,
#header .content > h1,
#headerSub .content > h1,
#header .content > #featured {display:none;}
#header .content .nav,
#headerSub .content .nav {position:relative; top:0;}

By the way, Scott, if you ever read this, I know you consider yourself a professional. Which makes it all the more aggravating when you are eternally incapable of having your webcomics actually follow a posting schedule. Occasionally, they’ll be up by noon on the day that they’re scheduled for. But those times are rare. Far more common, they may go up around 7pm, if you actually make the date they’re meant to be up for, instead of publishing them late and backdating them.

If you want to consider yourself a professional, then why are you chronically unreliable in having the fruits of your labor up, when every single one of the other webcomics I follow does manage it, day in, and day out?

For the record, the other webcomics I read are as follows:

  • XKCD
  • CandiComics
  • QuestionableContent
  • Sinfest
  • Nodwick/FFN
  • OutThere

 

Multiple Meta-Viewports for iPad/iPhone

It’s not ideal, as you’re manually targeting the iPad, but …

Default Viewport Code (change as needed for default mobile devices)


JavaScript code (play with as you like for your own purposes)

if( navigator.userAgent.match(/iPad/i) != null ){
	viewport = document.querySelector("meta[name=viewport]");
	viewport.setAttribute('content', 'width=1000px, user-scalable=0');
}

I used this in my submission for the CSS-Off, to ensure that the viewport specified for mobile devices didn’t also restrict the iPad’s version of the site.

Toggle All Checkboxes with jQuery

Just a little snippet I worked up that may be useful to someone …

jQuery(document).ready(function($){
$('div#checkall-wrapper input[type=checkbox]').click(function(){
	if( $(this).attr('checked') ){
		$('tdiv#wraparound-targets input[type=checkbox]').attr('checked','checked');
	}else{
		$('div#wraparound-targets input[type=checkbox]').removeAttr('checked');
	}
});
});

Make sense?