Facebook-style Topic Modal - Is it better?

I have been messing about trying to get a different look to other sites and have built a Facebook style topic modal. It seems to load quicker for me and seems to be more fluent on desktop.

What are your thoughts?

Link to test is https://www.carptalk-online.co.uk/ (only works on Desktop)

nicely done! i quite like that even though i’m not a facebook user. you have a cool site. :star_struck:

also, i didn’t realize carp can get so huge! :fishing_pole:

Very cool! have you tested it in topics with 20+ replies? that’s when we load more “pages” of posts on scroll and in my experience is one of the biggest complications with this sort of alternate view

No i’ll do this now and report back

Works fine - URL DELETED

I can see the page load like you say but it works as expected

The topic doesn’t show up in a modal when clicking the direct link. From the topic list, it works tho.

Yeah thats the next thing i need to look into but i have been pondering over this, does it matter? That’s the same as Facebook behaves, the direct link goes to the actual post, if clicked while browsing modal opens.

Nice concept, are you able to maintain a consistent connection with message bus for new messages while loading older messages?

Yes, because the modal loads the normal Discourse topic application inside an iframe rather than rendering a custom copy of the topic. The iframe establishes its own MessageBus connection and Discourse continues handling the post stream normally, including loading older posts and receiving newly published posts.

The parent page also remains active behind it, so technically there are two Discourse instances and two MessageBus connections while the modal is open. That works, although it is slightly heavier than a fully native implementation. Closing the modal removes the iframe and its connection.

I don’t know if it matters, and I don’t use facebook :stuck_out_tongue:

I was just surprised that clicking your link didn’t show the topic in a modal, when you posted it precisely to show it worked in a modal.

My original post was a link to the homepage, did you click my response to Kris testing the topics haha. Hows it going anyway not seen you in a while

Just finished the mobile version and am happy with the way it all works, next is to find a better way of doing this and get the load speed down. Has anyone got any suggestions?

After more testing, i have used the non js version that google sees and it loads instantly in the modal, if i can somehow use the contents of that it would make this rapid

It looks great on the desktop version,

but the loading speed on mobile is a bit slow. After tapping, you only see an empty page with a close button in the top right corner, seemingly requiring you to wait a long time in front of a blank screen.

This is what i’m working on now :slight_smile: At the minute its just a concept I want to make reality. However on my device it does load in 2 seconds on mobile with the discourse spinner loading icon so not sure whats happening. Probably because the server is in london UK with no CDN as of yet

That is indeed too far away, so the network takes time to transmit data.

I have now got the topics loading instantly in the modal, now to find a way to use the first discourse loaded instance for the replies etc. (this is on the test server)

I have turned this off on the main site as its now getting traffic with people registering, so its been put on a test server. Hopefully will have this done by middle of August if anyone is interested in a modal

Hello :waving_hand:

Different approach from @Damian_Boon’s though: no iframe, everything runs as native Glimmer components reusing Discourse’s own Post/post-stream mechanism. Since a few specific questions came up in the thread, here’s how I ended up solving each one.


Why not an iframe

An iframe is by far the fastest way to achieve this, full topic route, composer, moderation, all for free. The cost, which is what @nicolsdennis’s MessageBus question gets at, is that you end up with two live Discourse instances on the page: two post-streams, two MessageBus connections, two composers.

I went the other way. The modal is a DModal rendering the real Post/PostSmallAction components against a postStream built from store.createRecord("topic", …), same app, same MessageBus connection, same composer. That means a handful of core singleton services (modal, bookmarkApi, DiscourseURL.routeTo, the ambient controller:topic) need to be pointed at the modal’s topic for as long as it’s open:

// Core components (e.g. PostBookmarkManager) read/write topic.bookmarks
// from controller:topic. Point it at our topicModel while modal is open.
this.topicController = getOwner(this).lookup("controller:topic");
if (this.topicController) {
  this.originalTopicControllerModel = this.topicController.model;
}

Every patch gets undone by an idempotent restoreServicePatches() on close, destroy, or navigation away, so nothing leaks into the rest of the app once the modal is gone.


Direct links & internal routing

Two pieces. First, the modal should open where the reader actually left off, not always at post 1:

const lastRead = this.topic.last_read_post_number ?? 0;
const highestPostNumber = this.topic.highest_post_number ?? 1;
const initialPostNumber = Math.max(
  1,
  Math.min(lastRead + 1, highestPostNumber)
);

Second, links inside posts that point back into the same topic shouldn’t escape the modal. DiscourseURL.routeTo gets patched for the duration it’s open:

this.originalRouteTo = DiscourseURL.routeTo.bind(DiscourseURL);
DiscourseURL.routeTo = (path, opts) => {
  if (typeof path === "string") {
    const match = path.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/(\d+))?/);
    if (match && parseInt(match[1], 10) === this.topicId) {
      this.jumpToPost(match[2] ? parseInt(match[2], 10) : 1);
      return Promise.resolve();
    }
  }
  this.restoreServicePatches();
  return this.originalRouteTo(path, opts);
};

