| Resumo | Modal de Pré-visualização de Tópico – 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 | |
| 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 |
Instale este componente de tema
Modal de Pré-visualização de Tópico – abra e interaja com tópicos sem sair da lista de tópicos
Criei um novo componente de tema do Discourse chamado Modal de Pré-visualização de Tópico.
A ideia é bastante simples:
Abrir um tópico diretamente da lista de tópicos em um modal nativo do Discourse, ler e interagir com o tópico e, em seguida, continuar navegando na lista sem precisar sair dela.
Tudo começou em Facebook-style Topic Modal - Is it better? , mas acabou exigindo uma integração bastante complexa com os sistemas de tópicos, fluxo de posts, compositor, modal, favoritos, roteamento, presença, rastreamento de leitura e pré-busca do Discourse.
Por quê?
O fluxo normal do Discourse é:
- Você está navegando em 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 adequado.
No entanto, ao navegar em 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 trecho 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
- favoritar posts
- citar texto
- responder ao tópico
- responder a posts individuais
- editar posts quando permitido
- excluir/recuperar posts quando permitido
- sinalizar posts
- ver o histórico do post
- executar 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óximo possível de abrir o tópico de verdade.
Dois modos de gatilho
Há duas maneiras 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 gatilho do modal.
Isso torna a experiência muito rápida ao navegar em uma lista de tópicos.
2. Botão de expansão explícito
Como alternativa, o componente pode renderizar um pequeno ícone de expansão por meio de uma saída de plugin do Discourse. Temas personalizados podem simplesmente criar uma nova <PluginOutlet /> para mostrar o gatilho.
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 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.
Então, se um tópico tem 200 posts e o usuário leu até o post #165, abrir a 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 tem que lidar com ambos os lados do fluxo de posts:
- carregar posts anteriores quando necessário
- carregar posts mais recentes abaixo
O botão Posts anteriores é exibido quando há posts acima do intervalo atualmente carregado, enquanto um sentinel IntersectionObserver carrega automaticamente mais posts quando o usuário chega ao final.
Pré-busca
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 seja instantâneo.
Se começarmos a carregar o tópico apenas depois que o usuário clicar, o modal ainda pode gastar tempo perceptível esperando pela rede.
Em vez disso, o componente pode fazer pré-busca proativa de tópicos enquanto o usuário navega na lista.
Quando uma linha de tópico se aproxima da viewport, um IntersectionObserver pode agendar uma pré-busca.
Há várias salvaguardas para evitar que isso se transforme em tráfego de fundo descontrolado.
Debouncing
Um tópico não aciona imediatamente uma solicitação apenas porque apareceu brevemente na viewport.
O componente espera pelo período de debounce configurado.
Padrão:
400 ms
Isso é particularmente útil ao rolar rapidamente por uma lista de tópicos longa.
Margem raiz
A pré-busca pode começar um pouco antes que o tópico entre realmente na viewport.
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
Então, 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 funcionando normalmente. Os tópicos simplesmente são carregados quando a pré-visualização é aberta.
Os dados de pré-busca são mantidos separados da navegação normal de tópicos
Há um detalhe de implementação importante aqui.
A resposta pré-buscada não é gravada 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>
Somente quando o usuário realmente abre a pré-visualização é que a promessa 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 a partir de last_read_post_number + 1, e eu não quero que essa resposta específica da pré-visualização vaze para uma navegação de rota de tópico normal.
Então, o ciclo de vida é essencialmente:
tópico entra na viewport
↓
pré-busca
↓
armazenamento de pré-carregamento privado
↓
usário abre pré-visualização
↓
promover pré-carregamento
↓
Topic.find()/PostStream usa a mesma promessa
Isso também significa que o modal não precisa esperar que a solicitação de pré-busca seja concluída antes de abrir.
O modal pode abrir imediatamente com seu esqueleto enquanto a mesma promessa continua sendo resolvida.
Suporte a dispositivos móveis
Na verdade, esta foi uma das razões pelas quais passei consideravelmente mais tempo na implementação.
A ideia inicial funcionava razoavelmente bem na área de trabalho, mas os dispositivos móveis expuseram vários problemas relacionados a:
- interação por toque
- rolagem do modal
- foco
- menus aninhados
- o compositor
- visibilidade do post
- carregamento de imagens
- desempenho
A implementação final, portanto, evita tratar o modal como um fórum miniatura completamente separado.
Em vez disso, 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 porque, caso contrário, a pré-visualização se tornaria rapidamente uma segunda implementação da interface do usuário do post.
O componente passa as ações relevantes para os componentes de post normais, incluindo coisas como:
- responder
- editar
- excluir
- recuperar
- sinalizar
- histórico
- favorito
- 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 normal a um post.
Citar texto selecionado
O componente também se integra com 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.
Os posts podem abrir outros modais e diálogos:
- sinalização
- histórico
- diálogos relacionados a badges
- mudanças de propriedade
- confirmações de exclusão
- etc.
Se esses fossem permitidos para interagir com o serviço global de modais normalmente, abrir 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 posts
A pré-visualização permanece montada abaixo.
O componente corrige temporariamente os 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 contém um link para:
/t/meu-topico/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 suas correções temporárias de serviço e se fecha antes de permitir a transição de rota normal do Discourse.
Essa limpeza é importante porque, 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
Eu também queria 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 visita 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 para:
/topics/timings
Quando o modal é fechado, é executado um envio final 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 emblema não lido 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 do post
A pré-visualização usa um IntersectionObserver compartilhado para determinar quando posts individuais se tornam visíveis.
Há também uma verificação de visibilidade síncrona quando o observador é anexado.
Isso lida com um caso de borda em que um post já está visível quando é montado, mas a primeira chamada de retorno assíncrona do IntersectionObserver ainda não foi acionada.
Isso é especialmente relevante para tópicos muito curtos onde todo o tópico já pode estar visível quando o modal é aberto.
Considerações de desempenho
Um objetivo principal era evitar transformar o modal em uma página de tópico miniatura pesada em termos de 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 de destino.
Os posts restantes são então renderizados progressivamente usando:
requestIdleCallback
quando disponível, com fallback para setTimeout.
Isso é particularmente útil ao abrir um tópico longo em torno de um post muito abaixo do 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
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 usuário de esqueleto com:
- espaços reservados de avatar
- espaços reservados de nome de usuário/nome
- espaços reservados do corpo do post
- animação de brilho
O brilho respeita:
prefers-reduced-motion
então a animação é 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 a posição atual do usuário pular.
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 estabilizando.
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.
Então, os usuários podem ver quem mais está visualizando o tópico atualmente sem precisar sair da pré-visualização.
Interação com menus móveis e foco
Os dispositivos móveis introduziram outra categoria de problemas.
Alguns elementos de interface do usuário do Discourse usam serviços de modal/menu compartilhados, e esses serviços não necessariamente sabem que a pré-visualização do tópico está atuando atualmente como um contexto de navegação aninhado.
O componente, portanto, possui tratamento adicional em torno de:
modal.close()- menus Float Kit
- restauração de foco
- o compositor
- controles de teclado de lightbox
- bloqueios de rolagem do corpo
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 |
Torna a linha inteira clicável ou usa um botão explícito |
plugin_outlet |
topic-list-after-title |
Saída usada pelo gatilho 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 pré-busca este número de pixels antes que a linha entre na viewport |
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.
É também por isso que algumas partes da implementação são mais complicadas do que podem parecer à primeira vista.
O desafio mais interessante foi:
Um tópico pode se comportar quase como um tópico normal do Discourse enquanto é exibido dentro de outro contexto de interface do usuário?
Isso exigiu lidar com as fronteiras entre os serviços globais do Discourse e a pré-visualização local.


