Olá ![]()
Uma abordagem diferente da do @Damian_Boon, no entanto: sem iframe, tudo roda como componentes Glimmer nativos reutilizando o próprio mecanismo Post/post-stream do Discourse. Como algumas perguntas específicas surgiram no tópico, aqui está como acabei resolvendo cada uma delas.
Por que não um iframe
Um iframe é, de longe, a maneira mais rápida de alcançar isso, com rota de tópico completa, compositor, moderação, tudo grátis. O custo, que é o que a pergunta do @nicolsdennis sobre o MessageBus aborda, é que você acaba com duas instâncias do Discourse ativas na página: dois post-streams, duas conexões de MessageBus, dois compositores.
Eu segui o caminho oposto. O modal é um DModal renderizando os componentes reais Post/PostSmallAction contra um postStream construído a partir de store.createRecord("topic", …), mesmo aplicativo, mesma conexão de MessageBus, mesmo compositor. Isso significa que uma série de serviços singleton principais (modal, bookmarkApi, DiscourseURL.routeTo, o controller:topic ambiente) precisam ser direcionados para o tópico do modal enquanto ele estiver aberto:
// Componentes principais (ex. PostBookmarkManager) leem/escrevem topic.bookmarks
// do controller:topic. Aponte para nosso topicModel enquanto o modal estiver aberto.
this.topicController = getOwner(this).lookup("controller:topic");
if (this.topicController) {
this.originalTopicControllerModel = this.topicController.model;
}
Cada patch é desfeito por um restoreServicePatches() idempotente ao fechar, destruir ou navegar para fora, então nada vaza para o resto do aplicativo uma vez que o modal desaparece.
Links diretos e roteamento interno
Duas partes. Primeiro, o modal deve abrir onde o leitor realmente parou, não sempre no 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, links dentro dos posts que apontam de volta para o mesmo tópico não devem escapar do modal. DiscourseURL.routeTo é corrigido pela duração que ele estiver aberto:
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);
};
Qualquer coisa apontando para outro lugar restaura os patches primeiro e cai para uma navegação normal, então sair do modal nunca deixa roteamento obsoleto para trás.
O caso de resposta (20+ respostas)
Rola infinita abaixo via um sentinel + IntersectionObserver, um “carregar anteriores” simétrico acima (uma prévia pode abrir no meio do fio), ambos acionando o exato postStream.appendMore() / prependMore() que a rota de tópico real usa:
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();
});
Primeira pintura
Um esqueleto (avatar + espaços reservados de linha, brilho) preenche a primeira pintura em vez de um spinner, e a lista de posts em si renderiza em estágios em vez de tudo de uma vez, apenas posts suficientes para alcançar o post alvo primeiro, então mais cinco de cada vez via requestIdleCallback até que o resto se iguale:
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 wrapper de post também recebe content-visibility: auto com um contain-intrinsic-size aproximado, então o navegador ignora completamente o layout para posts que estão fora da tela:
.topic-post {
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
}
Esta é a parte em que eu insistiria mais. Em vez de reutilizar uma instância após o clique, eu pré-carrego antes do clique. Cada linha da lista de tópicos observa sua própria visibilidade, debounce 200ms para que uma rolagem rápida não dispare uma dúzia de solicitações, e transfere para uma pequena fila compartilhada limitada a 2 busca simultâneos:
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);
}
},
};
}
O pré-carregamento é armazenado sob uma chave privada PreloadStore, deliberadamente não a chave principal topic_${id}, escrever lá vazaria um JSON parcial escopado last_read+1 para uma navegação de tópico completo real. No clique real, ele é promovido para a chave que o carregador do modal espera:
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 {
// melhor esforço - modal recua para um carregamento fresco
}
}
Se uma linha rola para fora da visão antes de ser clicada, a busca pendente é cancelada e qualquer resultado em cache é descartado, então ele só mantém dados para tópicos realmente na tela agora.
Mais uma, não levantada no tópico ainda: modais aninhados
Alguns fluxos de modal (aqueles que o núcleo abre por conta própria em vez de através de uma prop de callback explícita conectada a showSubModal()) chamam o serviço modal diretamente. Em vez de deixar esses fechar o modal de prévia de baixo do leitor, modal.show() é corrigido para redirecionar seletivamente modais alvo para um pequeno slot de sub-modal local local enquanto a prévia estiver aberta:
this.originalModalShow = this.modal.show.bind(this.modal);
this.modal.show = (component, opts = {}) => {
return this.showSubModal(component, opts.model);
};
então esses fluxos alvo se sobrepõem à prévia em vez de substituí-la, enquanto o rastreamento de leitura (/topics/timings) continua rodando por baixo via um flush periódico de um conjunto de números de post visíveis rastreado por IntersectionObserver, então as contagens de não lidas ainda estão corretas após o fechamento, igual a uma visita normal de tópico.
É uma boa quantidade de código através do modal, o gatilho da lista de tópicos e o estilo (~1700 linhas no total), isso cobre talvez um terço disso. As partes mais complicadas de longe foram o patch de serviço e fazer content-visibility se comportar uma vez que as alturas dos posts mudam após as imagens terminarem de carregar. Feliz em entrar em mais detalhes sobre qualquer um deles, ou torná-lo código aberto, se houver interesse.