Hola 
Un enfoque diferente al de @Damian_Boon, aunque: sin iframe, todo se ejecuta como componentes nativos de Glimmer reutilizando el propio mecanismo de Post/post-stream de Discourse. Como surgieron algunas preguntas específicas en el hilo, aquí explico cómo terminé resolviendo cada una.
Por qué no usar un iframe
Un iframe es, con mucho, la forma más rápida de lograr esto: ruta completa del tema, compositor, moderación, todo gratis. El costo, que es lo que pregunta @nicolsdennis sobre MessageBus, es que terminas con dos instancias vivas de Discourse en la página: dos post-streams, dos conexiones de MessageBus, dos compositores.
Yo fui por el otro camino. El modal es un DModal que renderiza los componentes reales Post/PostSmallAction contra un postStream construido desde store.createRecord("topic", …), misma aplicación, misma conexión de MessageBus, mismo compositor. Eso significa que un puñado de servicios singleton principales (modal, bookmarkApi, DiscourseURL.routeTo, el controller:topic ambiental) necesitan apuntar al tema del modal mientras esté abierto:
// Los componentes principales (ej. PostBookmarkManager) leen/escriben topic.bookmarks
// desde controller:topic. Apúntalo a nuestro topicModel mientras el modal esté abierto.
this.topicController = getOwner(this).lookup("controller:topic");
if (this.topicController) {
this.originalTopicControllerModel = this.topicController.model;
}
Cada parche se deshace mediante un restoreServicePatches() idempotente al cerrar, destruir o navegar fuera, así que nada se filtra al resto de la aplicación una vez que el modal desaparece.
Enlaces directos y enrutamiento interno
Dos partes. Primero, el modal debería abrirse donde el lector realmente se quedó, no siempre en el 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)
);
Segundo, los enlaces dentro de los posts que apuntan de vuelta al mismo tema no deberían escapar del modal. DiscourseURL.routeTo se parchea durante el tiempo que esté abierto:
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);
};
Cualquier cosa que apunte a otro lugar restaura los parches primero y cae en una navegación normal, así que salir del modal nunca deja un enrutamiento obsoleto atrás.
El caso de respuesta (20+ respuestas)
Desplazamiento infinito abajo mediante un centinela + IntersectionObserver, un “cargar anteriores” simétrico arriba (una vista previa puede abrirse a mitad del hilo), ambos impulsando el exacto postStream.appendMore() / prependMore() que usa la ruta real del tema:
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();
});
Primera pintura
Un esqueleto (marcadores de posición de avatar + líneas, brillo) llena la primera pintura en lugar de un spinner, y la lista de posts en sí se renderiza en etapas en lugar de todo a la vez, justo suficientes posts para llegar al post objetivo primero, luego cinco más a la vez vía requestIdleCallback hasta que el resto se ponga al día:
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;
};
Cada contenedor de post también obtiene content-visibility: auto con un contain-intrinsic-size aproximado, así que el navegador omite el diseño por completo para posts que están fuera de pantalla:
.topic-post {
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
}
Esta es la parte en la que insistiría más. En lugar de reutilizar una instancia después del clic, hago prefetch antes del clic. Cada fila de la lista de temas observa su propia visibilidad, hace debounce de 200ms para que un desplazamiento rápido no dispare una docena de solicitudes, y lo delega a una pequeña cola compartida limitada a 2 solicitudes concurrentes:
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);
}
},
};
}
El prefetch se almacena bajo una clave privada de PreloadStore, deliberadamente no la clave principal topic_${id}, escribir allí filtraría un JSON parcial con alcance last_read+1 en una navegación real de tema completo. En el clic real se promociona a la clave que espera el cargador del modal:
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 {
// mejor esfuerzo - el modal vuelve a una carga fresca
}
}
Si una fila se desplaza fuera de la vista antes de que se haga clic, la solicitud pendiente se cancela y cualquier resultado en caché se descarta, así que solo retiene datos para temas que realmente están en pantalla en ese momento.
Uno más, no mencionado en el hilo aún: modales anidados
Algunos flujos de modal (los que el núcleo abre por sí mismo en lugar de a través de una propiedad de devolución de llamada explícita conectada a showSubModal()) llaman directamente al servicio modal. En lugar de permitir que esos cierren el modal de vista previa debajo del lector, modal.show() se parchea para redirigir selectivamente los modales objetivo a un pequeño espacio de sub-modal local local mientras la vista previa esté abierta:
this.originalModalShow = this.modal.show.bind(this.modal);
this.modal.show = (component, opts = {}) => {
return this.showSubModal(component, opts.model);
};
así que esos flujos objetivo se superponen a la vista previa en lugar de reemplazarla, mientras que el seguimiento de lectura (/topics/timings) sigue funcionando debajo mediante una descarga periódica de un conjunto de números de post visibles rastreados por IntersectionObserver, así que los conteos de no leídos siguen siendo correctos después de cerrar, igual que una visita normal a un tema.
Es un buen trozo de código a través del modal, el disparador de la lista de temas y el estilo (~1700 líneas en total), esto cubre quizás un tercio de ello. Las partes más difíciles por mucho fueron el parcheo de servicios y hacer que content-visibility se comporte una vez que las alturas de los posts cambian después de que las imágenes terminen de cargar. Feliz de entrar en más detalle sobre cualquiera de ellos, o hacerlo de código abierto, si hay interés.