| Resumo | Modal de Pré-visualização de Tópicos – abra e interaja com tópicos sem sair da lista de tópicos | |
| Pré-visualização | Theme Creator | |
| Repositório | GitHub - VaperinaDEV/discourse-topic-preview-modal: Open a topic directly from the topic list in a native Discourse modal, read and interact with the topic, and then continue browsing the list without navigating away from it. · GitHub | |
| Achei útil? | > ./support --coffee | |
| Guia de Instalação | Como instalar um tema ou componente de tema | |
| Novo em Temas do Discourse? | Guia para iniciantes sobre o uso de Temas do Discourse |
Instalar este componente de tema
Modal de Pré-visualização de Tópicos – abra e interaja com tópicos sem sair da lista de tópicos
Criei um novo componente de tema do Discourse chamado Topic Preview Modal.
A ideia é bastante simples:
Abra um tópico diretamente da lista de tópicos em um modal nativo do Discourse, leia e interaja com o tópico e, em seguida, continue navegando pela lista sem sair dela.
Começou a partir de Facebook-style Topic Modal - Is it better? , mas acabou exigindo uma integração considerável com os sistemas de tópico, fluxo de posts, compositor, modal, marcador de posição, roteamento, presença, rastreamento de leitura e pré-busca do Discourse.
Por quê?
O fluxo normal do Discourse é:
- Você está navegando por uma lista de tópicos.
- Você clica em um tópico.
- O Discourse navega para
/t/.... - Você lê/responde/interage com o tópico.
- Você volta para a lista de tópicos.
Para muitos fluxos de trabalho, isso é perfeitamente aceitável.
No entanto, ao navegar por uma lista de tópicos movimentada, às vezes eu só quero inspecionar rapidamente um tópico, ler alguns posts, verificar as últimas respostas, reagir a algo ou responder a uma pergunta rápida.
Para esse caso de uso, sair da lista de tópicos parece desnecessariamente custoso.
O objetivo deste componente foi, portanto, fazer com que a lista de tópicos se comportasse mais como uma caixa de entrada:
lista de tópicos → pré-visualização → interação → fechar → continuar exatamente de onde parou.
O que ele faz
A pré-visualização não é apenas um excerto estático.
Ela renderiza os componentes de post reais do Discourse dentro de um DModal nativo.
Isso significa que os usuários podem:
- ler posts
- rolar pelo tópico
- carregar posts anteriores
- carregar mais posts abaixo
- reagir a posts
- marcar posts com marcador de posição (bookmark)
- citar texto
- responder ao tópico
- responder a posts individuais
- editar posts quando permitido
- excluir/recuperar posts quando permitido
- sinalizar (flag) posts
- ver o histórico do post
- realizar várias ações normais de post
- ver a presença no tópico
- seguir links para outros posts dentro do mesmo tópico
- pular diretamente para o post relevante
- abrir o tópico completo quando necessário
A intenção é que a pré-visualização se sinta o mais próxima possível da abertura real do tópico.
Dois modos de acionamento
Há duas formas de abrir a pré-visualização.
1. Linha inteira da lista de tópicos
Este é o padrão.
A linha inteira da lista de tópicos se torna clicável, enquanto elementos interativos comuns, como:
- cartões de usuário
- participantes
- links de categoria
- tags
- links de status do tópico
- seleção em massa
são excluídos do acionamento do modal.
Isso torna a experiência muito rápida ao navegar por uma lista de tópicos.
2. Botão de expansão explícito
Alternativamente, o componente pode renderizar um pequeno ícone de expansão por meio de uma saída de plugin (plugin outlet) do Discourse. Temas personalizados podem simplesmente criar uma nova <PluginOutlet /> para mostrar o acionador.
Neste modo, o comportamento normal da lista de tópicos permanece completamente intacto.
O usuário clica no ícone de expansão para abrir a pré-visualização, enquanto clicar no título do tópico ainda executa a navegação normal do Discourse.
Isso é útil se um site quiser preservar o modelo de interação padrão da lista de tópicos.
A configuração é:
trigger_style:
row
ou:
trigger_style:
button
Ao usar o modo de botão, a saída (outlet) também é configurável.
A pré-visualização começa na posição não lida do usuário
Um dos detalhes importantes é que o modal não carrega simplesmente o primeiro post.
Quando um tópico já foi parcialmente lido, a pré-visualização calcula:
last_read_post_number + 1
e abre em torno desse post.
Portanto, se um tópico tem 200 posts e o usuário leu até o post #165, a abertura da pré-visualização começa em torno do #166.
Isso torna a pré-visualização muito mais útil para a navegação do mundo real.
Isso também significa que o componente precisa lidar com ambos os lados do fluxo de posts:
- carregar posts anteriores quando necessário
- carregar posts mais novos abaixo
O botão Posts anteriores é exibido quando há posts acima do intervalo atualmente carregado, enquanto um sentinela IntersectionObserver carrega automaticamente mais posts quando o usuário atinge o fundo.
Pré-busca (Prefetching)
Uma das maiores partes do componente é seu sistema de pré-busca.
O problema com um modal como este é que o usuário espera que ele se sinta instantâneo.
Se começarmos a carregar o tópico apenas após o usuário clicar, o modal ainda pode gastar tempo perceptível esperando pela rede.
Em vez disso, o componente pode pré-buscar tópicos proativamente enquanto o usuário navega pela lista.
Quando uma linha de tópico se aproxima da área de visualização (viewport), um IntersectionObserver pode agendar uma pré-busca.
Há várias salvaguardas para impedir que isso se torne tráfego de fundo descontrolado.
Debounce (Atraso)
Um tópico não dispara imediatamente uma solicitação apenas porque apareceu brevemente na área de visualização.
O componente espera pelo período de debounce configurado.
Padrão:
400 ms
Isso é particularmente útil ao rolar rapidamente por uma longa lista de tópicos.
Margem da raiz (Root margin)
A pré-busca pode começar um pouco antes que o tópico entre realmente na área de visualização.
Padrão:
50 px
Isso dá à solicitação uma pequena vantagem.
Limite de solicitações simultâneas
O número de pré-buscas simultâneas é limitado.
Padrão:
2
A configuração permite entre 1 e 6 pré-buscas simultâneas.
Orçamento por minuto
Há também um segundo mecanismo de proteção:
max_prefetches_per_minute
O padrão é:
15
Portanto, mesmo que o usuário continue rolando por centenas de tópicos, o componente não gerará continuamente solicitações especulativas.
0 desativa o limite.
A pré-busca pode ser desativada completamente
Se um site não quiser nenhum tráfego de rede especulativo:
enable_prefetch = false
O componente continua a funcionar normalmente. Os tópicos simplesmente carregam quando a pré-visualização é aberta.
Os dados de pré-busca são mantidos separados da navegação normal de tópicos
Há um importante detalhe de implementação aqui.
A resposta pré-buscada não é escrita imediatamente na chave de pré-carregamento normal topic_<id> do Discourse.
Em vez disso, o componente usa seu próprio namespace:
topic-preview-modal:prefetch:<topicId>
Apenas quando o usuário realmente abre a pré-visualização, a promessa (promise) pré-buscada é promovida para a chave de pré-carregamento do tópico principal.
Isso é intencional.
A pré-visualização pode estar carregando um tópico começando de last_read_post_number + 1, e não quero que essa resposta específica da pré-visualização vaze para uma navegação de rota de tópico normal.
Portanto, o ciclo de vida é essencialmente:
tópico entra na área de visualização
↓
pré-busca
↓
armazenamento de pré-carregamento privado
↓
usúário abre a pré-visualização
↓
promover pré-carregamento
↓
Topic.find()/PostStream usa a mesma promessa
Isso também significa que o modal não precisa esperar a solicitação de pré-busca terminar antes de abrir.
O modal pode abrir imediatamente com seu esqueleto (skeleton) enquanto a mesma promessa continua se resolvendo.
Suporte a dispositivos móveis
Na verdade, esta foi uma das razões pelas quais gastei consideravelmente mais tempo na implementação.
A ideia inicial funcionava razoavelmente bem no desktop, mas o mobile expôs vários problemas em torno de:
- interação por toque
- rolagem do modal
- foco
- menus aninhados
- o compositor
- visibilidade de posts
- carregamento de imagens
- desempenho
A implementação final, portanto, evita tratar o modal como um fórum miniatura completamente separado.
Em vez disso, ele reutiliza o máximo possível da infraestrutura existente do Discourse.
Componentes de post reais do Discourse
O modal não recria posts usando um modelo personalizado simplificado.
Ele renderiza os componentes reais do Discourse:
Post
PostSmallAction
Isso é importante, pois, caso contrário, a pré-visualização se tornaria rapidamente uma segunda implementação da interface de post.
O componente passa as ações relevantes para os componentes de post normais, incluindo coisas como:
- responder
- editar
- excluir
- recuperar
- sinalizar (flag)
- histórico
- marcador de posição (bookmark)
- wiki
- bloquear/desbloquear
- tipo de post
- mudanças de propriedade
- badges
- posts ocultos
- citação
- etc.
O resultado é que a pré-visualização pode se comportar muito mais como um tópico normal do que um componente de “pré-visualização” tradicional.
Respostas e o compositor
O compositor é uma das partes mais complicadas.
A pré-visualização pode abrir o compositor normal do Discourse para:
Responder ao tópico
O compositor do tópico é aberto com o modelo do tópico e as informações de rascunho corretas.
Responder a um post específico
O post é passado para o compositor para que a resposta se comporte como uma resposta de post normal.
Citar texto selecionado
O componente também se integra ao PostTextSelection.
Isso significa que os usuários podem selecionar texto dentro da pré-visualização e usar o fluxo normal de citação/resposta do Discourse.
Modais aninhados
Outra parte complicada foi o sistema de modais do Discourse.
Posts podem abrir outros modais e diálogos:
- sinalização (flagging)
- histórico
- diálogos relacionados a badges
- mudanças de propriedade
- confirmações de exclusão
- etc.
Se esses fossem permitidos a interagir normalmente com o serviço global de modais, a abertura de um deles poderia fechar toda a pré-visualização do tópico.
Para evitar isso, o componente cria um mecanismo de sub-modal local.
Conceitualmente:
Modal de Pré-visualização de Tópico
│
├── Modal de Sinalização
├── Modal de Histórico
├── Confirmação de Exclusão
├── Modal de Badge
└── outro modal relacionado a post
A pré-visualização permanece montada (mounted) abaixo.
O componente aplica temporariamente patches (patches) nos métodos relevantes do serviço de modal enquanto está ativo e os restaura quando é destruído.
Roteamento dentro do modal
Outro detalhe importante são os links para posts dentro do mesmo tópico.
Por exemplo, se um post contiver um link para:
/t/my-topic/123
a pré-visualização não precisa fechar e navegar para fora.
Em vez disso, o componente intercepta a navegação do mesmo tópico e pula para o post solicitado dentro do modal.
O mesmo se aplica a links que apontam para o tópico sem um número de post específico.
Isso mantém o usuário dentro da pré-visualização.
Se o link apontar para um tópico genuinamente diferente, o componente primeiro restaura seus patches temporários de serviço e se fecha antes de permitir a transição de rota normal do Discourse.
Essa limpeza é importante, pois, caso contrário, as assinaturas e o rastreador de tempo da pré-visualização poderiam permanecer ativos enquanto a rota real do tópico está sendo inicializada.
Rastreamento de leitura e rastreamento de tempo
Também quis que a pré-visualização se comportasse corretamente do ponto de vista do Discourse.
Abrir uma pré-visualização não deve significar que o rastreamento de leitura é completamente ignorado.
O componente, portanto, lida com:
- rastreamento de visitas ao tópico
- rastreamento de posts visíveis
- tempo do tópico
- atualizações do último post lido
O rastreador de tempo usa um IntersectionObserver para determinar quais posts estão realmente visíveis.
A cada 5 segundos, o tempo de posts visíveis é enviado (flushed) para:
/topics/timings
Quando o modal fecha, um envio final é realizado para que os últimos segundos não sejam perdidos.
A implementação também limita um único intervalo de tempo a 60 segundos.
Mantendo o estado não lido da lista de tópicos sincronizado
Havia outro problema sutil aqui.
Atualizar apenas o estado de rastreamento de tópicos do Discourse não é suficiente para atualizar o distintivo de não lidos exibido diretamente em uma linha da lista de tópicos.
O componente, portanto, atualiza o objeto de tópico real associado à linha após as informações de tempo terem sido enviadas.
Ele atualiza valores como:
last_read_post_number
unread_posts
unread
new_posts
quando apropriado.
Isso significa que, após ler um tópico dentro do modal, a lista de tópicos pode refletir imediatamente o novo estado de leitura, em vez de exigir uma atualização completa da página.
Visibilidade de posts
A pré-visualização usa um IntersectionObserver compartilhado para determinar quando posts individuais se tornam visíveis.
Também há uma verificação de visibilidade síncrona quando o observador é anexado.
Isso lida com um caso extremo em que um post já está visível quando é montado, mas o primeiro callback assíncrono do IntersectionObserver ainda não foi disparado.
Isso é especialmente relevante para tópicos muito curtos, onde o tópico inteiro pode já estar visível quando o modal abre.
Considerações de desempenho
Um objetivo principal foi evitar transformar o modal em uma página de tópico miniatura pesada em desempenho.
Algumas coisas são feitas especificamente para isso.
Renderização progressiva
O carregamento inicial não renderiza imediatamente todos os posts.
O componente primeiro renderiza posts suficientes para alcançar a posição alvo.
Os posts restantes são então renderizados progressivamente usando:
requestIdleCallback
quando disponível, com um fallback para setTimeout.
Isso é particularmente útil ao abrir um tópico longo em torno de um post muito abaixo no fluxo.
Contenção CSS
Os posts usam:
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
Isso permite que o navegador evite fazer trabalho de renderização desnecessário para posts que não estão atualmente visíveis.
Imagens preguiçosas (Lazy images)
Imagens que ainda não especificaram um modo de carregamento recebem automaticamente:
loading="lazy"
decoding="async"
Isso impede que um tópico longo com muitas imagens carregue tudo imediatamente.
Estado de carregamento
O modal não mostra apenas uma área branca/vazia enquanto a solicitação está sendo feita.
Ele tem uma interface de esqueleto (skeleton UI) com:
- placeholders de avatar
- placeholders de nome de usuário/nome
- placeholders de corpo do post
- animação shimmer
O shimmer respeita:
prefers-reduced-motion
para que a animação seja desativada para usuários que solicitaram movimento reduzido.
Mantendo a posição de rolagem estável
Há alguns lugares onde o componente precisa manipular a posição de rolagem manualmente.
Por exemplo, ao carregar posts anteriores, o conteúdo recém-inserido aumenta a altura de rolagem.
Simplesmente adicionar os posts no início faria com que a posição atual do usuário saltasse.
O componente, portanto, registra a altura de rolagem anterior e compensa a diferença após a inserção dos posts.
Isso mantém o conteúdo atualmente visível aproximadamente no mesmo lugar.
O mesmo se aplica ao pular para um post específico.
O componente executa uma etapa de posicionamento pós-renderização e verifica a posição novamente em quadros subsequentes para levar em conta o conteúdo que ainda pode estar se assentando.
Presença no tópico
Quando os dados relevantes do tópico estão disponíveis, a pré-visualização também pode exibir as informações de presença do tópico do Discourse na parte inferior do modal.
Assim, os usuários podem ver quem mais está atualmente visualizando o tópico sem precisar sair da pré-visualização.
Interação com menus móveis e foco
O mobile introduziu outra categoria de problemas.
Alguns elementos de interface do Discourse usam serviços compartilhados de modal/menu, e esses serviços nem sempre sabem que a pré-visualização do tópico está atualmente atuando como um contexto de navegação aninhado.
O componente, portanto, tem tratamento adicional em torno de:
modal.close()- menus do Float Kit
- restauração de foco
- o compositor
- controles de teclado do lightbox
- bloqueios de rolagem do corpo (body scroll locks)
Por exemplo, se um menu tentar internamente chamar o método de fechamento global do modal, isso não deve acidentalmente fechar toda a pré-visualização do tópico.
Da mesma forma, quando o compositor está aberto, o foco precisa permanecer dentro do compositor, em vez de ser puxado de volta para o contexto de foco da pré-visualização.
Configuração
O componente atualmente expõe as seguintes configurações:
| Configuração | Padrão | Descrição |
|---|---|---|
trigger_style |
row |
Tornar a linha inteira clicável ou usar um botão explícito |
plugin_outlet |
topic-list-after-title |
Saída (outlet) usada pelo acionador de botão |
enable_prefetch |
true |
Ativar/desativar pré-busca de tópicos em segundo plano |
max_concurrent_prefetches |
2 |
Número máximo de solicitações de pré-busca simultâneas |
prefetch_debounce_ms |
400 |
Atraso antes de iniciar uma pré-busca |
prefetch_root_margin_px |
50 |
Iniciar a pré-busca este número de pixels antes que a linha entre na área de visualização |
max_prefetches_per_minute |
15 |
Número máximo de solicitações especulativas por minuto |
Os controles de pré-busca são intencionalmente configuráveis porque comunidades diferentes podem ter padrões de tráfego e características de hospedagem/rede muito diferentes.
Um dos principais objetivos de design: não quebrar o Discourse normal
Tentei manter o componente o mais próximo possível da arquitetura existente do Discourse.
Ele não implementa seu próprio renderizador de posts, seu próprio compositor, seu próprio modelo de tópico ou seu próprio fluxo de posts completamente separado.
Em vez disso, ele constrói um contexto de navegação temporário em torno dos componentes e serviços existentes do Discourse.
É por isso que algumas partes da implementação são mais complicadas do que podem parecer inicialmente.
O desafio mais interessante foi:
Um tópico pode se comportar quase como um tópico normal do Discourse enquanto está sendo exibido dentro de outro contexto de interface?
Isso exigiu lidar com as fronteiras entre os serviços globais do Discourse e a pré-visualização local.





