Topic Preview Modal

Install this theme component

Topic Preview Modal – open and interact with topics without leaving the topic list

I’ve created a new Discourse theme component called Topic Preview Modal.

The idea is fairly simple:

Open a topic directly from the topic list in a native Discourse modal, read and interact with the topic, and then continue browsing the list without navigating away from it.

It started from Facebook-style Topic Modal - Is it better? , but it ended up requiring quite a bit of integration with Discourse’s topic, post stream, composer, modal, bookmark, routing, presence, read-tracking and prefetching systems.


Why?

The normal Discourse flow is:

  1. You are browsing a topic list.
  2. You click a topic.
  3. Discourse navigates to /t/....
  4. You read/reply/interact with the topic.
  5. You go back to the topic list.

For many workflows, this is perfectly fine.

However, when browsing a busy topic list, sometimes I only want to quickly inspect a topic, read a few posts, check the latest replies, react to something, or answer a quick question.

For that use case, leaving the topic list feels unnecessarily expensive.

The goal of this component was therefore to make the topic list behave more like an inbox:

topic list → preview → interact → close → continue exactly where you were.


What it does

The preview is not just a static excerpt.

It renders the actual Discourse post components inside a native DModal.

That means users can:

  • read posts
  • scroll through the topic
  • load earlier posts
  • load more posts below
  • react to posts
  • bookmark posts
  • quote text
  • reply to the topic
  • reply to individual posts
  • edit posts when permitted
  • delete/recover posts when permitted
  • flag posts
  • view post history
  • perform various normal post actions
  • see topic presence
  • follow links to other posts within the same topic
  • jump directly to the relevant post
  • open the full topic when needed

The intention is that the preview should feel as close as possible to actually opening the topic.


Two trigger modes

There are two ways to open the preview.

1. Entire topic-list row

This is the default.

The whole topic-list row becomes clickable, while common interactive elements such as:

  • user cards
  • participants
  • category links
  • tags
  • topic status links
  • bulk selection

are excluded from the modal trigger.

This makes the experience very fast when browsing a topic list.

2. Explicit expand button

Alternatively, the component can render a small expand icon through a Discourse plugin outlet. Custom themes can simple create a new <PluginOutlet /> to show the trigger.

In this mode the normal topic-list behavior remains completely untouched.

The user clicks the expand icon to open the preview, while clicking the topic title still performs the normal Discourse navigation.

This is useful if a site wants to preserve the standard topic-list interaction model.

The setting is:

trigger_style:
  row

or:

trigger_style:
  button

When using the button mode, the outlet is configurable as well.


The preview starts at the user’s unread position

One of the important details is that the modal does not simply load the first post.

When a topic has already been partially read, the preview calculates:

last_read_post_number + 1

and opens around that post.

So if a topic has 200 posts and the user has read through post #165, opening the preview starts around #166.

This makes the preview much more useful for real-world browsing.

It also means the component has to deal with both sides of the post stream:

  • loading earlier posts when necessary
  • loading newer posts below

The Earlier posts button is displayed when there are posts above the currently loaded range, while an IntersectionObserver sentinel automatically loads more posts when the user reaches the bottom.


Prefetching

One of the biggest parts of the component is its prefetch system.

The problem with a modal like this is that the user expects it to feel instantaneous.

If we only start loading the topic after the user clicks, the modal can still spend noticeable time waiting for the network.

Instead, the component can proactively prefetch topics while the user is browsing the list.

When a topic row approaches the viewport, an IntersectionObserver can schedule a prefetch.

There are several safeguards to prevent this from turning into uncontrolled background traffic.

Debouncing

A topic doesn’t immediately trigger a request just because it briefly appeared in the viewport.

The component waits for the configured debounce period.

Default:

400 ms

This is particularly useful when rapidly scrolling through a long topic list.

Root margin

Prefetching can start slightly before the topic actually enters the viewport.

Default:

50 px

This gives the request a small head start.

Concurrent request limit

