Modal de tópicos estilo Facebook - É melhor?

Estive experimentando para conseguir uma aparência diferente em relação a outros sites e construí um modal de tópico estilo Facebook. Parece carregar mais rápido para mim e parece ser mais fluido no desktop.

O que vocês acham?

O link para testar é https://www.carptalk-online.co.uk/ (funciona apenas no Desktop)

muito bem feito! eu gosto bastante disso, mesmo não sendo usuário do Facebook. você tem um site legal. :star_struck:

além disso, eu não sabia que o carp podia ficar tão grande! :fishing_pole:

Muito legal! Você testou em tópicos com 20+ respostas? É quando carregamos mais “páginas” de posts ao rolar a tela e, na minha experiência, é uma das maiores complicações com esse tipo de visual alternativo

Não, vou fazer isso agora e retorno com as informações

Funciona perfeitamente - URL EXCLuíDA

Vejo a página carregando como você diz, mas ela funciona conforme o esperado.

O tópico não aparece em uma janela modal ao clicar no link direto. Na lista de tópicos, funciona, porém.

É, essa é a próxima coisa que preciso investigar, mas tenho pensado nisso: faz diferença? É o mesmo comportamento do Facebook: o link direto leva à publicação real, e se clicado enquanto navega, abre o modal.

Bom conceito, você consegue manter uma conexão consistente com o barramento de mensagens para novas mensagens enquanto carrega mensagens mais antigas?

Sim, porque o modal carrega a aplicação normal de tópicos do Discourse dentro de um iframe, em vez de renderizar uma cópia personalizada do tópico. O iframe estabelece sua própria conexão com o MessageBus e o Discourse continua a gerenciar o fluxo de publicações normalmente, incluindo o carregamento de publicações mais antigas e o recebimento de novas publicações.

A página principal também permanece ativa ao fundo, então, tecnicamente, há duas instâncias do Discourse e duas conexões com o MessageBus enquanto o modal está aberto. Isso funciona, embora seja um pouco mais pesado do que uma implementação totalmente nativa. Fechar o modal remove o iframe e sua conexão.

Não sei se faz diferença, e não uso o Facebook :stuck_out_tongue:

Fiquei apenas surpreso porque clicar no seu link não exibiu o tópico em uma janela modal, já que você o postou justamente para mostrar que funcionava em uma janela modal.

A minha publicação original era um link para a página inicial. Você clicou na minha resposta ao Kris testando os tópicos, hahaha? Como estão as coisas, aliás? Já faz um tempo que não te vejo.

Acabei de terminar a versão móvel e estou satisfeito com o funcionamento geral. O próximo passo é encontrar uma maneira melhor de fazer isso e reduzir o tempo de carregamento. Alguém tem alguma sugestão?

Após mais testes, utilizei a versão sem JavaScript que o Google visualiza, e ela carrega instantaneamente no modal. Se eu puder de alguma forma usar o conteúdo dessa versão, isso tornaria o processo muito mais rápido.

No computador, a aparência é muito boa.

No entanto, a velocidade de carregamento no dispositivo móvel está um pouco lenta. Após clicar, é possível ver apenas uma página sem conteúdo e o botão de fechar no canto superior direito, o que parece exigir que se espere muito tempo diante de uma página em branco.

Isso é o que estou trabalhando agora :slight_smile: No momento, é apenas um conceito que quero tornar realidade. No entanto, no meu dispositivo, ele carrega em 2 segundos no celular com o ícone de carregamento do Discourse, então não tenho certeza do que está acontecendo. Provavelmente porque o servidor está em Londres, Reino Unido, sem CDN até o momento.

Então, a distância é realmente muito grande, por isso a rede precisa de tempo para transmitir os dados

Agora os tópicos carregam instantaneamente no modal, agora preciso encontrar uma maneira de usar a primeira instância do Discourse carregada para as respostas, etc. (isso está no servidor de teste)

Desativei isso no site principal, pois agora está recebendo tráfego com pessoas se registrando, então foi movido para um servidor de teste. Espero ter isso concluído até meados de agosto, caso alguém tenha interesse em um modal.

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.