Sometimes it makes sense for sites want to treat things differently based on whether the user has seen them. For example, I like sites that highlight new comments (ex: EA Forum and LessWrong) and I'd like them even better if comments didn't lose their "highlighted" status in cases where I hadn't scrolled them into view. In writing my Mastodon client, Shrubgrazer, I wanted a version of this so it would show me posts that I hadn't seen before. The implementation is a bit fussy, so I wanted to write up a bit on what approach I took.

The code is on github, and it counts posts as viewed if both the top of the post and bottom have been on screen for at least half a second. Specifically, whenever the top or bottom of a post enters the viewport it sets a 500ms timer, and if when the timer fires it's still within the viewport it keeps a record client side. If this now means that both the top and bottom have met the criteria it sends a beacon back so the server can track the entry as viewed.

Go back 4-7 years and this would have required a scroll listener, using a ton of CPU, but modern browsers now support the IntersectionObserver API. This lets us get callbacks whenever an entry enters or leaves the viewport.

I start by creating an IntersectionObserver:

const observer =
  new IntersectionObserver(
    handle_intersect, {
      root: null,
      threshold: [0, 1],
    });

We haven't told it which elements to observe yet, but once we do it will call the handle_intersect callback anytime those elements fully enter or fully exit the viewport.

Each entry has an element at the very top and very bottom, and to start tracking the entry we tell our observer about them:

observer.observe(entry_top);
observer.observe(entry_bottom);

What does our handle_intersect callback do? We maintain two sets of element IDs, onscreen_top and onscreen_bottom for keeping track of what is currently onscreen. The callback keeps those sets current, and also starts the 500ms timer:

function handle_intersect(entries, observer) {
  for (let entry of entries) {
    const target = entry.target;
    const id = target.getAttribute("id");
    const is_bottom =
        target.classList.contains("bottom");
    const onscreen_set =
        is_bottom ? onscreen_bottom : onscreen_top;

    if (entry.intersectionRatio > 0.99) {
      onscreen_set.add(id);
      window.setTimeout(function() {
        onscreen_timeout(
          target, post_id, is_bottom, onscreen_set);
      }, 500);
      start_observation_timer(target);
    } else if (entry.intersectionRatio < 0.01) {
      onscreen_set.delete(id);
    }
  }
}

What does onscreen_timeout do? It checks whether the element is still onscreen, and if it's not then it does nothing. This covers things like fling scrolling where something has been onscreen for such a short time that it really hasn't been seen. Otherwise, if the element is still onscreen, it marks the element as viewed and stops tracking it. And if now both the top and bottom of the entry have been viewed it tells the server about it:

function onscreen_timeout(
    target, post_id, is_bottom, onscreen_set) {
  if (!onscreen_set.has(post_id)) {
    // Element left the screen too quickly,
    // don't track it as being onscreen.
    return;
  }

  observer.unobserve(target);

  if (is_bottom) {
    viewed_bottom.add(post_id);
  } else {
    viewed_top.add(post_id);
  }

  if (viewed_top.has(post_id) &&
      viewed_bottom.has(post_id)) {
    send_view_ping(post_id);
    viewed_top.delete(post_id);
    viewed_bottom.delete(post_id);
  }
}

While Shrubgrazer hasn't had wide usage (I suspect I'm the only user, since it takes some work to host and I'm not hosting for anyone else) this has worked well for me. It makes the browser do almost all the work, so it's very fast.

New Comment