The number of simultaneous prefetches is limited.

Default:

2

The setting allows between 1 and 6 concurrent prefetches.

Per-minute budget

There is also a second protection mechanism:

max_prefetches_per_minute

The default is:

15

So even if the user continues scrolling through hundreds of topics, the component won’t continuously generate speculative requests.

0 disables the limit.

Prefetch can be disabled completely

If a site doesn’t want any speculative network traffic:

enable_prefetch = false

The component continues to work normally. Topics simply load when the preview is opened.


Prefetch data is kept separate from normal topic navigation

There is an important implementation detail here.

The prefetched response is not immediately written to Discourse’s normal topic_<id> preload key.

Instead, the component uses its own namespace:

topic-preview-modal:prefetch:<topicId>

Only when the user actually opens the preview is the prefetched promise promoted to the core topic preload key.

This is intentional.

The preview may be loading a topic starting from last_read_post_number + 1, and I don’t want that preview-specific response leaking into a normal topic route navigation.

So the lifecycle is essentially:

topic enters viewport
        ↓
prefetch
        ↓
private preload storage
        ↓
user opens preview
        ↓
promote preload
        ↓
Topic.find()/PostStream uses the same promise

This also means the modal doesn’t have to wait for the prefetch request to finish before opening.

The modal can open immediately with its skeleton while the same promise continues resolving.


Mobile support

This was actually one of the reasons I spent considerably more time on the implementation.

The initial idea worked reasonably well on desktop, but mobile exposed several problems around:

  • touch interaction
  • modal scrolling
  • focus
  • nested menus
  • the composer
  • post visibility
  • image loading
  • performance

The final implementation therefore avoids treating the modal as a completely separate miniature forum.

Instead, it reuses as much of Discourse’s existing infrastructure as possible.


Real Discourse post components

The modal doesn’t recreate posts using a simplified custom template.

It renders Discourse’s actual:

Post
PostSmallAction

components.

This is important because otherwise the preview would quickly become a second implementation of the post UI.

The component passes the relevant actions into the normal post components, including things like:

  • reply
  • edit
  • delete
  • recover
  • flag
  • history
  • bookmark
  • wiki
  • lock/unlock
  • post type
  • ownership changes
  • badges
  • hidden posts
  • quoting
  • etc.

The result is that the preview can behave much more like a normal topic than a traditional “preview” component.


Replies and the composer

The composer is one of the more complicated parts.

The preview can open the normal Discourse composer for:

Replying to the topic

The topic composer is opened with the topic model and the correct draft information.

Replying to a specific post

The post is passed to the composer so that the reply behaves like a normal post reply.

Quoting selected text

The component also integrates with PostTextSelection.

That means users can select text inside the preview and use Discourse’s normal quote/reply flow.


Nested modals

Another tricky part was Discourse’s modal system.

Posts can open other modals and dialogs:

  • flagging
  • history
  • badge-related dialogs
  • ownership changes
  • delete confirmations
  • etc.

If those were allowed to interact with the global modal service normally, opening one of them could close the entire topic preview.

To avoid that, the component creates a local sub-modal mechanism.

Conceptually:

Topic Preview Modal
        │
        ├── Flag modal
        ├── History modal
        ├── Delete confirmation
        ├── Badge modal
        └── other post-related modal

The preview remains mounted underneath.

The component temporarily patches the relevant modal service methods while it is active and restores them when it is destroyed.


Routing inside the modal

Another important detail is links to posts within the same topic.

For example, if a post contains a link to:

/t/my-topic/123

the preview doesn’t need to close and navigate away.

Instead, the component intercepts same-topic navigation and jumps to the requested post inside the modal.

The same applies to links targeting the topic without a specific post number.

This keeps the user inside the preview.

If the link points to a genuinely different topic, the component first restores its temporary service patches and closes itself before allowing the normal Discourse route transition.

That cleanup is important because otherwise the preview’s subscriptions and timing tracker could remain alive while the real topic route is being initialized.


