Bonjour 
Approche différente de celle de @Damian_Boon : pas d’iframe, tout fonctionne via des composants Glimmer natifs qui réutilisent le mécanisme Post/post-stream de Discourse. Comme quelques questions spécifiques ont été soulevées dans le fil de discussion, voici comment j’ai résolu chacune d’elles.
Pourquoi pas une iframe
Une iframe est de loin le moyen le plus rapide pour y parvenir : route complète du sujet, éditeur, modération, le tout gratuitement. Le coût, que la question de @nicolsdennis sur MessageBus soulève, c’est que vous vous retrouvez avec deux instances Discourse actives sur la page : deux flux de posts, deux connexions MessageBus, deux éditeurs.
J’ai choisi la voie opposée. La modale est un DModal qui rend les vrais composants Post/PostSmallAction contre un postStream construit à partir de store.createRecord("topic", …), même application, même connexion MessageBus, même éditeur. Cela signifie qu’une poignée de services singleton centraux (modal, bookmarkApi, DiscourseURL.routeTo, le controller:topic ambiant) doivent être redirigés vers le sujet de la modale tant qu’elle est ouverte :
// Les composants principaux (ex. PostBookmarkManager) lis/écrivent topic.bookmarks
// depuis controller:topic. On le pointe vers notre topicModel pendant que la modale est ouverte.
this.topicController = getOwner(this).lookup("controller:topic");
if (this.topicController) {
this.originalTopicControllerModel = this.topicController.model;
}
Chaque modification est annulée par un restoreServicePatches() idempotent lors de la fermeture, de la destruction ou de la navigation ailleurs, de sorte que rien ne fuit dans le reste de l’application une fois la modale fermée.
Liens directs et routage interne
Deux aspects. Premièrement, la modale devrait s’ouvrir là où le lecteur s’est arrêté, et non toujours au 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)
);
Deuxièmement, les liens à l’intérieur des posts qui pointent vers le même sujet ne devraient pas échapper à la modale. DiscourseURL.routeTo est modifié pour la durée de son ouverture :
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);
};
Tout ce qui pointe ailleurs restaure d’abord les modifications et passe à une navigation normale, de sorte que quitter la modale ne laisse jamais de routage obsolète.
Le cas des réponses (20+ réponses)
Défilement infini en bas via un sentinelle + IntersectionObserver, un “charger plus ancien” symétrique en haut (une prévisualisation peut s’ouvrir au milieu du fil), les deux utilisant exactement postStream.appendMore() / prependMore() que la route réelle du sujet utilise :
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();
});
Premier rendu
Un squelette (placeholders d’avatar et de ligne, effet shimmer) remplit le premier rendu au lieu d’un indicateur de chargement, et la liste des posts elle-même se rend par étapes au lieu de tout d’un coup, juste assez de posts pour atteindre le post cible en premier, puis cinq de plus à la fois via requestIdleCallback jusqu’à ce que le reste rattrape son retard :
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;
};
Chaque conteneur de post reçoit également content-visibility: auto avec une taille intrinsèque approximative (contain-intrinsic-size), de sorte que le navigateur ignore complètement la mise en page pour les posts qui sont hors écran :
.topic-post {
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
}
C’est la partie sur laquelle j’insisterais le plus. Au lieu de réutiliser une instance après le clic, je précharge avant le clic. Chaque ligne de la liste des sujets surveille sa propre visibilité, applique un délai d’attente de 200 ms pour qu’un défilement rapide ne déclenche pas une douzaine de requêtes, et délègue à une petite file d’attente partagée limitée à 2 requêtes simultanées :
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);
}
},
};
}
La précharge est stockée sous une clé privée PreloadStore, délibérément pas la clé centrale topic_${id}, écrire là-dedans ferait fuiter un JSON partiel limité à last_read+1 dans une navigation réelle vers un sujet complet. Au clic effectif, elle est promue vers la clé que le chargeur de la modale attend :
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 {
// tentative - la modale repasse à un chargement frais
}
}
Si une ligne sort de la vue avant d’être cliquée, la requête en attente est annulée et tout résultat mis en cache est rejeté, de sorte qu’elle ne contient jamais que des données pour les sujets actuellement affichés à l’écran.
Un dernier, non soulevé dans le fil : modales imbriquées
Quelques flux de modales (ceux que le noyau ouvre lui-même plutôt que via une propriété de rappel explicite câblée à showSubModal()) appellent le service modal directement. Plutôt que de laisser ceux-ci fermer la modale de prévisualisation sous les yeux du lecteur, modal.show() est modifié pour rediriger sélectivement les modales cibles vers un petit emplacement de sous-modale locale locale tant que la prévisualisation est ouverte :
this.originalModalShow = this.modal.show.bind(this.modal);
this.modal.show = (component, opts = {}) => {
return this.showSubModal(component, opts.model);
};
de sorte que ces flux cibles se superposent à la prévisualisation au lieu de la remplacer, tandis que le suivi de lecture (/topics/timings) continue de fonctionner en dessous via un vidage périodique d’un ensemble de numéros de posts visibles suivi par IntersectionObserver, de sorte que les comptes de non-lus restent corrects après la fermeture, tout comme une visite normale de sujet.
C’est un morceau de code assez conséquent réparti entre la modale, le déclencheur de la liste des sujets et le style (~1700 lignes au total), cela couvre peut-être un tiers de cela. Les parties les plus délicates de loin étaient le patching des services et le fait de faire se comporter content-visibility une fois que les hauteurs des posts changent après le chargement des images. Heureux d’entrer dans plus de détails sur l’un ou l’autre, ou de le rendre open source, s’il y a de l’intérêt.