Hallo 
Ein anderer Ansatz als der von @Damian_Boon: Kein iframe, alles läuft als native Glimmer-Komponenten und nutzt Discourses eigenen Post/post-stream-Mechanismus. Da im Thread einige spezifische Fragen aufkamen, hier, wie ich jedes Problem gelöst habe.
Warum kein iframe
Ein iframe ist mit Abstand der schnellste Weg, dies zu erreichen: vollständige Topic-Route, Composer, Moderation, alles kostenlos. Der Nachteil, auf den @nicolsdennis’ Frage zu MessageBus hinausläuft, ist, dass du am Ende zwei aktive Discourse-Instanzen auf der Seite hast: zwei post-streams, zwei MessageBus-Verbindungen, zwei Composer.
Ich habe den anderen Weg gewählt. Der Modal ist ein DModal, das die echten Post/PostSmallAction-Komponenten gegen einen postStream rendert, der aus store.createRecord("topic", …) aufgebaut ist. Gleiche App, gleiche MessageBus-Verbindung, gleicher Composer. Das bedeutet, dass eine Handvoll Kern-Singleton-Services (modal, bookmarkApi, DiscourseURL.routeTo, der umgebende controller:topic) solange auf das Topic des Modals zeigen müssen, wie es offen ist:
// Kernkomponenten (z. B. PostBookmarkManager) lesen/schreiben topic.bookmarks
// von controller:topic. Zeige es auf unser topicModel, während der Modal offen ist.
this.topicController = getOwner(this).lookup("controller:topic");
if (this.topicController) {
this.originalTopicControllerModel = this.topicController.model;
}
Jeder Patch wird durch eine idempotente restoreServicePatches() beim Schließen, Zerstören oder Navigieren rückgängig gemacht, sodass nichts in den Rest der App austritt, sobald der Modal verschwunden ist.
Direkte Links & internes Routing
Zwei Teile. Erstens sollte der Modal dort öffnen, wo der Leser tatsächlich aufgehört hat, nicht immer bei 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)
);
Zweitens sollten Links innerhalb von Posts, die zurück in dasselbe Topic zeigen, den Modal nicht verlassen. DiscourseURL.routeTo wird für die Dauer, in der er offen ist, gepatcht:
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);
};
Alles, was anderswohin zeigt, stellt die Patches zuerst wieder her und fällt auf eine normale Navigation zurück, sodass das Verlassen des Modals keine veralteten Routing-Zustände hinterlässt.
Der Antwort-Fall (20+ Antworten)
Endlos-Scrollen unten via Sentinel + IntersectionObserver, ein symmetrisches „frühere laden“ oben (eine Vorschau kann mitten im Thread öffnen), beide treiben genau die postStream.appendMore() / prependMore(), die die echte Topic-Route verwendet:
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();
});
Erster Render (First Paint)
Ein Skeleton (Avatar + Platzhalterlinien, Shimmer) füllt den ersten Render statt eines Spinners, und die Post-Liste selbst rendert in Stufen statt alles auf einmal, gerade genug Posts, um zuerst zum Zielpost zu gelangen, dann fünf weitere auf einmal via requestIdleCallback, bis der Rest nachgeholt hat:
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;
};
Jeder Post-Wrapper erhält auch content-visibility: auto mit einer groben contain-intrinsic-size, sodass der Browser das Layout für Posts, die außerhalb des Bildschirms liegen, vollständig überspringt:
.topic-post {
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
}
Das ist der Teil, auf den ich am meisten drängen würde. Anstatt eine Instanz nach dem Klick wiederzuverwenden, prefetche ich vor dem Klick. Jede Topic-Liste-Zeile beobachtet ihre eigene Sichtbarkeit, debouncet 200ms, damit ein schnelles Scrollen nicht ein Dutzend Anfragen auslöst, und übergibt an eine kleine gemeinsame Warteschlange, die auf 2 gleichzeitige Fetches begrenzt ist:
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);
}
},
};
}
Der Prefetch wird unter einem privaten PreloadStore-Schlüssel zwischengespeichert, bewusst nicht unter dem Kern-topic_${id}-Schlüssel, denn ein Schreiben dort würde ein last_read+1-skaliertes partielles JSON in eine echte Full-Topic-Navigation lecken. Beim tatsächlichen Klick wird er in den Schlüssel befördert, den der Loader des Modals erwartet:
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
}
}
Wenn eine Zeile aus dem Sichtfeld scrollt, bevor sie geklickt wird, wird der ausstehende Fetch abgebrochen und jedes zwischengespeicherte Ergebnis verworfen, sodass es nur Daten für Topics hält, die gerade auf dem Bildschirm sind.
Noch eins, das im Thread noch nicht angesprochen wurde: verschachtelte Modals
Einige Modal-Flows (die, die das Kernsystem selbst öffnet, anstatt durch eine explizite Callback-Prop, die an showSubModal() verkabelt ist) rufen den modal-Service direkt auf. Anstatt diese den Preview-Modal unter dem Leser weg zu schließen, wird modal.show() gepatcht, um gezielte Modals selektiv in einen kleinen lokalen Sub-Modal-Slot umzuleiten, solange die Vorschau offen ist:
this.originalModalShow = this.modal.show.bind(this.modal);
this.modal.show = (component, opts = {}) => {
return this.showSubModal(component, opts.model);
};
Damit werden diese gezielten Flows über der Vorschau geschichtet, anstatt sie zu ersetzen, während das Read-Tracking (/topics/timings) weiterhin im Hintergrund läuft, via periodischem Flush einer IntersectionObserver-verfolgten Menge sichtbarer Post-Nummern, sodass die Unread-Counts nach dem Schließen immer noch korrekt sind, genau wie bei einem normalen Topic-Besuch.
Es ist ein ziemlicher Code-Anteil über den Modal, den Topic-Liste-Trigger und das Styling (~1700 Zeilen zusammen), das deckt vielleicht ein Drittel davon ab. Die schwierigsten Teile waren bei weitem das Service-Patching und das Verhalten von content-visibility, sobald sich die Post-Höhen ändern, nachdem Bilder fertig geladen sind. Gerne gehe ich auf mehr Details ein oder open-source es, wenn Interesse besteht.