Read tracking and time tracking

I also wanted the preview to behave correctly from Discourse’s perspective.

Opening a preview shouldn’t mean that read tracking is completely bypassed.

The component therefore handles:

  • topic visit tracking
  • visible post tracking
  • topic timing
  • last-read post updates

The timing tracker uses an IntersectionObserver to determine which posts are actually visible.

Every 5 seconds, visible-post timing is flushed to:

/topics/timings

When the modal closes, one final flush is performed so that the last few seconds aren’t lost.

The implementation also caps a single timing interval at 60 seconds.


Keeping the topic list’s unread state synchronized

There was another subtle issue here.

Updating Discourse’s topic tracking state alone isn’t sufficient to update the unread badge shown directly on a topic-list row.

The component therefore updates the actual topic object associated with the row after timing information has been flushed.

It updates values such as:

last_read_post_number
unread_posts
unread
new_posts

when appropriate.

This means that after reading a topic inside the modal, the topic list can immediately reflect the new read state instead of requiring a full page refresh.


Post visibility

The preview uses a shared IntersectionObserver to determine when individual posts become visible.

There is also a synchronous visibility check when the observer is attached.

This handles an edge case where a post is already visible when it is mounted, but the asynchronous first IntersectionObserver callback hasn’t fired yet.

This is especially relevant for very short topics where the entire topic may already be visible when the modal opens.


Performance considerations

A major goal was to avoid turning the modal into a performance-heavy miniature topic page.

A few things are done specifically for that.

Progressive rendering

The initial load doesn’t immediately render every post.

The component first renders enough posts to reach the target position.

The remaining posts are then rendered progressively using:

requestIdleCallback

when available, with a fallback to setTimeout.

This is particularly useful when opening a long topic around a post far down the stream.

CSS containment

Posts use:

contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;

This allows the browser to avoid doing unnecessary rendering work for posts that aren’t currently visible.

Lazy images

Images that haven’t already specified a loading mode are automatically given:

loading="lazy"
decoding="async"

This prevents a long topic with many images from immediately loading everything.


Loading state

The modal doesn’t just show a blank white/empty area while the request is being made.

It has a skeleton UI with:

  • avatar placeholders
  • username/name placeholders
  • post body placeholders
  • shimmer animation

The shimmer respects:

prefers-reduced-motion

so the animation is disabled for users who have requested reduced motion.


Keeping scroll position stable

There are a few places where the component needs to manipulate the scroll position manually.

For example, when loading earlier posts, the newly inserted content increases the scroll height.

Simply prepending the posts would make the user’s current position jump.

The component therefore records the previous scroll height and compensates for the difference after the posts are inserted.

This keeps the currently visible content in approximately the same place.

The same applies when jumping to a particular post.

The component performs a post-render positioning step and verifies the position again on subsequent frames to account for content that may still be settling.


Topic presence

When the relevant topic data is available, the preview can also display Discourse’s topic presence information at the bottom of the modal.

So users can see who else is currently viewing the topic without having to leave the preview.


Interaction with mobile menus and focus

Mobile introduced another category of problems.

Some Discourse UI elements use shared modal/menu services, and those services don’t necessarily know that the topic preview is currently acting as a nested browsing context.

The component therefore has additional handling around:

  • modal.close()
  • Float Kit menus
  • focus restoration
  • the composer
  • lightbox keyboard controls
  • body scroll locks

For example, if a menu internally tries to call the global modal close method, that shouldn’t accidentally close the entire topic preview.

Similarly, when the composer is open, focus needs to remain inside the composer instead of being pulled back into the preview’s focus context.


Configuration

The component currently exposes the following settings:

Setting Default Description
trigger_style row Make the entire row clickable or use an explicit button
plugin_outlet topic-list-after-title Outlet used by the button trigger
enable_prefetch true Enable/disable background topic prefetching
max_concurrent_prefetches 2 Maximum simultaneous prefetch requests
prefetch_debounce_ms 400 Delay before starting a prefetch
prefetch_root_margin_px 50 Start prefetching this many pixels before the row enters the viewport
max_prefetches_per_minute 15 Maximum speculative requests per minute

