Detecting What's Been Seen

November 23rd, 2023
shrubgrazer, tech
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.

Comment via: facebook, lesswrong, substack

Recent posts on blogs I like:

Raffles

I think that raffles are kind of silly, because when you enter a raffle and you don't know what you're going to get, or if you're going to get anything, what's the point? If you pay for your raffle ticket, and you could pay the same amount…

via Anna Wise's Blog Posts September 13, 2026

Air Filters in Schools

My dad went to a conference recently, and when I asked him about what happened there he said it was a conference for people in biosecurity. When I asked him about the different kinds of biosecurity stuff that the different groups did, he said one did air f…

via Lily Wise's Blog Posts September 12, 2026

The One That I Want Another Sister

I want a baby sister or brother. Because I want to play with another kid, and I don't want to play with Josie because she is in preschool and I won't play with her very often. So I want to play with someone very often who is not just two people. …

via Nora Wise's Blog Posts September 5, 2026

more     (via openring)