Anything pointing elsewhere restores the patches first and falls through to a normal navigation, so leaving the modal never leaves stale routing behind.


The reply case (20+ replies)

Infinite scroll below via a sentinel + IntersectionObserver, a symmetric “load earlier” above (a preview can open mid-thread), both driving the exact postStream.appendMore() / prependMore() the real topic route uses:

sentinel = modifier((element) => {
  const obs = new IntersectionObserver(
    (entries) => {
      if (entries.some((e) => e.isIntersecting)) {
        this.loadBelow();
      }
    },
    {
      root: document.querySelector(".topic-preview-modal .d-modal__body"),
      rootMargin: "200px",
    }
  );
  obs.observe(element);
  return () => obs.disconnect();
});

First paint

A skeleton (avatar + line placeholders, shimmer) fills the first paint instead of a spinner, and the post list itself renders in stages instead of all at once, just enough posts to reach the target post first, then five more at a time via requestIdleCallback until the rest catches up:

const renderRemainingPosts = () => {
  const totalPosts = this.postStream?.posts?.length || 0;
  if (this.renderLimit < totalPosts) {
    this.renderLimit = Math.min(this.renderLimit + 5, totalPosts);
    if (this.renderLimit < totalPosts) {
      if (window.requestIdleCallback) {
        window.requestIdleCallback(renderRemainingPosts, { timeout: 300 });
      } else {
        setTimeout(renderRemainingPosts, 30);
      }
      return;
    }
  }
  this.renderLimit = Number.MAX_SAFE_INTEGER;
};

Each post wrapper also gets content-visibility: auto with a rough contain-intrinsic-size, so the browser skips layout entirely for posts that are off-screen:

.topic-post {
  contain: layout;
  content-visibility: auto;
  contain-intrinsic-size: 1px 180px;
}

This is the part I’d push hardest on. Instead of reusing an instance after the click, I prefetch before the click. Each topic-list row watches its own visibility, debounces 200ms so a fast scroll doesn’t fire a dozen requests, and hands off to a small shared queue capped at 2 concurrent fetches:

function createTopicPrefetchQueue(maxConcurrent) {
  const pending = [];
  const activeIds = new Set();
  function drain() {
    while (activeIds.size < maxConcurrent && pending.length) {
      const { id, task } = pending.shift();
      activeIds.add(id);
      Promise.resolve()
        .then(task)
        .finally(() => {
          activeIds.delete(id);
          drain();
        });
    }
  }
  return {
    schedule(id, task) {
      if (activeIds.has(id) || pending.some((item) => item.id === id)) {
        return;
      }
      pending.push({ id, task });
      drain();
    },
    cancel(id) {
      const index = pending.findIndex((item) => item.id === id);
      if (index !== -1) {
        pending.splice(index, 1);
      }
    },
  };
}

The prefetch is stashed under a private PreloadStore key, deliberately not the core topic_${id} key, writing there would leak a last_read+1-scoped partial JSON into a real full-topic navigation. On the actual click it gets promoted into the key the modal’s loader expects:

promoteTopicPrefetch(topic) {
  if (!topic?.id) {
    return;
  }
  try {
    const key = this.prefetchStoreKey(topic.id);
    const prefetched = PreloadStore.get?.(key);
    if (prefetched != null) {
      PreloadStore.remove?.(key);
      PreloadStore.store(`topic_${topic.id}`, prefetched);
    }
  } catch {
    // best-effort - modal falls back to a fresh load
  }
}

If a row scrolls back out of view before it’s clicked, the pending fetch is cancelled and any cached result discarded, so it only ever holds data for topics actually on screen right now.


One more, not raised in the thread yet: nested modals

A few modal flows (the ones core opens on its own rather than through an explicit callback prop wired to showSubModal()) call the modal service directly. Rather than let those close the preview modal out from under the reader, modal.show() is patched to selectively redirect targeted modals to a small local sub-modal slot for as long as the preview is open:

this.originalModalShow = this.modal.show.bind(this.modal);
this.modal.show = (component, opts = {}) => {
  return this.showSubModal(component, opts.model);
};

so those targeted flows layer on top of the preview instead of replacing it, while read-tracking (/topics/timings) keeps running underneath via a periodic flush of an IntersectionObserver-tracked set of visible post numbers, so unread counts are still correct after closing, same as a normal topic visit.


It’s a fair chunk of code across the modal, the topic-list trigger, and the styling (~1700 lines all together), this covers maybe a third of it. The trickiest parts by far were the service patching and getting content-visibility to behave once post heights change after images finish loading. Happy to go into more detail on any of it, or open-source it, if there’s interest.