The prefetch controls are intentionally configurable because different communities can have very different traffic patterns and hosting/network characteristics.


One of the main design goals: don’t break normal Discourse

I tried to keep the component as close as possible to Discourse’s existing architecture.

It doesn’t implement its own post renderer, its own composer, its own topic model or its own completely separate post stream.

Instead, it builds a temporary browsing context around Discourse’s existing components and services.

This is also why some parts of the implementation are more complicated than they might initially appear.

The more interesting challenge was:

Can a topic behave almost like a normal Discourse topic while it is actually being displayed inside another UI context?

That required dealing with the boundaries between Discourse’s global services and the local preview.

5 Likes

FYI:

It has problems with math. But this can be another edge case, though.

Absolute legend :slight_smile: now to figure out how to make this work with my setup :slight_smile: @awesomerobot what does your theme depend on for the whole row click

api.renderInOutlet("topic-list-before-link", TopicListItemClick);

For anyone using the Reddit-ish theme here is a fix i got to work.

Reddit-ish theme compatibility

Just a note for anyone using the Reddit-ish theme: the modal button itself works, but the default row trigger does not.

The issue is that Reddit-ish replaces the standard topic-list row behaviour and handles clicks on the whole topic card itself. Because of that, the modal’s normal row-click handling does not work as intended.

Changing the Topic Preview Modal setting to:

Trigger style: button
Plugin outlet: topic-list-after-title

works correctly, because Reddit-ish already includes the topic-list-after-title outlet.

To retain whole-card click behaviour, I left Topic Preview Modal in button mode and changed Reddit-ish’s existing openTopic() action so it triggers the modal’s working button.

The original Reddit-ish action is:

@action
openTopic(event) {
  if (
    (event.target.nodeName === "A" && !event.target.closest(".raw-link")) ||
    event.target.closest(".badge-wrapper")
  ) {
    return;
  }

  const { navigateToTopic, topic } = this.args.outletArgs;

  if (wantsNewWindow(event)) {
    window.open(topic.lastUnreadUrl, "_blank");
  } else {
    navigateToTopic(topic, topic.lastUnreadUrl);
  }
}

I changed it to:

@action
openTopic(event) {
  if (
    (event.target.nodeName === "A" && !event.target.closest(".raw-link")) ||
    event.target.closest(".badge-wrapper") ||
    event.target.closest(".topic-preview-modal__trigger-wrapper")
  ) {
    return;
  }

  const { navigateToTopic, topic } = this.args.outletArgs;

  if (wantsNewWindow(event)) {
    window.open(topic.lastUnreadUrl, "_blank");
    return;
  }

  const previewButton = event.currentTarget.querySelector(
    ".topic-preview-modal__trigger-wrapper--button"
  );

  if (previewButton) {
    event.preventDefault();
    event.stopPropagation();
    previewButton.click();
    return;
  }

  navigateToTopic(topic, topic.lastUnreadUrl);
}

The modal’s button trigger is rendered as:

<div class="topic-preview-modal__trigger-wrapper">
  <span
    role="button"
    class="topic-preview-modal__trigger-wrapper--button"
  >

So this does not recreate any of the modal logic. It simply makes the Reddit-ish card click trigger the existing working preview button.

The result is:

  • Topic card click opens the preview modal.

  • Topic title click opens the preview modal.

  • The preview button still works.

  • Cmd/Ctrl-click still opens the normal topic in a new tab.

  • Category and other normal links continue to behave normally.

  • If the preview button is not present, Reddit-ish falls back to its normal topic navigation.

So the underlying modal works fine with Reddit-ish; the incompatibility is specifically with the default row trigger.

I also hid the button using

.topic-preview-modal__trigger-wrapper {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
}