Modal de tópicos estilo Facebook - É melhor?

Olá :waving_hand:

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.